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
60 changes: 60 additions & 0 deletions src/__tests__/unit/core/mcp-host-extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,66 @@ describe('defineMcpHostExtension', () => {
.toBe(output.html.artifact.id);
});

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({
ok: true,
output: {
structuredContent: {
result: {
source: mdx,
validation: { valid: true },
},
},
},
});
const extension = defineMcpHostExtension({
id: 'slides',
serverId: 'deck_service',
includeTools: ['create-deck'],
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) {
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 — 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 });

// ...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 = `<!doctype html><html><body>${'x'.repeat(64)}</body></html>`;
Expand Down
23 changes: 23 additions & 0 deletions src/core/chat/engine/mcp-host-extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,29 @@ 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. 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
new inference is added, cover it in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions src/core/chat/engine/mcp-host-extension/result-artifact-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -76,6 +81,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: {
Expand Down
14 changes: 14 additions & 0 deletions src/core/chat/engine/mcp-host-extension/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | readonly string[]>;
kind: ArtifactKind;
Expand Down Expand Up @@ -167,4 +180,5 @@ export type McpManualResultArtifactApplyInput = McpResultArtifactApplyInput & {

export type McpAutoResultArtifactApplyInput = McpResultArtifactApplyInput & {
auto: McpHostAutoResultArtifactsOptions;
excludedPaths?: ReadonlyArray<readonly string[]>;
};
Loading