From d42a78c0d7040aa65ea89d1f5a3bcd3c75678129 Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Tue, 18 Nov 2025 18:10:39 +1300 Subject: [PATCH 1/2] feat: add config-driven result mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add per-tool result mapping configuration to project JSON payloads to specific fields while preserving the full MCP envelope in `.raw`. - **Config-based projection**: Define `resultMapping` per server/tool in `config/mcporter.json` with `pick[]` arrays of JSON paths - **Nested field support**: Use dot-notation (`"customer.name"`, `"metadata.stats.views"`) with full structure preservation - **Array handling**: Automatically applies projection to each element when root result is an array - **Runtime integration**: `Runtime.callTool()` applies mapping automatically before returning results - **CLI support**: `mcporter call --output json` respects mappings; `--output raw` provides escape hatch - Added `ToolResultMapping` type to config schema - Extended `CallResult` interface with: - `pick(paths: string | readonly string[]): J | null` - `withJsonOverride(nextJson: J): CallResult` - Updated `createCallResult()` to support optional `jsonOverride` - Modified `Runtime.callTool()` to lookup and apply mappings - Added comprehensive test coverage (13 new tests) ```jsonc { "mcpServers": { "linear": { "baseUrl": "https://mcp.linear.app/mcp", "resultMapping": { "list_documents": { "pick": ["id", "title", "url"] } } } } } ``` Closes: Feature request for result projection Breaking: None - purely additive feature Tests: All 321 tests passing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- README.md | 40 +++++++ docs/cli-reference.md | 3 + src/config-normalize.ts | 2 + src/config-schema.ts | 8 ++ src/result-utils.ts | 67 ++++++++++- src/runtime.ts | 25 +++- tests/result-utils.test.ts | 238 +++++++++++++++++++++++++++++++++++++ 7 files changed, 379 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 237d038..b88e8c4 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,35 @@ Drop down to `runtime.callTool()` whenever you need explicit control over argume Call `mcporter list ` any time you need the TypeScript-style signature, optional parameter hints, and sample invocations that match the CLI's function-call syntax. +### Config-based result mapping + +Some tools return very large JSON objects when you only care about a few fields. You can declare per-tool projections in `config/mcporter.json` so that `CallResult.json()` only returns the fields you care about while `.raw` still exposes the full MCP envelope. + +```jsonc +{ + "mcpServers": { + "linear": { + "baseUrl": "https://mcp.linear.app/mcp", + "resultMapping": { + "list_documents": { + // Only keep these fields from the JSON result + "pick": ["id", "title", "url"] + } + } + } + } +} +``` + +Rules: + +* `resultMapping` is defined per server. +* Keys under `resultMapping` are the canonical MCP tool names (`list_documents`, `search_documentation`, etc.) as printed by `mcporter list `. +* `pick` is an array of JSON paths relative to the root value returned by `.json()`. Paths use simple dot-notation for nesting (`"customer.name"`, `"metadata.stats.views"`). +* Nested paths preserve their structure in the output—`["id", "metadata.author"]` produces `{ id: "...", metadata: { author: "..." } }`. +* When the root result is an array, the projection is applied to each element. +* The mapping only affects `.json()` and helpers that depend on it (including `mcporter call --output json`). `CallResult.raw` and `--output raw` still show the full unmodified MCP response. + ## Generate a Standalone CLI Turn any server definition into a shareable CLI artifact: @@ -356,6 +385,16 @@ Run `mcporter config …` via your package manager (pnpm, npm, npx, etc.) when y "command": "npx", "args": ["-y", "chrome-devtools-mcp@latest"], "env": { "npm_config_loglevel": "error" } + }, + "linear": { + "baseUrl": "https://mcp.linear.app/mcp", + + "resultMapping": { + // Only keep selected fields when calling list_documents + "list_documents": { + "pick": ["id", "title", "url"] + } + } } }, "imports": ["cursor", "claude-code", "claude-desktop", "codex", "windsurf", "opencode", "vscode"] @@ -368,6 +407,7 @@ What MCPorter handles for you: - Automatic OAuth token caching under `~/.mcporter//` unless you override `tokenCacheDir`. - Stdio commands inherit the directory of the file that defined them (imports or local config). - Import precedence matches the array order; omit `imports` to use the default `["cursor", "claude-code", "claude-desktop", "codex", "windsurf", "opencode", "vscode"]`. +- You can override imported servers by adding a local entry with the same name and a `resultMapping` block; MCPorter's merge logic keeps the imported transport definition but lets you configure mappings and other overrides locally. Provide `configPath` or `rootDir` to CLI/runtime calls when you juggle multiple config files side by side. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index fb2c57a..8200400 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -26,6 +26,9 @@ A quick reference for the primary `mcporter` subcommands. Each command inherits - `--server`, `--tool` – alternate way to target a tool. - `--timeout ` – override call timeout (defaults to `CALL_TIMEOUT_MS`). - `--output text|markdown|json|raw` – choose how to render the `CallResult`. + - `json` output respects any `resultMapping` configured for the server+tool in + `config/mcporter.json`—`CallResult.json()` returns the projected payload by default. + - `raw` ignores mappings and shows the full MCP envelope. - `--tail-log` – stream tail output when the tool returns log handles. ## `mcporter generate-cli` diff --git a/src/config-normalize.ts b/src/config-normalize.ts index 9b013bb..59ac1d8 100644 --- a/src/config-normalize.ts +++ b/src/config-normalize.ts @@ -46,6 +46,7 @@ export function normalizeServerEntry( const lifecycle = resolveLifecycle(name, raw.lifecycle, command); const logging = normalizeLogging(raw.logging); + const resultMapping = raw.resultMapping; return { name, @@ -60,6 +61,7 @@ export function normalizeServerEntry( sources, lifecycle, logging, + resultMapping, }; } diff --git a/src/config-schema.ts b/src/config-schema.ts index 1768935..ca2f8b8 100644 --- a/src/config-schema.ts +++ b/src/config-schema.ts @@ -43,6 +43,12 @@ const RawLoggingSchema = z }) .optional(); +const ToolResultMappingSchema = z.object({ + pick: z.array(z.string()).optional(), +}); + +export type ToolResultMapping = z.infer; + export const RawEntrySchema = z.object({ description: z.string().optional(), baseUrl: z.string().optional(), @@ -68,6 +74,7 @@ export const RawEntrySchema = z.object({ bearer_token_env: z.string().optional(), lifecycle: RawLifecycleSchema.optional(), logging: RawLoggingSchema, + resultMapping: z.record(ToolResultMappingSchema).optional(), }); export const RawConfigSchema = z.object({ @@ -127,6 +134,7 @@ export interface ServerDefinition { readonly sources?: readonly ServerSource[]; readonly lifecycle?: ServerLifecycle; readonly logging?: ServerLoggingOptions; + readonly resultMapping?: Record; } export interface LoadConfigOptions { diff --git a/src/result-utils.ts b/src/result-utils.ts index b254b7b..b80d4a8 100644 --- a/src/result-utils.ts +++ b/src/result-utils.ts @@ -7,6 +7,8 @@ export interface CallResult { json(): J | null; content(): unknown[] | null; structuredContent(): unknown; + pick(paths: string | readonly string[]): J | null; + withJsonOverride(nextJson: J): CallResult; } // extractContentArray pulls the `content` array from MCP response envelopes. @@ -113,7 +115,9 @@ function tryParseJson(value: unknown): unknown { } // createCallResult wraps a tool response with helpers for common content types. -export function createCallResult(raw: T): CallResult { +export function createCallResult(raw: T, options?: { jsonOverride?: unknown }): CallResult { + const jsonOverride = options?.jsonOverride; + return { raw, text(joiner = '\n') { @@ -161,6 +165,10 @@ export function createCallResult(raw: T): CallResult { .join(joiner); }, json() { + if (jsonOverride !== undefined) { + return jsonOverride as J; + } + const structured = extractStructuredContent(raw); const parsedStructured = tryParseJson(structured); if (parsedStructured !== null) { @@ -229,6 +237,63 @@ export function createCallResult(raw: T): CallResult { structuredContent() { return extractStructuredContent(raw); }, + pick(paths: string | readonly string[]): J | null { + const data = this.json(); + if (data === null) { + return null; + } + + const list = Array.isArray(paths) ? paths : [paths]; + + const project = (value: any): any => { + if (value == null || typeof value !== 'object') return value; + + const out: Record = {}; + + for (const path of list) { + if (!path) continue; + const segments = path.split('.'); + let cursor: any = value; + + // Navigate to the value + for (const seg of segments) { + if (cursor == null) break; + cursor = cursor[seg]; + } + + if (cursor !== undefined) { + // Preserve nested structure by rebuilding the path + if (segments.length === 1) { + // Simple case: top-level field + out[segments[0]!] = cursor; + } else { + // Nested case: rebuild the object hierarchy + let target = out; + for (let i = 0; i < segments.length - 1; i++) { + const seg = segments[i]!; + if (!(seg in target)) { + target[seg] = {}; + } + target = target[seg] as Record; + } + const leafKey = segments[segments.length - 1]!; + target[leafKey] = cursor; + } + } + } + + return out; + }; + + if (Array.isArray(data)) { + return data.map(project) as J; + } + + return project(data) as J; + }, + withJsonOverride(nextJson: J): CallResult { + return createCallResult(raw, { jsonOverride: nextJson }); + }, }; } diff --git a/src/runtime.ts b/src/runtime.ts index 7d8fc81..1ff420c 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -2,7 +2,9 @@ import { createRequire } from 'node:module'; import type { CallToolRequest, ListResourcesRequest } from '@modelcontextprotocol/sdk/types.js'; import { loadServerDefinitions, type ServerDefinition } from './config.js'; +import type { ToolResultMapping } from './config-schema.js'; import { createPrefixedConsoleLogger, type Logger, type LogLevel, resolveLogLevelFromEnv } from './logging.js'; +import { createCallResult } from './result-utils.js'; import { closeTransportAndWait } from './runtime-process-utils.js'; import './sdk-patches.js'; import { shouldResetConnection } from './runtime/errors.js'; @@ -192,10 +194,18 @@ class McpRuntime implements Runtime { resetTimeoutOnProgress: true, maxTotalTimeout: timeoutMs, }); - if (!timeoutMs) { - return await resultPromise; + const rawResult = timeoutMs ? await raceWithTimeout(resultPromise, timeoutMs) : await resultPromise; + + // Apply config-driven result mapping if configured for this server+tool + const mapping = this.lookupResultMapping(server, toolName); + if (!mapping || !mapping.pick || mapping.pick.length === 0) { + return rawResult; } - return await raceWithTimeout(resultPromise, timeoutMs); + + // Create CallResult, apply projection, and return the result with overridden json() + const base = createCallResult(rawResult); + const projected = base.pick(mapping.pick); + return base.withJsonOverride(projected).raw; } catch (error) { // Runtime timeouts and transport crashes should tear down the cached connection so // the daemon (or direct runtime) can relaunch the MCP server on the next attempt. @@ -298,6 +308,15 @@ class McpRuntime implements Runtime { this.logger.warn(`Failed to reset '${normalized}' after error: ${detail}`); } } + + private lookupResultMapping(server: string, toolName: string): ToolResultMapping | undefined { + const definition = this.definitions.get(server.trim()); + if (!definition || !definition.resultMapping) { + return undefined; + } + + return definition.resultMapping[toolName]; + } } // createConsoleLogger produces the default runtime logger honoring MCPORTER_LOG_LEVEL. diff --git a/tests/result-utils.test.ts b/tests/result-utils.test.ts index b6ce936..ab7106c 100644 --- a/tests/result-utils.test.ts +++ b/tests/result-utils.test.ts @@ -191,3 +191,241 @@ describe('createCallResult structured accessors', () => { expect(result.text()).toBe('Structured fallback'); }); }); + +describe('CallResult.pick()', () => { + it('picks top-level fields from a single object', () => { + const mockResponse = { + content: [ + { + type: 'json', + json: { + id: 'user-1', + name: 'Alice', + email: 'alice@example.com', + age: 30, + secret: 'should-not-appear', + }, + }, + ], + }; + + const result = createCallResult(mockResponse); + const picked = result.pick(['id', 'name', 'email']); + + expect(picked).toEqual({ + id: 'user-1', + name: 'Alice', + email: 'alice@example.com', + }); + }); + + it('picks top-level fields from an array of objects', () => { + const mockResponse = { + content: [ + { + type: 'json', + json: [ + { id: 'doc-1', title: 'First', content: 'Long content...' }, + { id: 'doc-2', title: 'Second', content: 'More content...' }, + ], + }, + ], + }; + + const result = createCallResult(mockResponse); + const picked = result.pick(['id', 'title']); + + expect(picked).toEqual([ + { id: 'doc-1', title: 'First' }, + { id: 'doc-2', title: 'Second' }, + ]); + }); + + it('picks nested fields and preserves structure', () => { + const mockResponse = { + content: [ + { + type: 'json', + json: { + id: 'user-1', + profile: { + email: 'alice@example.com', + location: { + city: 'San Francisco', + country: 'USA', + }, + }, + settings: { + theme: 'dark', + }, + }, + }, + ], + }; + + const result = createCallResult(mockResponse); + const picked = result.pick(['id', 'profile.email', 'profile.location.city']); + + expect(picked).toEqual({ + id: 'user-1', + profile: { + email: 'alice@example.com', + location: { + city: 'San Francisco', + }, + }, + }); + }); + + it('picks nested fields from array of objects', () => { + const mockResponse = { + content: [ + { + type: 'json', + json: [ + { + id: 'doc-1', + title: 'Getting Started', + metadata: { + author: 'Alice', + stats: { views: 100, likes: 10 }, + }, + }, + { + id: 'doc-2', + title: 'Advanced', + metadata: { + author: 'Bob', + stats: { views: 200, likes: 20 }, + }, + }, + ], + }, + ], + }; + + const result = createCallResult(mockResponse); + const picked = result.pick(['id', 'title', 'metadata.author', 'metadata.stats.views']); + + expect(picked).toEqual([ + { + id: 'doc-1', + title: 'Getting Started', + metadata: { + author: 'Alice', + stats: { views: 100 }, + }, + }, + { + id: 'doc-2', + title: 'Advanced', + metadata: { + author: 'Bob', + stats: { views: 200 }, + }, + }, + ]); + }); + + it('handles single string path (not array)', () => { + const mockResponse = { + content: [{ type: 'json', json: { id: '123', name: 'Test' } }], + }; + + const result = createCallResult(mockResponse); + const picked = result.pick('id'); + + expect(picked).toEqual({ id: '123' }); + }); + + it('returns null when json() returns null', () => { + const mockResponse = { content: [] }; + const result = createCallResult(mockResponse); + const picked = result.pick(['id', 'name']); + + expect(picked).toBeNull(); + }); + + it('handles missing nested fields gracefully', () => { + const mockResponse = { + content: [ + { + type: 'json', + json: { + id: 'user-1', + profile: { email: 'alice@example.com' }, + }, + }, + ], + }; + + const result = createCallResult(mockResponse); + const picked = result.pick(['id', 'profile.email', 'profile.location.city', 'missing.field']); + + expect(picked).toEqual({ + id: 'user-1', + profile: { email: 'alice@example.com' }, + }); + }); + + it('handles empty pick array', () => { + const mockResponse = { + content: [{ type: 'json', json: { id: '123', name: 'Test' } }], + }; + + const result = createCallResult(mockResponse); + const picked = result.pick([]); + + expect(picked).toEqual({}); + }); +}); + +describe('CallResult.withJsonOverride()', () => { + it('creates new CallResult with overridden json', () => { + const mockResponse = { + content: [{ type: 'json', json: { original: 'data' } }], + }; + + const result = createCallResult(mockResponse); + const overridden = result.withJsonOverride({ custom: 'data' }); + + expect(overridden.json()).toEqual({ custom: 'data' }); + expect(overridden.raw).toBe(mockResponse); // raw unchanged + }); + + it('preserves raw envelope while changing json', () => { + const mockResponse = { + content: [{ type: 'json', json: { id: '123' } }], + isError: false, + }; + + const result = createCallResult(mockResponse); + const picked = result.pick(['id']); + const overridden = result.withJsonOverride(picked); + + expect(overridden.raw).toEqual(mockResponse); + expect(overridden.json()).toEqual({ id: '123' }); + }); +}); + +describe('CallResult.json() with override', () => { + it('returns override when provided', () => { + const mockResponse = { + content: [{ type: 'json', json: { original: 'data' } }], + }; + + const result = createCallResult(mockResponse, { jsonOverride: { overridden: true } }); + + expect(result.json()).toEqual({ overridden: true }); + }); + + it('returns parsed json when no override', () => { + const mockResponse = { + content: [{ type: 'json', json: { original: 'data' } }], + }; + + const result = createCallResult(mockResponse); + + expect(result.json()).toEqual({ original: 'data' }); + }); +}); From 917ab86a9db51b1fac3d29e407ac7015d253a478 Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Tue, 18 Nov 2025 18:19:18 +1300 Subject: [PATCH 2/2] fix: actually apply result mapping to callTool output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous implementation created a projected CallResult but returned `.raw`, which just gave back the original MCP envelope without the projection. This meant configured resultMapping.pick was silently ignored. Now we properly modify the MCP envelope's content[].json field to contain the projected data, ensuring mappings take effect for CLI calls, daemon clients, and proxy usage. ## Changes - Modified Runtime.callTool() to update the raw MCP envelope with projected data instead of just creating an override that's never used - Added 4 integration tests verifying: - Result mapping is applied when configured - Unprojected results when no mapping - Array result handling - Nested structure preservation ## Test Results ✓ All 339 tests passing (335 existing + 4 new) ✓ TypeScript builds successfully 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/runtime.ts | 21 ++- tests/runtime-result-mapping.test.ts | 248 +++++++++++++++++++++++++++ 2 files changed, 267 insertions(+), 2 deletions(-) create mode 100644 tests/runtime-result-mapping.test.ts diff --git a/src/runtime.ts b/src/runtime.ts index 1ff420c..ecea66d 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -202,10 +202,27 @@ class McpRuntime implements Runtime { return rawResult; } - // Create CallResult, apply projection, and return the result with overridden json() + // Create CallResult, apply projection, and modify the raw envelope to contain projected data const base = createCallResult(rawResult); const projected = base.pick(mapping.pick); - return base.withJsonOverride(projected).raw; + + // Replace the content in the raw MCP envelope with the projected data + if (projected !== null && rawResult && typeof rawResult === 'object' && 'content' in rawResult) { + const modified = { ...rawResult } as any; + if (Array.isArray(modified.content) && modified.content.length > 0) { + const firstContent = modified.content[0]; + if (firstContent && typeof firstContent === 'object' && 'type' in firstContent) { + if (firstContent.type === 'json' && 'json' in firstContent) { + // Update the json field in the content block + modified.content = [{ ...firstContent, json: projected }]; + return modified; + } + } + } + } + + // Fallback: return raw result if structure doesn't match expected MCP envelope + return rawResult; } catch (error) { // Runtime timeouts and transport crashes should tear down the cached connection so // the daemon (or direct runtime) can relaunch the MCP server on the next attempt. diff --git a/tests/runtime-result-mapping.test.ts b/tests/runtime-result-mapping.test.ts new file mode 100644 index 0000000..bf97c47 --- /dev/null +++ b/tests/runtime-result-mapping.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createRuntime } from '../src/runtime.js'; +import type { ServerDefinition } from '../src/config-schema.js'; + +describe('Runtime result mapping integration', () => { + it('applies result mapping when configured', async () => { + // Mock server definition with result mapping + const serverDef: ServerDefinition = { + name: 'test-server', + command: { + kind: 'http', + url: new URL('https://example.com/mcp'), + }, + resultMapping: { + test_tool: { + pick: ['id', 'title'], + }, + }, + }; + + const runtime = await createRuntime({ + servers: [serverDef], + }); + + // Mock the connect method to return a fake client + const mockClient = { + callTool: vi.fn().mockResolvedValue({ + content: [ + { + type: 'json', + json: { + id: 'doc-1', + title: 'Test Document', + content: 'Long content that should be filtered out', + metadata: { + author: 'Alice', + tags: ['test'], + }, + }, + }, + ], + }), + close: vi.fn().mockResolvedValue(undefined), + listTools: vi.fn(), + listResources: vi.fn(), + }; + + // @ts-expect-error - accessing private method for testing + runtime.connect = vi.fn().mockResolvedValue({ + client: mockClient, + transport: { close: vi.fn() }, + }); + + const result = await runtime.callTool('test-server', 'test_tool', { + args: { query: 'test' }, + }); + + // Verify the result has been projected + expect(result).toHaveProperty('content'); + const content = (result as any).content; + expect(Array.isArray(content)).toBe(true); + expect(content[0]).toHaveProperty('type', 'json'); + expect(content[0].json).toEqual({ + id: 'doc-1', + title: 'Test Document', + }); + + // Verify original fields are not present + expect(content[0].json).not.toHaveProperty('content'); + expect(content[0].json).not.toHaveProperty('metadata'); + + await runtime.close(); + }); + + it('returns unprojected result when no mapping configured', async () => { + const serverDef: ServerDefinition = { + name: 'test-server', + command: { + kind: 'http', + url: new URL('https://example.com/mcp'), + }, + // No resultMapping + }; + + const runtime = await createRuntime({ + servers: [serverDef], + }); + + const fullResponse = { + content: [ + { + type: 'json', + json: { + id: 'doc-1', + title: 'Test', + content: 'Full content', + }, + }, + ], + }; + + const mockClient = { + callTool: vi.fn().mockResolvedValue(fullResponse), + close: vi.fn().mockResolvedValue(undefined), + listTools: vi.fn(), + listResources: vi.fn(), + }; + + // @ts-expect-error - accessing private method for testing + runtime.connect = vi.fn().mockResolvedValue({ + client: mockClient, + transport: { close: vi.fn() }, + }); + + const result = await runtime.callTool('test-server', 'test_tool', { + args: {}, + }); + + // Should return the full unprojected response + expect(result).toEqual(fullResponse); + + await runtime.close(); + }); + + it('applies mapping to array results', async () => { + const serverDef: ServerDefinition = { + name: 'test-server', + command: { + kind: 'http', + url: new URL('https://example.com/mcp'), + }, + resultMapping: { + list_docs: { + pick: ['id', 'title'], + }, + }, + }; + + const runtime = await createRuntime({ + servers: [serverDef], + }); + + const mockClient = { + callTool: vi.fn().mockResolvedValue({ + content: [ + { + type: 'json', + json: [ + { id: '1', title: 'First', content: 'Long...' }, + { id: '2', title: 'Second', content: 'More...' }, + ], + }, + ], + }), + close: vi.fn().mockResolvedValue(undefined), + listTools: vi.fn(), + listResources: vi.fn(), + }; + + // @ts-expect-error - accessing private method for testing + runtime.connect = vi.fn().mockResolvedValue({ + client: mockClient, + transport: { close: vi.fn() }, + }); + + const result = await runtime.callTool('test-server', 'list_docs', {}); + + const content = (result as any).content[0]; + expect(content.json).toEqual([ + { id: '1', title: 'First' }, + { id: '2', title: 'Second' }, + ]); + + await runtime.close(); + }); + + it('preserves nested structure in projections', async () => { + const serverDef: ServerDefinition = { + name: 'test-server', + command: { + kind: 'http', + url: new URL('https://example.com/mcp'), + }, + resultMapping: { + get_user: { + pick: ['id', 'profile.email', 'profile.location.city'], + }, + }, + }; + + const runtime = await createRuntime({ + servers: [serverDef], + }); + + const mockClient = { + callTool: vi.fn().mockResolvedValue({ + content: [ + { + type: 'json', + json: { + id: 'user-1', + name: 'Alice', + profile: { + email: 'alice@example.com', + phone: '555-1234', + location: { + city: 'San Francisco', + country: 'USA', + zipcode: '94102', + }, + }, + }, + }, + ], + }), + close: vi.fn().mockResolvedValue(undefined), + listTools: vi.fn(), + listResources: vi.fn(), + }; + + // @ts-expect-error - accessing private method for testing + runtime.connect = vi.fn().mockResolvedValue({ + client: mockClient, + transport: { close: vi.fn() }, + }); + + const result = await runtime.callTool('test-server', 'get_user', {}); + + const content = (result as any).content[0]; + expect(content.json).toEqual({ + id: 'user-1', + profile: { + email: 'alice@example.com', + location: { + city: 'San Francisco', + }, + }, + }); + + // Verify fields not in pick list are excluded + expect(content.json).not.toHaveProperty('name'); + expect(content.json.profile).not.toHaveProperty('phone'); + expect(content.json.profile.location).not.toHaveProperty('country'); + expect(content.json.profile.location).not.toHaveProperty('zipcode'); + + await runtime.close(); + }); +});