From ea1c94b05b7dfa9cd71792f72af6f05f311641c9 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sun, 26 Apr 2026 23:38:39 +0200 Subject: [PATCH] fix: wrap non-object values in toolJson, add e2e smoke test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: }. 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) --- scripts/smoke-test.ts | 97 +++++++++++++++++++++++++++++++++++++++++++ src/errors.ts | 11 ++++- 2 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 scripts/smoke-test.ts diff --git a/scripts/smoke-test.ts b/scripts/smoke-test.ts new file mode 100644 index 0000000..8e66810 --- /dev/null +++ b/scripts/smoke-test.ts @@ -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 { + const file = readFileSync(resolve(process.cwd(), '.env'), 'utf8') + const out: Record = {} + 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 = {}) { + 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 + 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) +}) diff --git a/src/errors.ts b/src/errors.ts index e7298cc..b515c8a 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -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 = + value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : { result: value } return { - content: [{ type: 'text', text: JSON.stringify(value, null, 2) }], - structuredContent: value as Record, + content: [{ type: 'text', text }], + structuredContent: structured, } }