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 images, audio, and video are saved even when they can be delivered unchanged, because provider conversion or later history reduction may omit them. Session-attachment readers remain available without workspace filesystem access when the model supports the corresponding content. Originals are retained in the session's media storage instead of an evictable image cache. Saved originals, including images preserved during compression, have absolute paths and stable `kimi-file://` references. Pass a reference as the `path` to `Read` or `ReadMediaFile`; bytes are read from the current session's storage even when the workspace runtime cannot access it. Pagination keeps the reference, including after a fork. For binary formats that `Read` cannot open, its error includes a server-local path when available; an external converter must have access to that filesystem. Text attachments such as CSV, HTML, JSON, and plain SVG use readable extensions.

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 pass the list’s `kimi-file://` reference to `Read` and page through it. Canceling the tool stops subsequent attachment processing and signals active writes. 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 附件。内嵌图片、音频和视频即使能够原样交付也会保存,因为后续供应商协议转换或历史精简可能省略它们。模型支持相应内容时,即使工作区文件系统不可用,也仍可读取会话附件。原件随会话保存在媒体存储中,不会被图片缓存淘汰。保存的原件(包括图片压缩前的原图)均提供绝对路径和稳定的 `kimi-file://` 引用。将引用作为 `path` 传给 `Read` 或 `ReadMediaFile`,即使工作区 runtime 无法访问会话存储,也能直接从当前会话存储读取字节。分页续读会保留该引用,包括 fork 后的会话。对于 `Read` 无法打开的二进制格式,错误信息会在可用时提供服务端本地路径;外部转换工具必须能够访问该文件系统。CSV、HTML、JSON 和普通 SVG 等文本附件使用可读取的扩展名。

附件路径和压缩说明共用工具输出预算。较长的清单会保存为文本文件,结果中保留简短指针,即使伴随的文本被截短,该指针仍然可见;Agent 可将清单的 `kimi-file://` 引用传给 `Read`,分页读取完整内容。取消工具调用会停止后续附件处理,并通知正在进行的写入操作。如果解码或保存失败,结果会明确说明原件未能保留,并保留其他可用输出。资源链接不会被自动下载。

## 接入方式

Kimi Code CLI 支持三种 MCP server 接入方式:
Expand Down
28 changes: 28 additions & 0 deletions packages/agent-core-v2/src/_base/execEnv/decodeText.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
export type TextDecodeErrors = 'strict' | 'replace' | 'ignore';

export async function* readUtf8Lines(
source: AsyncIterable<Uint8Array>,
errors: TextDecodeErrors = 'strict',
): AsyncGenerator<string> {
let pending: Buffer[] = [];
let offset = 0;
let pendingOffset = 0;
for await (const bytes of source) {
const chunk = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
let start = 0;
for (let i = 0; i < chunk.length; i++) {
if (chunk[i] !== 0x0a) continue;
const piece = chunk.subarray(start, i + 1);
const lineOffset = pending.length === 0 ? offset + start : pendingOffset;
const line = pending.length === 0 ? piece : Buffer.concat([...pending, piece]);
yield decodeTextWithErrors(line, 'utf-8', errors, lineOffset !== 0);
pending = [];
start = i + 1;
}
if (start < chunk.length) {
if (pending.length === 0) pendingOffset = offset + start;
pending.push(Buffer.from(chunk.subarray(start)));
}
offset += chunk.length;
}
if (pending.length > 0) yield decodeTextWithErrors(Buffer.concat(pending), 'utf-8', errors, pendingOffset !== 0);
}

function isUtf8Continuation(byte: number): boolean {
return byte >= 0x80 && byte <= 0xbf;
}
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
174 changes: 161 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 { 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 { buildDaemonFileUrl, mediaExtensionForMime } from '#/agent/media/mediaRef';
import type { MCPContentBlock, MCPToolResult } from '#/mcpCore/types';

export interface McpOutputOptions {
readonly signal?: AbortSignal;
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 @@ -115,9 +125,43 @@ export async function mcpResultToExecutableOutput(
qualifiedToolName: string,
options: McpOutputOptions = {},
): Promise<ExecutableToolResult> {
options.signal?.throwIfAborted();
const converted: ContentPart[] = [];
const attachmentNotices: string[] = [];
const preservedUrls = new Set<string>();
let omittedAttachment = false;
const preserveInlineMedia = async (url: string): Promise<void> => {
options.signal?.throwIfAborted();
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));
options.signal?.throwIfAborted();
const part = convertMCPContentBlock(block, options.providerType);
if (part.type === 'image_url' && options.attachmentStore !== undefined) await preserveInlineMedia(part.imageUrl.url);
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 @@ -150,30 +194,130 @@ export async function mcpResultToExecutableOutput(
}

const compressed = await compressImageContentParts(wrapped, {
signal: options.signal,
telemetry: options.telemetry,
telemetrySource: 'mcp_tool_result',
providerType: options.providerType,
annotate: {
persistOriginal: (bytes, mimeType) =>
persistOriginalImage(
persistOriginal: async (bytes, mimeType) => {
if (options.attachmentStore !== undefined) {
const saved = await saveAttachment(bytes, mimeType, options.attachmentStore, options.signal);
attachmentNotices.push(attachmentNotice(saved, mimeType, bytes.length));
return saved.reference;
}
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 }> {
options.signal?.throwIfAborted();
const content = [...new Set(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 saved = await saveAttachment(Buffer.from(content, 'utf8'), 'text/plain', options.attachmentStore, options.signal);
const pointer = [
...(saved.path === undefined ? [] : [`MCP attachment details saved at: ${JSON.stringify(saved.path)}`]),
`Attachment details reference: ${JSON.stringify(saved.reference)}`,
`Session-relative attachment details: ${JSON.stringify(saved.relativePath)}`,
'Pass the attachment details reference to Read to retrieve all original attachment references and compression details in the current session.',
].join('\n');
return { content: pointer, suffix: pointer };
} catch {
options.signal?.throwIfAborted();
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> {
options.signal?.throwIfAborted();
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 saved = await saveAttachment(bytes, mimeType, options.attachmentStore, options.signal);
return attachmentNotice(saved, mimeType, bytes.length);
} catch (error) {
options.signal?.throwIfAborted();
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.`;
}
}

interface SavedAttachment {
readonly path?: string;
readonly reference: string;
readonly relativePath: string;
}

function attachmentNotice(saved: SavedAttachment, mimeType: string, size: number): string {
return [
...(saved.path === undefined ? [] : [`Original attachment saved at: ${JSON.stringify(saved.path)}`]),
`Attachment reference: ${JSON.stringify(saved.reference)}`,
`Session-relative attachment: ${JSON.stringify(saved.relativePath)}`,
`MIME: ${JSON.stringify(mimeType)}; size: ${String(size)} bytes. Pass the attachment reference to Read or ReadMediaFile in the current session. For other binary formats, Read reports the resolved local path for a converter.`,
].join('\n');
}

async function saveAttachment(
bytes: Uint8Array,
mimeType: string,
store: ISessionMediaStore,
signal?: AbortSignal,
): Promise<SavedAttachment> {
signal?.throwIfAborted();
const mime = mimeType.split(';')[0]!.trim().toLowerCase();
const hash = createHash('sha256').update(mime).update('\0').update(bytes).digest('hex');
const ext = mime === 'text/csv' ? '.csv'
: mime === 'text/html' ? '.html'
: mime === 'application/json' || mime.endsWith('+json') ? '.json'
: mime.startsWith('text/') ? '.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
const fileId = `f_mcp_${hash}`;
const path = await store.materialize({
fileId,
size: bytes.length,
name: `attachment${ext}`,
mimeType: mime,
stream: () => Readable.from([bytes]),
signal,
});
signal?.throwIfAborted();
return { path, reference: buildDaemonFileUrl(fileId), relativePath: `media/${fileId}${ext}` };
}

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 +370,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 +395,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
5 changes: 4 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,8 @@ export function createMcpTool(
result = await retryAfterReconnect(error, client, args, context, options, callTool);
}
return mcpResultToExecutableOutput(result, qualifiedName, {
signal: context.signal,
attachmentStore: options.attachmentStore,
Comment thread
RealKai42 marked this conversation as resolved.
originalsDir: options.originalsDir,
telemetry: options.telemetry,
providerType: options.providerType?.(),
Expand Down
Loading
Loading