From c29ae70b14e48b7b8d7ccaf677cab82cc2169424 Mon Sep 17 00:00:00 2001 From: Jay/Fienna Liang Date: Thu, 9 Jul 2026 17:42:51 +0800 Subject: [PATCH 1/2] feat: mirror mode for MCP result-artifact rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replacement compaction breaks hosts whose MCP server is stateless — each tool call takes the current document as input and returns the updated one, so the model must keep the full value inline for the next call. Those hosts had to disable resultArtifacts and hand-extract the outcome from raw tool results. A manual rule can now set `mode: 'mirror'`: the artifact is persisted exactly as before (including setCurrent), but the value stays inline and untouched. The host then reads the turn's outcome via engine.artifacts.current(...) instead of spelunking tool-result JSON. `replacePaths` is ignored in mirror mode; default behavior ('replace') is unchanged. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01CjFdZgZhov8wdwFinVTfjz --- .../unit/core/mcp-host-extension.test.ts | 57 +++++++++++++++++++ .../chat/engine/mcp-host-extension/README.md | 22 +++++++ .../result-artifact-service.ts | 6 ++ .../chat/engine/mcp-host-extension/types.ts | 13 +++++ 4 files changed, 98 insertions(+) diff --git a/src/__tests__/unit/core/mcp-host-extension.test.ts b/src/__tests__/unit/core/mcp-host-extension.test.ts index c82f5c53..5513773b 100644 --- a/src/__tests__/unit/core/mcp-host-extension.test.ts +++ b/src/__tests__/unit/core/mcp-host-extension.test.ts @@ -389,6 +389,63 @@ describe('defineMcpHostExtension', () => { .toBe(output.html.artifact.id); }); + it('mirror-mode rules persist the artifact but keep the value inline', async () => { + const context = contextFixture(); + const mdx = `# Deck\n\n---\n\n## Slide\n\n${'content '.repeat(64)}`; + vi.spyOn(McpService.prototype, 'callTool').mockResolvedValue({ + ok: true, + output: { + structuredContent: { + result: { + source: mdx, + validation: { valid: true }, + }, + }, + }, + }); + const extension = defineMcpHostExtension({ + id: 'slides', + serverId: 'deck_service', + includeTools: ['create-deck'], + resultArtifacts: [{ + toolName: 'create-deck', + path: 'structuredContent.result.source', + mode: 'mirror', + kind: 'source', + domain: 'deck', + title: 'deck.mdx', + setCurrent: true, + }], + }); + const [tool] = extension.toolkits?.flatMap((toolkit) => toolkit.createTools(context)) ?? []; + if (!tool) { + throw new Error('Expected create-deck host tool'); + } + + const result = await tool.execute({ title: 'Quarterly plan' }); + const output = result.output as { + structuredContent: { result: { source: unknown; validation: { valid: boolean } } }; + }; + + expect(result.ok).toBe(true); + // The value the model sees is untouched — the next stateless tool call can + // pass it straight back in. + expect(output.structuredContent.result.source).toBe(mdx); + expect(output.structuredContent.result.validation).toEqual({ valid: true }); + + // ...while the artifact was persisted and marked current for the host. + const artifacts = new ArtifactService({ artifactRoot: context.artifactRoot }); + const current = artifacts.current('session-123'); + expect(current).toEqual(expect.objectContaining({ + kind: 'source', + domain: 'deck', + title: 'deck.mdx', + sourceTool: 'create_deck', + })); + expect(artifacts.read(current!.id)?.content).toBe(mdx); + expect(artifacts.list({ sessionId: 'session-123' })).toHaveLength(1); + }); + it('auto-stores large duplicate MCP result strings as a single artifact', async () => { const context = contextFixture(); const html = `${'x'.repeat(64)}`; diff --git a/src/core/chat/engine/mcp-host-extension/README.md b/src/core/chat/engine/mcp-host-extension/README.md index e1c24366..1b580884 100644 --- a/src/core/chat/engine/mcp-host-extension/README.md +++ b/src/core/chat/engine/mcp-host-extension/README.md @@ -65,6 +65,28 @@ host author should be able to write: resultArtifacts: true ``` +### Mirror mode (keep the value inline) + +Replacement compaction breaks hosts whose MCP server is **stateless** — where +each tool call takes the current document as input and returns the updated +document (so the model must keep the full value inline for the next call). +For that shape, a manual rule can use `mode: 'mirror'`: + +```ts +resultArtifacts: [{ + toolName: 'create-deck', + path: 'structuredContent.result.source', + mode: 'mirror', // persist the artifact, leave the value inline untouched + kind: 'source', + setCurrent: true, // host reads the outcome via engine.artifacts.current(...) +}] +``` + +The model keeps seeing the full value; the host gets a durable artifact and a +one-line way to read "the outcome of this turn". `replacePaths` is ignored in +mirror mode. Avoid combining mirror rules with `auto` capture on the same tool — +auto may then capture the still-inline value a second time. + Only add public options such as `path`, `pathIncludes`, or explicit `replacePaths` for advanced cases where inference cannot be reliable. When a new inference is added, cover it in diff --git a/src/core/chat/engine/mcp-host-extension/result-artifact-service.ts b/src/core/chat/engine/mcp-host-extension/result-artifact-service.ts index b9d1ee15..ed483027 100644 --- a/src/core/chat/engine/mcp-host-extension/result-artifact-service.ts +++ b/src/core/chat/engine/mcp-host-extension/result-artifact-service.ts @@ -76,6 +76,12 @@ export class McpResultArtifactService { }, setCurrent: args.rule.setCurrent, }); + // Mirror mode: the artifact is persisted (above), but the value stays + // inline so downstream tool calls can keep consuming it directly. + if (args.rule.mode === 'mirror') { + return args.output; + } + const preview = content.slice(0, args.rule.maxPreviewChars ?? 1_000); const artifactOutput = { artifact: { diff --git a/src/core/chat/engine/mcp-host-extension/types.ts b/src/core/chat/engine/mcp-host-extension/types.ts index b135585d..8acdbbab 100644 --- a/src/core/chat/engine/mcp-host-extension/types.ts +++ b/src/core/chat/engine/mcp-host-extension/types.ts @@ -18,6 +18,19 @@ export type McpHostResultArtifactRule = { toolName: string; /** Path inside the MCP result output that should be persisted as an artifact. */ path: string | readonly string[]; + /** + * How the captured value is returned to the model. + * + * - `'replace'` (default): swap the value for a compact artifact reference — + * the context-compaction behavior. + * - `'mirror'`: persist the artifact but leave the value inline and + * untouched. Use when downstream tool calls need the full value as input + * (e.g. stateless MCP servers that take the current document as an + * argument) while the host still wants a durable artifact — typically with + * `setCurrent: true` so the host reads the outcome via + * `engine.artifacts.current(...)`. `replacePaths` is ignored in this mode. + */ + mode?: 'replace' | 'mirror'; /** Additional output paths to replace with the same artifact reference. */ replacePaths?: ReadonlyArray; kind: ArtifactKind; From e415e19a7ac9b2002ba10ffee87c046fdf433856 Mon Sep 17 00:00:00 2001 From: Jay/Fienna Liang Date: Fri, 10 Jul 2026 02:10:11 +0800 Subject: [PATCH 2/2] fix: preserve mirror rules during auto capture --- .../unit/core/mcp-host-extension.test.ts | 27 ++++++++++--------- .../chat/engine/mcp-host-extension/README.md | 5 ++-- .../artifact-path-service.ts | 6 +++++ .../auto-result-artifact-service.ts | 2 ++ .../result-artifact-service.ts | 5 ++++ .../chat/engine/mcp-host-extension/types.ts | 1 + 6 files changed, 32 insertions(+), 14 deletions(-) diff --git a/src/__tests__/unit/core/mcp-host-extension.test.ts b/src/__tests__/unit/core/mcp-host-extension.test.ts index 5513773b..8a27d991 100644 --- a/src/__tests__/unit/core/mcp-host-extension.test.ts +++ b/src/__tests__/unit/core/mcp-host-extension.test.ts @@ -389,7 +389,7 @@ describe('defineMcpHostExtension', () => { .toBe(output.html.artifact.id); }); - it('mirror-mode rules persist the artifact but keep the value inline', async () => { + it('mirror-mode rules persist the artifact but keep the value inline when auto capture is enabled', async () => { const context = contextFixture(); const mdx = `# Deck\n\n---\n\n## Slide\n\n${'content '.repeat(64)}`; vi.spyOn(McpService.prototype, 'callTool').mockResolvedValue({ @@ -407,15 +407,18 @@ describe('defineMcpHostExtension', () => { id: 'slides', serverId: 'deck_service', includeTools: ['create-deck'], - resultArtifacts: [{ - toolName: 'create-deck', - path: 'structuredContent.result.source', - mode: 'mirror', - kind: 'source', - domain: 'deck', - title: 'deck.mdx', - setCurrent: true, - }], + resultArtifacts: { + auto: { minChars: 16 }, + rules: [{ + toolName: 'create-deck', + path: 'structuredContent.result.source', + mode: 'mirror', + kind: 'source', + domain: 'deck', + title: 'deck.mdx', + setCurrent: true, + }], + }, }); const [tool] = extension.toolkits?.flatMap((toolkit) => toolkit.createTools(context)) ?? []; if (!tool) { @@ -428,8 +431,8 @@ describe('defineMcpHostExtension', () => { }; expect(result.ok).toBe(true); - // The value the model sees is untouched — the next stateless tool call can - // pass it straight back in. + // The value the model sees is untouched — including by the subsequent auto + // pass — so the next stateless tool call can pass it straight back in. expect(output.structuredContent.result.source).toBe(mdx); expect(output.structuredContent.result.validation).toEqual({ valid: true }); diff --git a/src/core/chat/engine/mcp-host-extension/README.md b/src/core/chat/engine/mcp-host-extension/README.md index 1b580884..a44a93c5 100644 --- a/src/core/chat/engine/mcp-host-extension/README.md +++ b/src/core/chat/engine/mcp-host-extension/README.md @@ -84,8 +84,9 @@ resultArtifacts: [{ The model keeps seeing the full value; the host gets a durable artifact and a one-line way to read "the outcome of this turn". `replacePaths` is ignored in -mirror mode. Avoid combining mirror rules with `auto` capture on the same tool — -auto may then capture the still-inline value a second time. +mirror mode. When manual mirror rules and `auto` capture are combined, the +mirrored path and its descendants are excluded from the auto pass so the full +value remains inline as declared. Only add public options such as `path`, `pathIncludes`, or explicit `replacePaths` for advanced cases where inference cannot be reliable. When a diff --git a/src/core/chat/engine/mcp-host-extension/artifact-path-service.ts b/src/core/chat/engine/mcp-host-extension/artifact-path-service.ts index 71d1ab27..83a9bbb9 100644 --- a/src/core/chat/engine/mcp-host-extension/artifact-path-service.ts +++ b/src/core/chat/engine/mcp-host-extension/artifact-path-service.ts @@ -53,4 +53,10 @@ export class McpArtifactPathService { static isArtifactReferencePath(path: string[]): boolean { return path.includes('artifact') || path.includes('contentPath') || path.includes('omittedCharacters'); } + + static isWithin(path: string[], ancestor: readonly string[]): boolean { + return ancestor.length > 0 + && ancestor.length <= path.length + && ancestor.every((part, index) => path[index] === part); + } } diff --git a/src/core/chat/engine/mcp-host-extension/auto-result-artifact-service.ts b/src/core/chat/engine/mcp-host-extension/auto-result-artifact-service.ts index d2396b61..7d82720b 100644 --- a/src/core/chat/engine/mcp-host-extension/auto-result-artifact-service.ts +++ b/src/core/chat/engine/mcp-host-extension/auto-result-artifact-service.ts @@ -26,6 +26,8 @@ export class McpAutoResultArtifactService { const originalCandidates = McpAutoResultArtifactService.findStringResultCandidates(outputBeforeAuto); const candidates = originalCandidates .filter((candidate) => candidate.content.length >= minChars) + .filter((candidate) => !(args.excludedPaths ?? []).some((path) => + McpArtifactPathService.isWithin(candidate.path, path))) .filter((candidate) => !McpArtifactPathService.isArtifactReferencePath(candidate.path)) .filter((candidate) => !McpStructuredContentMirrorService.isSerializedStructuredContentMirror({ candidate, diff --git a/src/core/chat/engine/mcp-host-extension/result-artifact-service.ts b/src/core/chat/engine/mcp-host-extension/result-artifact-service.ts index ed483027..635767df 100644 --- a/src/core/chat/engine/mcp-host-extension/result-artifact-service.ts +++ b/src/core/chat/engine/mcp-host-extension/result-artifact-service.ts @@ -33,11 +33,16 @@ export class McpResultArtifactService { output: currentOutput, rule, }), cloneDeep(args.output)); + const mirroredPaths = rules + .filter((rule) => rule.mode === 'mirror') + .map((rule) => McpArtifactPathService.normalize(rule.path)) + .filter((path) => path.length > 0); return resultArtifacts.auto ? McpAutoResultArtifactService.apply({ ...args, auto: resultArtifacts.auto, + excludedPaths: mirroredPaths, output: manuallyCaptured, }) : manuallyCaptured; diff --git a/src/core/chat/engine/mcp-host-extension/types.ts b/src/core/chat/engine/mcp-host-extension/types.ts index 8acdbbab..ad5b6281 100644 --- a/src/core/chat/engine/mcp-host-extension/types.ts +++ b/src/core/chat/engine/mcp-host-extension/types.ts @@ -180,4 +180,5 @@ export type McpManualResultArtifactApplyInput = McpResultArtifactApplyInput & { export type McpAutoResultArtifactApplyInput = McpResultArtifactApplyInput & { auto: McpHostAutoResultArtifactsOptions; + excludedPaths?: ReadonlyArray; };