Skip to content

Commit 9b5cdd8

Browse files
authored
fix(daemon): move connected-MCP directive out of the cached system prompt (#5336)
* fix(daemon): move connected-MCP directive out of the cached system prompt The '## External MCP servers — already authenticated' directive was composed into daemonSystemPrompt, part of the cacheable system prefix sent upstream. Its membership is driven by live OAuth Bearer validity (isTokenExpired), so a token expiring/refreshing/reconnecting mid- conversation changes the directive -> changes the system prompt bytes -> invalidates the whole downstream prompt-cache prefix, including the conversation-history cache AMR/Claude-on-Bedrock relies on. Production telemetry (stable_prompt_cache_miss_reason='stable-prompt-changed') attributes the bulk of resume-turn prefix drift to this directive, rising with conversation length as more tokens cross expiry. Move it from composeSystemPrompt into the per-turn client instruction slice (alongside runContext/cwd hints): re-sent uncached every turn, landing after the cached system+history prefix. The model still sees the current MCP auth state each turn (more accurately -- live state, not the seed snapshot), while the cacheable prefix stays byte-stable across resumes. renderConnectedExternalMcpDirective is exported and its leading separator dropped so it composes cleanly in the joined slice; composeSystemPrompt no longer takes connectedExternalMcp. * test(daemon): e2e red-spec for the connected-MCP directive moving to the per-turn slice Drives a real two-turn codex session (native resume) with a connected external MCP server (enabled config + live OAuth Bearer) through the daemon HTTP boundary, capturing the exact prompt each turn reaches the agent's stdin. Asserts the '## External MCP servers — already authenticated' directive is present on turn 1 AND re-sent on the turn-2 clean resume — which only holds once the directive rides in the per-turn instruction slice instead of the cached stable block. Verified red on origin/main (turn-2 stdin lacks the directive because the cached block is not re-sent on a clean resume) and green with the fix. * test(daemon): keep the MCP-directive red-spec hermetic The new test writes an authenticated github MCP server into the process-wide OD_DATA_DIR (tests/setup.ts shares one data root for the whole daemon Vitest run) and the shared afterEach never reset it, so the connected-MCP state could leak into later test files and make them suite-order dependent. Reset the MCP config (empty servers) and clear the github token in afterEach — both idempotent no-ops for the other tests in this file. * test(daemon): let MCP teardown failures fail fast Drop the unconditional .catch(() => {}) around the afterEach MCP reset. writeMcpConfig always overwrites to an empty server list and clearToken is a documented no-op when the entry is absent, so neither needs the catch — and swallowing a real teardown failure would silently let MCP state leak into later tests, defeating the isolation guarantee.
1 parent 81b20dc commit 9b5cdd8

4 files changed

Lines changed: 149 additions & 64 deletions

File tree

apps/daemon/src/prompts/system.ts

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -520,16 +520,6 @@ export interface ComposeInput {
520520
// Skill identifier. Required when critique is enabled;
521521
// ignored when critique is disabled or omitted.
522522
critiqueSkill?: { id: string } | undefined;
523-
// External MCP servers the daemon already holds a valid OAuth Bearer
524-
// token for at spawn time. We surface the list to the model so it does
525-
// NOT chase Claude Code's synthetic `*_authenticate` /
526-
// `*_complete_authentication` tools that get injected when the HTTP
527-
// transport's first connect transiently flips a server into
528-
// needs-auth state — the Bearer is in `.mcp.json`, the real tools are
529-
// available, and burning a turn on a redundant OAuth dance just
530-
// confuses the user.
531-
connectedExternalMcp?: ReadonlyArray<{ id: string; label?: string | undefined }>
532-
| undefined;
533523
// Optional `## Active plugin` / `## Plugin inputs` block. The daemon's
534524
// plugin module renders this from an AppliedPluginSnapshot; we splice
535525
// it in after the active skill so the plugin description sits next to
@@ -595,7 +585,6 @@ export function composeSystemPrompt({
595585
critique,
596586
critiqueBrand,
597587
critiqueSkill,
598-
connectedExternalMcp,
599588
pluginBlock,
600589
activeStageBlocks,
601590
streamFormat,
@@ -940,9 +929,6 @@ export function composeSystemPrompt({
940929
parts.push(ACTIVE_DESIGN_SYSTEM_VISUAL_DIRECTION_OVERRIDE);
941930
}
942931

943-
const mcpDirective = renderConnectedExternalMcpDirective(connectedExternalMcp);
944-
if (mcpDirective) parts.push(mcpDirective);
945-
946932
if (resolvedExecutionProfile === 'filesystem') {
947933
parts.push(FILESYSTEM_HANDOFF_OVERRIDE);
948934
}
@@ -1072,7 +1058,7 @@ If this is a plain API run where filesystem tools are unavailable, output the sa
10721058
// `*_authenticate` / `*_complete_authentication` tool for them. If
10731059
// the real tools really are missing, surface that as a separate
10741060
// failure instead of pivoting to the synthetic flow.
1075-
function renderConnectedExternalMcpDirective(
1061+
export function renderConnectedExternalMcpDirective(
10761062
connectedExternalMcp:
10771063
| ReadonlyArray<{ id: string; label?: string | undefined }>
10781064
| undefined,
@@ -1087,8 +1073,8 @@ function renderConnectedExternalMcpDirective(
10871073
})
10881074
.filter((line): line is string => typeof line === 'string');
10891075
if (lines.length === 0) return '';
1076+
// No leading separator: callers place this in a `---`-joined slice.
10901077
return [
1091-
'\n\n---\n\n',
10921078
'## External MCP servers — already authenticated\n\n',
10931079
'The following external MCP servers are already authenticated for this run via an OAuth Bearer token the daemon injected into `.mcp.json`. You can call their real tools directly:\n\n',
10941080
lines.join('\n'),

apps/daemon/src/server.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import net from 'node:net';
2020
import { executionProfileFromStreamFormat, PLUGIN_SHARE_ACTION_PLUGIN_IDS } from '@open-design/contracts';
2121
import {
2222
composeSystemPrompt,
23+
renderConnectedExternalMcpDirective,
2324
resolveExclusiveSurface,
2425
} from './prompts/system.js';
2526
import { emittedRenderableQuestionForm } from './question-form-detect.js';
@@ -3373,7 +3374,6 @@ export async function startServer({
33733374
streamFormat,
33743375
locale,
33753376
sessionMode,
3376-
connectedExternalMcp,
33773377
appliedPluginSnapshotId,
33783378
mediaExecution,
33793379
byokMediaDefaults,
@@ -3889,9 +3889,6 @@ export async function startServer({
38893889
byokMediaDefaults,
38903890
streamFormat,
38913891
executionProfile: executionProfileFromStreamFormat(streamFormat),
3892-
connectedExternalMcp: Array.isArray(connectedExternalMcp)
3893-
? connectedExternalMcp
3894-
: undefined,
38953892
...(pluginBlock ? { pluginBlock } : {}),
38963893
...(activeStageBlocks ? { activeStageBlocks } : {}),
38973894
userInstructions,
@@ -4385,7 +4382,6 @@ export async function startServer({
43854382
streamFormat: def?.streamFormat ?? 'plain',
43864383
locale,
43874384
sessionMode: runSessionMode,
4388-
connectedExternalMcp,
43894385
mediaExecution: run?.mediaExecution,
43904386
byokMediaDefaults,
43914387
// Plan §3.M2 / §3.V1 — forward the run's snapshot id so the
@@ -4698,9 +4694,16 @@ export async function startServer({
46984694
'Do not mention this title task to the user. Continue with the normal answer after the title marker.',
46994695
].join('\n')
47004696
: '';
4697+
// The connected-external-MCP directive reflects live OAuth token state,
4698+
// which flips mid-conversation as Bearers expire/refresh. Keeping it out of
4699+
// the cached stable prefix (daemonSystemPrompt) and re-sending it here in
4700+
// the per-turn slice keeps the upstream prompt-cache prefix byte-stable
4701+
// across resumes (protecting the conversation-history cache) while still
4702+
// giving the model the current MCP auth state on every turn.
4703+
const mcpConnectedDirective = renderConnectedExternalMcpDirective(connectedExternalMcp);
47014704
const clientInstructionParts = includeStableInstructions
4702-
? [researchCommandContract, runContextPrompt, browserUsePromptGuard, titleGenerationPrompt, systemPrompt]
4703-
: [researchCommandContract, runContextPrompt, browserUsePromptGuard, titleGenerationPrompt];
4705+
? [researchCommandContract, runContextPrompt, mcpConnectedDirective, browserUsePromptGuard, titleGenerationPrompt, systemPrompt]
4706+
: [researchCommandContract, runContextPrompt, mcpConnectedDirective, browserUsePromptGuard, titleGenerationPrompt];
47044707
const clientInstructionPrompt = clientInstructionParts
47054708
.map((part) => (typeof part === 'string' ? part.trim() : ''))
47064709
.filter(Boolean)

apps/daemon/tests/codex-session-resume.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import path from 'node:path';
66
import { afterEach, describe, expect, it } from 'vitest';
77

88
import { startServer } from '../src/server.js';
9+
import { writeMcpConfig } from '../src/mcp-config.js';
10+
import { clearToken, setToken } from '../src/mcp-tokens.js';
911

1012
// End-to-end coverage for codex native (capture-style) session resume.
1113
//
@@ -59,6 +61,19 @@ describe('codex native session resume', () => {
5961
if (binDir) await removeTempDir(binDir);
6062
binDir = null;
6163
restoreEnv(originalEnv);
64+
// The MCP-directive red-spec below writes an authenticated `github` server
65+
// into the process-wide OD_DATA_DIR (tests/setup.ts shares one root for the
66+
// whole daemon Vitest run). Reset it after every test so that state cannot
67+
// leak into later test files and make them suite-order dependent.
68+
// `writeMcpConfig` unconditionally overwrites to an empty server list and
69+
// `clearToken` is a documented no-op when the entry is absent, so neither
70+
// needs to tolerate "nothing was written" — let a real teardown failure
71+
// surface instead of silently degrading the isolation guarantee.
72+
const dataDir = process.env.OD_DATA_DIR;
73+
if (dataDir) {
74+
await writeMcpConfig(dataDir, { servers: [] });
75+
await clearToken(dataDir, 'github');
76+
}
6277
});
6378

6479
it('captures the thread id on turn 1 and resumes it (without resending history) on turn 2', async () => {
@@ -224,6 +239,80 @@ describe('codex native session resume', () => {
224239
expect(afterIntervening.argv).not.toContain('resume');
225240
expect(afterIntervening.argv[0]).toBe('exec');
226241
});
242+
243+
// Guards the fix that moved the connected-external-MCP directive out of the
244+
// cached `daemonSystemPrompt` and into the per-turn instruction slice. The
245+
// directive reflects live OAuth Bearer validity, so keeping it in the cached
246+
// prefix churned the whole prompt-cache prefix (history included) whenever a
247+
// token expired mid-conversation. Now it must ride in the per-turn slice, i.e.
248+
// be re-sent on EVERY turn — including a clean resume, which never re-sends the
249+
// cached stable block. On origin/main the directive lived in the stable block,
250+
// so a clean resume dropped it and the turn-2 assertion below goes red.
251+
it('re-sends the connected-MCP directive in the per-turn slice on resume turns', async () => {
252+
binDir = await mkdtemp(path.join(os.tmpdir(), 'od-codex-mcp-bin-'));
253+
const { bin, logPath } = await writeCapturingCodex(binDir, 'codex-mcp');
254+
255+
clearTelemetryEnv();
256+
started = (await startServer({ port: 0, returnServer: true })) as StartedServer;
257+
await putConfig(started.url, {
258+
agentId: 'codex',
259+
agentCliEnv: { codex: { CODEX_BIN: bin } },
260+
telemetry: { metrics: true, content: false, artifactManifest: false },
261+
privacyDecisionAt: Date.now(),
262+
});
263+
264+
// A connected external MCP server = enabled config + a live (non-expired)
265+
// OAuth Bearer. That is exactly what makes the daemon render the
266+
// "already authenticated" directive.
267+
const dataDir = process.env.OD_DATA_DIR;
268+
if (!dataDir) throw new Error('OD_DATA_DIR is required for the MCP directive test');
269+
await writeMcpConfig(dataDir, {
270+
servers: [
271+
{
272+
id: 'github',
273+
label: 'GitHub',
274+
transport: 'http',
275+
url: 'https://mcp.test.invalid/github',
276+
enabled: true,
277+
},
278+
],
279+
});
280+
await setToken(dataDir, 'github', {
281+
accessToken: 'live-access-token',
282+
tokenType: 'Bearer',
283+
expiresAt: Date.now() + 3_600_000,
284+
savedAt: Date.now(),
285+
});
286+
287+
const conversationId = await createConversation(started.url);
288+
const turn1 = await sendRunAndWait(started.url, conversationId, 'first user request');
289+
expect(turn1.status).toBe('succeeded');
290+
const turn2 = await sendRunAndWait(
291+
started.url,
292+
conversationId,
293+
'second user request please',
294+
);
295+
expect(turn2.status).toBe('succeeded');
296+
297+
const runs = await readChatTurnExecs(logPath, conversationId);
298+
expect(runs).toHaveLength(2);
299+
const [create, resume] = runs as [ExecInvocation, ExecInvocation];
300+
301+
const MCP_MARKER = 'External MCP servers — already authenticated';
302+
// A marker that only appears inside the cached stable block (daemonSystemPrompt).
303+
const STABLE_BLOCK_MARKER = '# Identity and workflow charter (background)';
304+
305+
// Turn 1 (fresh seed) carries the directive.
306+
expect(create.stdin).toContain(MCP_MARKER);
307+
expect(create.stdin).toContain('`github`');
308+
309+
// Turn 2 is a clean resume: the cached stable block is NOT re-sent...
310+
expect(resume.argv.slice(0, 2)).toEqual(['exec', 'resume']);
311+
expect(resume.stdin).not.toContain(STABLE_BLOCK_MARKER);
312+
// ...but the MCP directive IS, because it now lives in the per-turn slice.
313+
expect(resume.stdin).toContain(MCP_MARKER);
314+
expect(resume.stdin).toContain('`github`');
315+
});
227316
});
228317

229318
// Minimal fake Claude CLI (claude-stream-json): emits an init frame + one

apps/daemon/tests/prompts/system.test.ts

Lines changed: 48 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import { fileURLToPath } from 'node:url';
44

55
import { describe, expect, it } from 'vitest';
66

7-
import { composeSystemPrompt, resolveExclusiveSurface } from '../../src/prompts/system.js';
7+
import {
8+
composeSystemPrompt,
9+
renderConnectedExternalMcpDirective,
10+
resolveExclusiveSurface,
11+
} from '../../src/prompts/system.js';
812

913
const __filename = fileURLToPath(import.meta.url);
1014
const __dirname = path.dirname(__filename);
@@ -415,66 +419,69 @@ describe('composeSystemPrompt', () => {
415419
});
416420
});
417421

418-
describe('connectedExternalMcp directive', () => {
419-
it('omits the directive when no servers are passed', () => {
422+
// The connected-external-MCP directive reflects live OAuth token state, which
423+
// flips mid-conversation as Bearers expire/refresh. It now rides in the
424+
// per-turn instruction slice (server.ts), NOT the cached system prompt, so it
425+
// no longer churns the cacheable prefix across resumes. composeSystemPrompt
426+
// must therefore never emit it; the exported renderer is tested directly.
427+
describe('connectedExternalMcp directive is no longer in the system prompt', () => {
428+
it('never emits the MCP directive from composeSystemPrompt', () => {
420429
const prompt = composeSystemPrompt({});
421430
expect(prompt).not.toContain('External MCP servers — already authenticated');
422431
expect(prompt).not.toContain('mcp__<server>__authenticate');
423432
});
424433

425-
it('omits the directive when an empty array is passed', () => {
426-
const prompt = composeSystemPrompt({ connectedExternalMcp: [] });
434+
it('keeps the media-execution-disabled block, still with no MCP directive', () => {
435+
const prompt = composeSystemPrompt({
436+
metadata: { kind: 'image' },
437+
mediaExecution: { mode: 'disabled' },
438+
});
439+
expect(prompt).toContain('Open Design-owned media execution is **disabled for this run**');
440+
expect(prompt).not.toContain('## Media generation contract');
427441
expect(prompt).not.toContain('External MCP servers — already authenticated');
428442
});
443+
});
429444

430-
it('lists each connected server and forbids the synthetic auth tools', () => {
431-
const prompt = composeSystemPrompt({
432-
connectedExternalMcp: [
433-
{ id: 'higgsfield-openclaw', label: 'Higgsfield (OpenClaw)' },
434-
{ id: 'github' },
435-
],
436-
});
445+
describe('renderConnectedExternalMcpDirective', () => {
446+
it('returns an empty string for no / empty servers', () => {
447+
expect(renderConnectedExternalMcpDirective(undefined)).toBe('');
448+
expect(renderConnectedExternalMcpDirective([])).toBe('');
449+
});
437450

438-
expect(prompt).toContain('## External MCP servers — already authenticated');
439-
expect(prompt).toContain('`higgsfield-openclaw`');
440-
expect(prompt).toContain('Higgsfield (OpenClaw)');
441-
expect(prompt).toContain('`github`');
442-
expect(prompt).toContain(
451+
it('lists each connected server and forbids the synthetic auth tools', () => {
452+
const directive = renderConnectedExternalMcpDirective([
453+
{ id: 'higgsfield-openclaw', label: 'Higgsfield (OpenClaw)' },
454+
{ id: 'github' },
455+
]);
456+
expect(directive).toContain('## External MCP servers — already authenticated');
457+
expect(directive).toContain('`higgsfield-openclaw`');
458+
expect(directive).toContain('Higgsfield (OpenClaw)');
459+
expect(directive).toContain('`github`');
460+
expect(directive).toContain(
443461
'**Do NOT call any tool whose name matches `mcp__<server>__authenticate` or `mcp__<server>__complete_authentication`',
444462
);
445-
expect(prompt).toContain('localhost:<random>/callback');
446-
expect(prompt).toContain('Settings → External MCP');
463+
expect(directive).toContain('localhost:<random>/callback');
464+
expect(directive).toContain('Settings → External MCP');
447465
});
448466

449-
it('skips entries with blank ids and emits no directive when nothing usable remains', () => {
450-
const prompt = composeSystemPrompt({
451-
connectedExternalMcp: [
467+
it('skips entries with blank ids and emits nothing when none remain', () => {
468+
expect(
469+
renderConnectedExternalMcpDirective([
452470
{ id: ' ', label: 'blank' },
453471
{ id: '', label: 'empty' },
454-
] as any,
455-
});
456-
expect(prompt).not.toContain('External MCP servers — already authenticated');
472+
] as any),
473+
).toBe('');
457474
});
458475

459476
it('does not duplicate the label when it equals the id', () => {
460-
const prompt = composeSystemPrompt({
461-
connectedExternalMcp: [{ id: 'github', label: 'github' }],
462-
});
463-
expect(prompt).toContain('- `github`\n');
464-
expect(prompt).not.toContain('- `github` (github)');
477+
const directive = renderConnectedExternalMcpDirective([{ id: 'github', label: 'github' }]);
478+
expect(directive).toContain('- `github`\n');
479+
expect(directive).not.toContain('- `github` (github)');
465480
});
466481

467-
it('keeps external MCP tools visible when OD-owned media execution is disabled', () => {
468-
const prompt = composeSystemPrompt({
469-
connectedExternalMcp: [{ id: 'external-media', label: 'External media' }],
470-
metadata: { kind: 'image' },
471-
mediaExecution: { mode: 'disabled' },
472-
});
473-
474-
expect(prompt).toContain('## External MCP servers — already authenticated');
475-
expect(prompt).toContain('`external-media`');
476-
expect(prompt).toContain('Open Design-owned media execution is **disabled for this run**');
477-
expect(prompt).not.toContain('## Media generation contract');
482+
it('has no leading separator so it composes cleanly in a `---`-joined slice', () => {
483+
const directive = renderConnectedExternalMcpDirective([{ id: 'github' }]);
484+
expect(directive.startsWith('## External MCP servers')).toBe(true);
478485
});
479486
});
480487

0 commit comments

Comments
 (0)