NODEDC_PLATFORM/services/ontology-core/src/mcp-server.mjs

582 lines
21 KiB
JavaScript

#!/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)
}