diff --git a/projects/keepkey-vault/src/bun/mcp.test.ts b/projects/keepkey-vault/src/bun/mcp.test.ts index 965c4fbc..9630eb4b 100644 --- a/projects/keepkey-vault/src/bun/mcp.test.ts +++ b/projects/keepkey-vault/src/bun/mcp.test.ts @@ -5,17 +5,10 @@ * * Run: bun test src/bun/mcp.test.ts */ -import { describe, test, expect } from 'bun:test' +import { describe, test, expect, afterEach } from 'bun:test' import { handleMcpRequest } from './mcp' -import { callBex, onBexOpen, onBexClose, bridgeConnected } from './bex-bridge' +import { callBex, onBexOpen, onBexClose, onBexMessage, bridgeConnected } from './bex-bridge' -function post(body: unknown): Request { - return new Request('http://localhost:1646/mcp', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: typeof body === 'string' ? body : JSON.stringify(body), - }) -} const call = async (body: unknown, headers?: Record) => { const req = new Request('http://localhost:1646/mcp', { method: 'POST', @@ -78,7 +71,7 @@ describe('MCP JSON-RPC handler', () => { expect(json.result.protocolVersion).toBe('2025-06-18') }) - test('tools/list returns the read-only tool catalog', async () => { + test('tools/list serves the static fallback catalog with the bridge DOWN', async () => { const { json } = await call({ jsonrpc: '2.0', id: 2, method: 'tools/list' }) const names = json.result.tools.map((t: any) => t.name) expect(names).toContain('bex_status') @@ -90,8 +83,23 @@ describe('MCP JSON-RPC handler', () => { expect(json.error.code).toBe(-32601) }) - test('unknown tool → -32602', async () => { + test('an unknown (but well-formed) tool name is forwarded, not rejected on the catalog', async () => { + // The vault has no catalog to check against, so the name itself is the + // BEX's call. Bridge is down here, so what surfaces is the disconnect — + // an execution failure, hence isError rather than a protocol error. const { json } = await call({ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'nope' } }) + expect(json.error).toBeUndefined() + expect(json.result.isError).toBe(true) + }) + + test.each([ + ['missing', undefined], + ['empty', ''], + ['non-string', 42], + ])('a %s tool name → -32602 immediately, never a 30s bridge timeout', async (_label, name) => { + // The BEX drops frames with a falsy `tool` without ever replying, so + // forwarding one would hang until CALL_TIMEOUT_MS. Shape is ours to check. + const { json } = await call({ jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name } }) expect(json.error.code).toBe(-32602) }) @@ -104,6 +112,84 @@ describe('MCP JSON-RPC handler', () => { }) }) +describe('MCP dumb pipe (bridge UP)', () => { + // bex-bridge is a module singleton, so a socket left open by a failing test + // leaks into the next one. Close in afterEach, not at the end of each test + // body, which a failed expect() would skip. + let open: any = null + afterEach(() => { + if (open) onBexClose(open) + open = null + }) + + // A fake BEX that answers each forwarded call with a canned reply, keyed by + // tool name. Lets us drive the proxied catalog + content passthrough with no + // extension and no device. + const fakeBex = (replies: Record) => { + const ws = { + send(raw: string) { + const { id, tool } = JSON.parse(raw) + // Mirrors the real BEX: a frame without a truthy id/tool is dropped with + // NO reply (mcpBridge.ts `if (!msg?.id || !msg?.tool) return`). That + // silence is exactly what makes an unvalidated name hang for 30s. + if (!id || !tool) return + const result = replies[tool] + queueMicrotask(() => + onBexMessage(ws as any, JSON.stringify( + result === undefined + ? { id, error: { code: 'unknown_tool', message: `unknown tool: ${tool}` } } + : { id, result }, + )), + ) + }, + close() {}, + } + return ws + } + + test('tools/list serves the BEX catalog, not the vault fallback', async () => { + const ws = open = fakeBex({ bex_list_tools: { tools: [{ name: 'bex_screenshot' }, { name: 'bex_click' }] } }) + onBexOpen(ws as any) + const { json } = await call({ jsonrpc: '2.0', id: 10, method: 'tools/list' }) + expect(json.result.tools.map((t: any) => t.name)).toEqual(['bex_screenshot', 'bex_click']) + }) + + test('a content-block result passes through untouched (image is not stringified)', async () => { + const shot = { content: [{ type: 'image', data: 'AAAA', mimeType: 'image/jpeg' }] } + const ws = open = fakeBex({ bex_screenshot: shot }) + onBexOpen(ws as any) + const { json } = await call({ jsonrpc: '2.0', id: 11, method: 'tools/call', params: { name: 'bex_screenshot' } }) + expect(json.result).toEqual(shot) // not double-encoded into a text block + }) + + test('a plain (non-content) result is still text-wrapped', async () => { + const ws = open = fakeBex({ bex_accounts: { ethereum: '0xabc' } }) + onBexOpen(ws as any) + const { json } = await call({ jsonrpc: '2.0', id: 12, method: 'tools/call', params: { name: 'bex_accounts' } }) + expect(JSON.parse(json.result.content[0].text)).toEqual({ ethereum: '0xabc' }) + }) + + test("the BEX's unknown_tool becomes a -32602 protocol error, per the MCP tools spec", async () => { + // isError is for a valid tool that failed while executing; an unknown tool + // is a protocol error. The BEX still decides — we only translate. + const ws = open = fakeBex({}) + onBexOpen(ws as any) + const { json } = await call({ jsonrpc: '2.0', id: 13, method: 'tools/call', params: { name: 'nope' } }) + expect(json.result).toBeUndefined() + expect(json.error.code).toBe(-32602) + }) + + test('an empty tool name fails fast rather than hanging on a BEX that never replies', async () => { + // Regression: without the shape guard this forwards to a BEX that silently + // drops it, and the call sits for the full CALL_TIMEOUT_MS (30s). If this + // ever starts timing out instead of asserting, the guard is gone. + const ws = open = fakeBex({ bex_status: { ok: true } }) + onBexOpen(ws as any) + const { json } = await call({ jsonrpc: '2.0', id: 14, method: 'tools/call', params: { name: '' } }) + expect(json.error.code).toBe(-32602) + }, 1000) // must resolve WAY inside the 30s bridge timeout +}) + describe('BEX bridge call lifecycle', () => { const fakeWs = () => ({ sent: [] as string[], closed: false, send(d: string) { this.sent.push(d) }, close() { this.closed = true } }) diff --git a/projects/keepkey-vault/src/bun/mcp.ts b/projects/keepkey-vault/src/bun/mcp.ts index f3299e56..c08bd55b 100644 --- a/projects/keepkey-vault/src/bun/mcp.ts +++ b/projects/keepkey-vault/src/bun/mcp.ts @@ -11,9 +11,12 @@ * responses (no SSE stream, no sessions; both optional per spec). Swap in the * SDK if we ever need notifications/resources. * - * All tools execute inside the BEX; this file only owns the tool CATALOG and - * the forwarding. Bridge down → structured bridge_disconnected error - * (bex_status instead answers truthfully so agents can always probe). + * All tools execute inside the BEX, and the BEX also owns the CATALOG: this + * file is a dumb pipe that serves whatever bex_list_tools reports and passes + * tool content straight through. A new BEX tool therefore ships without a vault + * release. Bridge down → structured bridge_disconnected error, and tools/list + * serves FALLBACK_TOOLS (bex_status instead answers truthfully so agents can + * always probe). * * AUTH: /mcp requires a valid pairing bearer token — the SAME API keys as the * rest of the REST API (auth.requireAuth in rest-api's /mcp block). A local @@ -34,8 +37,12 @@ const PROTOCOL_VERSION = '2025-06-18' // negotiation), else offer ours. const SUPPORTED_PROTOCOL_VERSIONS = new Set(['2025-06-18', '2025-03-26', '2024-11-05']) -// Tier-1 read-only tools (Phase 1). Tier-2 control tools land in Phase 2. -const TOOLS = [ +// FALLBACK ONLY — not the source of truth. The BEX owns the catalog and +// answers bex_list_tools; these five tier-1 entries are served by tools/list +// only when the bridge is down, so `claude mcp add` still succeeds and the +// agent can reach bex_status to find out why. Do NOT add tools here: a new tool +// belongs in the BEX alone (HANDOFF_vault_mcp_dumb_pipe.md, keepkey-client). +const FALLBACK_TOOLS = [ { name: 'bex_status', description: @@ -84,8 +91,6 @@ const TOOLS = [ }, ] -const TOOL_NAMES = new Set(TOOLS.map(t => t.name)) - const json = (body: unknown, status = 200, cors: Record = {}) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json', ...cors } }) @@ -146,15 +151,38 @@ export async function handleMcpRequest(req: Request, cors: Record