feat: add authenticated same-app MCP transport
This commit is contained in:
parent
9c9a9d1866
commit
96da8ac4e8
10 changed files with 202 additions and 56 deletions
3
src/app/api/mcp/route.ts
Normal file
3
src/app/api/mcp/route.ts
Normal 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';
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" ||
|
||||
|
|
|
|||
7
src/lib/purchasing/types.ts
Normal file
7
src/lib/purchasing/types.ts
Normal 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
38
src/mcp/auth.test.ts
Normal 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
43
src/mcp/auth.ts
Normal 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
22
src/mcp/create-server.ts
Normal 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
31
src/mcp/http.test.ts
Normal 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
38
src/mcp/http.ts
Normal 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"'}:{})}});
|
||||
}
|
||||
}
|
||||
|
|
@ -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);});
|
||||
|
|
|
|||
Loading…
Reference in a new issue