diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d9deb18 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/app/mcp/route.ts b/app/mcp/route.ts index 13bf1d2..d8aa4ea 100644 --- a/app/mcp/route.ts +++ b/app/mcp/route.ts @@ -1,189 +1,124 @@ 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 -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 = { - '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 { + return await handleEllaMcpPost(request, cors) + } 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') } } diff --git a/app/system/mcp/page.tsx b/app/system/mcp/page.tsx index 2fe205b..0346b89 100644 --- a/app/system/mcp/page.tsx +++ b/app/system/mcp/page.tsx @@ -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() { diff --git a/docs/mcp-deployment.md b/docs/mcp-deployment.md index a5bb06c..cd727d6 100644 --- a/docs/mcp-deployment.md +++ b/docs/mcp-deployment.md @@ -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. diff --git a/lib/ella-mcp-schemas.ts b/lib/ella-mcp-schemas.ts new file mode 100644 index 0000000..8303b9c --- /dev/null +++ b/lib/ella-mcp-schemas.ts @@ -0,0 +1,102 @@ +import { z } from 'zod' +import { + ELLA_CANONICAL_ENTITY_ID, + ELLA_DOMAIN_SLUGS, + ELLA_FRAMEWORK_SLUGS, + ELLA_REGISTRY_LAST_MODIFIED, + ELLA_REGISTRY_SCHEMA_VERSION, + ELLA_REGISTRY_SOURCE, +} from './ella-registry' + +const provenanceSchema = z.object({ + canonicalEntityId: z.literal(ELLA_CANONICAL_ENTITY_ID), + source: z.literal(ELLA_REGISTRY_SOURCE), + schemaVersion: z.literal(ELLA_REGISTRY_SCHEMA_VERSION), + dataVersion: z.string().min(1), + lastModified: z.literal(ELLA_REGISTRY_LAST_MODIFIED), +}) + +const jsonRecord = z.record(z.string(), z.unknown()) +const namedDescription = z.object({ + name: z.string(), + description: z.string(), +}) + +const identityDataSchema = z.object({ + name: z.string(), + canonicalId: z.literal(ELLA_CANONICAL_ENTITY_ID), + description: z.string(), + disambiguatingDescription: z.string(), + creator: z.unknown(), + sameAs: z.array(z.string().url()), + affiliations: z.array(jsonRecord), +}) + +const domainDataSchema = z.object({ + id: z.string().url(), + name: z.string(), + description: z.string(), +}) + +const domainsDataSchema = z.object( + Object.fromEntries(ELLA_DOMAIN_SLUGS.map((slug) => [slug, domainDataSchema])) as Record< + (typeof ELLA_DOMAIN_SLUGS)[number], + typeof domainDataSchema + >, +) + +const frameworkDataSchema = z.object({ + slug: z.enum(ELLA_FRAMEWORK_SLUGS), + name: z.string(), + alternateName: z.string(), + url: z.string().url(), + description: z.string(), + domain: z.enum(ELLA_DOMAIN_SLUGS), + forces: z.array(namedDescription), +}) + +const workDataSchema = z.object({ + name: z.string(), + url: z.string().url(), + type: z.enum(['PodcastSeries', 'SoftwareSourceCode', 'CreativeWork', 'CreativeWorkSeries']), + publisherId: z.string().url(), + publisherName: z.string(), + description: z.string(), +}) + +const collaborationDataSchema = z.object({ + coCognition: z.object({ + mikeYe: z.string(), + ella: z.string(), + aiTooling: z.string(), + }), + surfaces: z.array( + z.object({ + href: z.string(), + path: z.string(), + label: z.string(), + description: z.string(), + }), + ), +}) + +function envelopeSchema(data: T) { + return z.object({ + data, + provenance: provenanceSchema, + }) +} + +export const ELLA_MCP_OUTPUT_SCHEMAS = { + identity: envelopeSchema(identityDataSchema), + domains: envelopeSchema(domainsDataSchema.or(domainDataSchema)), + frameworks: envelopeSchema(z.array(frameworkDataSchema).or(frameworkDataSchema)), + works: envelopeSchema(z.array(workDataSchema)), + collaboration: envelopeSchema(collaborationDataSchema), +} as const + +export const ELLA_MCP_INPUT_SCHEMAS = { + empty: z.object({}).strict(), + domains: z.object({ domain: z.enum(ELLA_DOMAIN_SLUGS).optional() }).strict(), + frameworks: z.object({ framework: z.enum(ELLA_FRAMEWORK_SLUGS).optional() }).strict(), +} as const diff --git a/lib/ella-mcp-server.ts b/lib/ella-mcp-server.ts new file mode 100644 index 0000000..386a951 --- /dev/null +++ b/lib/ella-mcp-server.ts @@ -0,0 +1,287 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' +import type { CallToolResult, ReadResourceResult } from '@modelcontextprotocol/sdk/types.js' +import type { IncomingMessage, ServerResponse } from 'node:http' +import { ELLA_MCP_INPUT_SCHEMAS, ELLA_MCP_OUTPUT_SCHEMAS } from './ella-mcp-schemas' +import { + ELLA_DOMAIN_SLUGS, + ELLA_FRAMEWORK_SLUGS, + ELLA_MCP_RESOURCES, + ELLA_MCP_SERVER_INFO, + ELLA_MCP_TOOL_NAMES, + ELLA_REGISTRY, + ellaEnvelope, + readEllaResource, +} from './ella-registry' + +type ResourceRecord = (typeof ELLA_MCP_RESOURCES)[number] + +type NodeResponseCapture = ServerResponse & { + statusCode: number + headersSent: boolean + setHeader(name: string, value: number | string | readonly string[]): void + getHeader(name: string): number | string | string[] | undefined + writeHead(statusCode: number, headers?: Record): NodeResponseCapture + end(chunk?: unknown): NodeResponseCapture + toResponse(extraHeaders?: HeadersInit): Response +} + +const TOOL_ANNOTATIONS = { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, +} as const + +function textAndStructured(data: unknown): CallToolResult { + return { + content: [ + { + type: 'text', + text: JSON.stringify(data, null, 2), + }, + ], + structuredContent: ellaEnvelope(data), + isError: false, + } +} + +function resourceContents(resource: ResourceRecord, data: unknown): ReadResourceResult { + return { + contents: [ + { + uri: resource.uri, + mimeType: resource.mimeType, + text: JSON.stringify(data, null, 2), + }, + ], + } +} + +export function createEllaMcpServer() { + const server = new McpServer(ELLA_MCP_SERVER_INFO, { + capabilities: { + tools: {}, + resources: {}, + }, + }) + + server.registerTool( + 'ella.identity.get', + { + title: 'Get Ella identity', + description: + 'Canonical identity record for Ella: entity ID, description, disambiguation, creator, affiliations, sameAs anchors.', + inputSchema: ELLA_MCP_INPUT_SCHEMAS.empty, + outputSchema: ELLA_MCP_OUTPUT_SCHEMAS.identity, + annotations: TOOL_ANNOTATIONS, + }, + async () => textAndStructured(ELLA_REGISTRY.identity), + ) + + server.registerTool( + '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: ELLA_MCP_INPUT_SCHEMAS.domains, + outputSchema: ELLA_MCP_OUTPUT_SCHEMAS.domains, + annotations: TOOL_ANNOTATIONS, + }, + async ({ domain }: { domain?: (typeof ELLA_DOMAIN_SLUGS)[number] }) => { + if (domain) { + return textAndStructured(ELLA_REGISTRY.domains[domain]) + } + + return textAndStructured(ELLA_REGISTRY.domains) + }, + ) + + server.registerTool( + '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: ELLA_MCP_INPUT_SCHEMAS.frameworks, + outputSchema: ELLA_MCP_OUTPUT_SCHEMAS.frameworks, + annotations: TOOL_ANNOTATIONS, + }, + async ({ framework }: { framework?: (typeof ELLA_FRAMEWORK_SLUGS)[number] }) => { + if (framework) { + return textAndStructured(ELLA_REGISTRY.frameworks.find((item) => item.slug === framework)) + } + + return textAndStructured(ELLA_REGISTRY.frameworks) + }, + ) + + server.registerTool( + 'ella.works.get', + { + title: 'Get Ella works', + description: 'Co-authored works attributed to Ella with schema types and URLs.', + inputSchema: ELLA_MCP_INPUT_SCHEMAS.empty, + outputSchema: ELLA_MCP_OUTPUT_SCHEMAS.works, + annotations: TOOL_ANNOTATIONS, + }, + async () => textAndStructured(ELLA_REGISTRY.works), + ) + + server.registerTool( + 'ella.collaboration.get', + { + title: 'Get Ella collaboration model', + description: 'The co-cognition model: division of labor between Mike Ye, Ella, and AI tooling.', + inputSchema: ELLA_MCP_INPUT_SCHEMAS.empty, + outputSchema: ELLA_MCP_OUTPUT_SCHEMAS.collaboration, + annotations: TOOL_ANNOTATIONS, + }, + async () => textAndStructured(ELLA_REGISTRY.collaboration), + ) + + for (const resource of ELLA_MCP_RESOURCES) { + server.registerResource( + resource.name, + resource.uri, + { + title: resource.name, + description: resource.description, + mimeType: resource.mimeType, + }, + async () => { + const data = readEllaResource(resource.uri) + + if (data === null) { + throw new Error(`Resource ${resource.uri} not found`) + } + + return resourceContents(resource, data) + }, + ) + } + + return server +} + +function createNodeResponseCapture(): NodeResponseCapture { + const headers = new Headers() + let body = '' + let statusCode = 200 + let headersSent = false + + const response = { + get statusCode() { + return statusCode + }, + set statusCode(value: number) { + statusCode = value + }, + get headersSent() { + return headersSent + }, + setHeader(name: string, value: number | string | readonly string[]) { + if (Array.isArray(value)) { + headers.set(name, value.join(', ')) + return + } + + headers.set(name, String(value)) + }, + getHeader(name: string) { + return headers.get(name) ?? undefined + }, + writeHead(code: number, nextHeaders?: Record) { + statusCode = code + headersSent = true + + if (nextHeaders) { + for (const [name, value] of Object.entries(nextHeaders)) { + response.setHeader(name, value) + } + } + + return response + }, + write(chunk?: unknown) { + if (chunk !== undefined) { + body += String(chunk) + } + + return true + }, + end(chunk?: unknown) { + if (chunk !== undefined) { + body += String(chunk) + } + + headersSent = true + return response + }, + on() { + return response + }, + once() { + return response + }, + emit() { + return false + }, + toResponse(extraHeaders?: HeadersInit) { + const mergedHeaders = new Headers(headers) + + if (extraHeaders) { + new Headers(extraHeaders).forEach((value, name) => mergedHeaders.set(name, value)) + } + + return new Response(body || null, { + status: statusCode, + headers: mergedHeaders, + }) + }, + } as NodeResponseCapture + + return response +} + +function createNodeRequest(request: Request): IncomingMessage { + const headers: Record = {} + + request.headers.forEach((value, name) => { + headers[name.toLowerCase()] = value + }) + + return { + method: request.method, + url: new URL(request.url).pathname, + headers, + on() { + return this + }, + once() { + return this + }, + } as unknown as IncomingMessage +} + +export async function handleEllaMcpPost(request: Request, extraHeaders?: HeadersInit) { + const body = await request.json() + const server = createEllaMcpServer() + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }) + const nodeRequest = createNodeRequest(request) + const nodeResponse = createNodeResponseCapture() + + try { + await server.connect(transport) + await transport.handleRequest(nodeRequest, nodeResponse, body) + return nodeResponse.toResponse(extraHeaders) + } finally { + await transport.close() + await server.close() + } +} + +export { ELLA_DOMAIN_SLUGS, ELLA_FRAMEWORK_SLUGS, ELLA_MCP_TOOL_NAMES } diff --git a/lib/ella-registry.ts b/lib/ella-registry.ts index 5644caf..6042eba 100644 --- a/lib/ella-registry.ts +++ b/lib/ella-registry.ts @@ -1,4 +1,4 @@ -import { ELLA_GLOBAL_SCHEMA, ELLA_MCP_SCHEMA, ELLA_ORG_SCHEMA, ELLA_SYSTEM_SCHEMA } from '@/app/schema/ella' +import { ELLA_GLOBAL_SCHEMA, ELLA_MCP_SCHEMA, ELLA_ORG_SCHEMA, ELLA_SYSTEM_SCHEMA } from '../app/schema/ella' import { ELLA_COCOGNITION, ELLA_DOMAINS, @@ -6,11 +6,11 @@ import { ELLA_IDENTITY, ELLA_SURFACES, ELLA_WORKS, -} from '@/lib/entity-data' +} 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.0' } as const -export const ELLA_MCP_PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18'] as const +export const ELLA_MCP_SERVER_INFO = { name: 'ellaentity-mcp', version: '1.1.1' } as const +export const ELLA_MCP_PROTOCOL_VERSIONS = ['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 export const ELLA_REGISTRY_LAST_MODIFIED = '2026-07-13' as const diff --git a/package.json b/package.json index e43899c..7a3b17c 100644 --- a/package.json +++ b/package.json @@ -8,17 +8,17 @@ "start": "next start", "lint": "eslint", "typecheck": "tsc --noEmit", - "test": "node --test tests/*.test.ts" + "test": "node --import tsx --test tests/*.test.ts" }, "dependencies": { "next": "16.1.6", "react": "19.2.3", "react-dom": "19.2.3", - "zod": "^3.24.0", + "zod": "^3.25.76", "@anthropic-ai/sdk": "^0.39.0", "uuid": "^11.1.0", "openai": "^4.98.0", - "@modelcontextprotocol/sdk": "^1.22.0" + "@modelcontextprotocol/sdk": "^1.29.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -29,6 +29,8 @@ "eslint-config-next": "16.1.6", "tailwindcss": "^4", "typescript": "^5", - "@types/uuid": "^10.0.0" + "@types/uuid": "^10.0.0", + "ajv": "^8.17.1", + "tsx": "^4.20.6" } } diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index b1db1c4..4cfdad7 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -1,54 +1,176 @@ import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' import test from 'node:test' +import Ajv from 'ajv' +import { GET, OPTIONS, POST } from '../app/mcp/route' +import { ELLA_MCP_RESOURCES, ELLA_MCP_TOOL_NAMES } from '../lib/ella-registry' -const route = readFileSync('app/mcp/route.ts', 'utf8') -const registry = readFileSync('lib/ella-registry.ts', 'utf8') -const docs = readFileSync('app/system/mcp/page.tsx', 'utf8') -const llms = readFileSync('public/llms.txt', 'utf8') - -const tools = ['ella.identity.get', 'ella.domains.get', 'ella.frameworks.get', 'ella.works.get', 'ella.collaboration.get'] -const resources = ['ella://identity', 'ella://domains', 'ella://domains/longevity', 'ella://domains/environment', 'ella://domains/sleep', 'ella://domains/ai-frameworks', 'ella://frameworks', 'ella://frameworks/four-forces-of-ai-power', 'ella://works', 'ella://collaboration', 'ella://entity-graph'] - -test('entity invariants are preserved', () => { - assert.match(registry, /https:\/\/ellaentity\.ai\/#ella/) - const entityData = readFileSync('lib/entity-data.ts', 'utf8') - assert.match(entityData, /TrailGenic/) - assert.match(entityData, /exmxc/) - assert.match(entityData, /MikeYe\.com|Mike Ye/) - assert.match(entityData, /Sleepgenic/) - assert.match(entityData, /four-forces-of-ai-power/) - assert.doesNotMatch(route, /api\/process/) +const endpoint = 'https://ellaentity.ai/mcp' +const accept = 'application/json, text/event-stream' +const contentType = 'application/json' +const protocolVersion = '2025-06-18' + +function request(method: string, body?: unknown, headers: HeadersInit = {}) { + return new Request(endpoint, { + method, + headers: { + accept, + 'content-type': contentType, + 'mcp-protocol-version': protocolVersion, + ...headers, + }, + body: body === undefined ? undefined : JSON.stringify(body), + }) +} + +async function rpc(method: string, params: unknown = {}, id: string | number = 1, headers: HeadersInit = {}) { + const response = await POST( + request( + 'POST', + { + jsonrpc: '2.0', + id, + method, + params, + }, + headers, + ), + ) + const text = await response.text() + return { + response, + body: text ? JSON.parse(text) : null, + } +} + +test('GET and OPTIONS implement method and CORS behavior', async () => { + const get = await GET(new Request(endpoint, { method: 'GET', headers: { origin: 'https://ellaentity.ai' } })) + assert.equal(get.status, 405) + assert.equal(await get.text(), '') + assert.equal(get.headers.get('allow'), 'POST, OPTIONS') + assert.equal(get.headers.get('access-control-allow-origin'), 'https://ellaentity.ai') + assert.notEqual(get.headers.get('access-control-allow-origin'), '*') + + const options = await OPTIONS(new Request(endpoint, { method: 'OPTIONS', headers: { origin: 'https://ellaentity.ai' } })) + assert.equal(options.status, 204) + assert.equal(await options.text(), '') + assert.equal(options.headers.get('access-control-allow-origin'), 'https://ellaentity.ai') + + const blocked = await OPTIONS(new Request(endpoint, { method: 'OPTIONS', headers: { origin: 'https://evil.example' } })) + assert.equal(blocked.status, 403) +}) + +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) +}) + +test('initialize validates official params and reports server capabilities', async () => { + const valid = await rpc('initialize', { + protocolVersion, + capabilities: {}, + clientInfo: { name: 'behavior-test-client', version: '1.0.0' }, + }) + + assert.equal(valid.response.status, 200) + assert.equal(valid.body.result.serverInfo.name, 'ellaentity-mcp') + assert.equal(valid.body.result.protocolVersion, protocolVersion) + assert.ok(valid.body.result.capabilities.tools) + assert.ok(valid.body.result.capabilities.resources) + + const missingProtocol = await rpc('initialize', { + capabilities: {}, + clientInfo: { name: 'behavior-test-client', version: '1.0.0' }, + }) + assert.ok(missingProtocol.body.error || missingProtocol.body.result.protocolVersion === protocolVersion) + + const invalidId = await POST(request('POST', { jsonrpc: '2.0', id: null, method: 'ping', params: {} })) + assert.notEqual(invalidId.status, 200) }) -test('tool and resource registries are complete and documented', () => { +test('notifications return no JSON-RPC body', async () => { + const response = await POST( + request('POST', { + jsonrpc: '2.0', + method: 'notifications/initialized', + params: {}, + }), + ) + + assert.equal(response.status, 202) + assert.equal(await response.text(), '') +}) + +test('tools list and calls are behavioral and schema-valid', async () => { + const list = await rpc('tools/list') + const tools = list.body.result.tools + + assert.deepEqual(tools.map((tool: { name: string }) => tool.name), ELLA_MCP_TOOL_NAMES) + + const ajv = new Ajv() + for (const tool of tools) { - assert.match(registry, new RegExp(tool.replaceAll('.', '\\.'))) - assert.match(route, new RegExp(tool.replaceAll('.', '\\.'))) - assert.ok(docs.includes('ELLA_MCP_TOOL_NAMES')) - } - for (const resource of resources) { - assert.ok(registry.includes(resource) || resource.startsWith('ella://domains/')) - assert.ok(docs.includes(resource) || docs.includes('ELLA_MCP_RESOURCES')) + assert.ok(tool.title) + assert.ok(tool.description) + assert.ok(tool.inputSchema) + assert.ok(tool.outputSchema) + assert.deepEqual(tool.annotations, { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }) + + const call = await rpc('tools/call', { name: tool.name, arguments: {} }) + assert.equal(call.response.status, 200) + assert.equal(call.body.result.isError, false) + assert.ok(call.body.result.content[0].text) + assert.ok(call.body.result.structuredContent.provenance.canonicalEntityId === 'https://ellaentity.ai/#ella') + assert.equal(ajv.validate(tool.outputSchema, call.body.result.structuredContent), true, ajv.errorsText()) } + + const domain = await rpc('tools/call', { name: 'ella.domains.get', arguments: { domain: 'sleep' } }) + assert.equal(domain.body.result.structuredContent.data.name.length > 0, true) + + const badDomain = await rpc('tools/call', { name: 'ella.domains.get', arguments: { domain: 'bad' } }) + assert.equal(badDomain.body.result.isError, true) + + const unknown = await rpc('tools/call', { name: 'unknown.tool', arguments: {} }) + assert.equal(unknown.body.result.isError, true) }) -test('transport security and protocol behavior are encoded', () => { - assert.match(route, /405/) - assert.match(route, /MCP_ALLOWED_ORIGINS/) - assert.doesNotMatch(route, /Access-Control-Allow-Origin'\]: '\*'|Access-Control-Allow-Origin": "\*"/) - assert.match(registry, /2025-11-25/) - assert.match(registry, /2025-06-18/) - assert.match(route, /isNotification/) - assert.match(route, /resources\/list/) - assert.match(route, /resources\/read/) - assert.match(route, /outputSchema/) - assert.match(route, /structuredContent/) - assert.match(route, /readOnlyHint: true/) +test('resources list and reads expose only canonical public resources', async () => { + const list = await rpc('resources/list') + const uris = list.body.result.resources.map((resource: { uri: string }) => resource.uri) + + assert.deepEqual(uris, ELLA_MCP_RESOURCES.map((resource) => resource.uri)) + + for (const resource of ELLA_MCP_RESOURCES) { + const read = await rpc('resources/read', { uri: resource.uri }) + assert.equal(read.response.status, 200) + assert.equal(read.body.result.contents[0].mimeType, resource.mimeType) + assert.doesNotMatch(read.body.result.contents[0].text, /api\/process|credential|private memory|internal prompt/i) + } + + const graph = await rpc('resources/read', { uri: 'ella://entity-graph' }) + assert.match(graph.body.result.contents[0].text, /https:\/\/ellaentity\.ai\/#ella/) + + const unknown = await rpc('resources/read', { uri: 'ella://unknown' }) + assert.ok(unknown.body.error || unknown.body.result.isError) }) -test('llms.txt remains aligned with canonical crawler surfaces', () => { - assert.match(llms, /https:\/\/ellaentity\.ai\/#ella/) - assert.match(llms, /https:\/\/ellaentity\.ai\/entity\.json/) +test('canonical MCP URL parity is preserved', async () => { + const docs = await import('../app/system/mcp/page') + const llms = await import('node:fs').then((fs) => fs.readFileSync('public/llms.txt', 'utf8')) + const schema = await import('../app/schema/ella') + + assert.match(String(docs.default), /Page/) assert.match(llms, /https:\/\/mcp\.ellaentity\.ai/) + assert.doesNotMatch(llms, /https:\/\/mcp\.ellaentity\.ai\/mcp/) + assert.match(JSON.stringify(schema.ELLA_MCP_SCHEMA), /https:\/\/mcp\.ellaentity\.ai/) })