返回 CodeWhale
mcp.test.mjs
根目录 / crates / tui / extension-host / test / mcp.test.mjs
1 // Real official SDK + committed builtin bundle; the Rust broker is a fake.
2 // Rust launch/ticket/HumanDecision receipts live in extension_host::mcp::tests.
3 import { test } from 'node:test'
4 import assert from 'node:assert/strict'
5 import { randomUUID } from 'node:crypto'
6 import { createMcpModule } from '../dist/builtin/mcp.mjs'
7 import { validateMessage } from '../dist/protocol.mjs'
8
9 const owner = { plugin_id: 'host:mcp', generation: 1, owner_token: 'builtin-test-token' }
10 const initParams = { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'codewhale-tui', version: '0.10.1' } }
11 class FakeBroker {
12 frames = []
13 writes = []
14 accepted = []
15 grants = new Map()
16 nextFacadeId = 1
17 serverPings = new Set()
18 waiting = undefined
19 closed = false
20 failWrite = undefined
21 silent = undefined
22 grant(method, params) {
23 const grant = { ticket: randomUUID(), operation_id: randomUUID(), method, ...(method.startsWith('notifications/') ? {} : { wire_id: String(this.nextFacadeId++) }), params: structuredClone(params) }
24 this.grants.set(grant.ticket, structuredClone(grant))
25 return grant
26 }
27 push(frame) { if (frame.method === 'ping') this.serverPings.add(JSON.stringify(frame.id)); if (this.waiting) { const take = this.waiting; this.waiting = undefined; take.resolve({ frame }) } else this.frames.push(frame) }
28 async request(method, params, signal) {
29 assert.deepEqual(params.owner, owner)
30 if (method === 'proc/launch') { assert.equal(params.ticket, 'launch-once'); return {} }
31 if (method === 'proc/close') { this.closed = true; this.waiting?.reject(new Error('fake broker closed')); this.waiting = undefined; return {} }
32 if (method === 'proc/read') {
33 if (this.closed) return { closed: true }
34 if (this.frames.length) return { frame: this.frames.shift() }
35 return new Promise((resolve, reject) => {
36 const abort = () => { this.waiting = undefined; reject(new Error('fake read aborted')) }
37 this.waiting = { resolve: value => { signal?.removeEventListener('abort', abort); resolve(value) }, reject: error => { signal?.removeEventListener('abort', abort); reject(error) } }
38 if (signal?.aborted) abort(); else signal?.addEventListener('abort', abort, { once: true })
39 })
40 }
41 assert.equal(method, 'proc/write')
42 const frame = params.frame
43 this.writes.push(structuredClone(frame))
44 if (frame.method === undefined) {
45 assert.ok(this.serverPings.delete(JSON.stringify(frame.id)), 'response must target an observed ping')
46 assert.deepEqual(frame.result, {})
47 } else if (frame.method !== 'notifications/cancelled') {
48 const grant = this.grants.get(params.ticket)
49 assert.ok(grant, 'single-use grant must exist')
50 assert.equal(grant.operation_id, params.operation_id)
51 assert.equal(grant.method, frame.method)
52 if (grant.wire_id !== undefined) assert.equal(frame.id, grant.wire_id)
53 assert.deepEqual(grant.params, frame.params ?? {})
54 this.grants.delete(params.ticket)
55 }
56 if (this.failWrite !== undefined && frame.method === this.failWrite) throw new Error('fake partial write failure')
57 this.accepted.push(structuredClone(frame))
58 if (frame.method === undefined || !Object.hasOwn(frame, 'id') || frame.method === this.silent) return {}
59 const result = frame.method === 'initialize'
60 ? { protocolVersion: '2025-06-18', capabilities: { tools: {}, resources: {}, prompts: {} }, serverInfo: { name: 'fixture', version: '1.0' } }
61 : frame.method === 'tools/list'
62 ? { tools: [{ name: 'echo', inputSchema: { type: 'object' } }], nextCursor: 'next-page' }
63 : frame.method === 'resources/list' ? { resources: [] }
64 : frame.method === 'resources/templates/list' ? { resourceTemplates: [] }
65 : frame.method === 'prompts/list' ? { prompts: [] }
66 : { content: [{ type: 'text', text: 'ok' }], isError: false }
67 this.push({ jsonrpc: '2.0', id: frame.id, result })
68 return {}
69 }
70 }
71 async function fixture() {
72 const broker = new FakeBroker(), module = createMcpModule(broker, owner)
73 const open = { owner, session_id: randomUUID(), launch_ticket: 'launch-once', initialize_grant: broker.grant('initialize', initParams), initialized_grant: broker.grant('notifications/initialized', {}), client_version: '0.10.1', deadline_ms: 1000 }
74 return { broker, module, open, signal: new AbortController().signal }
75 }
76
77 test('pinned SDK performs one legacy handshake and preserves per-page cursor without list cache aggregation', async () => {
78 const f = await fixture()
79 try {
80 const ready = await f.module.open(f.open, f.signal)
81 assert.equal(ready.protocolVersion, '2025-06-18')
82 assert.deepEqual(f.broker.accepted.map(f => f.method), ['initialize', 'notifications/initialized'])
83 assert.equal(f.broker.accepted[0].id, f.open.initialize_grant.wire_id)
84 assert.equal(f.broker.accepted[0].id, '1')
85 const result = await f.module.request({ owner, session_id: f.open.session_id, grant: f.broker.grant('tools/list', {}), deadline_ms: 1000 }, f.signal)
86 assert.equal(result.nextCursor, 'next-page')
87 assert.equal(f.broker.accepted.filter(f => f.method === 'tools/list').length, 1)
88 assert.equal(f.broker.accepted.some(f => f.method === 'server/discover'), false)
89 } finally { await f.module.dispose() }
90 assert.equal(f.broker.closed, true)
91 })
92
93 test('decoded initialize mismatch fails before a broker write', async () => {
94 const f = await fixture()
95 f.open.initialize_grant.params.capabilities = { sampling: {} }
96 await assert.rejects(f.module.open(f.open, f.signal), /exact operation grant/)
97 assert.equal(f.broker.writes.length, 0)
98 assert.equal(f.broker.closed, true)
99 })
100
101 test('plugin-tier frames cannot redeem imported builtin code, and stale owner identity is refused', async () => {
102 const frame = { jsonrpc: '2.0', id: 1, method: 'proc/write', params: { owner, session_id: 's', frame: { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }, ticket: 't', operation_id: 'o' } }
103 assert.throws(() => validateMessage(frame, 'host_to_core', 'plugin'), /not allowed/)
104 validateMessage(frame, 'host_to_core', 'builtin')
105 assert.throws(() => createMcpModule(new FakeBroker(), { ...owner, plugin_id: 'user/p/foreign' }), /pinned builtin/)
106 const f = await fixture()
107 await assert.rejects(f.module.open({ ...f.open, owner: { ...owner, generation: 2 } }, f.signal), /no longer live/)
108 assert.equal(f.broker.writes.length, 0)
109 await f.module.dispose()
110 })
111
112 test('partial write retires the pipe and never replays tools/call', async () => {
113 const f = await fixture()
114 await f.module.open(f.open, f.signal)
115 f.broker.failWrite = 'tools/call'
116 await assert.rejects(f.module.request({ owner, session_id: f.open.session_id, grant: f.broker.grant('tools/call', { name: 'echo', arguments: {} }), deadline_ms: 1000 }, f.signal), /partial write|Connection closed/)
117 assert.equal(f.broker.closed, true)
118 assert.equal(f.broker.writes.filter(f => f.method === 'tools/call').length, 1)
119 await f.module.dispose()
120 })
121
122 test('single-use request replay is refused and shuts down the same session', async () => {
123 const f = await fixture()
124 try {
125 await f.module.open(f.open, f.signal)
126 const grant = f.broker.grant('tools/list', {})
127 await f.module.request({ owner, session_id: f.open.session_id, grant, deadline_ms: 1000 }, f.signal)
128 await assert.rejects(f.module.request({ owner, session_id: f.open.session_id, grant, deadline_ms: 1000 }, f.signal), /single-use grant|Connection closed/)
129 assert.equal(f.broker.accepted.filter(f => f.method === 'tools/list').length, 1)
130 assert.equal(f.broker.closed, true)
131 } finally { await f.module.dispose() }
132 })
133
134 test('deadline cancellation targets the admitted request and owner disposal refuses queued work', async () => {
135 const f = await fixture()
136 await f.module.open(f.open, f.signal)
137 f.broker.silent = 'tools/list'
138 const first = f.module.request({ owner, session_id: f.open.session_id, grant: f.broker.grant('tools/list', {}), deadline_ms: 20 }, f.signal)
139 const queued = f.module.request({ owner, session_id: f.open.session_id, grant: f.broker.grant('prompts/list', {}), deadline_ms: 1000 }, f.signal)
140 const waitFirst = assert.rejects(first, /timed out/)
141 const waitQueued = assert.rejects(queued, /no longer live|closed|cancelled|stale/)
142 await waitFirst
143 await f.module.dispose()
144 await waitQueued
145 const request = f.broker.accepted.find(f => f.method === 'tools/list')
146 const cancel = f.broker.writes.find(f => f.method === 'notifications/cancelled')
147 assert.equal(cancel?.params.requestId, request.id)
148 assert.equal(f.broker.accepted.some(f => f.method === 'prompts/list'), false)
149 })
150
151 test('SDK answers an observed ping with exactly empty result and no operation ticket', async () => {
152 const f = await fixture()
153 try {
154 await f.module.open(f.open, f.signal)
155 f.broker.push({ jsonrpc: '2.0', id: 'server-ping', method: 'ping', params: {} })
156 await new Promise((resolve, reject) => { const timer = setInterval(() => {
157 if (f.broker.accepted.some(frame => frame.id === 'server-ping')) { clearInterval(timer); resolve() }
158 }, 1); setTimeout(() => { clearInterval(timer); reject(new Error('ping not answered')) }, 500).unref() })
159 const answer = f.broker.accepted.find(frame => frame.id === 'server-ping')
160 assert.deepEqual(answer, { jsonrpc: '2.0', id: 'server-ping', result: {} })
161 } finally { await f.module.dispose() }
162 })
163
163 lines Plain Text