mcp-fixture.mjs 3.2 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
  1. /** Private stdio browser fixture; each process owns independent state. */
  2. import { appendFileSync, existsSync, watch } from 'node:fs'
  3. import { join } from 'node:path'
  4. import { createInterface } from 'node:readline'
  5. const [root, mode] = process.argv.slice(2)
  6. const record = (event, values = {}) => appendFileSync(join(root, 'events.ndjson'), JSON.stringify({ event, pid: process.pid, ...values }) + '\n')
  7. const reply = (id, result) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n')
  8. let counter = 0
  9. record('start')
  10. process.once('exit', () => record('exit'))
  11. const lines = createInterface({ input: process.stdin })
  12. lines.once('close', () => process.exit(0))
  13. lines.on('line', line => {
  14. const request = JSON.parse(line)
  15. if (request.id === undefined) return
  16. switch (request.method) {
  17. case 'server/discover':
  18. record('probe')
  19. if (mode === 'fail') process.exit(1)
  20. if (mode === 'hold') return
  21. {
  22. const respond = () => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, error: { code: -32601, message: 'Legacy browser fixture' } }) + '\n')
  23. if (mode === 'gate' && !existsSync(join(root, 'release'))) {
  24. const watcher = watch(root, () => {
  25. if (!existsSync(join(root, 'release'))) return
  26. watcher.close()
  27. respond()
  28. })
  29. if (existsSync(join(root, 'release'))) { watcher.close(); respond() }
  30. } else respond()
  31. }
  32. break
  33. case 'initialize':
  34. record('initialize')
  35. if (mode === 'fail') process.exit(1)
  36. if (mode === 'hold') return
  37. reply(request.id, {
  38. protocolVersion: request.params.protocolVersion, capabilities: { tools: {}, resources: {} },
  39. serverInfo: { name: 'browser-fixture', version: '1' }, instructions: 'BROWSER_FIXTURE_INSTRUCTION: use this Session browser.',
  40. })
  41. break
  42. case 'tools/list':
  43. reply(request.id, { tools: [
  44. { name: 'visit', description: 'Visit the fixture page.', inputSchema: { type: 'object', properties: { label: { type: 'string' } }, required: ['label'], additionalProperties: false } },
  45. { name: 'disconnect', description: 'Disconnect the fixture.', inputSchema: { type: 'object', properties: {} } },
  46. ] })
  47. break
  48. case 'tools/call':
  49. record('call', { name: request.params.name })
  50. if (request.params.name === 'disconnect') process.exit(0)
  51. counter += 1
  52. reply(request.id, { content: [{ type: 'text', text: `Visit ${counter}: ${request.params.arguments.label}` }], structuredContent: { counter, pid: process.pid } })
  53. break
  54. case 'resources/list':
  55. record('resource', { name: request.method })
  56. reply(request.id, { resources: [{ uri: 'browser-fixture://state', name: 'Browser state' }] })
  57. break
  58. case 'resources/templates/list':
  59. record('resource', { name: request.method })
  60. reply(request.id, { resourceTemplates: [] })
  61. break
  62. case 'resources/read':
  63. record('resource', { name: request.method })
  64. reply(request.id, { contents: [{ uri: request.params.uri, text: JSON.stringify({ counter, pid: process.pid }) }] })
  65. break
  66. default:
  67. throw new Error(`Unexpected method ${request.method}`)
  68. }
  69. })