diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts new file mode 100644 index 0000000..bbf70df --- /dev/null +++ b/src/app/api/mcp/route.ts @@ -0,0 +1,3 @@ +export const runtime='nodejs'; +export const dynamic='force-dynamic'; +export {handleMcp as POST,handleMcp as GET,handleMcp as DELETE} from '@/mcp/http'; diff --git a/src/lib/auth/proxy-path.test.ts b/src/lib/auth/proxy-path.test.ts index 577bd5b..bdce57a 100644 --- a/src/lib/auth/proxy-path.test.ts +++ b/src/lib/auth/proxy-path.test.ts @@ -38,3 +38,12 @@ describe("auth proxy paths", () => { expect(classifyAuthPath("/dashboard/settings/users")).toBe("manager"); }); }); + +it('exempts only bounded MCP machine routes',()=>{ + expect(classifyAuthPath('/api/mcp')).toBe('machine'); + expect(classifyAuthPath('/api/mcp/jobs/tick')).toBe('machine'); + expect(classifyAuthPath('/api/mcp/documents/po/abcdefghijklmno')).toBe('machine'); + expect(classifyAuthPath('/api/mcpish')).toBe('admin'); + expect(classifyAuthPath('/api/mcp/unknown')).toBe('admin'); + expect(classifyAuthPath('/api/mcp/documents/unknown/abcdefghijklmno')).toBe('admin'); +}); diff --git a/src/lib/auth/proxy-path.ts b/src/lib/auth/proxy-path.ts index 44c56de..68bdec6 100644 --- a/src/lib/auth/proxy-path.ts +++ b/src/lib/auth/proxy-path.ts @@ -3,6 +3,9 @@ export function classifyAuthPath(pathname: string): AuthPath { if (pathname === "/api/dashboard-attention") return "dashboard"; if (pathname === "/api/health" || pathname.startsWith("/api/auth/")) return "public"; if ( + pathname === "/api/mcp" || + pathname === "/api/mcp/jobs/tick" || + /^\/api\/mcp\/documents\/(po|receipt|source|page|candidate)\/[a-z0-9]{15}$/.test(pathname) || pathname === "/api/purchase-orders/ingest" || pathname === "/api/purchase-orders/process-batch" || pathname === "/api/dn-batches/tick" || diff --git a/src/lib/purchasing/types.ts b/src/lib/purchasing/types.ts new file mode 100644 index 0000000..0bfd5b3 --- /dev/null +++ b/src/lib/purchasing/types.ts @@ -0,0 +1,7 @@ +import type PocketBase from 'pocketbase'; +export type Permission = 'read' | 'write' | 'process'; +export type Principal = {id: string; permissions: ReadonlySet}; +export type PurchasingContext = {pb: PocketBase; principal: Principal}; +export type MutationInput = {operationId: string; reason?: string}; +export type OperationResult = {operationId: string; status: 'pending' | 'committed' | 'failed' | 'uncertain'; result: Record | null}; +export type DocumentReference = {kind: 'po' | 'receipt' | 'source' | 'page' | 'candidate'; id: string}; diff --git a/src/mcp/auth.test.ts b/src/mcp/auth.test.ts new file mode 100644 index 0000000..eb7f6af --- /dev/null +++ b/src/mcp/auth.test.ts @@ -0,0 +1,38 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createHash } from 'node:crypto'; +import { authenticateMcp, requirePermission } from './auth'; +const token = 'a-long-test-token-with-32-random-characters'; +const digest = createHash('sha256').update(token).digest('hex'); +const request = (headers: Record = {}) => new Request('https://mega.test/api/mcp', {headers}); +beforeEach(() => { + vi.stubEnv('MCP_PUBLIC_URL','https://mega.test/api/mcp'); + vi.stubEnv('MCP_ALLOWED_ORIGINS','https://mega.test'); + vi.stubEnv('MCP_CLIENTS_JSON',JSON.stringify([{id:'test',tokenHash:digest,permissions:['read']} ])); +}); +afterEach(() => vi.unstubAllEnvs()); +describe('MCP authentication', () => { + it('accepts bearer auth without cookies', () => expect(authenticateMcp(request({Authorization:`Bearer ${token}`})).id).toBe('test')); + it.each([undefined,'Bearer wrong','Basic password'])('rejects missing/invalid auth %s', value => { + expect(() => authenticateMcp(request(value ? {Authorization:value} : {}))).toThrow('unauthorized'); + }); + it('revokes credentials on the next request', () => { + vi.stubEnv('MCP_CLIENTS_JSON',JSON.stringify([{id:'replacement',tokenHash:'a'.repeat(64),permissions:['read']}])); + expect(() => authenticateMcp(request({Authorization:`Bearer ${token}`}))).toThrow('unauthorized'); + }); + it.each(['','{}','[]',JSON.stringify([{id:'bad',tokenHash:'placeholder',permissions:['read']}])])('fails closed for malformed configuration', config => { + vi.stubEnv('MCP_CLIENTS_JSON',config); + expect(() => authenticateMcp(request())).toThrow('mcp_setup_required'); + }); + it('rejects duplicate credentials', () => { + vi.stubEnv('MCP_CLIENTS_JSON',JSON.stringify([{id:'one',tokenHash:digest,permissions:['read']},{id:'two',tokenHash:digest,permissions:['read']}])); + expect(() => authenticateMcp(request())).toThrow('mcp_setup_required'); + }); + it('checks origins and host', () => { + expect(() => authenticateMcp(request({Authorization:`Bearer ${token}`,Origin:'https://evil.test'}))).toThrow('origin_forbidden'); + expect(() => authenticateMcp(new Request('https://evil.test/api/mcp',{headers:{Authorization:`Bearer ${token}`}}))).toThrow('host_forbidden'); + }); + it('enforces capabilities inside tool handlers', () => { + const principal=authenticateMcp(request({Authorization:`Bearer ${token}`})); + expect(() => requirePermission(principal,'write')).toThrow('permission_denied'); + }); +}); diff --git a/src/mcp/auth.ts b/src/mcp/auth.ts new file mode 100644 index 0000000..0fabbd4 --- /dev/null +++ b/src/mcp/auth.ts @@ -0,0 +1,43 @@ +import { hash, timingSafeEqual } from 'node:crypto'; +import {z} from 'zod'; +import type {Permission, Principal} from '@/lib/purchasing/types'; +export class McpHttpError extends Error { + constructor(public status: number, message: string) {super(message);} +} +const ClientsSchema=z.array(z.object({ + id:z.string().min(1).max(80).regex(/^[a-zA-Z0-9_-]+$/), + tokenHash:z.string().regex(/^[a-f0-9]{64}$/), + permissions:z.array(z.enum(['read','write','process'])).min(1), +}).strict()).min(1).max(100); +export function requirePermission(principal:Principal,permission:Permission):void { + if(!principal.permissions.has(permission))throw new McpHttpError(403,'permission_denied'); +} +export function authenticateMcp(request:Request):Principal { + let clients:z.infer; + let publicUrl:URL; + let origins:string[]; + try { + clients=ClientsSchema.parse(JSON.parse(process.env.MCP_CLIENTS_JSON||'')); + if(new Set(clients.map(c=>c.id)).size!==clients.length||new Set(clients.map(c=>c.tokenHash)).size!==clients.length)throw Error(); + if(clients.some(c=>c.permissions.includes('write')&&!c.permissions.includes('read')))throw Error(); + publicUrl=new URL(process.env.MCP_PUBLIC_URL||''); + if(!['https:','http:'].includes(publicUrl.protocol)||publicUrl.username||publicUrl.password||publicUrl.pathname!=='/api/mcp'||publicUrl.search||publicUrl.hash)throw Error(); + if(publicUrl.protocol==='http:'&&!['localhost','127.0.0.1','[::1]'].includes(publicUrl.hostname))throw Error(); + origins=(process.env.MCP_ALLOWED_ORIGINS||publicUrl.origin).split(',').map(s=>s.trim()); + if(origins.some(o=>new URL(o).origin!==o))throw Error(); + } catch {throw new McpHttpError(503,'mcp_setup_required');} + const requestUrl=new URL(request.url); + const host=request.headers.get('host'); + if(requestUrl.host!==publicUrl.host||(host&&host!==publicUrl.host))throw new McpHttpError(403,'host_forbidden'); + const origin=request.headers.get('origin'); + if(origin&&!origins.includes(origin))throw new McpHttpError(403,'origin_forbidden'); + const match=/^Bearer ([^\s]{16,512})$/i.exec(request.headers.get('authorization')||''); + if(!match)throw new McpHttpError(401,'unauthorized'); + const digest=Buffer.from(hash('sha256',match[1]),'hex'); + let principal:Principal|undefined; + for(const client of clients){ + if(timingSafeEqual(digest,Buffer.from(client.tokenHash,'hex')))principal={id:client.id,permissions:new Set(client.permissions)}; + } + if(!principal)throw new McpHttpError(401,'unauthorized'); + return principal; +} diff --git a/src/mcp/create-server.ts b/src/mcp/create-server.ts new file mode 100644 index 0000000..8119c3f --- /dev/null +++ b/src/mcp/create-server.ts @@ -0,0 +1,22 @@ +import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js'; +import {tools} from '@/lib/agent/tools'; +import type {Principal} from '@/lib/purchasing/types'; +import {requirePermission} from './auth'; +export function createMcpServer(principal:Principal):McpServer { + const server=new McpServer({name:'mega-infrastructure',version:'1.1.0'}); + if(principal.permissions.has('read'))for(const tool of tools){ + server.registerTool(tool.name,{ + description:tool.description,inputSchema:tool.schema, + annotations:{readOnlyHint:true,destructiveHint:false}, + },async(args)=>{ + try { + requirePermission(principal,'read'); + const result=await tool.handler(args as Record); + return {content:[{type:'text' as const,text:JSON.stringify(result)}]}; + } catch { + return {isError:true,content:[{type:'text' as const,text:'read_failed: check server credentials and data availability'}]}; + } + }); + } + return server; +} diff --git a/src/mcp/http.test.ts b/src/mcp/http.test.ts new file mode 100644 index 0000000..19b35ba --- /dev/null +++ b/src/mcp/http.test.ts @@ -0,0 +1,31 @@ +import {afterEach,beforeEach,expect,it,vi} from 'vitest'; +import {createHash} from 'node:crypto'; +vi.mock('@/lib/agent/tools',()=>({tools:[{name:'fixture_read',description:'Read a fixture',schema:{},handler:vi.fn(async()=>({ok:true}))}]})); +import {handleMcp} from './http'; +const token='fixture-token-with-sufficient-entropy-for-tests'; +const req=(body:unknown, auth=true)=>new Request('https://mega.test/api/mcp',{method:'POST',headers:{'Content-Type':'application/json',Accept:'application/json, text/event-stream',...(auth?{Authorization:`Bearer ${token}`}:{})},body:JSON.stringify(body)}); +beforeEach(()=>{ + vi.stubEnv('MCP_PUBLIC_URL','https://mega.test/api/mcp'); + vi.stubEnv('MCP_CLIENTS_JSON',JSON.stringify([{id:'fixture',tokenHash:createHash('sha256').update(token).digest('hex'),permissions:['read']}])); +}); +afterEach(()=>vi.unstubAllEnvs()); +it('initializes using the actual SDK without a cookie or session',async()=>{ + const res=await handleMcp(req({jsonrpc:'2.0',id:1,method:'initialize',params:{protocolVersion:'2025-03-26',capabilities:{},clientInfo:{name:'test',version:'1'}}})); + expect(res.status).toBe(200);expect((await res.json()).result.serverInfo.name).toBe('mega-infrastructure'); +}); +it('discovers and calls read tools through fresh requests',async()=>{ + const listed=await handleMcp(req({jsonrpc:'2.0',id:2,method:'tools/list',params:{}})); + expect((await listed.json()).result.tools.map((t:{name:string})=>t.name)).toEqual(['fixture_read']); + const called=await handleMcp(req({jsonrpc:'2.0',id:3,method:'tools/call',params:{name:'fixture_read',arguments:{}}})); + expect(JSON.parse((await called.json()).result.content[0].text)).toEqual({ok:true}); +}); +it('rejects missing auth and malformed config before protocol work',async()=>{ + expect((await handleMcp(req({},false))).status).toBe(401); + vi.stubEnv('MCP_CLIENTS_JSON','');expect((await handleMcp(req({}))).status).toBe(503); +}); +it('returns protocol errors for malformed JSON and unsupported methods',async()=>{ + const headers={Authorization:`Bearer ${token}`,Accept:'application/json, text/event-stream'}; + expect((await handleMcp(new Request('https://mega.test/api/mcp',{method:'POST',headers,body:'{'}))).status).toBe(400); + expect((await handleMcp(new Request('https://mega.test/api/mcp',{method:'DELETE',headers}))).status).toBe(405); + expect((await handleMcp(new Request('https://mega.test/api/mcp',{method:'GET',headers}))).status).toBe(405); +}); diff --git a/src/mcp/http.ts b/src/mcp/http.ts new file mode 100644 index 0000000..e04d219 --- /dev/null +++ b/src/mcp/http.ts @@ -0,0 +1,38 @@ +import {WebStandardStreamableHTTPServerTransport} from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; +import {authenticateMcp,McpHttpError} from './auth'; +import {createMcpServer} from './create-server'; +const MAX_BODY=32*1024*1024; +async function readBody(request:Request):Promise{ + const length=request.headers.get('content-length'); + if(length&&(!/^\d+$/.test(length)||Number(length)>MAX_BODY))throw new McpHttpError(413,'request_too_large'); + const reader=request.body?.getReader(); + if(!reader)throw new McpHttpError(400,'invalid_json'); + const chunks:Uint8Array[]=[];let size=0; + try { + for(;;){const part=await reader.read();if(part.done)break;size+=part.value.byteLength; + if(size>MAX_BODY){await reader.cancel();throw new McpHttpError(413,'request_too_large');}chunks.push(part.value); + } + try{return JSON.parse(Buffer.concat(chunks).toString('utf8'));}catch{throw new McpHttpError(400,'invalid_json');} + } finally {reader.releaseLock();} +} +export async function handleMcp(request:Request):Promise{ + try { + const principal=authenticateMcp(request); + // Stateless JSON mode has no standalone event stream or session to terminate. + if(request.method!=='POST')return Response.json({error:'method_not_supported'},{status:405,headers:{Allow:'POST'}}); + const parsedBody=await readBody(request); + const server=createMcpServer(principal); + const transport=new WebStandardStreamableHTTPServerTransport({sessionIdGenerator:undefined,enableJsonResponse:true}); + try { + await server.connect(transport); + const response=await transport.handleRequest(request,{parsedBody}); + // Consume the JSON response before closing the per-request transport. + const bytes=await response.arrayBuffer(); + const headers=new Headers(response.headers);headers.set('Cache-Control','no-store'); + return new Response(bytes.byteLength?bytes:null,{status:response.status,headers}); + } finally {await server.close();} + } catch(error){ + const status=error instanceof McpHttpError?error.status:500; + return Response.json({error:error instanceof McpHttpError?error.message:'mcp_request_failed'},{status,headers:{'Cache-Control':'no-store',...(status===401?{'WWW-Authenticate':'Bearer realm="MEGA MCP"'}:{})}}); + } +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 0af9529..217dc98 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,57 +1,9 @@ -/** - * MEGA INFRASTRUCTURE MCP server (read-only). - * - * Runs locally over stdio as your own process, using the PocketBase - * credentials in .env.local. It opens no network listener, so it adds - * no attack surface to the deployed app. - * - * npm run mcp - */ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { tools } from "@/lib/agent/tools"; - -const server = new McpServer({ - name: "mega-infrastructure", - version: "1.0.0", -}); - -for (const tool of tools) { - server.tool(tool.name, tool.description, tool.schema, async (args) => { - try { - const result = await tool.handler(args as Record); - return { - content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }], - }; - } catch (err) { - // Surface the real reason — a silent empty result would let the - // model invent an answer. - return { - isError: true, - content: [ - { - type: "text" as const, - text: `Tool ${tool.name} failed: ${ - err instanceof Error ? err.message : String(err) - }`, - }, - ], - }; - } - }); +/** Local MCP entry point. Remote write credentials never enable stdio writes. */ +import {StdioServerTransport} from "@modelcontextprotocol/sdk/server/stdio.js"; +import {createMcpServer} from "./create-server"; +async function main(){ + const server=createMcpServer({id:"local-stdio",permissions:new Set(["read"])}); + await server.connect(new StdioServerTransport()); + console.error("mega-infrastructure MCP ready (read-only, stdio)"); } - -// Wrapped rather than top-level await: package.json has no "type": -// "module", so top-level await is not guaranteed to be available. -async function main() { - const transport = new StdioServerTransport(); - await server.connect(transport); - // stdout is the MCP channel — anything logged there corrupts the - // protocol. Diagnostics must go to stderr. - console.error("mega-infrastructure MCP server ready (read-only, stdio)"); -} - -main().catch((err) => { - console.error("MCP server failed to start:", err); - process.exit(1); -}); \ No newline at end of file +main().catch(()=>{console.error("MCP startup failed; check server configuration");process.exit(1);});