582 lines
21 KiB
JavaScript
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)
|
|
}
|