fix: wrap non-object values in toolJson, add e2e smoke test
The MCP SDK rejects tools/call results where structuredContent is not a
record — array returns from list_products triggered an MCP error code
-32602. toolJson now wraps arrays/primitives as { result: <value> }.
scripts/smoke-test.ts spawns the built server over stdio, calls each
read-side tool against the live DB and asserts shape — surfaces this
bug class before regressions ship.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
56448559c7
commit
ea1c94b05b
2 changed files with 106 additions and 2 deletions
97
scripts/smoke-test.ts
Normal file
97
scripts/smoke-test.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// One-shot e2e smoke test against the live Scrum4Me database.
|
||||
// Reads .env, spawns the built server over stdio, calls each tool.
|
||||
//
|
||||
// npm run build && npx tsx scripts/smoke-test.ts
|
||||
//
|
||||
// Exits 0 on success, 1 on any tool failure.
|
||||
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
||||
import { readFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
|
||||
function loadEnv(): Record<string, string> {
|
||||
const file = readFileSync(resolve(process.cwd(), '.env'), 'utf8')
|
||||
const out: Record<string, string> = {}
|
||||
for (const line of file.split('\n')) {
|
||||
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/)
|
||||
if (m) out[m[1]] = m[2].replace(/^"(.*)"$/, '$1')
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function callTool(client: Client, name: string, args: Record<string, unknown> = {}) {
|
||||
const res = await client.callTool({ name, arguments: args })
|
||||
const text = (res.content as Array<{ type: string; text?: string }>)
|
||||
.filter((c) => c.type === 'text')
|
||||
.map((c) => c.text)
|
||||
.join('\n')
|
||||
return { isError: Boolean(res.isError), text }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const env = { ...process.env, ...loadEnv() } as Record<string, string>
|
||||
if (!env.DATABASE_URL) throw new Error('DATABASE_URL missing')
|
||||
if (!env.SCRUM4ME_TOKEN) throw new Error('SCRUM4ME_TOKEN missing')
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command: 'node',
|
||||
args: ['dist/index.js'],
|
||||
env,
|
||||
})
|
||||
const client = new Client({ name: 'smoke-test', version: '0.0.1' })
|
||||
await client.connect(transport)
|
||||
|
||||
let failed = 0
|
||||
const log = (label: string, ok: boolean, detail: string) => {
|
||||
const tag = ok ? 'PASS' : 'FAIL'
|
||||
console.log(`[${tag}] ${label}: ${detail.slice(0, 240)}${detail.length > 240 ? '…' : ''}`)
|
||||
if (!ok) failed++
|
||||
}
|
||||
|
||||
// tools/list
|
||||
const tools = await client.listTools()
|
||||
log(
|
||||
'tools/list',
|
||||
tools.tools.length === 9,
|
||||
`${tools.tools.length} tools: ${tools.tools.map((t) => t.name).join(', ')}`,
|
||||
)
|
||||
|
||||
// health
|
||||
const health = await callTool(client, 'health')
|
||||
log('health', !health.isError, health.text)
|
||||
const healthJson = JSON.parse(health.text)
|
||||
log('health.database', healthJson.database === 'ok', `database=${healthJson.database}`)
|
||||
|
||||
// list_products
|
||||
const products = await callTool(client, 'list_products')
|
||||
log('list_products', !products.isError, products.text)
|
||||
const productList = JSON.parse(products.text) as Array<{ id: string; name: string; code: string | null }>
|
||||
log('list_products.count', productList.length > 0, `count=${productList.length}`)
|
||||
|
||||
if (productList.length > 0) {
|
||||
const productId = productList[0].id
|
||||
const ctx = await callTool(client, 'get_claude_context', { product_id: productId })
|
||||
log('get_claude_context', !ctx.isError, ctx.text)
|
||||
}
|
||||
|
||||
// prompts/list
|
||||
const prompts = await client.listPrompts()
|
||||
log(
|
||||
'prompts/list',
|
||||
prompts.prompts.some((p) => p.name === 'implement_next_story'),
|
||||
`prompts: ${prompts.prompts.map((p) => p.name).join(', ')}`,
|
||||
)
|
||||
|
||||
await client.close()
|
||||
if (failed > 0) {
|
||||
console.error(`\n${failed} check(s) failed`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('\nall checks passed')
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('fatal:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
|
|
@ -19,9 +19,16 @@ export function toolError(message: string): CallToolResult {
|
|||
}
|
||||
|
||||
export function toolJson(value: unknown): CallToolResult {
|
||||
const text = JSON.stringify(value, null, 2)
|
||||
// structuredContent must be a JSON object per the MCP spec — wrap arrays
|
||||
// and primitives so the SDK's response validator accepts them.
|
||||
const structured: Record<string, unknown> =
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: { result: value }
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
||||
structuredContent: value as Record<string, unknown>,
|
||||
content: [{ type: 'text', text }],
|
||||
structuredContent: structured,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue