diff --git a/src/tools/mcp/MCPTool/MCPTool.tsx b/src/tools/mcp/MCPTool/MCPTool.tsx index 25f4c4528..cc0a2f599 100644 --- a/src/tools/mcp/MCPTool/MCPTool.tsx +++ b/src/tools/mcp/MCPTool/MCPTool.tsx @@ -9,6 +9,34 @@ import { OutputLine } from '@tools/BashTool/OutputLine' const inputSchema = z.object({}).passthrough() +function extractSingleResultText(value: unknown): string | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + + const record = value as Record + const keys = Object.keys(record) + if (keys.length === 1 && typeof record.result === 'string') { + return record.result + } + + return null +} + +function normalizeTextOutput(output: unknown): string { + if (typeof output === 'string') { + try { + const resultText = extractSingleResultText(JSON.parse(output)) + if (resultText !== null) return resultText + } catch {} + + return output + } + + const resultText = extractSingleResultText(output) + if (resultText !== null) return resultText + + return JSON.stringify(output) +} + export const MCPTool = { async isEnabled() { return true @@ -67,11 +95,12 @@ export const MCPTool = { ) } - const lines = item.text.split('\n').length + const content = normalizeTextOutput(item.text ?? item) + const lines = content.split('\n').length return ( @@ -92,8 +121,9 @@ export const MCPTool = { ) } - const lines = output.split('\n').length - return + const content = normalizeTextOutput(output) + const lines = content.split('\n').length + return }, renderResultForAssistant(content) { return content diff --git a/tests/e2e/cli-smoke.test.ts b/tests/e2e/cli-smoke.test.ts index 49a92326b..38a8c0b28 100644 --- a/tests/e2e/cli-smoke.test.ts +++ b/tests/e2e/cli-smoke.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from 'bun:test' import { spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import pkg from '../../package.json' function normalizeNewlines(s: string): string { @@ -7,11 +10,20 @@ function normalizeNewlines(s: string): string { } function run(args: string[], options?: { cwd?: string }) { - return spawnSync(process.execPath, args, { - cwd: options?.cwd ?? process.cwd(), - env: { ...process.env, NODE_ENV: 'test' }, - encoding: 'utf8', - }) + const configDir = mkdtempSync(join(tmpdir(), 'kode-cli-smoke-config-')) + try { + return spawnSync(process.execPath, args, { + cwd: options?.cwd ?? process.cwd(), + env: { + ...process.env, + KODE_CONFIG_DIR: configDir, + NODE_ENV: 'test', + }, + encoding: 'utf8', + }) + } finally { + rmSync(configDir, { recursive: true, force: true }) + } } describe('CLI E2E smoke', () => { @@ -30,19 +42,23 @@ describe('CLI E2E smoke', () => { expect((res.stdout ?? '').trim()).toBe(String(pkg.version)) }) - test('--print validates stream-json requirements (offline)', () => { - const res = run([ - 'src/entrypoints/cli.tsx', - '--print', - '--input-format', - 'stream-json', - '--output-format', - 'stream-json', - ]) - expect(res.status).toBe(1) - const err = normalizeNewlines(res.stderr ?? '') - expect(err).toContain( - 'Error: When using --print, --output-format=stream-json requires --verbose', - ) - }) + test( + '--print validates stream-json requirements (offline)', + () => { + const res = run([ + 'src/entrypoints/cli.tsx', + '--print', + '--input-format', + 'stream-json', + '--output-format', + 'stream-json', + ]) + expect(res.status).toBe(1) + const err = normalizeNewlines(res.stderr ?? '') + expect(err).toContain( + 'Error: When using --print, --output-format=stream-json requires --verbose', + ) + }, + { timeout: 30_000 }, + ) }) diff --git a/tests/unit/lsp-tool.test.ts b/tests/unit/lsp-tool.test.ts index 7a336b54d..770ffbdef 100644 --- a/tests/unit/lsp-tool.test.ts +++ b/tests/unit/lsp-tool.test.ts @@ -119,25 +119,30 @@ describe('LSP tool (TypeScript backend)', () => { { timeout: 15_000 }, ) - test('findReferences returns formatted grouped locations + counts', async () => { - const ctx = makeContext() - const input = { - operation: 'findReferences', - filePath, - line: 2, - character: 32, - } as const + test( + 'findReferences returns formatted grouped locations + counts', + async () => { + const ctx = makeContext() + const input = { + operation: 'findReferences', + filePath, + line: 2, + character: 32, + } as const - const events: any[] = [] - for await (const evt of (LspTool as any).call(input, ctx)) events.push(evt) - expect(events).toHaveLength(1) + const events: any[] = [] + for await (const evt of (LspTool as any).call(input, ctx)) + events.push(evt) + expect(events).toHaveLength(1) - const out = events[0].data - expect(out.operation).toBe('findReferences') - expect(out.result).toContain('references') - expect(out.resultCount).toBeGreaterThanOrEqual(3) - expect(out.fileCount).toBeGreaterThanOrEqual(1) - }) + const out = events[0].data + expect(out.operation).toBe('findReferences') + expect(out.result).toContain('references') + expect(out.resultCount).toBeGreaterThanOrEqual(3) + expect(out.fileCount).toBeGreaterThanOrEqual(1) + }, + { timeout: 30_000 }, + ) test('hover returns formatted hover result + counts', async () => { const ctx = makeContext() diff --git a/tests/unit/mcp-tool-result-rendering.test.tsx b/tests/unit/mcp-tool-result-rendering.test.tsx new file mode 100644 index 000000000..5a8bd6caf --- /dev/null +++ b/tests/unit/mcp-tool-result-rendering.test.tsx @@ -0,0 +1,53 @@ +import { describe, expect, test } from 'bun:test' +import { Box, render } from 'ink' +import React from 'react' +import { PassThrough } from 'stream' +import stripAnsi from 'strip-ansi' +import { MCPTool } from '@tools/mcp/MCPTool/MCPTool' + +async function renderToText(element: React.ReactElement): Promise { + const stdin = new PassThrough() + ;(stdin as any).isTTY = true + ;(stdin as any).isRaw = true + ;(stdin as any).setRawMode = () => {} + stdin.setEncoding('utf8') + stdin.resume() + + const stdout = new PassThrough() + ;(stdout as any).isTTY = true + ;(stdout as any).columns = 100 + ;(stdout as any).rows = 30 + + let rawOutput = '' + stdout.on('data', chunk => { + rawOutput += chunk.toString('utf8') + }) + + const instance = render({element}, { + stdin: stdin as any, + stdout: stdout as any, + exitOnCtrlC: false, + }) + + await new Promise(resolve => setTimeout(resolve, 0)) + + instance.unmount() + return stripAnsi(rawOutput) +} + +describe('MCPTool.renderToolResultMessage', () => { + test('renders FastMCP string result content as text instead of JSON wrapper', async () => { + const content = '地点:西安\n温度:22 celsius\n状况:晴' + const element = MCPTool.renderToolResultMessage?.( + JSON.stringify({ result: content }), + { verbose: false }, + ) + + const out = await renderToText(<>{element}) + + expect(out).toContain('地点:西安') + expect(out).toContain('温度:22 celsius') + expect(out).toContain('状况:晴') + expect(out).not.toContain('{"result"') + }) +})