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
7 changes: 7 additions & 0 deletions .changeset/bound-probe-teardown-redaction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"agent-bundle": patch
---

Keep timed-out MCP probes responsive when transport teardown stalls, while
continuing the transport close path that terminates stdio children. Redact
absolute paths that follow common key-value and list separators.
17 changes: 15 additions & 2 deletions packages/agent-bundle/src/dev/playground/mcp-probe-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export const mcpProbeFailureTextLimit = 2_048;

const mcpProbeCapabilityLimit = 32;
const mcpProbeNameTextLimit = 256;
const mcpProbeTeardownWaitMs = 50;
const safeCapabilityName = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/u;
const connectionErrorCodes = new Set([
'EACCES',
Expand Down Expand Up @@ -126,7 +127,7 @@ const bundlePathPattern = (bundleRoot: string): RegExp => {
};

const hasAbsolutePath = (value: string): boolean =>
/(?:file:|(?:^|[\s"'([{])\/[^\s,;{}()[\]<>"']+|(?:^|[\s"'([{])[A-Za-z]:[\\/]|\\\\)/u.test(value);
/(?:file:|(?:^|[\s"'([{=,:])\/[^\s,;{}()[\]<>"']+|(?:^|[\s"'([{=,:])[A-Za-z]:[\\/]|\\\\)/u.test(value);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid treating URL schemes as path separators

When instructions, tool metadata, server metadata, or an error contains a normal URL such as https://example.com/docs, allowing : as the prefix causes this regex to match ://example.com/docs; redactProbeText then replaces the entire field with [REDACTED] even though it contains no local path. This hides common MCP documentation and link guidance, so exclude URI scheme delimiters before interpreting a colon as a path separator.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #397 (merged as d25a9c6). redactProbeText no longer treats the :// of a URI scheme as a path separator, so https://… links survive, while userinfo (scheme://user:secret@host) is masked through the final @, non-network schemes (unix://, vscode://file/, postgres://…/…, file:) still fail closed, and URIs glued to a preceding identifier are handled. tests/mcp-probe-service.test.ts covers URLs beside real bundle paths plus the userinfo and local-URI cases.


/**
* Probe text follows the Dev Log browser-wire precedent without coupling this
Expand Down Expand Up @@ -465,7 +466,19 @@ export class McpProbeService {
});
return report;
} finally {
await Promise.allSettled([client.close(), transport.close()]);
let timer: NodeJS.Timeout | undefined;
// Keep transport teardown running through its TERM/KILL path without
// allowing a stalled close to extend the probe's total time budget.
const teardown = Promise.allSettled([client.close(), transport.close()]);
const teardownWait = new Promise<void>((resolvePromise) => {
timer = setTimeout(resolvePromise, mcpProbeTeardownWaitMs);
timer.unref();
});
try {
await Promise.race([teardown, teardownWait]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep plugin-data cleanup behind transport teardown

When a stdio server uses the resolved plugin-data directory and its close takes longer than 50 ms, this race lets #execute return while that child still has the directory open, after which #run immediately removes it. On Windows, a server holding a database or another non-delete-shared file can make that rm reject, turning the intended timed-out report into the route's generic 502; on other platforms the directory can disappear while shutdown is still using it. The detached close path should retain responsibility for removing plugin data after teardown instead of allowing the outer cleanup to run at the 50 ms response boundary.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #397 (merged as d25a9c6). Plugin-data removal in mcp-probe-service.ts is now chained after the transport teardown settles (bounded by a 10 s cap, with one fenced retry when an early removal fails because the child still holds the directory), the timeout path reuses the single memoized close() rather than issuing a second one, a synchronously throwing close() no longer skips cleanup, and settle() / Workbench server.close() join in-flight probes and detached cleanups. Proven by the slow-teardown, settle, and dev-server tests in tests/mcp-probe-service.test.ts and tests/mcp-probe-dev-server.test.ts.

} finally {
if (timer !== undefined) clearTimeout(timer);
}
}
}

Expand Down
77 changes: 77 additions & 0 deletions packages/agent-bundle/tests/mcp-probe-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,41 @@ it('maps a bounded, frozen successful probe snapshot and redacted launch', async
}
});

it('redacts absolute paths after key-value and list separators', async () => {
const root = await createBundle();
try {
const service = serviceFor(root, {
createClient: () => client({
getInstructions: () => 'config=/home/alice/private.json',
listTools: async () => ({
tools: [
{
description: String.raw`cwd:C:\Users\alice\private`,
inputSchema: { type: 'object' as const },
name: 'windows-path',
},
{
description: 'paths,/var/private/config.json',
inputSchema: { type: 'object' as const },
name: 'list-path',
},
],
}),
}),
});

const report = await service.probe({ host: 'claude', serverName: 'timeline' });

expect(report.snapshot?.instructions).toBe('[REDACTED]');
expect(report.snapshot?.tools.map((tool) => tool.description)).toEqual([
'[REDACTED]',
'[REDACTED]',
]);
} finally {
await rm(root, { force: true, recursive: true });
}
});

it('truncates server instructions to the named text budget', async () => {
const root = await createBundle();
try {
Expand Down Expand Up @@ -194,6 +229,48 @@ it('times out within the total budget and destroys the transport', async () => {
}
});

it('returns a timed-out report without awaiting stalled teardown', async () => {
const root = await createBundle();
let clientCloses = 0;
let transportCloses = 0;
let guard: NodeJS.Timeout | undefined;
try {
const stalledClose = async (): Promise<void> =>
new Promise((resolvePromise) => setTimeout(resolvePromise, 250));
const service = serviceFor(root, {
createClient: () => client({
close: async () => {
clientCloses += 1;
await stalledClose();
},
connect: () => new Promise(() => undefined),
}),
createStdioTransport: () => transport(async () => {
transportCloses += 1;
await stalledClose();
}),
timeoutMs: 10,
});

const report = await Promise.race([
service.probe({ host: 'claude', serverName: 'timeline' }),
new Promise<never>((_resolve, reject) => {
guard = setTimeout(
() => reject(new Error('The timed-out probe remained blocked on teardown.')),
150,
);
}),
]);

expect(report.status).toBe('timed-out');
expect(clientCloses).toBe(1);
expect(transportCloses).toBeGreaterThan(0);
} finally {
if (guard !== undefined) clearTimeout(guard);
await rm(root, { force: true, recursive: true });
}
});

it('coalesces only identical in-flight probes and clears them after settlement', async () => {
const root = await createBundle();
const connectStarted = Promise.withResolvers<void>();
Expand Down
Loading