feat: add authenticated same-app MCP transport

This commit is contained in:
DEls 2026-10-05 01:31:17 +02:00
parent 9c9a9d1866
commit 96da8ac4e8
10 changed files with 202 additions and 56 deletions

3
src/app/api/mcp/route.ts Normal file
View file

@ -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';

View file

@ -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');
});

View file

@ -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" ||

View file

@ -0,0 +1,7 @@
import type PocketBase from 'pocketbase';
export type Permission = 'read' | 'write' | 'process';
export type Principal = {id: string; permissions: ReadonlySet<Permission>};
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<string,unknown> | null};
export type DocumentReference = {kind: 'po' | 'receipt' | 'source' | 'page' | 'candidate'; id: string};

38
src/mcp/auth.test.ts Normal file
View file

@ -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<string,string> = {}) => 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');
});
});

43
src/mcp/auth.ts Normal file
View file

@ -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<typeof ClientsSchema>;
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;
}

22
src/mcp/create-server.ts Normal file
View file

@ -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<string,unknown>);
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;
}

31
src/mcp/http.test.ts Normal file
View file

@ -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);
});

38
src/mcp/http.ts Normal file
View file

@ -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<unknown>{
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<Response>{
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"'}:{})}});
}
}

View file

@ -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<string, unknown>);
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);
});
main().catch(()=>{console.error("MCP startup failed; check server configuration");process.exit(1);});