#!/usr/bin/env node import { createHash, timingSafeEqual } from 'node:crypto' import { createServer } from 'node:http' import path from 'node:path' import { fileURLToPath } from 'node:url' import { canonicalizeEntityId, loadCatalog } from './catalog.mjs' import { resolveContext } from './resolver.mjs' const MCP_PROTOCOL_VERSION = '2025-06-18' const MAX_BODY_BYTES = 1024 * 1024 const MAX_SEARCH_RESULTS = 50 const TOOLS = [ { name: 'ontology_status', title: 'Ontology catalog status', description: 'Returns a safe, read-only summary of the live NODE.DC ontology catalog and its loaded domain packages.', inputSchema: { type: 'object', additionalProperties: false, properties: {} }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, }, { name: 'ontology_search', title: 'Search ontology catalog', description: 'Searches canonical entities, relations and aliases in the live ontology catalog. Use this before introducing names or data contracts into a workflow.', inputSchema: { type: 'object', additionalProperties: false, required: ['query'], properties: { query: { type: 'string', minLength: 1, maxLength: 200 }, limit: { type: 'integer', minimum: 1, maximum: MAX_SEARCH_RESULTS }, }, }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, }, { name: 'ontology_get_entity', title: 'Get ontology entity', description: 'Resolves an entity id or alias and returns its safe definition, aliases, relations and applicable guardrails. It never returns runtime data, credentials or source filesystem paths.', inputSchema: { type: 'object', additionalProperties: false, properties: { entityId: { type: 'string', minLength: 1, maxLength: 240 }, term: { type: 'string', minLength: 1, maxLength: 240 }, }, anyOf: [{ required: ['entityId'] }, { required: ['term'] }], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, }, { name: 'ontology_get_guardrails', title: 'Get ontology guardrails', description: 'Returns semantic and safety guardrails for the full catalog or for one entity/alias. This is read-only advice; it does not grant capability access.', inputSchema: { type: 'object', additionalProperties: false, properties: { entityId: { type: 'string', minLength: 1, maxLength: 240 }, term: { type: 'string', minLength: 1, maxLength: 240 }, }, }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, }, { name: 'ontology_resolve_context', title: 'Resolve ontology context', description: 'Resolves a semantic request to canonical entities and context routes. It deliberately returns no source-system ids, secrets, runtime data or execution capability.', inputSchema: { type: 'object', additionalProperties: false, properties: { surface: { type: 'string', maxLength: 120 }, entityId: { type: 'string', maxLength: 240 }, alias: { type: 'string', maxLength: 240 }, term: { type: 'string', maxLength: 240 }, intent: { type: 'string', maxLength: 1000 }, contextId: { type: 'string', maxLength: 240 }, }, }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, }, ] export function createOntologyMcpServer(overrides = {}) { const config = { ...readConfig(), ...overrides } return createServer((req, res) => handleRequest(req, res, config).catch((error) => { if (!res.headersSent) { sendJson(res, 500, { ok: false, error: 'ontology_mcp_internal_error' }) } else { res.destroy(error) } })) } async function handleRequest(req, res, config) { const url = new URL(req.url || '/', 'http://localhost') if (url.pathname === '/healthz') { const catalog = await loadCatalog() sendJson(res, 200, { ok: true, service: 'nodedc-ontology-core-mcp', mcp: { protocolVersion: MCP_PROTOCOL_VERSION, tools: TOOLS.length, readOnly: true, }, catalog: catalogStatus(catalog), internalApiConfigured: config.internalTokens.length > 0, }) return } if (url.pathname !== '/mcp') { sendJson(res, 404, { ok: false, error: 'not_found' }) return } if (!isAllowedOrigin(req.headers.origin, config.allowedOrigins)) { sendJson(res, 403, { ok: false, error: 'mcp_origin_forbidden' }) return } if (!isAuthorized(req.headers.authorization, config.internalTokens)) { sendJson(res, config.internalTokens.length ? 401 : 503, { ok: false, error: config.internalTokens.length ? 'ontology_mcp_unauthorized' : 'ontology_mcp_token_not_configured', }) return } const method = String(req.method || 'GET').toUpperCase() if (method === 'GET') { res.setHeader('allow', 'POST') sendJson(res, 405, { ok: false, error: 'mcp_sse_not_supported' }) return } if (method !== 'POST') { res.setHeader('allow', 'POST') sendJson(res, 405, { ok: false, error: 'method_not_allowed' }) return } let payload try { payload = JSON.parse((await readRequestBody(req, config.maxBodyBytes)).toString('utf8')) } catch (error) { sendMcpError(res, null, -32700, error?.code === 'body_too_large' ? 'request_too_large' : 'parse_error') return } if (!isPlainObject(payload) || payload.jsonrpc !== '2.0' || !cleanString(payload.method, 160)) { sendMcpError(res, isPlainObject(payload) ? payload.id ?? null : null, -32600, 'invalid_request') return } const isNotification = !Object.hasOwn(payload, 'id') const response = await dispatchMcpRequest(payload) if (isNotification) { res.statusCode = 202 res.end() return } sendJson(res, 200, response, { 'mcp-protocol-version': MCP_PROTOCOL_VERSION }) } async function dispatchMcpRequest(payload) { const id = payload.id ?? null const method = cleanString(payload.method, 160) const params = isPlainObject(payload.params) ? payload.params : {} if (method === 'initialize') { return mcpResult(id, { protocolVersion: MCP_PROTOCOL_VERSION, capabilities: { tools: { listChanged: false } }, serverInfo: { name: 'nodedc-ontology-core', version: '0.1.0', }, instructions: 'Read-only semantic catalog. It exposes no runtime telemetry, database access, credentials, source paths, workflow mutations, command dispatch or Studio rendering controls.', }) } if (method === 'notifications/initialized') return mcpResult(id, {}) if (method === 'ping') return mcpResult(id, {}) if (method === 'tools/list') return mcpResult(id, { tools: TOOLS }) if (method !== 'tools/call') return mcpError(id, -32601, 'method_not_found') const toolName = cleanString(params.name, 120) const argumentsValue = isPlainObject(params.arguments) ? params.arguments : {} return mcpResult(id, await callTool(toolName, argumentsValue)) } async function callTool(name, input) { if (name === 'ontology_status') { const catalog = await loadCatalog() return toolResult({ schemaVersion: 'nodedc.ontology-mcp.status.v1', readOnly: true, catalog: catalogStatus(catalog), }) } if (name === 'ontology_search') { const query = cleanString(input.query, 200) if (!query) return toolError('query_required') const catalog = await loadCatalog() return toolResult(searchCatalog(catalog, query, boundedLimit(input.limit))) } if (name === 'ontology_get_entity') { const catalog = await loadCatalog() const requested = cleanString(input.entityId || input.term, 240) const entityId = canonicalizeEntityId(requested, catalog) const entity = catalog.entityById.get(entityId) if (!entity) return toolError('entity_not_found', { requested }) return toolResult(entityDetails(catalog, entity)) } if (name === 'ontology_get_guardrails') { const catalog = await loadCatalog() const requested = cleanString(input.entityId || input.term, 240) const entityId = requested ? canonicalizeEntityId(requested, catalog) : '' if (requested && !catalog.entityById.has(entityId)) return toolError('entity_not_found', { requested }) return toolResult(guardrailDetails(catalog, entityId)) } if (name === 'ontology_resolve_context') { const resolved = await resolveContext({ surface: cleanString(input.surface, 120), entityId: cleanString(input.entityId, 240), alias: cleanString(input.alias, 240), term: cleanString(input.term, 240), intent: cleanString(input.intent, 1000), contextId: cleanString(input.contextId, 240), }) return toolResult(sanitizeResolvedContext(resolved)) } return toolError('tool_not_found', { name }) } function catalogStatus(catalog) { const packages = (catalog.domainPackages || []) .map((item) => ({ id: cleanString(item.id, 120), version: cleanString(item.version, 80), updatedAt: cleanString(item.updatedAt, 80), status: cleanString(item.status, 120), summary: cleanString(item.summary, 1000), })) .sort((left, right) => left.id.localeCompare(right.id)) const snapshot = { entityIds: (catalog.entities?.entities || []).map((item) => item.id).sort(), relationIds: (catalog.relations?.relations || []).map((item) => item.id).sort(), aliases: (catalog.aliases?.aliases || []).map((item) => [item.alias, item.canonicalId]).sort((left, right) => String(left[0]).localeCompare(String(right[0]))), packages, } return { schemaVersion: 'nodedc.ontology-catalog.status.v1', catalogHash: createHash('sha256').update(JSON.stringify(snapshot)).digest('hex').slice(0, 16), entities: snapshot.entityIds.length, relations: snapshot.relationIds.length, aliases: snapshot.aliases.length, guardrails: (catalog.guardrails?.rules || []).length, blockedConflations: (catalog.guardrails?.blockedConflations || []).length, domainPackages: packages, } } function searchCatalog(catalog, query, limit) { const needle = normalizeSearch(query) const items = [] for (const entity of catalog.entities?.entities || []) { const aliasScore = (catalog.aliases?.aliases || []) .filter((alias) => alias.canonicalId === entity.id) .reduce((best, alias) => Math.max(best, searchScore(needle, alias.alias)), 0) const score = Math.max( aliasScore, searchScore(needle, entity.id, entity.name, entity.summary, entity.surface, entity.authority), ) if (score > 0) items.push({ kind: 'entity', score, entity: safeEntity(entity) }) } for (const relation of catalog.relations?.relations || []) { const score = searchScore(needle, relation.id, relation.summary, ...(relation.from || []), ...(relation.to || [])) if (score > 0) items.push({ kind: 'relation', score, relation: safeRelation(relation) }) } for (const alias of catalog.aliases?.aliases || []) { const score = searchScore(needle, alias.alias, alias.canonicalId) if (score > 0) items.push({ kind: 'alias', score, alias: safeAlias(alias) }) } items.sort((left, right) => right.score - left.score || stableItemId(left).localeCompare(stableItemId(right))) return { schemaVersion: 'nodedc.ontology-mcp.search.v1', query, totalMatches: items.length, results: items.slice(0, limit).map(({ score, ...item }) => item), } } function entityDetails(catalog, entity) { const entityId = entity.id return { schemaVersion: 'nodedc.ontology-mcp.entity.v1', entity: safeEntity(entity), aliases: (catalog.aliases?.aliases || []) .filter((item) => item.canonicalId === entityId) .map(safeAlias) .sort((left, right) => left.alias.localeCompare(right.alias)), relations: (catalog.relations?.relations || []) .filter((item) => (item.from || []).includes(entityId) || (item.to || []).includes(entityId)) .map(safeRelation) .sort((left, right) => left.id.localeCompare(right.id)), guardrails: (catalog.guardrails?.rules || []) .filter((item) => (item.entityIds || []).includes(entityId)) .map(safeGuardrail) .sort((left, right) => left.id.localeCompare(right.id)), blockedConflations: (catalog.guardrails?.blockedConflations || []) .filter((pair) => Array.isArray(pair) && pair.includes(entityId)) .map((pair) => pair.map((item) => cleanString(item, 240))), } } function guardrailDetails(catalog, entityId) { const rules = (catalog.guardrails?.rules || []) .filter((item) => !entityId || (item.entityIds || []).includes(entityId)) .map(safeGuardrail) .sort((left, right) => left.id.localeCompare(right.id)) const blockedConflations = (catalog.guardrails?.blockedConflations || []) .filter((pair) => !entityId || (Array.isArray(pair) && pair.includes(entityId))) .map((pair) => Array.isArray(pair) ? pair.map((item) => cleanString(item, 240)) : []) return { schemaVersion: 'nodedc.ontology-mcp.guardrails.v1', entityId: entityId || null, rules, blockedConflations, } } function sanitizeResolvedContext(resolved) { return { schemaVersion: 'nodedc.ontology-mcp.context-resolution.v1', input: { surface: cleanString(resolved?.input?.surface, 120), entityId: cleanString(resolved?.input?.entityId, 240), intent: cleanString(resolved?.input?.intent, 1000), }, canonicalEntity: resolved?.canonicalEntity ? safeEntity(resolved.canonicalEntity) : null, matchedContexts: (resolved?.matchedContexts || []).map((item) => ({ surface: cleanString(item.surface, 120), entityId: cleanString(item.entityId, 240), label: cleanString(item.label, 1000), sourceSystem: cleanString(item.sourceSystem, 160), status: cleanString(item.status, 120), })), bindings: (resolved?.bindings || []).map((item) => ({ typeId: cleanString(item.typeId, 240), status: cleanString(item.status, 120), relationId: cleanString(item.relationId, 240), summary: cleanString(item.summary, 1000), })), missingBindingTypes: (resolved?.missingBindingTypes || []).map((item) => ({ id: cleanString(item.id, 240), relationId: cleanString(item.relationId, 240), summary: cleanString(item.summary, 1000), })), selectedRule: resolved?.selectedRule ? { id: cleanString(resolved.selectedRule.id, 240), fromSurface: cleanString(resolved.selectedRule.fromSurface, 120), outputSurface: cleanString(resolved.selectedRule.outputSurface, 120), inputEntityIds: stringList(resolved.selectedRule.inputEntityIds), outputEntityIds: stringList(resolved.selectedRule.outputEntityIds), requiredBindings: stringList(resolved.selectedRule.requiredBindings), summary: cleanString(resolved.selectedRule.summary, 1000), } : null, candidates: (resolved?.candidates || []).map((item) => ({ id: cleanString(item.id, 240), score: Number.isFinite(Number(item.score)) ? Number(item.score) : 0, outputSurface: cleanString(item.outputSurface, 120), outputEntityIds: stringList(item.outputEntityIds), requiredBindings: stringList(item.requiredBindings), summary: cleanString(item.summary, 1000), })), } } function safeEntity(value) { return { id: cleanString(value?.id, 240), name: cleanString(value?.name, 240), surface: cleanString(value?.surface, 120), status: stringList(value?.status), authority: cleanString(value?.authority, 240), summary: cleanString(value?.summary, 2000), } } function safeRelation(value) { return { id: cleanString(value?.id, 240), from: stringList(value?.from), to: stringList(value?.to), status: cleanString(value?.status, 120), summary: cleanString(value?.summary, 2000), } } function safeAlias(value) { return { alias: cleanString(value?.alias, 240), canonicalId: cleanString(value?.canonicalId, 240), } } function safeGuardrail(value) { return { id: cleanString(value?.id, 240), severity: cleanString(value?.severity, 40), summary: cleanString(value?.summary, 2000), entityIds: stringList(value?.entityIds), } } function toolResult(value) { return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }], structuredContent: value, } } function toolError(error, details = {}) { const value = { ok: false, error, ...details } return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }], structuredContent: value, isError: true, } } function mcpResult(id, result) { return { jsonrpc: '2.0', id, result } } function mcpError(id, code, message) { return { jsonrpc: '2.0', id, error: { code, message } } } function sendMcpError(res, id, code, message) { sendJson(res, 200, mcpError(id, code, message), { 'mcp-protocol-version': MCP_PROTOCOL_VERSION }) } function sendJson(res, status, body, headers = {}) { res.statusCode = status res.setHeader('content-type', 'application/json; charset=utf-8') res.setHeader('cache-control', 'no-store') for (const [key, value] of Object.entries(headers)) res.setHeader(key, value) res.end(JSON.stringify(body)) } async function readRequestBody(req, maxBytes) { const chunks = [] let size = 0 for await (const chunk of req) { size += chunk.length if (size > maxBytes) { const error = new Error('body_too_large') error.code = 'body_too_large' throw error } chunks.push(chunk) } return Buffer.concat(chunks) } function isAuthorized(header, candidates) { const match = String(header || '').match(/^Bearer\s+(.+)$/i) const token = match?.[1]?.trim() || '' if (!token) return false return (candidates || []).some((candidate) => safeEqual(token, candidate)) } function safeEqual(left, right) { const leftBuffer = Buffer.from(String(left || '')) const rightBuffer = Buffer.from(String(right || '')) return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer) } function isAllowedOrigin(origin, allowedOrigins) { if (!origin) return true const normalized = normalizeOrigin(origin) return Boolean(normalized && allowedOrigins.includes(normalized)) } function normalizeOrigin(value) { try { const url = new URL(String(value || '')) if (url.protocol !== 'http:' && url.protocol !== 'https:') return '' return url.origin } catch { return '' } } function readConfig() { const internalTokens = uniqueStrings([ process.env.ONTOLOGY_CORE_TOKEN, process.env.NODEDC_INTERNAL_ACCESS_TOKEN, process.env.NODEDC_PLATFORM_SERVICE_TOKEN, ]) return { port: boundedNumber(process.env.PORT || process.env.ONTOLOGY_CORE_PORT, 18104, 1, 65535), internalTokens, allowedOrigins: uniqueStrings(String(process.env.ONTOLOGY_MCP_ALLOWED_ORIGINS || '') .split(/[\n,;]+/) .map(normalizeOrigin) .filter(Boolean)), maxBodyBytes: boundedNumber(process.env.ONTOLOGY_MCP_MAX_BODY_BYTES, MAX_BODY_BYTES, 1024, 10 * 1024 * 1024), } } function boundedLimit(value) { return boundedNumber(value, 20, 1, MAX_SEARCH_RESULTS) } function boundedNumber(value, fallback, min, max) { const numeric = Number(value) if (!Number.isFinite(numeric)) return fallback return Math.min(Math.max(Math.trunc(numeric), min), max) } function normalizeSearch(value) { return String(value || '').trim().toLocaleLowerCase('ru-RU') } function searchScore(needle, ...values) { let score = 0 for (const value of values) { const haystack = normalizeSearch(value) if (!haystack) continue if (haystack === needle) score = Math.max(score, 100) else if (haystack.startsWith(needle)) score = Math.max(score, 60) else if (haystack.includes(needle)) score = Math.max(score, 30) } return score } function stableItemId(item) { return item.entity?.id || item.relation?.id || item.alias?.alias || '' } function stringList(value) { if (!Array.isArray(value)) return [] return value.map((item) => cleanString(item, 240)).filter(Boolean) } function cleanString(value, maxLength) { return String(value || '').trim().slice(0, maxLength) } function uniqueStrings(values) { return [...new Set((values || []).map((item) => cleanString(item, 4000)).filter(Boolean))] } function isPlainObject(value) { return Boolean(value) && typeof value === 'object' && !Array.isArray(value) } const currentFile = fileURLToPath(import.meta.url) const isEntrypoint = process.argv[1] && path.resolve(process.argv[1]) === currentFile if (isEntrypoint) { const config = readConfig() const server = createOntologyMcpServer(config) server.listen(config.port, '0.0.0.0', () => { console.log(`NODE.DC Ontology Core MCP listening on http://0.0.0.0:${config.port}`) }) const shutdown = () => server.close(() => process.exit(0)) process.on('SIGTERM', shutdown) process.on('SIGINT', shutdown) }