From 95f85b656b5a8b1902566e9cfa99fc1983f375b4 Mon Sep 17 00:00:00 2001 From: Trailgenic Date: Mon, 13 Jul 2026 19:22:00 -0700 Subject: [PATCH] Fix Vercel MCP Accept normalization --- app/mcp/route.ts | 109 +++++++++++++++++++++++++++++++++++++++---- lib/ella-registry.ts | 2 +- next.config.ts | 2 +- tests/mcp.test.ts | 98 ++++++++++++++++++++++++++++++++++---- 4 files changed, 191 insertions(+), 20 deletions(-) diff --git a/app/mcp/route.ts b/app/mcp/route.ts index f7fb1d5..7f61597 100644 --- a/app/mcp/route.ts +++ b/app/mcp/route.ts @@ -1,12 +1,20 @@ import { NextResponse } from 'next/server' import { handleEllaMcpPost } from '../../lib/ella-mcp-server' +import { + ELLA_CANONICAL_ENTITY_ID, + ELLA_MCP_PROTOCOL_VERSIONS, + ELLA_MCP_RESOURCES, + ELLA_MCP_SERVER_INFO, + ELLA_MCP_TOOL_NAMES, +} from '../../lib/ella-registry' export const runtime = 'nodejs' const DEFAULT_ALLOWED_ORIGINS = ['https://ellaentity.ai', 'https://www.ellaentity.ai', 'https://mcp.ellaentity.ai'] const ALLOW_METHODS = 'POST, OPTIONS' const ALLOW_HEADERS = 'Content-Type, Accept, Mcp-Session-Id, MCP-Protocol-Version' -const REQUIRED_ACCEPT_TYPES = ['application/json', 'text/event-stream'] as const +const NORMALIZED_ACCEPT = 'application/json, text/event-stream' +const MCP_ROOT_HOST = 'mcp.ellaentity.ai' function allowedOrigins() { return process.env.MCP_ALLOWED_ORIGINS?.split(',').map((origin) => origin.trim()).filter(Boolean) ?? DEFAULT_ALLOWED_ORIGINS @@ -62,14 +70,86 @@ function hasJsonContentType(request: Request) { return request.headers.get('content-type')?.toLowerCase().includes('application/json') ?? false } -function hasRequiredAcceptTypes(request: Request) { - const accept = request.headers.get('accept')?.toLowerCase() +function acceptMediaTypes(request: Request) { + return (request.headers.get('accept') ?? '') + .split(',') + .map((entry) => entry.split(';', 1)[0]?.trim().toLowerCase()) + .filter(Boolean) +} + +function acceptedMcpRequestNeedsNormalization(request: Request) { + const types = acceptMediaTypes(request) + + if (types.length === 0) { + return true + } - if (!accept) { + const hasWildcard = types.includes('*/*') + const hasJson = types.includes('application/json') + const hasEventStream = types.includes('text/event-stream') + + if (hasJson && hasEventStream) { return false } - return REQUIRED_ACCEPT_TYPES.every((type) => accept.includes(type)) + if (hasWildcard || hasJson) { + return true + } + + return null +} + +async function normalizedMcpRequest(request: Request) { + const needsNormalization = acceptedMcpRequestNeedsNormalization(request) + + if (needsNormalization === null) { + return null + } + + const body = await request.text() + const headers: Record = {} + + request.headers.forEach((value, key) => { + headers[key] = value + }) + + if (needsNormalization) { + headers.accept = NORMALIZED_ACCEPT + } + + return new Request(request.url, { + method: request.method, + headers, + body, + }) +} + +function isCanonicalMcpRootRequest(request: Request) { + const url = new URL(request.url) + const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? url.host + return host === MCP_ROOT_HOST && (url.pathname === '/' || url.searchParams.get('mcpRoot') === '1') +} + +function discoveryBody() { + return { + service: 'EllaEntity MCP', + name: ELLA_MCP_SERVER_INFO.name, + description: 'Native public read-only MCP endpoint for Ella canonical identity, domains, frameworks, works, collaboration records, and the entity graph.', + status: 'operational', + canonicalEntityId: ELLA_CANONICAL_ENTITY_ID, + server: ELLA_MCP_SERVER_INFO, + supportedProtocolVersions: ELLA_MCP_PROTOCOL_VERSIONS, + transport: { + type: 'streamable-http', + url: 'https://mcp.ellaentity.ai', + aliases: ['https://mcp.ellaentity.ai/mcp', 'https://ellaentity.ai/mcp'], + }, + tools: ELLA_MCP_TOOL_NAMES, + resources: ELLA_MCP_RESOURCES.map((resource) => resource.uri), + documentationUrl: 'https://ellaentity.ai/system/mcp', + entityGraphUrl: 'https://ellaentity.ai/entity.json', + scope: 'Public read-only access only. This MCP server excludes private conversations, credentials, memory, traces, internal prompts, unpublished content, private user information, and /api/process.', + } } export function OPTIONS(request: Request) { @@ -89,10 +169,19 @@ export function GET(request: Request) { const cors = corsHeaders(request) const headers = cors instanceof NextResponse ? new Headers(cors.headers) : cors + if (cors instanceof NextResponse) { + headers.set('Allow', ALLOW_METHODS) + return new NextResponse(null, { status: 403, headers }) + } + + if (isCanonicalMcpRootRequest(request)) { + return NextResponse.json(discoveryBody(), { status: 200, headers }) + } + headers.set('Allow', ALLOW_METHODS) return new NextResponse(null, { - status: cors instanceof NextResponse ? 403 : 405, + status: 405, headers, }) } @@ -108,12 +197,14 @@ export async function POST(request: Request) { return jsonRpcError(request, 415, -32600, 'Content-Type must be application/json') } - if (!hasRequiredAcceptTypes(request)) { - return jsonRpcError(request, 406, -32600, 'Accept must include application/json and text/event-stream') + const mcpRequest = await normalizedMcpRequest(request) + + if (!mcpRequest) { + return jsonRpcError(request, 406, -32600, 'Accept must include application/json or */*; text/event-stream alone is not supported for JSON responses') } try { - const sdkResponse = await handleEllaMcpPost(request) + const sdkResponse = await handleEllaMcpPost(mcpRequest) const headers = new Headers(sdkResponse.headers) cors.forEach((value, key) => { diff --git a/lib/ella-registry.ts b/lib/ella-registry.ts index a431ee9..713ee1a 100644 --- a/lib/ella-registry.ts +++ b/lib/ella-registry.ts @@ -9,7 +9,7 @@ import { } from './entity-data' export const ELLA_CANONICAL_ENTITY_ID = 'https://ellaentity.ai/#ella' as const -export const ELLA_MCP_SERVER_INFO = { name: 'ellaentity-mcp', version: '1.1.2' } as const +export const ELLA_MCP_SERVER_INFO = { name: 'ellaentity-mcp', version: '1.1.4' } as const export const ELLA_MCP_PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18'] as const export const ELLA_MCP_DEFAULT_PROTOCOL_VERSION = ELLA_MCP_PROTOCOL_VERSIONS[0] export const ELLA_REGISTRY_DATA_VERSION = '2026-07-13.mcp-v1-1' as const diff --git a/next.config.ts b/next.config.ts index b608c6a..20ab9d4 100644 --- a/next.config.ts +++ b/next.config.ts @@ -8,7 +8,7 @@ const nextConfig: NextConfig = { { source: '/', has: [{ type: 'host', value: 'mcp.ellaentity.ai' }], - destination: '/mcp', + destination: '/mcp?mcpRoot=1', }, ], } diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index b8f6f34..c313a6e 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -3,9 +3,10 @@ import test from 'node:test' import Ajv from 'ajv' import addFormats from 'ajv-formats' import { GET, OPTIONS, POST } from '../app/mcp/route' -import { ELLA_MCP_PROTOCOL_VERSIONS, ELLA_MCP_RESOURCES, ELLA_MCP_TOOL_NAMES } from '../lib/ella-registry' +import { ELLA_CANONICAL_ENTITY_ID, ELLA_MCP_PROTOCOL_VERSIONS, ELLA_MCP_RESOURCES, ELLA_MCP_SERVER_INFO, ELLA_MCP_TOOL_NAMES } from '../lib/ella-registry' const endpoint = 'https://ellaentity.ai/mcp' +const mcpRootEndpoint = 'https://mcp.ellaentity.ai/' const accept = 'application/json, text/event-stream' const contentType = 'application/json' const defaultProtocolVersion = ELLA_MCP_PROTOCOL_VERSIONS[0] @@ -61,15 +62,94 @@ test('GET and OPTIONS implement method and CORS behavior', async () => { assert.equal(blocked.status, 403) }) +async function pingWithHeaders(headers: HeadersInit, includeDefaultAccept = true) { + const response = await POST( + new Request(endpoint, { + method: 'POST', + headers: { + ...(includeDefaultAccept ? { accept } : {}), + 'content-type': contentType, + origin: 'https://ellaentity.ai', + 'mcp-protocol-version': defaultProtocolVersion, + 'x-test-preserved': 'preserved-value', + ...headers, + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 'ping-accept', method: 'ping', params: {} }), + }), + ) + const text = await response.text() + return { response, body: text ? JSON.parse(text) : null } +} + test('POST validates content negotiation and origins', async () => { - const body = { jsonrpc: '2.0', id: 1, method: 'ping', params: {} } - - assert.equal((await POST(request('POST', body, { accept: '' }))).status, 406) - assert.equal((await POST(request('POST', body, { accept: 'application/json' }))).status, 406) - assert.equal((await POST(request('POST', body, { accept: 'text/event-stream' }))).status, 406) - assert.equal((await POST(request('POST', body, { accept: 'text/plain' }))).status, 406) - assert.equal((await POST(request('POST', body, { 'content-type': 'text/plain' }))).status, 415) - assert.equal((await POST(request('POST', body, { origin: 'https://evil.example' }))).status, 403) + const jsonOnly = await pingWithHeaders({ accept: 'Application/Json; charset=utf-8' }) + const accepted = [ + await pingWithHeaders({}, false), + await pingWithHeaders({ accept: '' }), + await pingWithHeaders({ accept: '*/*' }), + jsonOnly, + await pingWithHeaders({ accept }), + ] + + assert.deepEqual(jsonOnly.body, { + jsonrpc: '2.0', + id: 'ping-accept', + result: {}, + }) + + for (const result of accepted) { + assert.equal(result.response.status, 200) + assert.deepEqual(result.body.result, {}) + assert.equal(result.body.id, 'ping-accept') + assert.equal(result.response.headers.get('access-control-allow-origin'), 'https://ellaentity.ai') + } + + assert.equal((await pingWithHeaders({ accept: 'text/event-stream' })).response.status, 406) + assert.equal((await pingWithHeaders({ accept: 'text/plain' })).response.status, 406) + assert.equal((await POST(request('POST', { jsonrpc: '2.0', id: 1, method: 'ping', params: {} }, { 'content-type': 'text/plain' }))).status, 415) + assert.equal((await POST(request('POST', { jsonrpc: '2.0', id: 1, method: 'ping', params: {} }, { origin: 'https://evil.example' }))).status, 403) +}) + + +test('canonical MCP root discovery and route aliases are preserved', async () => { + const discovery = await GET(new Request(mcpRootEndpoint, { method: 'GET', headers: { host: 'mcp.ellaentity.ai' } })) + assert.equal(discovery.status, 200) + assert.match(discovery.headers.get('content-type') ?? '', /application\/json/) + const body = await discovery.json() + + assert.equal(body.canonicalEntityId, ELLA_CANONICAL_ENTITY_ID) + assert.deepEqual(body.tools, ELLA_MCP_TOOL_NAMES) + assert.deepEqual(body.resources, ELLA_MCP_RESOURCES.map((resource) => resource.uri)) + assert.deepEqual(body.supportedProtocolVersions, ELLA_MCP_PROTOCOL_VERSIONS) + assert.equal(body.server.version, ELLA_MCP_SERVER_INFO.version) + assert.equal(body.documentationUrl, 'https://ellaentity.ai/system/mcp') + assert.equal(body.entityGraphUrl, 'https://ellaentity.ai/entity.json') + assert.match(body.scope, /Public read-only/) + + const rewrittenDiscovery = await GET(new Request('https://mcp.ellaentity.ai/mcp?mcpRoot=1', { method: 'GET', headers: { host: 'mcp.ellaentity.ai' } })) + assert.equal(rewrittenDiscovery.status, 200) + + const getMcp = await GET(new Request('https://mcp.ellaentity.ai/mcp', { method: 'GET', headers: { host: 'mcp.ellaentity.ai' } })) + assert.equal(getMcp.status, 405) + assert.equal(getMcp.headers.get('allow'), 'POST, OPTIONS') + + const rootInitialize = await POST(new Request(mcpRootEndpoint, { + method: 'POST', + headers: { accept, 'content-type': contentType, origin: 'https://mcp.ellaentity.ai', 'mcp-protocol-version': defaultProtocolVersion }, + body: JSON.stringify({ jsonrpc: '2.0', id: 'root-init', method: 'initialize', params: { protocolVersion: defaultProtocolVersion, capabilities: {}, clientInfo: { name: 'root-test-client', version: '1.0.0' } } }), + })) + assert.equal(rootInitialize.status, 200) + const rootInitializeBody = await rootInitialize.json() + assert.equal(rootInitializeBody.result.serverInfo.version, ELLA_MCP_SERVER_INFO.version) + + const aliasTools = await POST(new Request('https://mcp.ellaentity.ai/mcp', { + method: 'POST', + headers: { accept, 'content-type': contentType, origin: 'https://mcp.ellaentity.ai', 'mcp-protocol-version': defaultProtocolVersion }, + body: JSON.stringify({ jsonrpc: '2.0', id: 'alias-tools', method: 'tools/list', params: {} }), + })) + assert.equal(aliasTools.status, 200) + const aliasToolsBody = await aliasTools.json() + assert.deepEqual(aliasToolsBody.result.tools.map((tool: { name: string }) => tool.name), ELLA_MCP_TOOL_NAMES) }) test('initialize validates official params and reports server capabilities', async () => {