Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,35 @@ Drop down to `runtime.callTool()` whenever you need explicit control over argume

Call `mcporter list <server>` 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 <server>`.
* `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:
Expand Down Expand Up @@ -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"]
Expand All @@ -368,6 +407,7 @@ What MCPorter handles for you:
- Automatic OAuth token caching under `~/.mcporter/<server>/` 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.

Expand Down
3 changes: 3 additions & 0 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ A quick reference for the primary `mcporter` subcommands. Each command inherits
- `--server`, `--tool` – alternate way to target a tool.
- `--timeout <ms>` – 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`
Expand Down
2 changes: 2 additions & 0 deletions src/config-normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -60,6 +61,7 @@ export function normalizeServerEntry(
sources,
lifecycle,
logging,
resultMapping,
};
}

Expand Down
8 changes: 8 additions & 0 deletions src/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ const RawLoggingSchema = z
})
.optional();

const ToolResultMappingSchema = z.object({
pick: z.array(z.string()).optional(),
});

export type ToolResultMapping = z.infer<typeof ToolResultMappingSchema>;

export const RawEntrySchema = z.object({
description: z.string().optional(),
baseUrl: z.string().optional(),
Expand All @@ -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({
Expand Down Expand Up @@ -127,6 +134,7 @@ export interface ServerDefinition {
readonly sources?: readonly ServerSource[];
readonly lifecycle?: ServerLifecycle;
readonly logging?: ServerLoggingOptions;
readonly resultMapping?: Record<string, ToolResultMapping>;
}

export interface LoadConfigOptions {
Expand Down
67 changes: 66 additions & 1 deletion src/result-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export interface CallResult<T = unknown> {
json<J = unknown>(): J | null;
content(): unknown[] | null;
structuredContent(): unknown;
pick<J = unknown>(paths: string | readonly string[]): J | null;
withJsonOverride<J = unknown>(nextJson: J): CallResult<T>;
}

// extractContentArray pulls the `content` array from MCP response envelopes.
Expand Down Expand Up @@ -113,7 +115,9 @@ function tryParseJson(value: unknown): unknown {
}

// createCallResult wraps a tool response with helpers for common content types.
export function createCallResult<T = unknown>(raw: T): CallResult<T> {
export function createCallResult<T = unknown>(raw: T, options?: { jsonOverride?: unknown }): CallResult<T> {
const jsonOverride = options?.jsonOverride;

return {
raw,
text(joiner = '\n') {
Expand Down Expand Up @@ -161,6 +165,10 @@ export function createCallResult<T = unknown>(raw: T): CallResult<T> {
.join(joiner);
},
json<J = unknown>() {
if (jsonOverride !== undefined) {
return jsonOverride as J;
}

const structured = extractStructuredContent(raw);
const parsedStructured = tryParseJson(structured);
if (parsedStructured !== null) {
Expand Down Expand Up @@ -229,6 +237,63 @@ export function createCallResult<T = unknown>(raw: T): CallResult<T> {
structuredContent() {
return extractStructuredContent(raw);
},
pick<J = unknown>(paths: string | readonly string[]): J | null {
const data = this.json<any>();
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<string, unknown> = {};

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<string, unknown>;
}
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<J = unknown>(nextJson: J): CallResult<T> {
return createCallResult(raw, { jsonOverride: nextJson });
},
};
}

Expand Down
42 changes: 39 additions & 3 deletions src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -192,10 +194,35 @@ 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;
}

// Create CallResult, apply projection, and modify the raw envelope to contain projected data
const base = createCallResult(rawResult);
const projected = base.pick(mapping.pick);

// 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;
}
}
}
}
return await raceWithTimeout(resultPromise, timeoutMs);

// 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.
Expand Down Expand Up @@ -298,6 +325,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.
Expand Down
Loading
Loading