From 6041f845e9b306e1d719fb64c2d5cef0d0c0917a Mon Sep 17 00:00:00 2001 From: root Date: Thu, 4 Jun 2026 20:09:30 +1000 Subject: [PATCH] feat(mcp): mount /mcp Streamable HTTP endpoint Co-Authored-By: Claude Opus 4.8 --- server.js | 5 +++++ tests/mcp/mcp_http.test.js | 39 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 tests/mcp/mcp_http.test.js diff --git a/server.js b/server.js index 46d9f46..813f17a 100644 --- a/server.js +++ b/server.js @@ -9,6 +9,8 @@ import { router as ingestRouter } from './lib/api/routes/ingest.js'; import { router as iconsRouter } from './lib/api/routes/icons.js'; import { startCron } from './lib/cron/index.js'; import { seedFromConfig } from './lib/health/registry.js'; +import { mcpAuth } from './lib/api/middleware/mcp_auth.js'; +import { handleMcp } from './lib/mcp/http.js'; const VERSION = '2.0.0-alpha.13'; @@ -41,6 +43,9 @@ export function createApp() { mountApi(app); + // MCP Streamable HTTP for external agents (read + suggest-only, space-scoped). + app.all('/mcp', mcpAuth, handleMcp); + app.use((_req, res) => res.status(404).json({ error: { code: 'not_found' } })); app.use((err, _req, res, _next) => { diff --git a/tests/mcp/mcp_http.test.js b/tests/mcp/mcp_http.test.js new file mode 100644 index 0000000..a55e4ce --- /dev/null +++ b/tests/mcp/mcp_http.test.js @@ -0,0 +1,39 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import request from 'supertest'; +import { setup } from '../api/helpers.js'; +import * as agentsRepo from '../../lib/db/repos/agents.js'; +import { pool } from '../../lib/db/pool.js'; + +let app, ownerHeaders, scopedToken; +const owner = { kind: 'user', id: null }; +const ACCEPT = 'application/json, text/event-stream'; +const init = { + jsonrpc: '2.0', id: 1, method: 'initialize', + params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 't', version: '1' } } +}; + +beforeAll(async () => { + ({ app, ownerHeaders } = await setup()); + const { rows: [{ id: spaceId }] } = await pool.query(`INSERT INTO spaces(slug,name) VALUES('s','S') RETURNING id`); + const agent = await agentsRepo.create({ slug: `m-${Date.now()}`, name: 'M', kind: 'claude', model: 'sonnet', + capabilities: { read: true, suggest: true }, scopes: { space_id: spaceId } }, owner); + ({ token: scopedToken } = await agentsRepo.createToken(agent.id, 'mcp')); +}); + +describe('POST /mcp', () => { + it('no bearer → 401', async () => { + const res = await request(app).post('/mcp').set('Accept', ACCEPT).send(init); + expect(res.status).toBe(401); + }); + it('owner token → 401 agent_required', async () => { + const res = await request(app).post('/mcp').set(ownerHeaders).set('Accept', ACCEPT).send(init); + expect(res.status).toBe(401); + expect(res.body.error.code).toBe('agent_required'); + }); + it('scoped agent initialize → 200 with serverInfo', async () => { + const res = await request(app).post('/mcp') + .set('Authorization', `Bearer ${scopedToken}`).set('Accept', ACCEPT).send(init); + expect(res.status).toBe(200); + expect(res.body.result.serverInfo.name).toBe('void-external'); + }); +});