Skip to content
5 changes: 5 additions & 0 deletions .changeset/mcp-attachment-originals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Preserve MCP attachments that cannot be delivered directly to the model.
4 changes: 4 additions & 0 deletions docs/en/customization/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

MCP tool results can include text (`content`) and structured data (`structuredContent`). Kimi Code CLI makes both available to the agent and omits the structured copy only when it can confirm that a text block already contains the same complete JSON value. Text summaries and media do not replace structured records.

Kimi Code CLI preserves embedded MCP attachments that cannot be delivered directly because of format or size limits. Embedded audio and video are also saved, including small files, because provider conversion may omit them later. Originals are retained in the session's media storage instead of an evictable image cache. Saved originals, including images preserved during compression, have absolute and session-relative paths so the agent can locate the copy in a forked session. Plain SVG is saved as `.svg` and can be read as text; other binary formats need an appropriate local reader or converter.

Attachment paths and compression details share the tool-output budget. Large lists are saved to a text file, with a short pointer that remains visible when accompanying text is shortened; the agent can page through the complete list with `Read`. If decoding or saving fails, the result explicitly reports that the original could not be preserved while retaining other usable output. Resource links are not automatically downloaded.

## Connection Methods

Kimi Code CLI supports three MCP server connection methods:
Expand Down
4 changes: 4 additions & 0 deletions docs/zh/customization/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

MCP 工具结果可以包含文本(`content`)和结构化数据(`structuredContent`)。Kimi Code CLI 会将两者提供给 Agent,只有能够确认某个文本块已包含同一份完整 JSON 值时,才省略重复的结构化内容。文本摘要和媒体不会替代结构化记录。

Kimi Code CLI 会保留因格式或大小限制而无法直接交付的内嵌 MCP 附件。内嵌音视频无论大小也会保存,因为后续供应商协议转换可能省略它们。原件随会话保存在媒体存储中,不会被图片缓存淘汰。保存的原件(包括图片压缩前的原图)均提供绝对路径和会话相对路径,便于 Agent 在 fork 后的会话中找到副本。普通 SVG 保存为 `.svg`,可作为文本读取;其他二进制格式需要合适的本地阅读器或转换工具。

附件路径和压缩说明共用工具输出预算。较长的清单会保存为文本文件,结果中保留简短指针,即使伴随的文本被截短,该指针仍然可见;Agent 可用 `Read` 分页读取完整清单。如果解码或保存失败,结果会明确说明原件未能保留,并保留其他可用输出。资源链接不会被自动下载。

## 接入方式

Kimi Code CLI 支持三种 MCP server 接入方式:
Expand Down
7 changes: 3 additions & 4 deletions packages/agent-core-v2/src/agent/mcp/mcpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,13 @@ import { abortable } from '#/_base/utils/abort';
import { IAgentProfileService } from '#/agent/profile/profile';
import { IAgentStateService } from '#/agent/state/agentState';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import { sessionMediaOriginalsDir } from '#/agent/media/image-originals';
import { ISessionMediaStore } from '#/agent/media/sessionMediaStore';
import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor';
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
import { IAgentLoopService } from '#/agent/loop/loop';
import { createMcpAuthTool } from '#/agent/mcp/tools/auth';
import { createMcpTool } from '#/agent/mcp/tools/mcp';
import { ISessionContext } from '#/session/sessionContext/sessionContext';
import { ISessionMcpHandle } from '#/session/mcp/sessionMcpHandle';
import type { McpServerEntry } from '#/mcpCore/connection-manager';
import { IAgentMcpService } from './mcp';
Expand Down Expand Up @@ -53,7 +52,6 @@ export class AgentMcpService extends Service implements IAgentMcpService {

constructor(
@ISessionMcpHandle private readonly mcpHandle: ISessionMcpHandle,
@ISessionContext private readonly sessionContext: ISessionContext,
@IAgentToolRegistryService private readonly registry: IAgentToolRegistryService,
@IAgentToolExecutorService toolExecutor: IAgentToolExecutorService,
@IAgentLoopService loop: IAgentLoopService,
Expand All @@ -62,6 +60,7 @@ export class AgentMcpService extends Service implements IAgentMcpService {
@IAgentScopeContext private readonly scopeContext: IAgentScopeContext,
@IAgentStateService private readonly states: IAgentStateService,
@IAgentProfileService private readonly profile: IAgentProfileService,
@ISessionMediaStore private readonly attachmentStore: ISessionMediaStore,
) {
super();
this.states.contributeState(mcpDiscoveryKey);
Expand Down Expand Up @@ -286,7 +285,7 @@ export class AgentMcpService extends Service implements IAgentMcpService {
const disposable = this._register(
this.registry.register(
createMcpTool(qualified, tool, client, {
originalsDir: sessionMediaOriginalsDir(this.sessionContext.sessionDir),
attachmentStore: this.attachmentStore,
telemetry: this.telemetry,
providerType: () => this.profile.getModelProviderType(),
reconnect: (signal) => this.reconnectForToolCall(serverName, client, signal),
Expand Down
150 changes: 137 additions & 13 deletions packages/agent-core-v2/src/agent/mcp/output.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,35 @@
import { createHash } from 'node:crypto';
import { basename } from 'node:path';
import { Readable } from 'node:stream';
import { isDeepStrictEqual } from 'node:util';

import type { ContentPart } from '#human/llm/message';
import type { ITelemetryService } from '#/app/telemetry/telemetry';
import type { ExecutableToolResult } from '#/tool/toolContract';

import { compressImageContentParts } from '#/agent/media/image-compress';
import { compressImageContentParts, gateImageFormatParts } from '#/agent/media/image-compress';
import {
buildUnsupportedImageNotice,
isModelAcceptedImageMime,
parseImageDataUrl,
resolveEffectiveImageMime,
decodeBase64Prefix,
} from '#/agent/media/image-format-policy';
import { persistOriginalImage } from '#/agent/media/image-originals';
import type { ISessionMediaStore } from '#/agent/media/sessionMediaStore';
import { mediaExtensionForMime } from '#/agent/media/mediaRef';
import type { MCPContentBlock, MCPToolResult } from '#/mcpCore/types';

export interface McpOutputOptions {
readonly attachmentStore?: ISessionMediaStore;
readonly originalsDir?: string;
readonly telemetry?: ITelemetryService;
readonly providerType?: string;
}

export const MCP_MAX_BINARY_PART_BYTES = 10 * 1024 * 1024;
const MCP_MAX_BINARY_PART_CHARS = Math.ceil((MCP_MAX_BINARY_PART_BYTES * 4) / 3);
const MCP_MAX_INLINE_NOTICES_CHARS = 4096;

function binaryPartTooLargeNotice(kind: 'image' | 'audio' | 'video', urlLength: number): string {
const approxMb = ((urlLength * 3) / 4 / (1024 * 1024)).toFixed(1);
Expand Down Expand Up @@ -116,8 +126,38 @@ export async function mcpResultToExecutableOutput(
options: McpOutputOptions = {},
): Promise<ExecutableToolResult> {
const converted: ContentPart[] = [];
const attachmentNotices: string[] = [];
const preservedUrls = new Set<string>();
let omittedAttachment = false;
const preserveInlineMedia = async (url: string): Promise<void> => {
if (preservedUrls.has(url)) return;
const parsed = parseImageDataUrl(url);
if (parsed === null) return;
preservedUrls.add(url);
const mime = parsed.mimeType.startsWith('image/')
? resolveEffectiveImageMime(parsed.mimeType, decodeBase64Prefix(parsed.base64))
: parsed.mimeType;
attachmentNotices.push(await preserveAttachment(parsed.base64, mime, options));
};
for (const block of result.content) {
converted.push(convertMCPContentBlock(block, options.providerType));
const part = convertMCPContentBlock(block, options.providerType);
if (part.type === 'audio_url') await preserveInlineMedia(part.audioUrl.url);
if (part.type === 'video_url') await preserveInlineMedia(part.videoUrl.url);
Comment thread
RealKai42 marked this conversation as resolved.
const gated = gateImageFormatParts([part], options.providerType);
converted.push(...gated);
if (part.type === 'image_url' && gated[0]?.type === 'text') {
omittedAttachment = true;
await preserveInlineMedia(part.imageUrl.url);
Comment thread
RealKai42 marked this conversation as resolved.
}
if (part.type === 'text' && block.type === 'resource' &&
typeof block.resource?.blob === 'string' && typeof block.resource.text !== 'string') {
omittedAttachment = true;
attachmentNotices.push(await preserveAttachment(
block.resource.blob,
block.resource.mimeType ?? 'application/octet-stream',
options,
));
}
}

const wrapped = wrapMediaOnly(converted, qualifiedToolName);
Expand Down Expand Up @@ -154,26 +194,106 @@ export async function mcpResultToExecutableOutput(
telemetrySource: 'mcp_tool_result',
providerType: options.providerType,
annotate: {
persistOriginal: (bytes, mimeType) =>
persistOriginalImage(
persistOriginal: async (bytes, mimeType) => {
if (options.attachmentStore !== undefined) {
const path = await saveAttachment(bytes, mimeType, options.attachmentStore);
if (path !== undefined) attachmentNotices.push(attachmentNotice(path, mimeType, bytes.length));
return path ?? null;
}
return persistOriginalImage(
bytes,
mimeType,
options.originalsDir === undefined ? {} : { dir: options.originalsDir },
),
);
},
},
});
const capped = applyBinaryPartCap(compressed.parts);
const output = collapseSingleText(capped.parts);
const note = compressed.captions.length > 0 ? compressed.captions.join('\n') : undefined;
const capped = await applyBinaryPartCap(compressed.parts, preserveInlineMedia);
const notices = await attachmentDetails(
[...compressed.captions, ...attachmentNotices, ...capped.notices], options,
);
const parts = [...capped.parts];
if (notices.content.length > 0) parts.push({ type: 'text', text: notices.content });
const output = collapseSingleText(parts);
const base = {
output,
note,
truncated: capped.truncated ? true : undefined,
spill: capped.notices.length > 0 ? { suffix: capped.notices.join('\n') } : undefined,
truncated: capped.truncated || omittedAttachment ? true : undefined,
spill: notices.suffix.length > 0 ? { suffix: notices.suffix } : undefined,
};
return result.isError ? { ...base, isError: true } : base;
}

async function attachmentDetails(
notices: readonly string[],
options: McpOutputOptions,
): Promise<{ readonly content: string; readonly suffix: string }> {
const content = notices.join('\n');
if (content.length <= MCP_MAX_INLINE_NOTICES_CHARS) return { content, suffix: content };
try {
if (options.attachmentStore === undefined) throw new Error('Session attachment storage is unavailable');
const path = await saveAttachment(Buffer.from(content, 'utf8'), 'text/plain', options.attachmentStore);
if (path === undefined) throw new Error('Attachment storage has no accessible file path');
const pointer = [
`MCP attachment details saved at: ${JSON.stringify(path)}`,
`Session-relative attachment details: ${JSON.stringify(`media/${basename(path)}`)}`,
'Use Read to retrieve all original attachment paths and compression details.',
].join('\n');
return { content: pointer, suffix: pointer };
} catch {
const suffix = 'The complete MCP attachment list could not be saved separately. Attachment details are included in the tool output and may be truncated. Do not repeat the MCP call automatically.';
return { content: `${content}\n${suffix}`, suffix };
}
}

async function preserveAttachment(
base64: string,
mimeType: string,
options: McpOutputOptions,
): Promise<string> {
try {
if (options.attachmentStore === undefined) throw new Error('Session attachment storage is unavailable');
const compact = base64.replaceAll(/\s/g, '');
const bytes = Buffer.from(compact, 'base64');
const canonical = bytes.toString('base64');
if (canonical !== compact && canonical.replace(/=+$/, '') !== compact) {
throw new Error('Invalid base64 attachment');
}
const path = await saveAttachment(bytes, mimeType, options.attachmentStore);
if (path === undefined) throw new Error('Attachment storage has no accessible file path');
return attachmentNotice(path, mimeType, bytes.length);
} catch (error) {
return `Original attachment could not be saved (${JSON.stringify(mimeType)}): ${error instanceof Error ? error.message : String(error)}. No readable original path is available; original attachment preservation is incomplete. Do not repeat the MCP call automatically.`;
}
}

function attachmentNotice(path: string, mimeType: string, size: number): string {
return [
`Original attachment saved at: ${JSON.stringify(path)}`,
`Session-relative attachment: ${JSON.stringify(`media/${basename(path)}`)}`,
Comment thread
RealKai42 marked this conversation as resolved.
Outdated
`MIME: ${JSON.stringify(mimeType)}; size: ${String(size)} bytes. Use an appropriate local reader or converter; Read accepts text files only.`,
].join('\n');
}

async function saveAttachment(
bytes: Uint8Array,
mimeType: string,
store: ISessionMediaStore,
): Promise<string | undefined> {
const mime = mimeType.split(';')[0]!.trim().toLowerCase();
const hash = createHash('sha256').update(mime).update('\0').update(bytes).digest('hex');
const ext = mime === 'text/plain' ? '.txt'
: mime === 'application/pdf' ? '.pdf'
: mime === 'image/svg+xml' ? (bytes[0] === 0x1f && bytes[1] === 0x8b ? '.svgz' : '.svg')
: mediaExtensionForMime(mime) ?? '.bin';
Comment thread
RealKai42 marked this conversation as resolved.
Outdated
Comment thread
RealKai42 marked this conversation as resolved.
Outdated
return store.materialize({
fileId: `f_mcp_${hash}`,
size: bytes.length,
name: `attachment${ext}`,
mimeType: mime,
stream: () => Readable.from([bytes]),
});
}

function parseComparableJson(text: string): unknown {
return JSON.parse(text, (_key: string, value: unknown, context?: { source?: string }) => {
if (typeof value === 'number' && context?.source !== JSON.stringify(value)) {
Expand Down Expand Up @@ -226,11 +346,14 @@ function wrapMediaOnly(parts: readonly ContentPart[], qualifiedToolName: string)
];
}

function applyBinaryPartCap(parts: readonly ContentPart[]): {
async function applyBinaryPartCap(
parts: readonly ContentPart[],
preserve: (url: string) => Promise<void>,
): Promise<{
readonly parts: ContentPart[];
readonly truncated: boolean;
readonly notices: string[];
} {
}> {
let truncated = false;
const out: ContentPart[] = [];
const notices: string[] = [];
Expand All @@ -248,6 +371,7 @@ function applyBinaryPartCap(parts: readonly ContentPart[]): {
? part.audioUrl.url
: part.videoUrl.url;
if (url.length > MCP_MAX_BINARY_PART_CHARS) {
await preserve(url);
const kind =
part.type === 'image_url' ? 'image' : part.type === 'audio_url' ? 'audio' : 'video';
const notice = binaryPartTooLargeNotice(kind, url.length);
Expand Down
4 changes: 3 additions & 1 deletion packages/agent-core-v2/src/agent/mcp/tools/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Error2, ErrorCodes, toErrorMessage } from '#/errors';
import { isAbortError } from '#/_base/utils/abort';

import type { ExecutableTool, ExecutableToolContext } from '#/tool/toolContract';
import { mcpResultToExecutableOutput } from '#/agent/mcp/output';
import { mcpResultToExecutableOutput, type McpOutputOptions } from '#/agent/mcp/output';
import type { MCPClient, MCPToolResult } from '#/mcpCore/types';
import {
isMcpConnectionClosedError,
Expand All @@ -14,6 +14,7 @@ import {
} from '#/mcpCore/client-shared';

interface McpToolOptions {
readonly attachmentStore?: McpOutputOptions['attachmentStore'];
readonly originalsDir?: string;
readonly telemetry?: ITelemetryService;
readonly providerType?: () => string | undefined;
Expand Down Expand Up @@ -51,6 +52,7 @@ export function createMcpTool(
result = await retryAfterReconnect(error, client, args, context, options, callTool);
}
return mcpResultToExecutableOutput(result, qualifiedName, {
attachmentStore: options.attachmentStore,
Comment thread
RealKai42 marked this conversation as resolved.
originalsDir: options.originalsDir,
telemetry: options.telemetry,
providerType: options.providerType?.(),
Expand Down
44 changes: 43 additions & 1 deletion packages/agent-core-v2/test/agent/mcp/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import type { ContentPart, ToolDescription as KosongTool } from '#human/llm/mess
import { Jimp } from 'jimp';
import { CallToolResultSchema, ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore, toDisposable } from '#/_base/di/lifecycle';
Expand All @@ -13,9 +16,14 @@ import { IEventBus } from '#/app/event/eventBus';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import type { McpConnectionManager, McpServerEntry } from '#/mcpCore/connection-manager';
import { IAgentMcpService } from '#/agent/mcp/mcp';
import { renderToolResultForModel } from '#/agent/contextMemory/toolResultRender';
import { AgentMcpService } from '#/agent/mcp/mcpService';
import { ISessionMcpHandle } from '#/session/mcp/sessionMcpHandle';
import { ISessionContext } from '#/session/sessionContext/sessionContext';
import { ISessionContext, makeSessionContext } from '#/session/sessionContext/sessionContext';
import { ISessionMediaStore } from '#/agent/media/sessionMediaStore';
import { SessionMediaStoreService } from '#/agent/media/sessionMediaStoreService';
import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService';
import { JsonAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDocumentStore';
import type { McpOAuthService } from '#/mcpCore/oauth/service';
import type { MCPClient, MCPToolDefinition } from '#/mcpCore/types';
import { IEventDispatcher } from '#/state/eventDispatcher';
Expand Down Expand Up @@ -322,6 +330,40 @@ describe('AgentMcpService', () => {
);
});

it('connects registered MCP tools to the session attachment store', async () => {
const home = await mkdtemp(join(tmpdir(), 'mcp-session-attachments-'));
try {
const storage = new FileStorageService(home);
const context = makeSessionContext({
sessionId: 'session', workspaceId: 'workspace', cwd: home,
sessionDir: join(home, 'sessions/session'), sessionScope: 'sessions/session',
});
ix.stub(ISessionMediaStore, new SessionMediaStoreService(context, storage, new JsonAtomicDocumentStore(storage)));
const bytes = Buffer.from('%PDF-1.4\nexample\n%%EOF');
const client: MCPClient = {
async listTools() { return [{ name: 'report', description: 'Example report', inputSchema: { type: 'object' } }]; },
async callTool() { return { isError: false, content: [{ type: 'resource', resource: {
uri: 'example://report', mimeType: 'application/pdf', blob: bytes.toString('base64'),
} }] }; },
async ping() {},
};
const manager = new FakeMcpManager();
manager.setResolved('example', client, await discoverTools(client));
createService(manager);
manager.connect('example');
const tool = ix.get(IAgentToolRegistryService).resolve('mcp__example__report');
const output = await executeTool(tool!, {
turnId: 1, toolCallId: 'report', args: {}, signal: new AbortController().signal,
});
const text = renderToolResultForModel(output).map((part) => part.type === 'text' ? part.text : '').join('\n');
const path = /Original attachment saved at: ("[^\n]+")/.exec(text)?.[1];
expect(path).toBeDefined();
expect((await readFile(JSON.parse(path!) as string)).equals(bytes)).toBe(true);
} finally {
await rm(home, { recursive: true, force: true });
}
});

it('ignores status changes from servers outside the session baseline', async () => {
const manager = new FakeMcpManager();
const lateClient = fakeMcpClient();
Expand Down
Loading
Loading