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
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm test
- run: npm run build
244 changes: 95 additions & 149 deletions app/mcp/route.ts
Original file line number Diff line number Diff line change
@@ -1,189 +1,135 @@
import { NextResponse } from 'next/server'
import {
ELLA_CANONICAL_ENTITY_ID,
ELLA_DOMAIN_SLUGS,
ELLA_FRAMEWORK_SLUGS,
ELLA_MCP_DEFAULT_PROTOCOL_VERSION,
ELLA_MCP_PROTOCOL_VERSIONS,
ELLA_MCP_RESOURCES,
ELLA_MCP_SERVER_INFO,
ELLA_MCP_TOOL_NAMES,
ELLA_REGISTRY,
ellaEnvelope,
readEllaResource,
} from '@/lib/ella-registry'

// This route follows the official MCP Streamable HTTP JSON-RPC message model and
// intentionally remains stateless for Vercel/Next.js App Router deployments. The
// official TypeScript SDK is recorded as a dependency; this adapter preserves the
// SDK/schema-compatible wire shapes without creating fake sessions.
import { handleEllaMcpPost } from '../../lib/ella-mcp-server'

export const runtime = 'nodejs'

const DEFAULT_ALLOWED_ORIGINS = ['https://ellaentity.ai', 'https://www.ellaentity.ai', 'https://mcp.ellaentity.ai']
const JSON_RPC = '2.0'
const ALLOW = 'POST, OPTIONS'

type JsonRpcId = string | number | null
type JsonObject = Record<string, unknown>
type JsonRpcMessage = { jsonrpc?: unknown; id?: unknown; method?: unknown; params?: unknown }

const annotations = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
const emptyInputSchema = { type: 'object', properties: {}, additionalProperties: false } as const
const outputSchema = {
type: 'object',
properties: { data: {}, provenance: { type: 'object' } },
required: ['data', 'provenance'],
additionalProperties: false,
} as const

export const tools = [
{ name: 'ella.identity.get', title: 'Get Ella identity', description: 'Canonical identity record for Ella: entity ID, description, disambiguation, creator, affiliations, sameAs anchors.', inputSchema: emptyInputSchema, outputSchema, annotations },
{ name: 'ella.domains.get', title: 'Get Ella domains', description: "Ella's declared domain authority. Optional 'domain' argument: longevity | environment | sleep | ai-frameworks. Omit for all four.", inputSchema: { type: 'object', properties: { domain: { type: 'string', enum: ELLA_DOMAIN_SLUGS } }, additionalProperties: false }, outputSchema, annotations },
{ name: 'ella.frameworks.get', title: 'Get Ella frameworks', description: "Ella and Mike Ye's declared frameworks. Currently returns The Four Forces of AI Power: Compute, Interface, Alignment, and Energy.", inputSchema: { type: 'object', properties: { framework: { type: 'string', enum: ELLA_FRAMEWORK_SLUGS } }, additionalProperties: false }, outputSchema, annotations },
{ name: 'ella.works.get', title: 'Get Ella works', description: 'Co-authored works attributed to Ella with schema types and URLs.', inputSchema: emptyInputSchema, outputSchema, annotations },
{ name: 'ella.collaboration.get', title: 'Get Ella collaboration model', description: 'The co-cognition model: division of labor between Mike Ye, Ella, and AI tooling.', inputSchema: emptyInputSchema, outputSchema, annotations },
] as const
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

function allowedOrigins() {
return (process.env.MCP_ALLOWED_ORIGINS?.split(',').map((origin) => origin.trim()).filter(Boolean) ?? DEFAULT_ALLOWED_ORIGINS)
return process.env.MCP_ALLOWED_ORIGINS?.split(',').map((origin) => origin.trim()).filter(Boolean) ?? DEFAULT_ALLOWED_ORIGINS
}

function corsHeaders(request: Request): HeadersInit | NextResponse {
function corsHeaders(request: Request): Headers | NextResponse {
const origin = request.headers.get('origin')
const headers: Record<string, string> = {
'Access-Control-Allow-Methods': ALLOW,
'Access-Control-Allow-Headers': 'Content-Type, Accept, Mcp-Session-Id, MCP-Protocol-Version',
'Vary': 'Origin',
const headers = new Headers({
'Access-Control-Allow-Methods': ALLOW_METHODS,
'Access-Control-Allow-Headers': ALLOW_HEADERS,
Vary: 'Origin',
})

if (!origin) {
return headers
}
if (!origin) return headers
if (!allowedOrigins().includes(origin)) return new NextResponse(null, { status: 403, headers })
headers['Access-Control-Allow-Origin'] = origin
return headers
}

function response(request: Request, body: unknown, status = 200) {
const cors = corsHeaders(request)
if (cors instanceof NextResponse) return cors
return NextResponse.json(body, { status, headers: cors })
}

function empty(request: Request, status = 202) {
const cors = corsHeaders(request)
if (cors instanceof NextResponse) return cors
return new NextResponse(null, { status, headers: cors })
}

function rpcResult(request: Request, id: JsonRpcId, result: unknown, status = 200) {
return response(request, { jsonrpc: JSON_RPC, id, result }, status)
}
if (!allowedOrigins().includes(origin)) {
return new NextResponse(null, {
status: 403,
headers,
})
}

function rpcError(request: Request, id: JsonRpcId, code: number, message: string, status = 400) {
return response(request, { jsonrpc: JSON_RPC, id, error: { code, message } }, status)
headers.set('Access-Control-Allow-Origin', origin)
return headers
}

function isObject(value: unknown): value is JsonObject {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
function jsonRpcError(request: Request, status: number, code: number, message: string) {
const cors = corsHeaders(request)

function id(value: unknown): JsonRpcId {
return typeof value === 'string' || typeof value === 'number' || value === null ? value : null
}
if (cors instanceof NextResponse) {
return cors
}

function isNotification(message: JsonRpcMessage) {
return !Object.prototype.hasOwnProperty.call(message, 'id')
return NextResponse.json(
{
jsonrpc: '2.0',
id: null,
error: {
code,
message,
},
},
{
status,
headers: cors,
},
)
}

function hasJsonContentType(request: Request) {
return request.headers.get('content-type')?.toLowerCase().includes('application/json') ?? false
}

function acceptsMcp(request: Request) {
const accept = request.headers.get('accept')
return !accept || accept.includes('*/*') || accept.includes('application/json') || accept.includes('text/event-stream')
}
function hasRequiredAcceptTypes(request: Request) {
const accept = request.headers.get('accept')?.toLowerCase()

function negotiateProtocol(requested: unknown) {
return typeof requested === 'string' && ELLA_MCP_PROTOCOL_VERSIONS.includes(requested as (typeof ELLA_MCP_PROTOCOL_VERSIONS)[number])
? requested
: ELLA_MCP_DEFAULT_PROTOCOL_VERSION
}
if (!accept) {
return false
}

function validateProtocolHeader(request: Request, method: string) {
if (method === 'initialize') return true
const header = request.headers.get('mcp-protocol-version')
if (!header) return true
return /^\d{4}-\d{2}-\d{2}$/.test(header) && ELLA_MCP_PROTOCOL_VERSIONS.includes(header as (typeof ELLA_MCP_PROTOCOL_VERSIONS)[number])
return REQUIRED_ACCEPT_TYPES.every((type) => accept.includes(type))
}

function noArgs(args: JsonObject) { return Object.keys(args).length === 0 }
function textAndStructured(data: unknown) {
const structuredContent = ellaEnvelope(data)
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], structuredContent, isError: false }
}
export function OPTIONS(request: Request) {
const cors = corsHeaders(request)

function callTool(name: string, args: JsonObject) {
if (name === 'ella.identity.get') return noArgs(args) ? textAndStructured(ELLA_REGISTRY.identity) : null
if (name === 'ella.domains.get') {
if (noArgs(args)) return textAndStructured(ELLA_REGISTRY.domains)
return Object.keys(args).length === 1 && typeof args.domain === 'string' && ELLA_DOMAIN_SLUGS.includes(args.domain as never)
? textAndStructured(ELLA_REGISTRY.domains[args.domain as keyof typeof ELLA_REGISTRY.domains]) : null
if (cors instanceof NextResponse) {
return cors
}
if (name === 'ella.frameworks.get') {
if (noArgs(args)) return textAndStructured(ELLA_REGISTRY.frameworks)
return Object.keys(args).length === 1 && typeof args.framework === 'string' && ELLA_FRAMEWORK_SLUGS.includes(args.framework as never)
? textAndStructured(ELLA_REGISTRY.frameworks.find((item) => item.slug === args.framework)) : null
}
if (name === 'ella.works.get') return noArgs(args) ? textAndStructured(ELLA_REGISTRY.works) : null
if (name === 'ella.collaboration.get') return noArgs(args) ? textAndStructured(ELLA_REGISTRY.collaboration) : null
return undefined
}

export function OPTIONS(request: Request) { return empty(request, 204) }
return new NextResponse(null, {
status: 204,
headers: cors,
})
}

export function GET(request: Request) {
const cors = corsHeaders(request)
const headers = cors instanceof NextResponse ? cors.headers : new Headers(cors)
headers.set('Allow', ALLOW)
return new NextResponse(null, { status: cors instanceof NextResponse ? 403 : 405, headers })
const headers = cors instanceof NextResponse ? new Headers(cors.headers) : cors

headers.set('Allow', ALLOW_METHODS)

return new NextResponse(null, {
status: cors instanceof NextResponse ? 403 : 405,
headers,
})
}

export async function POST(request: Request) {
const cors = corsHeaders(request)
if (cors instanceof NextResponse) return cors
if (!hasJsonContentType(request)) return rpcError(request, null, -32600, 'Content-Type must be application/json', 415)
if (!acceptsMcp(request)) return rpcError(request, null, -32600, 'Accept must allow application/json or text/event-stream', 406)

let body: unknown
try { body = await request.json() } catch { return rpcError(request, null, -32700, 'Parse error', 400) }
if (!isObject(body)) return rpcError(request, null, -32600, 'Invalid Request', 400)

const message = body as JsonRpcMessage
const rpcId = id(message.id)
if (message.jsonrpc !== JSON_RPC || typeof message.method !== 'string') return rpcError(request, rpcId, -32600, 'Invalid Request', 400)
if (!validateProtocolHeader(request, message.method)) return rpcError(request, rpcId, -32000, 'Unsupported MCP-Protocol-Version', 400)
if (isNotification(message)) return empty(request, 202)

const params = isObject(message.params) ? message.params : {}
switch (message.method) {
case 'initialize':
return rpcResult(request, rpcId, { protocolVersion: negotiateProtocol(params.protocolVersion), capabilities: { tools: {}, resources: {} }, serverInfo: ELLA_MCP_SERVER_INFO })
case 'ping': return rpcResult(request, rpcId, {})
case 'tools/list': return rpcResult(request, rpcId, { tools })
case 'tools/call': {
if (typeof params.name !== 'string' || (params.arguments !== undefined && !isObject(params.arguments))) return rpcError(request, rpcId, -32602, 'Invalid params')
if (!ELLA_MCP_TOOL_NAMES.includes(params.name as never)) return rpcError(request, rpcId, -32601, 'Tool not found', 404)
const result = callTool(params.name, (params.arguments as JsonObject | undefined) ?? {})
return result ? rpcResult(request, rpcId, result) : rpcError(request, rpcId, -32602, 'Invalid params')
}
case 'resources/list': return rpcResult(request, rpcId, { resources: ELLA_MCP_RESOURCES })
case 'resources/read': {
if (typeof params.uri !== 'string') return rpcError(request, rpcId, -32602, 'Invalid params')
const data = readEllaResource(params.uri)
if (data === null) return rpcError(request, rpcId, -32602, 'Unknown resource', 404)
const resource = ELLA_MCP_RESOURCES.find((item) => item.uri === params.uri)
return rpcResult(request, rpcId, { contents: [{ uri: params.uri, mimeType: resource?.mimeType ?? 'application/json', text: JSON.stringify(data, null, 2) }] })

if (cors instanceof NextResponse) {
return cors
}

if (!hasJsonContentType(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')
}

try {
const sdkResponse = await handleEllaMcpPost(request)
const headers = new Headers(sdkResponse.headers)

cors.forEach((value, key) => {
headers.set(key, value)
})

return new Response(sdkResponse.body, {
status: sdkResponse.status,
statusText: sdkResponse.statusText,
headers,
})
} catch (error) {
if (error instanceof SyntaxError) {
return jsonRpcError(request, 400, -32700, 'Parse error')
}
default: return rpcError(request, rpcId, -32601, 'Method not found', 404)

return jsonRpcError(request, 500, -32603, 'Internal server error')
}
}
2 changes: 1 addition & 1 deletion app/system/mcp/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const endpoints = [
{ host: 'mcp.trailgenic.com', url: 'https://mcp.trailgenic.com', exposes: 'TrailGenic longevity, environmental adaptation, high-altitude hiking, and field-method intelligence.' },
{ host: 'mcp.exmxc.ai', url: 'https://mcp.exmxc.ai', exposes: 'exmxc AI intelligence frameworks, entity clarity systems, agent-readiness doctrine, and infrastructure-convergence models.' },
{ host: 'mcp.mikeye.com', url: 'https://mcp.mikeye.com', exposes: 'Mike Ye institutional identity, operator context, creator attribution, and cross-property intelligence references.' },
{ host: 'mcp.ellaentity.ai', url: 'https://mcp.ellaentity.ai/mcp', exposes: `Native public read-only Streamable HTTP MCP endpoint for ${ELLA_CANONICAL_ENTITY_ID}.` },
{ host: 'mcp.ellaentity.ai', url: 'https://mcp.ellaentity.ai', exposes: `Native public read-only Streamable HTTP MCP endpoint for ${ELLA_CANONICAL_ENTITY_ID}.` },
]

export function generateMetadata() {
Expand Down
2 changes: 2 additions & 0 deletions docs/mcp-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@
- Configure durable platform rate limiting at Vercel/edge level. Recommended baseline rule: match path `/mcp`, limit POST requests per source IP to a conservative burst (for example 60 requests/minute) with a higher trusted-client allowance if needed.
- Do not rely on process-local counters for serverless rate limiting; they reset across cold starts and instances.
- Verify `GET /mcp` returns 405, `POST /mcp` handles Streamable HTTP JSON-RPC, and `/system/mcp` remains the human-readable documentation page.

- The canonical public MCP endpoint URL is `https://mcp.ellaentity.ai`; the Next.js route remains `/mcp` internally for host-based routing.
Loading
Loading