enterprise-architect-mcp 2.2.0 → 2.3.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.
package/README.md CHANGED
@@ -102,7 +102,7 @@ takes the first source that actually opens:
102
102
 
103
103
  A source naming a path that cannot be opened is **skipped** rather than fatal, so the next source gets
104
104
  its turn. The reason goes to the server log, and once some later source opens, `ea_get_model_info`
105
- lists it under `ignored`. That is deliberate — a sample value left in an `env` block would otherwise
105
+ lists it under `skipped`. That is deliberate — a sample value left in an `env` block would otherwise
106
106
  outrank every answer you could give, and answering the prompt would never help. The cost is that a
107
107
  genuine typo is demoted quietly, so check `ea_get_model_info` if the server opens a different model
108
108
  than you expected.
@@ -148,8 +148,9 @@ read a large set; advancing `offset` is.
148
148
 
149
149
  When far more rows match than one window could hold, the response also carries a `breakdown` of how
150
150
  they distribute. Its keys are parameter names and its values are argument values, so a breakdown is
151
- a prompt to narrow — by `objectType`, `stereotype`, or `diagramType` rather than to page through
152
- thousands of rows.
151
+ a prompt to narrow — by `objectType`, `stereotype`, `diagramType`, or, for `ea_search` when the
152
+ result isn't already scoped, by `packageScope` (reported as the matching package's id, which the
153
+ next call can pass straight back) — rather than to page through thousands of rows.
153
154
 
154
155
  ### Naming the path up front
155
156
 
@@ -180,12 +181,12 @@ hand; answering the prompt once is what makes that unnecessary.
180
181
 
181
182
  | Tool | Description |
182
183
  |------|-------------|
183
- | `ea_search` | Full-text search across elements, attributes, operations, and constraints. Case- and diacritic-insensitive across European Latin alphabets, decodes entity-encoded text. |
184
+ | `ea_search` | Full-text search across elements, attributes, operations, and constraints. Case- and diacritic-insensitive across European Latin alphabets, decodes entity-encoded text. Each result carries the evidence for its match — the field, the attribute or operation it came from, and a snippet of the author's own text. Accepts a `packageScope` (package id or name) to restrict results to a package and its descendants, and reports a package breakdown axis when unscoped. |
184
185
  | `ea_get_element` | Full element detail — attributes, operations, diagrams it appears on, constraints (pre/post/invariant/process). Flags whether attribute multiplicity is contrastive. |
185
186
  | `ea_list_elements` | List elements in a package, optionally filtered by type. Windowed: reports the total and pages with `offset`. |
186
187
  | `ea_get_connectors` | Relationships for an element — includes feature-link resolution (which attribute/operation each end attaches to). |
187
188
  | `ea_get_diagram_elements` | Elements and connectors on a diagram, including implied connectors and feature links. |
188
- | `ea_get_scenarios` | Use case scenario steps with all attributes (trigger, uses, result, link, state) and scenario notes. |
189
+ | `ea_get_scenarios` | Use case scenario steps with all attributes (trigger, uses, result, link, state) and scenario notes. A step's `uses` may name a business rule or constraint by code — that code isn't independently searchable, it's retrieved via `ea_get_element` on the same element. |
189
190
  | `ea_get_package_tree` | Navigate the package hierarchy with recursive depth. |
190
191
  | `ea_list_diagrams` | Search diagrams by name, type and package. Windowed like the tools above. |
191
192
  | `ea_resolve` | Resolve analyst references (braced GUID or plain name) to model candidates with full package path. Falls back to name-prefix matching for analyst codes; every candidate carries a `match` of `guid`, `exact`, or `prefix`. |
@@ -1,2 +1,18 @@
1
1
  import type { Database } from "./database.js";
2
2
  export declare function buildPackagePath(db: Database, packageId: number): string;
3
+ /** The scope package plus every descendant, walked down the same hierarchy buildPackagePath walks up. */
4
+ export declare function getPackageSubtree(db: Database, rootId: number): Set<number>;
5
+ export type PackageScopeResolution = {
6
+ kind: "found";
7
+ packageId: number;
8
+ } | {
9
+ kind: "not_found";
10
+ } | {
11
+ kind: "ambiguous";
12
+ candidates: {
13
+ id: number;
14
+ fullPackagePath: string;
15
+ }[];
16
+ };
17
+ /** Resolves a package id or name to a single package, the way ea_resolve resolves a name to a candidate. */
18
+ export declare function resolvePackageScope(db: Database, scope: number | string): PackageScopeResolution;
@@ -1,8 +1,10 @@
1
+ import { foldText } from "./text.js";
1
2
  /**
2
3
  * Build the full package path from root to the given package, dot-separated.
3
4
  * Uses a preloaded package map memoized per database connection.
4
5
  */
5
6
  const packageMaps = new WeakMap();
7
+ const childrenMaps = new WeakMap();
6
8
  function getPackageMap(db) {
7
9
  let map = packageMaps.get(db);
8
10
  if (map)
@@ -17,6 +19,22 @@ function getPackageMap(db) {
17
19
  packageMaps.set(db, map);
18
20
  return map;
19
21
  }
22
+ /** Parent-to-children index, built once from the same map buildPackagePath reads upward. */
23
+ function getChildrenMap(db) {
24
+ let map = childrenMaps.get(db);
25
+ if (map)
26
+ return map;
27
+ map = new Map();
28
+ for (const [id, pkg] of getPackageMap(db)) {
29
+ const siblings = map.get(pkg.parentId);
30
+ if (siblings)
31
+ siblings.push(id);
32
+ else
33
+ map.set(pkg.parentId, [id]);
34
+ }
35
+ childrenMaps.set(db, map);
36
+ return map;
37
+ }
20
38
  export function buildPackagePath(db, packageId) {
21
39
  const map = getPackageMap(db);
22
40
  const parts = [];
@@ -34,3 +52,37 @@ export function buildPackagePath(db, packageId) {
34
52
  }
35
53
  return parts.join(".");
36
54
  }
55
+ /** The scope package plus every descendant, walked down the same hierarchy buildPackagePath walks up. */
56
+ export function getPackageSubtree(db, rootId) {
57
+ const children = getChildrenMap(db);
58
+ const result = new Set([rootId]);
59
+ const stack = [rootId];
60
+ while (stack.length > 0) {
61
+ const current = stack.pop();
62
+ for (const child of children.get(current) ?? []) {
63
+ if (!result.has(child)) {
64
+ result.add(child);
65
+ stack.push(child);
66
+ }
67
+ }
68
+ }
69
+ return result;
70
+ }
71
+ /** Resolves a package id or name to a single package, the way ea_resolve resolves a name to a candidate. */
72
+ export function resolvePackageScope(db, scope) {
73
+ const map = getPackageMap(db);
74
+ if (typeof scope === "number") {
75
+ return map.has(scope) ? { kind: "found", packageId: scope } : { kind: "not_found" };
76
+ }
77
+ const folded = foldText(scope);
78
+ const matches = [];
79
+ for (const [id, pkg] of map) {
80
+ if (foldText(pkg.name) === folded)
81
+ matches.push(id);
82
+ }
83
+ if (matches.length === 0)
84
+ return { kind: "not_found" };
85
+ if (matches.length === 1)
86
+ return { kind: "found", packageId: matches[0] };
87
+ return { kind: "ambiguous", candidates: matches.map((id) => ({ id, fullPackagePath: buildPackagePath(db, id) })) };
88
+ }
@@ -38,7 +38,7 @@ const SCENARIO_TYPE_ORDER = {
38
38
  "Exception": 2,
39
39
  };
40
40
  export function configureScenarioTools(server, model) {
41
- server.tool("ea_get_scenarios", "Get use case scenario flows for an element. `scenarios` holds the parsed flows; each has `name`, `type`, `notes`, and `steps`, and each step carries `stepNumber` plus its attributes (`trigger`, `uses`, `result`, `state`, `link`). Steps are numbered within each scenario. Scenarios ordered by type: Basic Path first, then Alternate, then Exception.", {
41
+ server.tool("ea_get_scenarios", "Get use case scenario flows for an element. `scenarios` holds the parsed flows; each has `name`, `type`, `notes`, and `steps`, and each step carries `stepNumber` plus its attributes (`trigger`, `uses`, `result`, `state`, `link`). Steps are numbered within each scenario. Scenarios ordered by type: Basic Path first, then Alternate, then Exception. A step's `uses` may name a business rule or constraint by code; that code is not independently searchable or resolvable — look it up among this same elementId's own constraints via `ea_get_element`, not by a separate lookup.", {
42
42
  elementId: z.coerce.number().describe("The Object_ID of the element (typically a UseCase) to get scenarios for"),
43
43
  }, READ_ONLY, async ({ elementId }) => {
44
44
  const db = await model.database();
@@ -2,7 +2,53 @@ import { READ_ONLY } from "./annotations.js";
2
2
  import { z } from "zod";
3
3
  import { decodeEntities, foldText } from "../text.js";
4
4
  import { breakdownApplies, buildBreakdown, buildContinuation, countBy, isTruncated, limitParam, offsetParam } from "./windowing.js";
5
+ import { getPackageSubtree, resolvePackageScope } from "../package-path.js";
5
6
  const corpora = new WeakMap();
7
+ const MAX_INLINE_MATCHES = 3;
8
+ const SNIPPET_CHARS = 150;
9
+ const NOTE_PREVIEW_CHARS = 200;
10
+ function wordSpans(s) {
11
+ const spans = [];
12
+ const re = /\S+/g;
13
+ for (let m = re.exec(s); m !== null; m = re.exec(s))
14
+ spans.push([m.index, m.index + m[0].length]);
15
+ return spans;
16
+ }
17
+ /**
18
+ * No step in foldText creates or removes whitespace, so the k-th word of the folded text
19
+ * is the k-th word of the original. That is what locates a match in the author's own text
20
+ * without keeping an offset map between the two forms.
21
+ */
22
+ function excerptAround(original, folded, foldedQuery, budget) {
23
+ const at = folded.indexOf(foldedQuery);
24
+ const foldedWords = wordSpans(folded);
25
+ const words = wordSpans(original);
26
+ // A word that folds away entirely would break the correspondence; fall back rather than misquote.
27
+ if (at < 0 || foldedWords.length !== words.length || words.length === 0) {
28
+ return { text: original.slice(0, budget), truncated: original.length > budget };
29
+ }
30
+ const end = at + foldedQuery.length;
31
+ let lo = foldedWords.findIndex(([, e]) => e > at);
32
+ if (lo < 0)
33
+ lo = 0;
34
+ let hi = lo;
35
+ while (hi + 1 < foldedWords.length && foldedWords[hi + 1][0] < end)
36
+ hi++;
37
+ for (let grew = true; grew;) {
38
+ grew = false;
39
+ if (lo > 0 && words[hi][1] - words[lo - 1][0] <= budget) {
40
+ lo--;
41
+ grew = true;
42
+ }
43
+ if (hi + 1 < words.length && words[hi + 1][1] - words[lo][0] <= budget) {
44
+ hi++;
45
+ grew = true;
46
+ }
47
+ }
48
+ const head = lo > 0 ? "…" : "";
49
+ const tail = hi < words.length - 1 ? "…" : "";
50
+ return { text: head + original.slice(words[lo][0], words[hi][1]) + tail, truncated: head !== "" || tail !== "" };
51
+ }
6
52
  /** True when the query begins somewhere other than mid-word. */
7
53
  function startsAtWordBoundary(text, query) {
8
54
  for (let idx = text.indexOf(query); idx > 0; idx = text.indexOf(query, idx + 1)) {
@@ -80,19 +126,147 @@ function buildCorpus(db) {
80
126
  corpora.set(db, entries);
81
127
  return entries;
82
128
  }
129
+ function selectIn(db, sql, ids) {
130
+ if (ids.size === 0)
131
+ return [];
132
+ const list = [...ids];
133
+ return db.prepare(`${sql} (${list.map(() => "?").join(",")})`).all(...list);
134
+ }
135
+ /** The author's own text behind a corpus entry, with the name of whatever carried it. */
136
+ function originalFor(entry, src) {
137
+ const decoded = (raw, name) => typeof raw === "string" && raw.length > 0 ? { text: decodeEntities(raw), name } : null;
138
+ if (entry.sourceTable === "t_object") {
139
+ return decoded(src.rows.get(entry.objectId)?.[entry.sourceField], null);
140
+ }
141
+ if (entry.sourceTable === "t_attribute") {
142
+ const a = src.attributes.get(entry.sourceId);
143
+ return a ? decoded(entry.sourceField === "Name" ? a.Name : a.Notes, a.Name ?? null) : null;
144
+ }
145
+ if (entry.sourceTable === "t_operation") {
146
+ const op = src.operations.get(entry.sourceId);
147
+ return op ? decoded(entry.sourceField === "Name" ? op.Name : op.Notes, op.Name ?? null) : null;
148
+ }
149
+ if (entry.sourceTable === "t_objectconstraint") {
150
+ // Constraint rows carry no identity of their own, so the right note is found by its folded form.
151
+ const row = (src.constraints.get(entry.sourceId) ?? [])
152
+ .find((c) => typeof c.Notes === "string" && foldText(decodeEntities(c.Notes)) === entry.foldedText);
153
+ return row ? decoded(row.Notes, row.Constraint ?? null) : null;
154
+ }
155
+ return null;
156
+ }
157
+ /**
158
+ * Why each windowed element matched. Scanning is confined to the window, so the cost is
159
+ * bounded by what the response shows rather than by the corpus.
160
+ */
161
+ function collectEvidence(db, entries, rows, windowIds, foldedQuery) {
162
+ const hits = new Map();
163
+ for (const entry of entries) {
164
+ if (!windowIds.has(entry.objectId) || !entry.foldedText.includes(foldedQuery))
165
+ continue;
166
+ const list = hits.get(entry.objectId);
167
+ if (list)
168
+ list.push(entry);
169
+ else
170
+ hits.set(entry.objectId, [entry]);
171
+ }
172
+ const kept = new Map();
173
+ for (const [objectId, list] of hits) {
174
+ const ranked = list
175
+ .map((e) => ({ e, ...scoreMatch(e, foldedQuery) }))
176
+ .sort((a, b) => a.rank - b.rank || b.coverage - a.coverage || a.e.sourceId - b.e.sourceId)
177
+ .slice(0, MAX_INLINE_MATCHES)
178
+ .map((r) => r.e);
179
+ kept.set(objectId, { entries: ranked, totalMatched: list.length });
180
+ }
181
+ const attributeIds = new Set();
182
+ const operationIds = new Set();
183
+ const constraintOwners = new Set();
184
+ for (const { entries: shown } of kept.values()) {
185
+ for (const e of shown) {
186
+ if (e.sourceTable === "t_attribute")
187
+ attributeIds.add(e.sourceId);
188
+ else if (e.sourceTable === "t_operation")
189
+ operationIds.add(e.sourceId);
190
+ else if (e.sourceTable === "t_objectconstraint")
191
+ constraintOwners.add(e.sourceId);
192
+ }
193
+ }
194
+ const constraints = new Map();
195
+ for (const c of selectIn(db, `SELECT Object_ID, "Constraint", Notes FROM t_objectconstraint WHERE Object_ID IN`, constraintOwners)) {
196
+ const list = constraints.get(c.Object_ID);
197
+ if (list)
198
+ list.push(c);
199
+ else
200
+ constraints.set(c.Object_ID, [c]);
201
+ }
202
+ const src = {
203
+ rows,
204
+ attributes: new Map(selectIn(db, "SELECT ID, Name, Notes FROM t_attribute WHERE ID IN", attributeIds).map((a) => [a.ID, a])),
205
+ operations: new Map(selectIn(db, "SELECT OperationID, Name, Notes FROM t_operation WHERE OperationID IN", operationIds).map((o) => [o.OperationID, o])),
206
+ constraints,
207
+ };
208
+ const evidence = new Map();
209
+ for (const [objectId, { entries: shown, totalMatched }] of kept) {
210
+ const items = [];
211
+ for (const entry of shown) {
212
+ const original = originalFor(entry, src);
213
+ if (!original)
214
+ continue;
215
+ const excerpt = excerptAround(original.text, entry.foldedText, foldedQuery, SNIPPET_CHARS);
216
+ items.push({
217
+ matchedIn: `${entry.sourceTable}.${entry.sourceField}`,
218
+ sourceId: entry.sourceId,
219
+ sourceName: original.name,
220
+ snippet: excerpt.text,
221
+ snippetTruncated: excerpt.truncated,
222
+ });
223
+ }
224
+ evidence.set(objectId, { items, totalMatched });
225
+ }
226
+ return evidence;
227
+ }
83
228
  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.", {
229
+ 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. Each result also carries `matches`, the evidence for why it was returned: the field that matched, the id and name of the attribute, operation or constraint it came from, and a snippet of the author's own text around the match. Evidence is strongest-first and capped, and `_meta.matches` on the result reports how many matches were found and how many were withheld. The note preview centres on the match when the element's own note is what matched. `packageScope` restricts results to a package (given as its id or its name) and its descendants. 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 — by `objectType`, `stereotype`, or, unless already scoped, by `packageScope` (reported as the matching package's id, which the next call can pass straight back) so the next call can narrow instead of paging.", {
85
230
  query: z.string().describe("Search term to find across all model text (names, notes, aliases, attributes, operations, constraints)"),
86
231
  objectType: z
87
232
  .string()
88
233
  .optional()
89
234
  .describe("Filter by object type (e.g., Class, UseCase, Activity, Screen, Requirement, Interface, Component)"),
90
235
  stereotype: z.string().optional().describe("Filter by stereotype"),
236
+ packageScope: z
237
+ .union([z.number().int(), z.string()])
238
+ .optional()
239
+ .describe("Restrict results to this package and its descendants, given as a package id or name"),
91
240
  limit: limitParam(25),
92
241
  offset: offsetParam,
93
- }, READ_ONLY, async ({ query, objectType, stereotype, limit, offset }) => {
242
+ }, READ_ONLY, async ({ query, objectType, stereotype, packageScope, limit, offset }) => {
94
243
  const db = await model.database();
95
244
  try {
245
+ let subtree;
246
+ if (packageScope !== undefined) {
247
+ const resolution = resolvePackageScope(db, packageScope);
248
+ if (resolution.kind === "not_found") {
249
+ return {
250
+ content: [{ type: "text", text: JSON.stringify({
251
+ error: "not_found",
252
+ message: `Package scope "${packageScope}" was not found.`,
253
+ packageScope,
254
+ }, null, 2) }],
255
+ isError: true,
256
+ };
257
+ }
258
+ if (resolution.kind === "ambiguous") {
259
+ return {
260
+ content: [{ type: "text", text: JSON.stringify({
261
+ error: "ambiguous_package",
262
+ message: `Package scope "${packageScope}" matches more than one package; use a package id instead.`,
263
+ candidates: resolution.candidates,
264
+ }, null, 2) }],
265
+ isError: true,
266
+ };
267
+ }
268
+ subtree = getPackageSubtree(db, resolution.packageId);
269
+ }
96
270
  const entries = buildCorpus(db);
97
271
  const foldedQuery = foldText(query).trim();
98
272
  if (foldedQuery.length === 0) {
@@ -160,18 +334,28 @@ export function configureSearchTools(server, model) {
160
334
  WHERE o.Object_ID IN (${placeholders})${filterClauses}
161
335
  `;
162
336
  const allRows = db.prepare(sql).all(...sortedIds, ...filterParams);
337
+ // Package_ID is on every fetched row, so scoping is a subtree membership check, not a query change.
338
+ const scopedRows = subtree ? allRows.filter((r) => subtree.has(r.Package_ID)) : allRows;
163
339
  // 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]));
340
+ const rowMap = new Map(scopedRows.map((r) => [r.Object_ID, r]));
165
341
  const totalMatched = sortedIds.filter((id) => rowMap.has(id)).length;
166
342
  const sorted = sortedIds
167
343
  .filter((id) => rowMap.has(id))
168
344
  .map((id) => rowMap.get(id));
169
345
  const window = sorted.slice(offset, offset + limit);
170
346
  const truncated = isTruncated(offset, window.length, totalMatched);
347
+ const evidence = collectEvidence(db, entries, rowMap, new Set(window.map((r) => r.Object_ID)), foldedQuery);
171
348
  const results = window.map((r) => {
172
349
  const decodedNote = decodeEntities(r.Note);
173
- const notePreview = decodedNote ? decodedNote.slice(0, 200) : null;
174
- const notePreviewTruncated = decodedNote != null && decodedNote.length > 200;
350
+ const matchedIn = matchMap.get(r.Object_ID)?.matchedIn ?? null;
351
+ // Previewing from the start hides the reason for a match that lies deeper in the note.
352
+ const notePreview = !decodedNote
353
+ ? null
354
+ : matchedIn === "t_object.Note"
355
+ ? excerptAround(decodedNote, foldText(decodedNote), foldedQuery, NOTE_PREVIEW_CHARS).text
356
+ : decodedNote.slice(0, NOTE_PREVIEW_CHARS);
357
+ const notePreviewTruncated = decodedNote != null && decodedNote.length > NOTE_PREVIEW_CHARS;
358
+ const matches = evidence.get(r.Object_ID);
175
359
  return {
176
360
  Object_ID: r.Object_ID,
177
361
  Object_Type: r.Object_Type,
@@ -182,16 +366,25 @@ export function configureSearchTools(server, model) {
182
366
  PackageName: r.PackageName,
183
367
  NotePreview: notePreview,
184
368
  notePreviewTruncated,
185
- matchedIn: matchMap.get(r.Object_ID)?.matchedIn ?? null,
369
+ matchedIn,
370
+ matches: matches?.items ?? [],
371
+ _meta: {
372
+ matches: {
373
+ totalMatched: matches?.totalMatched ?? 0,
374
+ returned: matches?.items.length ?? 0,
375
+ truncated: (matches?.totalMatched ?? 0) > (matches?.items.length ?? 0),
376
+ },
377
+ },
186
378
  };
187
379
  });
188
380
  const breakdown = breakdownApplies(totalMatched, limit)
189
381
  ? buildBreakdown({
190
382
  objectType: objectType ? undefined : countBy(sorted, (r) => r.Object_Type),
191
383
  stereotype: stereotype ? undefined : countBy(sorted, (r) => r.Stereotype),
384
+ packageScope: packageScope !== undefined ? undefined : countBy(sorted, (r) => r.Package_ID),
192
385
  })
193
386
  : undefined;
194
- const continuation = buildContinuation("ea_search", { query, objectType, stereotype, limit }, offset, results.length, totalMatched);
387
+ const continuation = buildContinuation("ea_search", { query, objectType, stereotype, packageScope, limit }, offset, results.length, totalMatched);
195
388
  const response = {
196
389
  results,
197
390
  totalMatched,
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const packageVersion = "2.2.0+g9b1dad6";
1
+ export declare const packageVersion = "2.3.0+gf6910be";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const packageVersion = "2.2.0+g9b1dad6";
1
+ export const packageVersion = "2.3.0+gf6910be";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "enterprise-architect-mcp",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "mcpName": "io.github.mm6502/enterprise-architect-mcp",
5
5
  "description": "MCP server for read-only access to Sparx Enterprise Architect .qea exports — search elements, navigate packages, read use case scenarios and traverse connectors from an AI agent",
6
6
  "keywords": [
@@ -48,7 +48,8 @@
48
48
  "watch": "tsc --watch",
49
49
  "test": "jest",
50
50
  "eval:run": "tsx eval/runner.ts",
51
- "eval:model": "tsx eval/build-model.ts"
51
+ "eval:model": "tsx eval/build-model.ts",
52
+ "eval:agent": "tsx eval/agent-runner-cli.ts"
52
53
  },
53
54
  "dependencies": {
54
55
  "@modelcontextprotocol/sdk": "^1.30.0",