enterprise-architect-mcp 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +226 -0
  3. package/dist/database.d.ts +3 -0
  4. package/dist/database.js +25 -0
  5. package/dist/index.d.ts +2 -0
  6. package/dist/index.js +68 -0
  7. package/dist/model-session.d.ts +29 -0
  8. package/dist/model-session.js +172 -0
  9. package/dist/package-path.d.ts +2 -0
  10. package/dist/package-path.js +36 -0
  11. package/dist/remembered-path.d.ts +5 -0
  12. package/dist/remembered-path.js +43 -0
  13. package/dist/resolve-qea-path.d.ts +26 -0
  14. package/dist/resolve-qea-path.js +58 -0
  15. package/dist/text.d.ts +37 -0
  16. package/dist/text.js +152 -0
  17. package/dist/tools/annotations.d.ts +3 -0
  18. package/dist/tools/annotations.js +5 -0
  19. package/dist/tools/connectors.d.ts +3 -0
  20. package/dist/tools/connectors.js +152 -0
  21. package/dist/tools/diagrams.d.ts +3 -0
  22. package/dist/tools/diagrams.js +225 -0
  23. package/dist/tools/elements.d.ts +3 -0
  24. package/dist/tools/elements.js +210 -0
  25. package/dist/tools/packages.d.ts +3 -0
  26. package/dist/tools/packages.js +80 -0
  27. package/dist/tools/resolve.d.ts +3 -0
  28. package/dist/tools/resolve.js +135 -0
  29. package/dist/tools/scenarios.d.ts +3 -0
  30. package/dist/tools/scenarios.js +106 -0
  31. package/dist/tools/schema.d.ts +3 -0
  32. package/dist/tools/schema.js +164 -0
  33. package/dist/tools/search.d.ts +3 -0
  34. package/dist/tools/search.js +217 -0
  35. package/dist/tools/windowing.d.ts +37 -0
  36. package/dist/tools/windowing.js +92 -0
  37. package/dist/tools.d.ts +3 -0
  38. package/dist/tools.js +18 -0
  39. package/dist/version.d.ts +1 -0
  40. package/dist/version.js +1 -0
  41. package/package.json +68 -0
@@ -0,0 +1,217 @@
1
+ import { READ_ONLY } from "./annotations.js";
2
+ import { z } from "zod";
3
+ import { decodeEntities, foldText } from "../text.js";
4
+ import { breakdownApplies, buildBreakdown, buildContinuation, countBy, isTruncated, limitParam, offsetParam } from "./windowing.js";
5
+ const corpora = new WeakMap();
6
+ /** True when the query begins somewhere other than mid-word. */
7
+ function startsAtWordBoundary(text, query) {
8
+ for (let idx = text.indexOf(query); idx > 0; idx = text.indexOf(query, idx + 1)) {
9
+ if (!/[\p{L}\p{N}]/u.test(text[idx - 1]))
10
+ return true;
11
+ }
12
+ return false;
13
+ }
14
+ /**
15
+ * The ladder is injective on (sourceTable, sourceField) above rank 3, and ranks 0-3 all
16
+ * resolve to t_object.Name. That is what makes `matchedIn` independent of corpus scan
17
+ * order — collapsing any two of these ranks would put an unordered SELECT back in charge
18
+ * of the answer. Coverage refines name and alias hits, where a query filling more of the
19
+ * text is a stronger match; for notes it would only measure document length.
20
+ */
21
+ function scoreMatch(entry, foldedQuery) {
22
+ const text = entry.foldedText;
23
+ const coverage = text.length > 0 ? foldedQuery.length / text.length : 0;
24
+ if (entry.sourceTable === "t_object") {
25
+ if (entry.sourceField === "Name") {
26
+ if (text === foldedQuery)
27
+ return { rank: 0, coverage: 1 };
28
+ if (text.startsWith(foldedQuery))
29
+ return { rank: 1, coverage };
30
+ if (startsAtWordBoundary(text, foldedQuery))
31
+ return { rank: 2, coverage };
32
+ return { rank: 3, coverage };
33
+ }
34
+ if (entry.sourceField === "Alias")
35
+ return { rank: 4, coverage };
36
+ return { rank: 5, coverage: 0 };
37
+ }
38
+ if (entry.sourceTable === "t_attribute")
39
+ return { rank: entry.sourceField === "Name" ? 6 : 8, coverage: 0 };
40
+ if (entry.sourceTable === "t_operation")
41
+ return { rank: entry.sourceField === "Name" ? 7 : 9, coverage: 0 };
42
+ return { rank: 10, coverage: 0 };
43
+ }
44
+ function buildCorpus(db) {
45
+ const cached = corpora.get(db);
46
+ if (cached)
47
+ return cached;
48
+ const entries = [];
49
+ // t_object: Name, Alias, Note
50
+ const objects = db.prepare("SELECT Object_ID, Name, Alias, Note FROM t_object").all();
51
+ for (const o of objects) {
52
+ if (o.Name)
53
+ entries.push({ sourceTable: "t_object", sourceId: o.Object_ID, sourceField: "Name", objectId: o.Object_ID, foldedText: foldText(decodeEntities(o.Name)) });
54
+ if (o.Alias)
55
+ entries.push({ sourceTable: "t_object", sourceId: o.Object_ID, sourceField: "Alias", objectId: o.Object_ID, foldedText: foldText(decodeEntities(o.Alias)) });
56
+ if (o.Note)
57
+ entries.push({ sourceTable: "t_object", sourceId: o.Object_ID, sourceField: "Note", objectId: o.Object_ID, foldedText: foldText(decodeEntities(o.Note)) });
58
+ }
59
+ // t_attribute: Name, Notes
60
+ const attrs = db.prepare("SELECT ID, Object_ID, Name, Notes FROM t_attribute").all();
61
+ for (const a of attrs) {
62
+ if (a.Name)
63
+ entries.push({ sourceTable: "t_attribute", sourceId: a.ID, sourceField: "Name", objectId: a.Object_ID, foldedText: foldText(decodeEntities(a.Name)) });
64
+ if (a.Notes)
65
+ entries.push({ sourceTable: "t_attribute", sourceId: a.ID, sourceField: "Notes", objectId: a.Object_ID, foldedText: foldText(decodeEntities(a.Notes)) });
66
+ }
67
+ // t_operation: Name, Notes
68
+ const ops = db.prepare("SELECT OperationID, Object_ID, Name, Notes FROM t_operation").all();
69
+ for (const op of ops) {
70
+ if (op.Name)
71
+ entries.push({ sourceTable: "t_operation", sourceId: op.OperationID, sourceField: "Name", objectId: op.Object_ID, foldedText: foldText(decodeEntities(op.Name)) });
72
+ if (op.Notes)
73
+ entries.push({ sourceTable: "t_operation", sourceId: op.OperationID, sourceField: "Notes", objectId: op.Object_ID, foldedText: foldText(decodeEntities(op.Notes)) });
74
+ }
75
+ // t_objectconstraint: Notes
76
+ const constraints = db.prepare(`SELECT Object_ID, Notes FROM t_objectconstraint WHERE Notes IS NOT NULL AND Notes != ''`).all();
77
+ for (const c of constraints) {
78
+ entries.push({ sourceTable: "t_objectconstraint", sourceId: c.Object_ID, sourceField: "Notes", objectId: c.Object_ID, foldedText: foldText(decodeEntities(c.Notes)) });
79
+ }
80
+ corpora.set(db, entries);
81
+ return entries;
82
+ }
83
+ export function configureSearchTools(server, model) {
84
+ server.tool("ea_search", "Search Enterprise Architect model elements by name, alias, notes, attribute names/notes, operation names/notes, or constraint notes. Matching is case- and diacritic-insensitive across European Latin alphabets and sees through entity-encoded text. Matching elements are returned in `results`, strongest match first, each with a decoded note preview and a truncation flag; equally strong matches fall back to the model's internal identity, a stable but artificial order. Walk a large result set with `offset` rather than a larger `limit`; while rows remain, `continuation` names the next call. When far more elements match than one window can hold, `breakdown` reports how they distribute, so the next call can narrow by `objectType` or `stereotype` instead of paging.", {
85
+ query: z.string().describe("Search term to find across all model text (names, notes, aliases, attributes, operations, constraints)"),
86
+ objectType: z
87
+ .string()
88
+ .optional()
89
+ .describe("Filter by object type (e.g., Class, UseCase, Activity, Screen, Requirement, Interface, Component)"),
90
+ stereotype: z.string().optional().describe("Filter by stereotype"),
91
+ limit: limitParam(25),
92
+ offset: offsetParam,
93
+ }, READ_ONLY, async ({ query, objectType, stereotype, limit, offset }) => {
94
+ const db = await model.database();
95
+ try {
96
+ const entries = buildCorpus(db);
97
+ const foldedQuery = foldText(query).trim();
98
+ if (foldedQuery.length === 0) {
99
+ return {
100
+ content: [{ type: "text", text: JSON.stringify({
101
+ results: [],
102
+ totalMatched: 0,
103
+ returned: 0,
104
+ offset,
105
+ truncated: false,
106
+ _meta: { sourceTables: ["t_object", "t_attribute", "t_operation", "t_objectconstraint", "t_package"] },
107
+ error: "Query is empty after normalization.",
108
+ }, null, 2) }],
109
+ };
110
+ }
111
+ // Find matching object IDs with match quality ranking
112
+ const matchMap = new Map();
113
+ for (const entry of entries) {
114
+ if (!entry.foldedText.includes(foldedQuery))
115
+ continue;
116
+ const { rank, coverage } = scoreMatch(entry, foldedQuery);
117
+ const existing = matchMap.get(entry.objectId);
118
+ if (existing && (existing.rank < rank || (existing.rank === rank && existing.coverage >= coverage)))
119
+ continue;
120
+ matchMap.set(entry.objectId, { rank, coverage, matchedIn: `${entry.sourceTable}.${entry.sourceField}` });
121
+ }
122
+ if (matchMap.size === 0) {
123
+ return {
124
+ content: [{
125
+ type: "text",
126
+ text: JSON.stringify({
127
+ results: [],
128
+ totalMatched: 0,
129
+ returned: 0,
130
+ offset,
131
+ truncated: false,
132
+ _meta: { sourceTables: ["t_object", "t_attribute", "t_operation", "t_objectconstraint", "t_package"] },
133
+ }, null, 2),
134
+ }],
135
+ };
136
+ }
137
+ // Strongest first, then identity: without the final tiebreak, paging a large
138
+ // tie could show the same row twice and never show another.
139
+ const sortedIds = [...matchMap.entries()]
140
+ .sort((a, b) => a[1].rank - b[1].rank || b[1].coverage - a[1].coverage || a[0] - b[0])
141
+ .map(([id]) => id);
142
+ // Build SQL to fetch matched elements with filters
143
+ let filterClauses = "";
144
+ const filterParams = [];
145
+ if (objectType) {
146
+ filterClauses += " AND o.Object_Type = ?";
147
+ filterParams.push(objectType);
148
+ }
149
+ if (stereotype) {
150
+ filterClauses += " AND o.Stereotype = ?";
151
+ filterParams.push(stereotype);
152
+ }
153
+ // Fetch all matching elements and apply filters
154
+ const placeholders = sortedIds.map(() => "?").join(",");
155
+ const sql = `
156
+ SELECT o.Object_ID, o.Object_Type, o.Name, o.Alias, o.Stereotype,
157
+ o.Package_ID, p.Name as PackageName, o.Note
158
+ FROM t_object o
159
+ LEFT JOIN t_package p ON o.Package_ID = p.Package_ID
160
+ WHERE o.Object_ID IN (${placeholders})${filterClauses}
161
+ `;
162
+ const allRows = db.prepare(sql).all(...sortedIds, ...filterParams);
163
+ // IN (...) returns rows in whatever order the plan produces, so rank order is restored here.
164
+ const rowMap = new Map(allRows.map((r) => [r.Object_ID, r]));
165
+ const totalMatched = sortedIds.filter((id) => rowMap.has(id)).length;
166
+ const sorted = sortedIds
167
+ .filter((id) => rowMap.has(id))
168
+ .map((id) => rowMap.get(id));
169
+ const window = sorted.slice(offset, offset + limit);
170
+ const truncated = isTruncated(offset, window.length, totalMatched);
171
+ const results = window.map((r) => {
172
+ const decodedNote = decodeEntities(r.Note);
173
+ const notePreview = decodedNote ? decodedNote.slice(0, 200) : null;
174
+ const notePreviewTruncated = decodedNote != null && decodedNote.length > 200;
175
+ return {
176
+ Object_ID: r.Object_ID,
177
+ Object_Type: r.Object_Type,
178
+ Name: r.Name,
179
+ Alias: r.Alias,
180
+ Stereotype: r.Stereotype,
181
+ Package_ID: r.Package_ID,
182
+ PackageName: r.PackageName,
183
+ NotePreview: notePreview,
184
+ notePreviewTruncated,
185
+ matchedIn: matchMap.get(r.Object_ID)?.matchedIn ?? null,
186
+ };
187
+ });
188
+ const breakdown = breakdownApplies(totalMatched, limit)
189
+ ? buildBreakdown({
190
+ objectType: objectType ? undefined : countBy(sorted, (r) => r.Object_Type),
191
+ stereotype: stereotype ? undefined : countBy(sorted, (r) => r.Stereotype),
192
+ })
193
+ : undefined;
194
+ const continuation = buildContinuation("ea_search", { query, objectType, stereotype, limit }, offset, results.length, totalMatched);
195
+ const response = {
196
+ results,
197
+ totalMatched,
198
+ returned: results.length,
199
+ offset,
200
+ truncated,
201
+ ...(breakdown ? { breakdown } : {}),
202
+ ...(continuation ? { continuation } : {}),
203
+ _meta: { sourceTables: ["t_object", "t_attribute", "t_operation", "t_objectconstraint", "t_package"] },
204
+ };
205
+ return {
206
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }],
207
+ };
208
+ }
209
+ catch (error) {
210
+ const msg = error instanceof Error ? error.message : String(error);
211
+ return {
212
+ content: [{ type: "text", text: `Error searching elements: ${msg}` }],
213
+ isError: true,
214
+ };
215
+ }
216
+ });
217
+ }
@@ -0,0 +1,37 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * A result set worth more than this many windows cannot be usefully paged through,
4
+ * so the response describes its shape instead of only sampling it.
5
+ */
6
+ export declare const BREAKDOWN_LIMIT_FACTOR = 10;
7
+ export declare const offsetParam: z.ZodDefault<z.ZodNumber>;
8
+ /**
9
+ * A window of zero or fewer rows cannot advance, so a continuation built from it would
10
+ * repeat the same call forever — and a negative LIMIT makes SQLite return every row.
11
+ */
12
+ export declare const limitParam: (fallback: number) => z.ZodDefault<z.ZodNumber>;
13
+ export declare function isTruncated(offset: number, returned: number, totalMatched: number): boolean;
14
+ export declare function buildContinuation<T extends Record<string, unknown>>(tool: string, args: T, offset: number, returned: number, totalMatched: number): {
15
+ tool: string;
16
+ arguments: T & {
17
+ offset: number;
18
+ };
19
+ } | undefined;
20
+ export declare function breakdownApplies(totalMatched: number, limit: number): boolean;
21
+ export interface BreakdownAxis {
22
+ values: {
23
+ value: string;
24
+ count: number;
25
+ }[];
26
+ totalMatched: number;
27
+ returned: number;
28
+ truncated: boolean;
29
+ }
30
+ export declare function buildAxis(counts: Map<string, number>): BreakdownAxis | undefined;
31
+ /** Tallies a filterable column, skipping blanks: a blank is not an argument a caller can pass back. */
32
+ export declare function countBy<T>(rows: T[], pick: (row: T) => unknown): Map<string, number>;
33
+ /**
34
+ * Assembles the axes a tool offers, keyed by the parameter name each one narrows.
35
+ * An axis passed as undefined is one whose filter the caller already supplied.
36
+ */
37
+ export declare function buildBreakdown(axes: Record<string, Map<string, number> | undefined>): Record<string, BreakdownAxis> | undefined;
@@ -0,0 +1,92 @@
1
+ import { z } from "zod";
2
+ import { foldText } from "../text.js";
3
+ /**
4
+ * A result set worth more than this many windows cannot be usefully paged through,
5
+ * so the response describes its shape instead of only sampling it.
6
+ */
7
+ export const BREAKDOWN_LIMIT_FACTOR = 10;
8
+ const MAX_BREAKDOWN_VALUES = 20;
9
+ export const offsetParam = z.coerce
10
+ .number()
11
+ .int()
12
+ .min(0)
13
+ .default(0)
14
+ .describe("Zero-based index of the first result to return (default 0). Page by re-calling with the offset carried in continuation.");
15
+ /**
16
+ * A window of zero or fewer rows cannot advance, so a continuation built from it would
17
+ * repeat the same call forever — and a negative LIMIT makes SQLite return every row.
18
+ */
19
+ export const limitParam = (fallback) => z.coerce
20
+ .number()
21
+ .int()
22
+ .min(1)
23
+ .default(fallback)
24
+ .describe(`Maximum number of results to return (default ${fallback})`);
25
+ export function isTruncated(offset, returned, totalMatched) {
26
+ return offset + returned < totalMatched;
27
+ }
28
+ export function buildContinuation(tool, args, offset, returned, totalMatched) {
29
+ if (!isTruncated(offset, returned, totalMatched))
30
+ return undefined;
31
+ return { tool, arguments: { ...args, offset: offset + returned } };
32
+ }
33
+ export function breakdownApplies(totalMatched, limit) {
34
+ return totalMatched > BREAKDOWN_LIMIT_FACTOR * limit;
35
+ }
36
+ /**
37
+ * Counts in, one breakdown axis out. Axis extraction stays with the tool, because
38
+ * only the tool knows which of its parameters a column corresponds to.
39
+ */
40
+ /**
41
+ * The cap below makes the value list a truncated window, and under binary order every
42
+ * accented value sorts past every plain one — so a tie at the cap would drop the
43
+ * accented value every time. Folding first is locale-independent, unlike compareNames,
44
+ * which matters because these values come back as filter arguments.
45
+ */
46
+ function compareValues(a, b) {
47
+ const foldedA = foldText(a);
48
+ const foldedB = foldText(b);
49
+ if (foldedA !== foldedB)
50
+ return foldedA < foldedB ? -1 : 1;
51
+ return a < b ? -1 : a > b ? 1 : 0;
52
+ }
53
+ export function buildAxis(counts) {
54
+ // One value only restates a filter the caller could already have applied.
55
+ if (counts.size < 2)
56
+ return undefined;
57
+ const sorted = [...counts.entries()]
58
+ .sort((a, b) => b[1] - a[1] || compareValues(a[0], b[0]))
59
+ .map(([value, count]) => ({ value, count }));
60
+ const values = sorted.slice(0, MAX_BREAKDOWN_VALUES);
61
+ return {
62
+ values,
63
+ totalMatched: sorted.length,
64
+ returned: values.length,
65
+ truncated: sorted.length > values.length,
66
+ };
67
+ }
68
+ /** Tallies a filterable column, skipping blanks: a blank is not an argument a caller can pass back. */
69
+ export function countBy(rows, pick) {
70
+ const counts = new Map();
71
+ for (const row of rows) {
72
+ const raw = pick(row);
73
+ if (raw == null || raw === "")
74
+ continue;
75
+ const key = String(raw);
76
+ counts.set(key, (counts.get(key) ?? 0) + 1);
77
+ }
78
+ return counts;
79
+ }
80
+ /**
81
+ * Assembles the axes a tool offers, keyed by the parameter name each one narrows.
82
+ * An axis passed as undefined is one whose filter the caller already supplied.
83
+ */
84
+ export function buildBreakdown(axes) {
85
+ const built = {};
86
+ for (const [parameter, counts] of Object.entries(axes)) {
87
+ const axis = counts && buildAxis(counts);
88
+ if (axis)
89
+ built[parameter] = axis;
90
+ }
91
+ return Object.keys(built).length > 0 ? built : undefined;
92
+ }
@@ -0,0 +1,3 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { ModelAccess } from "./model-session.js";
3
+ export declare function configureAllTools(server: McpServer, model: ModelAccess): void;
package/dist/tools.js ADDED
@@ -0,0 +1,18 @@
1
+ import { configureSearchTools } from "./tools/search.js";
2
+ import { configureElementTools } from "./tools/elements.js";
3
+ import { configureConnectorTools } from "./tools/connectors.js";
4
+ import { configurePackageTools } from "./tools/packages.js";
5
+ import { configureDiagramTools } from "./tools/diagrams.js";
6
+ import { configureScenarioTools } from "./tools/scenarios.js";
7
+ import { configureSchemaTools } from "./tools/schema.js";
8
+ import { configureResolveTools } from "./tools/resolve.js";
9
+ export function configureAllTools(server, model) {
10
+ configureSearchTools(server, model);
11
+ configureElementTools(server, model);
12
+ configureConnectorTools(server, model);
13
+ configurePackageTools(server, model);
14
+ configureDiagramTools(server, model);
15
+ configureScenarioTools(server, model);
16
+ configureSchemaTools(server, model);
17
+ configureResolveTools(server, model);
18
+ }
@@ -0,0 +1 @@
1
+ export declare const packageVersion = "2.1.0+20260828043121";
@@ -0,0 +1 @@
1
+ export const packageVersion = "2.1.0+20260828043121";
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "enterprise-architect-mcp",
3
+ "version": "2.1.0",
4
+ "mcpName": "io.github.mm6502/enterprise-architect-mcp",
5
+ "description": "MCP server for read-only access to Sparx Enterprise Architect .qea exports \u2014 search elements, navigate packages, read use case scenarios and traverse connectors from an AI agent",
6
+ "keywords": [
7
+ "mcp",
8
+ "mcp-server",
9
+ "model-context-protocol",
10
+ "enterprise-architect",
11
+ "sparx",
12
+ "sparx-ea",
13
+ "qea",
14
+ "uml",
15
+ "use-case",
16
+ "architecture",
17
+ "ai-agents",
18
+ "llm-tools",
19
+ "claude",
20
+ "sqlite",
21
+ "typescript"
22
+ ],
23
+ "homepage": "https://github.com/mm6502/enterprise-architect-mcp#readme",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/mm6502/enterprise-architect-mcp.git"
27
+ },
28
+ "bugs": {
29
+ "url": "https://github.com/mm6502/enterprise-architect-mcp/issues"
30
+ },
31
+ "license": "EUPL-1.2",
32
+ "author": "Michal Mracka",
33
+ "type": "module",
34
+ "engines": {
35
+ "node": ">=22.0.0"
36
+ },
37
+ "bin": {
38
+ "mcp-server-ea": "dist/index.js"
39
+ },
40
+ "files": [
41
+ "dist"
42
+ ],
43
+ "scripts": {
44
+ "prebuild": "node -e \"const v=require('./package.json').version;const ts=new Date().toISOString().replace(/[-:T]/g,'').slice(0,14);require('fs').writeFileSync('src/version.ts','export const packageVersion = '+JSON.stringify(v+'+'+ts)+';\\n')\"",
45
+ "build": "tsc",
46
+ "precommit": "npm run build",
47
+ "prepublishOnly": "npm run build && npm test",
48
+ "watch": "tsc --watch",
49
+ "test": "jest",
50
+ "eval:run": "npx tsx eval/runner.ts",
51
+ "eval:model": "npx tsx eval/build-model.ts"
52
+ },
53
+ "dependencies": {
54
+ "@modelcontextprotocol/sdk": "^1.30.0",
55
+ "fast-xml-parser": "^5.10.1",
56
+ "yargs": "^17.7.2",
57
+ "zod": "^3.25.67"
58
+ },
59
+ "devDependencies": {
60
+ "@types/jest": "^29.5.14",
61
+ "@types/node": "^22.15.31",
62
+ "@types/yargs": "^17.0.33",
63
+ "jest": "^29.7.0",
64
+ "shx": "^0.3.4",
65
+ "ts-jest": "^29.3.4",
66
+ "typescript": "^5.8.3"
67
+ }
68
+ }