Skip to content
Merged
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
10 changes: 2 additions & 8 deletions src/exec/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
registerSourceCredential,
} from "../config/source-credentials.js";
import { formatDirectorSystemPrompt } from "../agent/directors/identity.js";
import { formatMcpTrustQuestion } from "../trust/project-trust.js";
import { DIRECTOR_REGISTRY } from "../agent/directors/registry.js";
import type { DirectorId, DirectorPackage } from "../agent/directors/types.js";
import { submitOutputDefinition } from "../agent/director.js";
Expand Down Expand Up @@ -165,14 +166,7 @@ const logger = getLogger([LOG_NAMESPACE_ROOT, "exec"]);
const SELECTED_PROVIDER_FAILURE = "SelectedProviderFailure";

export function formatExecMcpTrustQuestion(server: MCPServerConfig): string {
return (
`Trust local MCP server "${server.name}" for this project?` +
(server.command !== undefined
? `\nCommand: ${server.command}${(server.args ?? []).length > 0 ? ` ${(server.args ?? []).join(" ")}` : ""}`
: server.url !== undefined
? `\nURL: ${server.url}`
: "")
);
return formatMcpTrustQuestion(server);
}

export async function refreshSelectedProviderCredential<T>(
Expand Down
8 changes: 1 addition & 7 deletions src/mcp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { normalizeMCPServerURL } from "./auth-store.js";
import type { ResolvedMCPServerConfig } from "./exa.js";
import type { McpToolAnnotations } from "./tool-permissions.js";
import { buildStdioMcpProcessEnv } from "./stdio-env.js";
import { isHttpServer } from "./is-http-server.js";
import { MCP_CLIENT_NAME } from "../branding.js";

export interface MCPTool {
Expand Down Expand Up @@ -95,13 +96,6 @@ export interface MCPConnectOptions {
onDisconnect?: () => void;
}

function isHttpServer(config: ResolvedMCPServerConfig): boolean {
return (
config.type === "http" ||
(config.type === undefined && config.url !== undefined)
);
}

export function unwrapToolContent(content: unknown): string {
if (!Array.isArray(content) || content.length === 0) return "";
return content
Expand Down
31 changes: 31 additions & 0 deletions src/mcp/is-http-server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, test } from "bun:test";
import { isHttpServer } from "./is-http-server.js";

describe("isHttpServer", () => {
test("HTTP wins when type is unset and url is set, even with command", () => {
const config = { command: "run", url: "https://mcp.example.test" };
expect(isHttpServer(config)).toBe(true);
});

test("type http wins even when command is also set", () => {
const config = {
type: "http" as const,
command: "run",
url: "https://mcp.example.test",
};
expect(isHttpServer(config)).toBe(true);
});

test("type stdio is not HTTP even when url is set", () => {
expect(
isHttpServer({
type: "stdio",
url: "https://mcp.example.test",
}),
).toBe(false);
});

test("unset type and url is not HTTP", () => {
expect(isHttpServer({})).toBe(false);
});
});
15 changes: 15 additions & 0 deletions src/mcp/is-http-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* Connect-path transport: HTTP wins when `type` is `"http"`, or when `type` is
* unset and `url` is present — even if `command` is also set. Trust-prompt
* display must use this same predicate so the operator grants the identity
* that `connectMCPServer` will actually open.
*/
export function isHttpServer(config: {
type?: "stdio" | "http";
url?: string;
}): boolean {
return (
config.type === "http" ||
(config.type === undefined && config.url !== undefined)
);
}
67 changes: 67 additions & 0 deletions src/trust/project-trust.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { type } from "arktype";
import { getLogger } from "@intx/log";
import type { MCPServerConfig } from "../config/settings.js";
import { isBuiltinExaMCPServer } from "../mcp/exa.js";
import { isHttpServer } from "../mcp/is-http-server.js";
import { LOG_NAMESPACE_ROOT, SETTINGS_DIR_NAME } from "../branding.js";

const logger = getLogger([LOG_NAMESPACE_ROOT, "trust"]);
Expand Down Expand Up @@ -298,6 +299,72 @@ export function mcpServerFingerprint(server: MCPServerConfig): string {
return createHash("sha256").update(payload).digest("hex");
}

// Display-only quoting for the MCP trust prompt: argv, name, and url all pass
// through the same escape so a newline, quote, or Unicode/C1 line break cannot
// spoof extra prompt lines. An arg containing whitespace (or a quote, or empty)
// renders double-quoted so ["a b"] and ["a", "b"] never look alike. Approval
// identity still comes from mcpServerFingerprint above, never from this rendering.
function isTrustPromptControlChar(code: number): boolean {
return (
code <= 0x1f ||
code === 0x7f ||
(code >= 0x80 && code <= 0x9f) ||
code === 0x2028 ||
code === 0x2029
);
}

function escapeMcpTrustText(value: string): string {
const named = value
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r")
.replace(/\t/g, "\\t");
let escaped = "";
for (const ch of named) {
const code = ch.charCodeAt(0);
if (!isTrustPromptControlChar(code)) {
escaped += ch;
continue;
}
escaped +=
code <= 0xff
? `\\x${code.toString(16).toUpperCase().padStart(2, "0")}`
: `\\u${code.toString(16).toUpperCase().padStart(4, "0")}`;
}
return escaped;
}

function quoteMcpTrustArg(arg: string): string {
const needsQuotes =
arg === "" ||
/[\s"]/.test(arg) ||
[...arg].some((ch) => isTrustPromptControlChar(ch.charCodeAt(0)));
if (!needsQuotes) return arg;
return `"${escapeMcpTrustText(arg)}"`;
}

function formatMcpSpawnCommand(command: string, args: string[]): string {
const head = quoteMcpTrustArg(command);
return args.length === 0
? head
: `${head} ${args.map(quoteMcpTrustArg).join(" ")}`;
}

export function formatMcpTrustQuestion(server: MCPServerConfig): string {
const header = `Trust local MCP server "${escapeMcpTrustText(server.name)}" for this project?`;
if (isHttpServer(server)) {
return server.url !== undefined
? `${header}\nURL: ${quoteMcpTrustArg(server.url)}`
: header;
}
if (server.command !== undefined) {
return `${header}\nCommand: ${formatMcpSpawnCommand(server.command, server.args ?? [])}`;
}
return header;
}

export function isMcpServerTrusted(
store: ProjectTrustStore,
server: MCPServerConfig,
Expand Down
14 changes: 7 additions & 7 deletions src/tui/runner/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { EventEmitter } from "node:events";
import {
shellTimeoutFromSettings,
toolWatchdogFromSettings,
type MCPServerConfig,
} from "../../config/settings.js";
import { isCodexProviderName } from "../../config/codex-providers.js";
import { peekSourceCredentialSecret } from "../../config/source-credentials.js";
Expand Down Expand Up @@ -126,6 +127,11 @@ import {
} from "./state.js";
import { createParkedOverlayAbortBinding } from "./parked-overlay-abort.js";
import { createTUISettingsWriters } from "./settings-writers.js";
import { formatMcpTrustQuestion } from "../../trust/project-trust.js";

export function formatTuiMcpTrustQuestion(server: MCPServerConfig): string {
return formatMcpTrustQuestion(server);
}

export async function assembleTUISession(
state: RunnerState,
Expand Down Expand Up @@ -406,13 +412,7 @@ export async function assembleTUISession(
const timeout = approvalTimeout();
const event: OperatorGateEvent = {
id: randomUUID(),
question:
`Trust local MCP server "${server.name}" for this project?` +
(server.command !== undefined
? `\nCommand: ${server.command}${(server.args ?? []).length > 0 ? ` ${(server.args ?? []).join(" ")}` : ""}`
: server.url !== undefined
? `\nURL: ${server.url}`
: ""),
question: formatTuiMcpTrustQuestion(server),
options: ["Trust and connect", "Deny"],
resolve: finish,
...(timeout !== undefined ? timeout : {}),
Expand Down
51 changes: 51 additions & 0 deletions tests/unit/exec/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,57 @@ describe("exec MCP trust prompt", () => {
});
});

describe("exec MCP trust prompt argv boundaries", () => {
test("quotes an arg containing whitespace", () => {
expect(
formatExecMcpTrustQuestion({
name: "notes",
command: "server",
args: ["--dir", "/tmp/my work"],
}),
).toBe(
'Trust local MCP server "notes" for this project?\nCommand: server --dir "/tmp/my work"',
);
});

test("renders one spaced arg distinctly from two args", () => {
const one = formatExecMcpTrustQuestion({
name: "s",
command: "run",
args: ["a b"],
});
const two = formatExecMcpTrustQuestion({
name: "s",
command: "run",
args: ["a", "b"],
});
expect(one).toContain('"a b"');
expect(one).not.toBe(two);
});

test("quotes empty args so they stay visible", () => {
const question = formatExecMcpTrustQuestion({
name: "s",
command: "run",
args: [""],
});
expect(question).toContain('""');
expect(question).not.toBe(
formatExecMcpTrustQuestion({ name: "s", command: "run", args: [] }),
);
});

test("escapes quotes inside a quoted arg", () => {
expect(
formatExecMcpTrustQuestion({
name: "s",
command: "run",
args: ['say "hi"'],
}),
).toContain('"say \\"hi\\""');
});
});

describe("formatCaughtError", () => {
test("prefers Error.message and stringifies other values", () => {
expect(formatCaughtError(new Error("disk full"))).toBe("disk full");
Expand Down
Loading
Loading