Skip to content

Commit 55d111d

Browse files
committed
feat(cli): warn when print mode skips untrusted project MCP servers
1 parent 6559142 commit 55d111d

3 files changed

Lines changed: 84 additions & 0 deletions

File tree

.changeset/warn-trust-gated-mcp.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": patch
3+
---
4+
5+
Warn in print mode when an untrusted folder skips project-level MCP servers.

apps/pythinker-code/src/cli/v2/run-v2-print.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ import {
3030
IBootstrapService,
3131
IConfigService,
3232
IEventBus,
33+
IHostFileSystem,
3334
ISessionIndex,
35+
IWorkspaceInstanceManager,
3436
ISessionManager,
3537
ITelemetryService,
3638
PRINT_MAX_TURNS_DEFAULT,
@@ -54,8 +56,13 @@ import {
5456
type ISessionScopeHandle,
5557
type LoopRunResult,
5658
type PrintBackgroundMode,
59+
type McpServerConfig,
5760
type Scope,
5861
} from '@pymodel/agent-core-v2';
62+
import {
63+
loadMcpServersDetailed,
64+
resolveMcpJsonPaths,
65+
} from '@pymodel/agent-core-v2/app/mcpConfig/configLoader';
5966
import { createPythinkerDefaultHeaders, createPythinkerDeviceId } from '@pymodel/pythinker-code-oauth';
6067
import type { GoalUpdated } from '@pymodel/agent-core-v2/features/goal/goalOps';
6168
import type { TurnEnded } from '@pymodel/agent-core-v2/agent/loop/turnOps';
@@ -217,6 +224,13 @@ export async function runV2Print(
217224
);
218225
}
219226

227+
try {
228+
const gated = await listTrustGatedMcpServers(app, workDir, homeDir);
229+
if (gated.length > 0) stderr.write(formatTrustGatedMcpWarning(gated));
230+
} catch {
231+
// Best-effort: a broken mcp.json or trust store must not fail the run.
232+
}
233+
220234
const resolved = await resolveNativeSession(app, opts, workDir, defaultModel, stderr);
221235
restorePermission = resolved.restorePermission;
222236

@@ -266,6 +280,49 @@ interface ResolvedNativeSession {
266280
readonly goalModel: string | undefined;
267281
}
268282

283+
export interface TrustGatedMcpServer {
284+
readonly name: string;
285+
readonly target: string;
286+
}
287+
288+
export async function listTrustGatedMcpServers(
289+
app: Scope,
290+
workDir: string,
291+
homeDir: string,
292+
): Promise<readonly TrustGatedMcpServer[]> {
293+
const workspace = await app.accessor
294+
.get(IWorkspaceInstanceManager)
295+
.getOrCreate({ root: workDir });
296+
if (await workspace.program.trust.get()) return [];
297+
const fs = app.accessor.get(IHostFileSystem);
298+
const [paths, loaded] = await Promise.all([
299+
resolveMcpJsonPaths({ fs, cwd: workDir, homeDir }),
300+
loadMcpServersDetailed({ fs, cwd: workDir, homeDir, includeProject: true }),
301+
]);
302+
const projectPaths = new Set([paths.projectRoot, paths.project]);
303+
return Object.entries(loaded.servers)
304+
.filter(([name]) => projectPaths.has(loaded.origins[name] ?? ''))
305+
.map(([name, config]) => ({ name, target: describeMcpTarget(config) }))
306+
.toSorted((a, b) => a.name.localeCompare(b.name));
307+
}
308+
309+
export function formatTrustGatedMcpWarning(servers: readonly TrustGatedMcpServer[]): string {
310+
const noun = servers.length === 1 ? 'server' : 'servers';
311+
const list = servers.map((server) => `${server.name} (${server.target})`).join(', ');
312+
return (
313+
`Warning: this folder is not trusted; skipped ${servers.length} project-level MCP ${noun}: ${list}.\n` +
314+
' Run `pythinker` here and choose "Trust this folder" to enable them.\n\n'
315+
);
316+
}
317+
318+
function describeMcpTarget(config: McpServerConfig): string {
319+
if (config.transport === 'stdio') {
320+
const args = config.args === undefined ? '' : ` ${config.args.join(' ')}`;
321+
return `stdio: ${config.command}${args}`;
322+
}
323+
return `${config.transport}: ${config.url}`;
324+
}
325+
269326
async function resolveNativeSession(
270327
app: Scope,
271328
opts: CLIOptions,

apps/pythinker-code/test/cli/run-v2-print.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import { describe, expect, it, vi } from 'vitest';
44
import {
55
applyPrintBackgroundPolicy,
66
createPrintTurnEndings,
7+
formatTrustGatedMcpWarning,
78
PrintSteeredTurnFailedError,
89
type PrintTurnEnding,
910
type PrintTurnEndings,
11+
type TrustGatedMcpServer,
1012
} from '#/cli/v2/run-v2-print';
1113

1214
function ending(
@@ -502,3 +504,23 @@ describe('createPrintTurnEndings', () => {
502504
await expect(pending).resolves.toMatchObject({ turnId: 7 });
503505
});
504506
});
507+
508+
describe('formatTrustGatedMcpWarning', () => {
509+
it('singularizes the noun for one skipped server', () => {
510+
const text = formatTrustGatedMcpWarning([
511+
{ name: 'fs', target: 'stdio: node server.js' },
512+
]);
513+
expect(text).toContain('skipped 1 project-level MCP server: fs (stdio: node server.js).');
514+
expect(text).toContain('"Trust this folder"');
515+
});
516+
517+
it('pluralizes and joins multiple skipped servers', () => {
518+
const servers: readonly TrustGatedMcpServer[] = [
519+
{ name: 'api', target: 'http: https://example.test/mcp' },
520+
{ name: 'fs', target: 'stdio: node server.js' },
521+
];
522+
const text = formatTrustGatedMcpWarning(servers);
523+
expect(text).toContain('skipped 2 project-level MCP servers:');
524+
expect(text).toContain('api (http: https://example.test/mcp), fs (stdio: node server.js)');
525+
});
526+
});

0 commit comments

Comments
 (0)