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
38 changes: 34 additions & 4 deletions src/tools/mcp/MCPTool/MCPTool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
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
Expand Down Expand Up @@ -67,11 +95,12 @@ export const MCPTool = {
</Box>
)
}
const lines = item.text.split('\n').length
const content = normalizeTextOutput(item.text ?? item)
const lines = content.split('\n').length
return (
<OutputLine
key={i}
content={item.text}
content={content}
lines={lines}
verbose={verbose}
/>
Expand All @@ -92,8 +121,9 @@ export const MCPTool = {
)
}

const lines = output.split('\n').length
return <OutputLine content={output} lines={lines} verbose={verbose} />
const content = normalizeTextOutput(output)
const lines = content.split('\n').length
return <OutputLine content={content} lines={lines} verbose={verbose} />
},
renderResultForAssistant(content) {
return content
Expand Down
56 changes: 36 additions & 20 deletions tests/e2e/cli-smoke.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,29 @@
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 {
return s.replace(/\r\n/g, '\n')
}

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', () => {
Expand All @@ -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 },
)
})
39 changes: 22 additions & 17 deletions tests/unit/lsp-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
53 changes: 53 additions & 0 deletions tests/unit/mcp-tool-result-rendering.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string> {
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(<Box>{element}</Box>, {
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"')
})
})
Loading