Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 91 additions & 9 deletions app/mcp/route.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -62,14 +70,77 @@ 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
}

function normalizedMcpRequest(request: Request) {
const needsNormalization = acceptedMcpRequestNeedsNormalization(request)

if (needsNormalization === null) {
return null
}

if (!needsNormalization) {
return request
}

const headers = new Headers(request.headers)
headers.set('accept', NORMALIZED_ACCEPT)
return new Request(request, { headers })
}

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) {
Expand All @@ -89,10 +160,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,
})
}
Expand All @@ -108,12 +188,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 = 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) => {
Expand Down
2 changes: 1 addition & 1 deletion lib/ella-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.3' } 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
Expand Down
2 changes: 1 addition & 1 deletion next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const nextConfig: NextConfig = {
{
source: '/',
has: [{ type: 'host', value: 'mcp.ellaentity.ai' }],
destination: '/mcp',
destination: '/mcp?mcpRoot=1',
},
],
}
Expand Down
91 changes: 82 additions & 9 deletions tests/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -61,15 +62,87 @@ 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 accepted = [
await pingWithHeaders({}, false),
await pingWithHeaders({ accept: '' }),
await pingWithHeaders({ accept: '*/*' }),
await pingWithHeaders({ accept: 'Application/Json; charset=utf-8' }),
await pingWithHeaders({ accept }),
]

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 () => {
Expand Down
Loading