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.
2 changes: 2 additions & 0 deletions docs/en/customization/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

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.

When an embedded MCP attachment cannot be delivered directly because its format is unsupported or it exceeds the attachment size limit, Kimi Code CLI saves the original bytes in the session's media storage and provides the file path. The path remains visible when accompanying text is shortened, and the file is retained with the session instead of being evicted from the image cache. A session-relative path is also provided to locate the copy in a forked session. The agent can use an appropriate local reader or converter; `Read` only accepts text files. If decoding or saving fails, the result explicitly reports incomplete attachment delivery while preserving other usable output. Resource links are not automatically downloaded.

## Connection Methods

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

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

内嵌 MCP 附件因格式不受支持或超过附件大小限制而无法直接交付时,Kimi Code CLI 会将原始字节保存到会话媒体存储,并提供文件路径。即使伴随的文本被截短,该路径仍然可见;原件随会话保留,不会被图片缓存淘汰。结果还会提供会话相对路径,便于在 fork 后的会话中找到副本。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
106 changes: 94 additions & 12 deletions packages/agent-core-v2/src/agent/mcp/output.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
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;
Expand Down Expand Up @@ -116,8 +125,29 @@ export async function mcpResultToExecutableOutput(
options: McpOutputOptions = {},
): Promise<ExecutableToolResult> {
const converted: ContentPart[] = [];
const attachmentNotices: string[] = [];
for (const block of result.content) {
converted.push(convertMCPContentBlock(block, options.providerType));
const part = convertMCPContentBlock(block, options.providerType);
const gated = gateImageFormatParts([part], options.providerType);
converted.push(...gated);
if (part.type === 'image_url' && gated[0]?.type === 'text') {
const parsed = parseImageDataUrl(part.imageUrl.url);
if (parsed !== null) {
attachmentNotices.push(await preserveAttachment(
parsed.base64,
resolveEffectiveImageMime(parsed.mimeType, decodeBase64Prefix(parsed.base64)),
options,
));
}
}
if (part.type === 'text' && block.type === 'resource' &&
typeof block.resource?.blob === 'string' && typeof block.resource.text !== 'string') {
attachmentNotices.push(await preserveAttachment(
block.resource.blob,
block.resource.mimeType ?? 'application/octet-stream',
options,
));
}
}

const wrapped = wrapMediaOnly(converted, qualifiedToolName);
Expand Down Expand Up @@ -155,25 +185,72 @@ export async function mcpResultToExecutableOutput(
providerType: options.providerType,
annotate: {
persistOriginal: (bytes, mimeType) =>
persistOriginalImage(
bytes,
mimeType,
options.originalsDir === undefined ? {} : { dir: options.originalsDir },
),
options.attachmentStore !== undefined
? saveAttachment(bytes, mimeType, options.attachmentStore).then((path) => path ?? null)
Comment thread
RealKai42 marked this conversation as resolved.
Outdated
: persistOriginalImage(
bytes,
mimeType,
options.originalsDir === undefined ? {} : { dir: options.originalsDir },
),
},
});
const capped = applyBinaryPartCap(compressed.parts);
const capped = await applyBinaryPartCap(compressed.parts, async (base64, mimeType) => {
attachmentNotices.push(await preserveAttachment(base64, mimeType, options));
});
const output = collapseSingleText(capped.parts);
const note = compressed.captions.length > 0 ? compressed.captions.join('\n') : undefined;
const notices = [...compressed.captions, ...attachmentNotices];
const note = notices.length > 0 ? notices.join('\n') : undefined;
Comment thread
RealKai42 marked this conversation as resolved.
Outdated
const base = {
output,
note,
truncated: capped.truncated ? true : undefined,
truncated: capped.truncated || attachmentNotices.length > 0 ? true : undefined,
spill: capped.notices.length > 0 ? { suffix: capped.notices.join('\n') } : undefined,
};
return result.isError ? { ...base, isError: true } : base;
}

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 [
`Original attachment saved at: ${JSON.stringify(path)}`,
`Session-relative attachment: ${JSON.stringify(`media/${basename(path)}`)}`,
`MIME: ${JSON.stringify(mimeType)}; size: ${String(bytes.length)} bytes. Use an appropriate local reader or converter; Read accepts text files only.`,
].join('\n');
} 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; attachment delivery is incomplete. Do not repeat the MCP call automatically.`;
}
}

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 === 'application/pdf' ? '.pdf' : mediaExtensionForMime(mime) ?? '.bin';
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 +303,14 @@ function wrapMediaOnly(parts: readonly ContentPart[], qualifiedToolName: string)
];
}

function applyBinaryPartCap(parts: readonly ContentPart[]): {
async function applyBinaryPartCap(
parts: readonly ContentPart[],
preserve: (base64: string, mimeType: 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 +328,8 @@ function applyBinaryPartCap(parts: readonly ContentPart[]): {
? part.audioUrl.url
: part.videoUrl.url;
if (url.length > MCP_MAX_BINARY_PART_CHARS) {
const parsed = parseImageDataUrl(url);
if (parsed !== null) await preserve(parsed.base64, parsed.mimeType);
Comment thread
RealKai42 marked this conversation as resolved.
Outdated
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
42 changes: 41 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 @@ -15,7 +18,11 @@ import type { McpConnectionManager, McpServerEntry } from '#/mcpCore/connection-
import { IAgentMcpService } from '#/agent/mcp/mcp';
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 +329,39 @@ 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 path = /Original attachment saved at: ("[^\n]+")/.exec(output.note ?? '')?.[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