Skip to content

Commit b9a898a

Browse files
authored
Merge pull request #2639 from modelcontextprotocol/v2/fix/2638-merge-review-findings
fix: v2.10.0 merge review findings — TUI error redaction, mcpdo daemon flush, CodeQL #78-#81
2 parents 617532f + b25bb58 commit b9a898a

11 files changed

Lines changed: 87 additions & 14 deletions

File tree

‎clients/cli/__tests__/completion.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
parseCompletionShell,
2222
registerCompletionOption,
2323
renderCompletion,
24+
renderZsh,
2425
type CompletionShell,
2526
} from "../src/completion.js";
2627
import { ONE_SHOT_METHODS } from "@inspector/core/cli/handlers/method-types.js";
@@ -185,6 +186,14 @@ describe("collectCompletionFlags", () => {
185186
});
186187

187188
describe("shell helpers", () => {
189+
it("escapes backslashes before colons in a zsh _describe name (CodeQL #78)", () => {
190+
const out = renderZsh([
191+
{ long: "--a\\b:c", takesValue: false, description: "Desc" },
192+
]);
193+
// Name --a\b:c → --a\\b\:c, then ":" and the description.
194+
expect(out).toContain("'--a\\\\b\\:c:Desc'");
195+
});
196+
188197
it("parseCompletionShell / isCompletionShell", () => {
189198
expect(isCompletionShell("zsh")).toBe(true);
190199
expect(isCompletionShell("csh")).toBe(false);

‎clients/cli/src/completion.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,9 +213,14 @@ complete -o default -F ${FUNCTION_NAME} ${COMPLETION_COMMAND}
213213
`;
214214
}
215215

216-
/** `name:description` for zsh `_describe`; colons in the name are escaped. */
216+
/**
217+
* `name:description` for zsh `_describe`. `_describe` reads `\` as an escape
218+
* and the first unescaped `:` as the separator, so backslashes in the name are
219+
* escaped first, then colons (CodeQL #78).
220+
*/
217221
function zshDescribeEntry(name: string, description: string): string {
218-
return shQuote(`${name.replace(/:/g, "\\:")}:${description}`);
222+
const escaped = name.replace(/\\/g, "\\\\").replace(/:/g, "\\:");
223+
return shQuote(`${escaped}:${description}`);
219224
}
220225

221226
export function renderZsh(flags: readonly CompletionFlag[]): string {

‎clients/mcpdo/__tests__/connection-stored-auth.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ describe("connection stored-auth helpers", () => {
144144
hasRefreshToken: true,
145145
});
146146
expect(
147-
list.servers.find((s) => s.url.includes("example.com")),
147+
list.servers.find((s) => s.url === "https://example.com/mcp"),
148148
).toMatchObject({ hasTokens: true, hasRefreshToken: true });
149149
expect(list.servers.find((s) => s.url.includes("other"))).toMatchObject({
150150
hasTokens: true,
@@ -190,7 +190,7 @@ describe("connection stored-auth helpers", () => {
190190

191191
const list = await listStoredAuth();
192192
expect(
193-
list.servers.find((s) => s.url.includes("example.com")),
193+
list.servers.find((s) => s.url === "https://example.com/mcp"),
194194
).toMatchObject({ hasTokens: true, hasRefreshToken: true });
195195
});
196196

‎clients/mcpdo/src/daemon/run.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { DaemonServer } from "./server.js";
77
import { generateDaemonToken, getDaemonTokenFromEnv } from "./auth.js";
88
import { ensureDaemonDir } from "./paths.js";
99
import { disallowMemorySecretStoreFallback } from "@inspector/core/auth/node/secret-store-selection.js";
10+
import { awaitableError } from "@inspector/core/cli/utils/awaitable-log.js";
1011

1112
// Name the process `mcpdod` (Unix d-suffix convention) so `ps`/`pgrep`/`pkill`
1213
// see the daemon under a greppable name instead of a bare `node .../mcpdod.js`.
@@ -44,8 +45,10 @@ async function main(): Promise<void> {
4445
await server.start();
4546
}
4647

47-
main().catch((error: unknown) => {
48+
main().catch(async (error: unknown) => {
4849
const message = error instanceof Error ? error.message : String(error);
49-
process.stderr.write(`mcpdo daemon: ${message}\n`);
50+
// Exit only once the write has been performed: on a pipe or file stderr is
51+
// asynchronous, and process.exit() would discard the diagnostic (#2638).
52+
await awaitableError(`mcpdo daemon: ${message}\n`);
5053
process.exit(1);
5154
});

‎clients/tui/__tests__/RootsModal.test.tsx‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ import type { Root } from "@modelcontextprotocol/client";
55
import type { InspectorClient } from "@inspector/core/mcp/index.js";
66

77
vi.mock("ink-form", () => import("./helpers/inkFormMock.js"));
8+
// Passthrough spy: the modal's frame is empty under ink-testing-library (see
9+
// below), so the redaction test asserts the error reached the display boundary.
10+
vi.mock("../src/utils/errorText.js", async (importOriginal) => {
11+
const actual =
12+
await importOriginal<typeof import("../src/utils/errorText.js")>();
13+
return { ...actual, errorMessage: vi.fn(actual.errorMessage) };
14+
});
15+
import * as errorText from "../src/utils/errorText.js";
816

917
import { RootsModal, rootFromForm } from "../src/components/RootsModal.js";
1018

@@ -156,6 +164,23 @@ describe("RootsModal", () => {
156164
expect(setRoots).toHaveBeenCalledTimes(1);
157165
});
158166

167+
it("shows a failed save through the redacting display boundary (#2638)", async () => {
168+
const failure = new Error(
169+
"Request failed: https://auth.example/cb?code=s3cret&state=ok",
170+
);
171+
const setRoots = vi.fn(async () => {
172+
throw failure;
173+
});
174+
const { stdin } = renderModal({ inspectorClient: fakeClient(setRoots) });
175+
await tick();
176+
stdin.write("x");
177+
await tick();
178+
expect(errorText.errorMessage).toHaveBeenCalledWith(failure);
179+
expect(vi.mocked(errorText.errorMessage).mock.results.at(-1)?.value).toBe(
180+
"Request failed: https://auth.example/cb?code=%5BREDACTED%5D&state=ok",
181+
);
182+
});
183+
159184
it("reports a non-Error failure", async () => {
160185
const setRoots = vi.fn(() => Promise.reject("nope"));
161186
const { stdin } = renderModal({ inspectorClient: fakeClient(setRoots) });

‎clients/tui/__tests__/SubscriptionsTab.test.tsx‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,22 @@ describe("SubscriptionsTab", () => {
155155
expect(lastFrame()).toContain("does not support resource subscriptions");
156156
});
157157

158+
it("redacts URL query secrets in a surfaced failure (#2638)", async () => {
159+
const client = fakeClient({
160+
subscribeToResource: vi.fn(async () => {
161+
throw new Error(
162+
"Request failed: https://auth.example/cb?code=s3cret&state=ok",
163+
);
164+
}),
165+
});
166+
const { stdin, lastFrame } = renderTab({ inspectorClient: client });
167+
stdin.write("\r");
168+
await tick();
169+
const frame = (lastFrame() ?? "").replace(/\s+/g, "");
170+
expect(frame).toContain("code=%5BREDACTED%5D&state=ok");
171+
expect(frame).not.toContain("s3cret");
172+
});
173+
158174
it("surfaces a non-Error failure", async () => {
159175
const client = fakeClient({
160176
subscribeToResource: vi.fn(() => Promise.reject("nope")),

‎clients/tui/__tests__/TasksTab.test.tsx‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,21 @@ describe("TasksTab", () => {
160160
expect(lastFrame()).toContain("list failed");
161161
});
162162

163+
it("redacts URL query secrets in a surfaced failure (#2638)", async () => {
164+
const { stdin, lastFrame } = renderTab({
165+
onRefresh: vi.fn(async () => {
166+
throw new Error(
167+
"Request failed: https://auth.example/cb?code=s3cret&state=ok",
168+
);
169+
}),
170+
});
171+
stdin.write("f");
172+
await tick();
173+
const frame = (lastFrame() ?? "").replace(/\s+/g, "");
174+
expect(frame).toContain("code=%5BREDACTED%5D&state=ok");
175+
expect(frame).not.toContain("s3cret");
176+
});
177+
163178
it("surfaces a non-Error failure with no task selected", async () => {
164179
const { stdin, lastFrame } = renderTab({
165180
tasks: [],

‎clients/tui/src/components/RootsModal.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { Form, type FormStructure } from "ink-form";
1818
import type { Root } from "@modelcontextprotocol/client";
1919
import type { InspectorClient } from "@inspector/core/mcp/index.js";
2020
import { useSelectableList } from "../hooks/useSelectableList.js";
21+
import { errorMessage } from "../utils/errorText.js";
2122

2223
export const ADD_ROOT_FORM: FormStructure = {
2324
title: "Add Root",
@@ -89,7 +90,7 @@ export function RootsModal({
8990
await inspectorClient.setRoots(next);
9091
setMode("list");
9192
} catch (err) {
92-
setError(err instanceof Error ? err.message : String(err));
93+
setError(errorMessage(err));
9394
} finally {
9495
savingRef.current = false;
9596
setSaving(false);

‎clients/tui/src/components/SubscriptionsTab.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
findNestedAuthError,
2929
} from "@inspector/core/auth/challenge.js";
3030
import { useSelectableList } from "../hooks/useSelectableList.js";
31+
import { errorMessage } from "../utils/errorText.js";
3132
import {
3233
resourceUpdateFeed,
3334
subscribableResources,
@@ -116,7 +117,7 @@ export function SubscriptionsTab({
116117
onAuthRecoveryRequired?.(authErr);
117118
return;
118119
}
119-
setError(err instanceof Error ? err.message : String(err));
120+
setError(errorMessage(err));
120121
} finally {
121122
inFlightRef.current = false;
122123
setPendingUri(null);

‎clients/tui/src/components/TasksTab.tsx‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type { CallToolResult, Task } from "@modelcontextprotocol/client";
2020
import type { InspectorClient } from "@inspector/core/mcp/index.js";
2121
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
2222
import { useSelectableList } from "../hooks/useSelectableList.js";
23+
import { errorMessage } from "../utils/errorText.js";
2324

2425
/** Glyph and color per task status; unknown statuses fall back to gray. */
2526
const STATUS_STYLE: Record<string, { glyph: string; color: string }> = {
@@ -47,10 +48,6 @@ export function hasTaskResult(status: string): boolean {
4748
return status === "completed" || status === "failed";
4849
}
4950

50-
function errorMessage(err: unknown): string {
51-
return err instanceof Error ? err.message : String(err);
52-
}
53-
5451
interface TasksTabProps {
5552
tasks: Task[];
5653
inspectorClient: InspectorClient | null;

0 commit comments

Comments
 (0)