Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions packages/mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@
"source": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./in-process": {
"source": "./src/in-process.ts",
"types": "./dist/in-process.d.ts",
"import": "./dist/in-process.js"
}
},
"publishConfig": {
Expand All @@ -59,7 +64,16 @@
"@node-i3x/core": "0.11.2",
"zod": "^4.6.7"
},
"peerDependencies": {
"@node-i3x/rest-server": "0.11.2"
},
"peerDependenciesMeta": {
"@node-i3x/rest-server": {
"optional": true
}
},
"devDependencies": {
"@node-i3x/rest-server": "0.11.2",
"@types/node": "^26.6.5",
"tsup": "^9.0.0",
"typescript": "6"
Expand Down
205 changes: 40 additions & 165 deletions packages/mcp/src/bindings/connector-binding.ts
Original file line number Diff line number Diff line change
@@ -1,169 +1,44 @@
import type {
CurrentValueResult,
HistoricalValueResult,
I3xStack,
ModelNode,
Namespace,
ObjectInstanceResponse,
ObjectType,
RelationshipType,
ServerInfo,
} from '@node-i3x/core';
import { rankSuggestions } from '../utils/similarity.js';
import type { I3XClientBinding } from './types.js';

export class InProcessConnectorBinding implements I3XClientBinding {
constructor(private readonly stack: I3xStack) {}

async getInfo(): Promise<ServerInfo> {
return {
specVersion: '1.0.0-beta',
serverVersion: '0.10.0',
serverName: 'node-i3x (In-Process Direct)',
capabilities: {
query: { history: true },
update: { current: true, history: true },
subscribe: { stream: true },
},
};
}

async getNamespaces(): Promise<Namespace[]> {
const model = await this.stack.modelService.getOrBuildModel();
const uris = new Set<string>();
for (const node of model.nodesById.values()) {
if (node.namespaceUri) uris.add(node.namespaceUri);
}
return Array.from(uris).map((uri) => ({
uri,
displayName: uri,
}));
}

async getObjectTypes(namespaceUri?: string): Promise<ObjectType[]> {
return this.stack.typeService.getObjectTypes(namespaceUri);
}

async getRelationshipTypes(): Promise<RelationshipType[]> {
return [
{
elementId: 'HasComponent',
displayName: 'Has Component',
namespaceUri: 'http://opcfoundation.org/UA/',
relationshipId: 'i=47',
reverseOf: 'ComponentOf',
},
{
elementId: 'HasProperty',
displayName: 'Has Property',
namespaceUri: 'http://opcfoundation.org/UA/',
relationshipId: 'i=46',
reverseOf: 'PropertyOf',
},
];
}

async getObjects(options?: {
rootOnly?: boolean;
typeElementId?: string;
}): Promise<ObjectInstanceResponse[]> {
const model = await this.stack.modelService.getOrBuildModel();
let nodes: ModelNode[];

if (options?.rootOnly) {
nodes = model.rootIds
.map((id) => model.nodesById.get(id))
.filter((n): n is ModelNode => n !== undefined && n.kind === 'instance');
} else {
nodes = Array.from(model.nodesById.values()).filter(
(n): n is ModelNode => n.kind === 'instance' || n.kind === 'property',
);
}

if (options?.typeElementId) {
nodes = nodes.filter((n) => n.type === options.typeElementId);
}

const rootSet = new Set(model.rootIds);

return nodes.map((n) => ({
elementId: n.id,
displayName: n.name,
typeElementId: n.type || 'BaseObjectType',
parentId: rootSet.has(n.id) ? null : undefined,
isComposition: n.kind === 'instance',
metadata: {
typeNamespaceUri: n.namespaceUri,
engUnit: n.engUnit || null,
sourceTypeId: n.sourceTypeId || null,
},
}));
}

async getObjectById(elementId: string): Promise<ObjectInstanceResponse | null> {
const objects = await this.getObjects();
return objects.find((o) => o.elementId === elementId) || null;
}

async getRelatedObjects(elementId: string): Promise<ObjectInstanceResponse[]> {
const model = await this.stack.modelService.getOrBuildModel();
const node = model.nodesById.get(elementId);
if (!node?.children) return [];

const children = node.children
.map((childId) => model.nodesById.get(childId))
.filter((n): n is ModelNode => n !== undefined);

return children.map((n) => ({
elementId: n.id,
displayName: n.name,
typeElementId: n.type || 'BaseObjectType',
parentId: elementId,
isComposition: n.kind === 'instance',
metadata: {
typeNamespaceUri: n.namespaceUri,
engUnit: n.engUnit || null,
sourceTypeId: n.sourceTypeId || null,
},
}));
}

async getObjectValues(
elementIds: string[],
maxDepth = 1,
): Promise<CurrentValueResult[]> {
const bulkResults = await this.stack.valueService.readValues(elementIds, maxDepth);
return bulkResults
.map((r) => r.result)
.filter((v): v is CurrentValueResult => v !== null && v !== undefined);
import type { RestServerDeps } from '@node-i3x/rest-server';
import { RestClientBinding } from './rest-binding.js';

type InjectableApp = Awaited<
ReturnType<typeof import('@node-i3x/rest-server').createApp>
>;

/**
* Binding to an i3X stack running in this process.
*
* Requests go through the real rest-server routes (Fastify `inject()`, no socket),
* so in-process answers are exactly what a remote node-i3x server would send:
* same parentId, roots, related objects, values and server info.
*/
export class InProcessConnectorBinding extends RestClientBinding {
constructor(deps: RestServerDeps) {
super('http://in-process', undefined, 15_000, injectFetch(deps));
}
}

async getHistoricalValues(
elementIds: string[],
startTime: string,
endTime: string,
maxDepth = 1,
): Promise<HistoricalValueResult[]> {
const start = startTime ? new Date(startTime) : null;
const end = endTime ? new Date(endTime) : null;
const bulkResults = await this.stack.historyService.readHistory(
elementIds,
start,
end,
maxDepth,
/** A fetch that answers from an in-process rest-server instead of the network. */
export function injectFetch(deps: RestServerDeps): typeof fetch {
let app: Promise<InjectableApp> | undefined;
return async (input, init) => {
// Loaded lazily so the REST-only CLI never pulls in Fastify. No request logging:
// Fastify logs to stdout, which is the MCP stdio channel.
app ??= import('@node-i3x/rest-server').then(({ createApp }) =>
createApp({ ...deps, requireAuth: false }, { logger: false }),
);
return bulkResults
.map((r) => r.result)
.filter((v): v is HistoricalValueResult => v !== null && v !== undefined);
}

async findSuggestions(unknownId: string): Promise<string[]> {
try {
const allObjects = await this.getObjects();
const candidates = allObjects.map((o) => o.elementId);
return rankSuggestions(unknownId, candidates, 5, 0.4);
} catch {
return [];
}
}
const url = new URL(String(input));
const res = await (await app).inject({
method: (init?.method ?? 'GET') as 'GET' | 'POST',
url: `${url.pathname}${url.search}`,
headers: init?.headers as Record<string, string> | undefined,
payload: typeof init?.body === 'string' ? init.body : undefined,
});
return new Response(res.body, {
status: res.statusCode,
headers: {
'content-type': String(res.headers['content-type'] ?? 'application/json'),
},
});
};
}
Loading
Loading