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 +6 -5
- package/dist/package-path.d.ts +16 -0
- package/dist/package-path.js +52 -0
- package/dist/tools/scenarios.js +1 -1
- package/dist/tools/search.js +200 -7
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -2
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 `
|
|
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`,
|
|
152
|
-
|
|
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`. |
|
package/dist/package-path.d.ts
CHANGED
|
@@ -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;
|
package/dist/package-path.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/tools/scenarios.js
CHANGED
|
@@ -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();
|
package/dist/tools/search.js
CHANGED
|
@@ -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,
|
|
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(
|
|
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
|
|
174
|
-
|
|
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
|
|
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.
|
|
1
|
+
export declare const packageVersion = "2.3.0+gf6910be";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const packageVersion = "2.
|
|
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.
|
|
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",
|