Skip to content

Commit e7db36f

Browse files
author
Sterfive CI
committed
release: v1.3.0 (synced from monorepo 26b5ad1e)
1 parent 17dcd78 commit e7db36f

4 files changed

Lines changed: 132 additions & 5 deletions

File tree

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "node-opcua-modeler-mcp-server",
3-
"version": "1.2.3",
3+
"version": "1.3.0",
44
"mcpName": "io.github.node-opcua/node-opcua-modeler-mcp-server",
55
"description": "OPC UA Modeler MCP Server — exposes companion spec tools to AI agents",
66
"type": "module",

‎server.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"name": "io.github.node-opcua/node-opcua-modeler-mcp-server",
44
"title": "OPC UA Modeler",
55
"description": "OPC UA companion spec types, namespace dependencies, and engineering units for AI agents",
6-
"version": "1.2.3",
6+
"version": "1.3.0",
77
"websiteUrl": "https://opcua-modeler.sterfive.io",
88
"repository": {
99
"url": "https://github.com/node-opcua/node-opcua-modeler-mcp-server",
@@ -13,7 +13,7 @@
1313
{
1414
"registryType": "npm",
1515
"identifier": "node-opcua-modeler-mcp-server",
16-
"version": "1.2.3",
16+
"version": "1.3.0",
1717
"registryBaseUrl": "https://registry.npmjs.org",
1818
"runtimeHint": "npx",
1919
"transport": {

‎src/catalog.ts‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,102 @@ export function searchTypes(query: string): (TypeSummary & { alias: string })[]
149149
return results;
150150
}
151151

152+
// ── Reusable Block Discovery (interfaces & addins by capability) ─────────
153+
154+
export interface ReusableBlockMatch {
155+
/** Alias-prefixed type name, e.g. `di:IVendorNameplateType`. */
156+
browseName: string;
157+
alias: string;
158+
kind: "interface" | "addin";
159+
isAbstract: boolean;
160+
/** True if this addin type declares a DefaultInstanceBrowseName (composable by default name). */
161+
hasDefaultInstanceBrowseName: boolean;
162+
/** Member browseNames that matched the query. */
163+
matchedMembers: string[];
164+
/** All member browseNames (components + properties + methods). */
165+
members: string[];
166+
}
167+
168+
function memberBrowseNames(d: TypeDetails): string[] {
169+
return [...d.components.map((m) => m.browseName), ...d.properties.map((m) => m.browseName), ...d.methods];
170+
}
171+
172+
/** Build the set of interface types (alias:browseName) via a subtypeOf → BaseInterfaceType fixpoint. */
173+
function buildInterfaceSet(): Set<string> {
174+
const catalog = getCatalog();
175+
const entries: { key: string; subtypeOf?: string }[] = [];
176+
for (const [alias, details] of Object.entries(catalog.typeDetails)) {
177+
for (const [bn, d] of Object.entries(details)) {
178+
entries.push({ key: `${alias}:${bn}`, subtypeOf: d.subtypeOf });
179+
}
180+
}
181+
const isInterface = new Set<string>();
182+
let changed = true;
183+
while (changed) {
184+
changed = false;
185+
for (const e of entries) {
186+
if (isInterface.has(e.key)) continue;
187+
if (e.subtypeOf === "ua:BaseInterfaceType" || (e.subtypeOf !== undefined && isInterface.has(e.subtypeOf))) {
188+
isInterface.add(e.key);
189+
changed = true;
190+
}
191+
}
192+
}
193+
return isInterface;
194+
}
195+
196+
let _interfaceSet: Set<string> | null = null;
197+
198+
/**
199+
* Find reusable Interfaces / AddIns by capability — e.g. `find_reusable_block("SerialNumber")`
200+
* returns the interfaces/addins that expose a matching member. Matches member browseNames,
201+
* plus the block's own name/description. Prefer an existing block over redefining members.
202+
*/
203+
export function findReusableBlock(query: string): ReusableBlockMatch[] {
204+
const catalog = getCatalog();
205+
const q = query.toLowerCase();
206+
if (!_interfaceSet) _interfaceSet = buildInterfaceSet();
207+
208+
const matches: ReusableBlockMatch[] = [];
209+
for (const [alias, details] of Object.entries(catalog.typeDetails)) {
210+
for (const [bn, d] of Object.entries(details)) {
211+
const key = `${alias}:${bn}`;
212+
const isInterface = _interfaceSet.has(key);
213+
const hasDefaultName = d.properties.some((m) => /defaultinstancebrowsename/i.test(m.browseName));
214+
// Only interfaces and addin-capable types are "reusable blocks".
215+
if (!isInterface && !hasDefaultName) continue;
216+
217+
const members = memberBrowseNames(d);
218+
const matchedMembers = members.filter((m) => m.toLowerCase().includes(q));
219+
const nameMatch = bn.toLowerCase().includes(q) || (d.description?.toLowerCase().includes(q) ?? false);
220+
if (matchedMembers.length === 0 && !nameMatch) continue;
221+
222+
matches.push({
223+
browseName: key,
224+
alias,
225+
kind: isInterface ? "interface" : "addin",
226+
isAbstract: d.isAbstract,
227+
hasDefaultInstanceBrowseName: hasDefaultName,
228+
matchedMembers,
229+
members
230+
});
231+
}
232+
}
233+
// Rank: member match before name-only; interfaces (the canonical member
234+
// definers) before addins; more matched members first; abstract addin bases
235+
// after concrete composable ones.
236+
matches.sort((a, b) => {
237+
const am = a.matchedMembers.length > 0 ? 0 : 1;
238+
const bm = b.matchedMembers.length > 0 ? 0 : 1;
239+
if (am !== bm) return am - bm;
240+
if (a.kind !== b.kind) return a.kind === "interface" ? -1 : 1;
241+
if (a.kind === "addin" && a.isAbstract !== b.isAbstract) return a.isAbstract ? 1 : -1;
242+
return b.matchedMembers.length - a.matchedMembers.length;
243+
});
244+
// Cap: this is a "point me at the right block" tool, not an exhaustive dump.
245+
return matches.slice(0, 15);
246+
}
247+
152248
// ── Engineering Unit Lookup ──────────────────────────────────────────────
153249

154250
export interface UnitLookupResult {

‎src/server.ts‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,19 @@
1515

1616
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1717
import { z } from "zod";
18-
import { findEngineeringUnit, getTypeDetails, listNamespaces, listTypes, resolveDependencies, searchTypes } from "./catalog.js";
18+
import {
19+
findEngineeringUnit,
20+
findReusableBlock,
21+
getTypeDetails,
22+
listNamespaces,
23+
listTypes,
24+
resolveDependencies,
25+
searchTypes
26+
} from "./catalog.js";
1927
import { cloudFetch, formatCloudError } from "./cloud.js";
2028

2129
// Re-export for testing
22-
export { findEngineeringUnit, getTypeDetails, listNamespaces, listTypes, resolveDependencies, searchTypes };
30+
export { findEngineeringUnit, findReusableBlock, getTypeDetails, listNamespaces, listTypes, resolveDependencies, searchTypes };
2331

2432
/** Tool name type */
2533
export type ToolName =
@@ -28,6 +36,7 @@ export type ToolName =
2836
| "list_types"
2937
| "get_type_details"
3038
| "search_types"
39+
| "find_reusable_block"
3140
| "find_engineering_unit"
3241
| "opcua_model_validate"
3342
| "opcua_model_generate"
@@ -67,6 +76,9 @@ export async function handleToolCall(name: ToolName, args: Record<string, unknow
6776
case "search_types": {
6877
return { content: [{ type: "text" as const, text: JSON.stringify(searchTypes(args.query as string)) }] };
6978
}
79+
case "find_reusable_block": {
80+
return { content: [{ type: "text" as const, text: JSON.stringify(findReusableBlock(args.query as string)) }] };
81+
}
7082
case "find_engineering_unit": {
7183
const result = findEngineeringUnit(String(args.query ?? ""));
7284
if (!result) {
@@ -193,6 +205,25 @@ export function createServer(): McpServer {
193205
}
194206
);
195207

208+
// ── 5b. find_reusable_block ───────────────────────────────────────────
209+
210+
server.tool(
211+
"find_reusable_block",
212+
"Find reusable Interfaces / AddIns by capability — pass a member name or keyword " +
213+
'(e.g. "SerialNumber", "DeviceHealth", "Location") and get the standard blocks that ' +
214+
"already expose it. PREFER applying/composing an existing block over redefining its " +
215+
"members inline. Interfaces are applied with `interfaces:`; addins (types with a " +
216+
"DefaultInstanceBrowseName) are composed with `addIns:`. A single property → use the " +
217+
"Interface; a whole named sub-object → use the AddIn.",
218+
{ query: z.string().describe('Member name or capability keyword (e.g. "SerialNumber", "health", "calibration")') },
219+
async ({ query }) => {
220+
const results = findReusableBlock(query);
221+
return {
222+
content: [{ type: "text" as const, text: JSON.stringify(results) }]
223+
};
224+
}
225+
);
226+
196227
// ── 6. find_engineering_unit ──────────────────────────────────────────
197228

198229
server.tool(

0 commit comments

Comments
 (0)