enterprise-architect-mcp 2.3.0 → 3.0.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 +67 -68
- package/dist/tools/connectors.js +4 -3
- package/dist/tools/diagrams.js +1 -1
- package/dist/tools/elements.js +3 -3
- package/dist/tools/search.js +412 -178
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
[](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522enterprise-architect%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522-y%2522%252C%2522enterprise-architect-mcp%2522%255D%257D)
|
|
7
7
|
[](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522enterprise-architect%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522-y%2522%252C%2522enterprise-architect-mcp%2522%255D%257D)
|
|
8
|
+
[](https://github.com/EveryInc/compound-engineering-plugin)
|
|
8
9
|
|
|
9
10
|
A read-only [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server for Sparx Enterprise Architect `.qea` exports. Gives AI agents access to EA analysis models — search elements, navigate packages, read use case scenarios, and traverse connectors — without a running EA instance.
|
|
10
11
|
|
|
@@ -89,6 +90,18 @@ you can name the path in an `env` block:
|
|
|
89
90
|
|
|
90
91
|
To run straight from source instead of npm, use `"args": ["-y", "github:mm6502/enterprise-architect-mcp"]`.
|
|
91
92
|
|
|
93
|
+
## Example Prompts
|
|
94
|
+
|
|
95
|
+
Once connected, try prompts like:
|
|
96
|
+
|
|
97
|
+
- "Search for elements related to 'legal entity'"
|
|
98
|
+
- "Show me the package structure under the root"
|
|
99
|
+
- "What are the use case scenarios for UC_SUBMIT_APPLICATION?"
|
|
100
|
+
- "What elements and connectors are on diagram 0103 Application Processing?"
|
|
101
|
+
- "Resolve the reference {3F2A7C10-5B4D-4e8a-9C1F-27D6E8B0A4F3}"
|
|
102
|
+
- "What columns does t_connector have?"
|
|
103
|
+
- "Which diagrams does element a7680 appear on?"
|
|
104
|
+
|
|
92
105
|
## Configuration
|
|
93
106
|
|
|
94
107
|
The server does not need a path to start. It looks for one when an agent first queries the model, and
|
|
@@ -100,60 +113,6 @@ takes the first source that actually opens:
|
|
|
100
113
|
4. **A remembered answer** — whatever you last told the prompt
|
|
101
114
|
5. **The prompt** — the client asks, and a working answer is remembered for next time
|
|
102
115
|
|
|
103
|
-
A source naming a path that cannot be opened is **skipped** rather than fatal, so the next source gets
|
|
104
|
-
its turn. The reason goes to the server log, and once some later source opens, `ea_get_model_info`
|
|
105
|
-
lists it under `skipped`. That is deliberate — a sample value left in an `env` block would otherwise
|
|
106
|
-
outrank every answer you could give, and answering the prompt would never help. The cost is that a
|
|
107
|
-
genuine typo is demoted quietly, so check `ea_get_model_info` if the server opens a different model
|
|
108
|
-
than you expected.
|
|
109
|
-
|
|
110
|
-
Skipping is only worth it when an answer can take the skipped source's place, so two cases stay
|
|
111
|
-
fatal: a path you passed **on the command line** (that is this run's explicit instruction, not a
|
|
112
|
-
stale default), and any broken source in a client that **cannot show a prompt** — falling through
|
|
113
|
-
there would quietly open some other model instead of telling you.
|
|
114
|
-
|
|
115
|
-
Answers are remembered per machine, in `%APPDATA%\enterprise-architect-mcp\` on Windows,
|
|
116
|
-
`~/Library/Application Support/enterprise-architect-mcp/` on macOS, and `$XDG_CONFIG_HOME` (or
|
|
117
|
-
`~/.config`) elsewhere; set `EA_MCP_CONFIG_DIR` to keep that file somewhere else. A path that does
|
|
118
|
-
not open is never remembered, so asking again is enough to correct a mistyped answer.
|
|
119
|
-
|
|
120
|
-
If the path points to a **directory**, the server automatically picks the newest `.qea` file by
|
|
121
|
-
modification time. Pointing at your export folder means new exports are picked up without
|
|
122
|
-
reconfiguring anything.
|
|
123
|
-
|
|
124
|
-
### Name ordering
|
|
125
|
-
|
|
126
|
-
Matching is locale-independent — search folds case and diacritics across European Latin alphabets,
|
|
127
|
-
so `Straße`, `Łódź` and `Győr` are found however they are typed or entity-encoded.
|
|
128
|
-
|
|
129
|
-
The enumeration tools — `ea_search`, `ea_list_elements`, `ea_list_diagrams` — do not order by name
|
|
130
|
-
at all. They order by the model's internal identity: stable and repeatable, but artificial, so
|
|
131
|
-
nothing should be read into which row follows which. That is a deliberate trade. Alphabetical
|
|
132
|
-
ordering under SQLite's binary collation sorts every accented initial after `Z`, and since these
|
|
133
|
-
tools return a window rather than the whole set, it does not merely reorder the list — it pushes
|
|
134
|
-
accented names out of the window entirely. Measured on a real export, names with an accented
|
|
135
|
-
initial filled 1.3% of visible slots under binary ordering against 3.0% under identity order, in a
|
|
136
|
-
model where they make up 3.9% of all names.
|
|
137
|
-
|
|
138
|
-
Only `ea_get_scenarios` still orders names by locale, where the whole set is always returned and no
|
|
139
|
-
name can be cut off. `EA_LOCALE` pins that ordering to a BCP 47 tag (`sk`, `pl`, `hu`, `de`, …) in
|
|
140
|
-
an `env` block or system env; unset, the host default applies. It does not affect matching.
|
|
141
|
-
|
|
142
|
-
### Paging and narrowing
|
|
143
|
-
|
|
144
|
-
The enumeration tools return a window, not a sample. Each response carries `totalMatched`,
|
|
145
|
-
`returned`, `offset` and `truncated`, and when rows remain, a `continuation` naming the next call —
|
|
146
|
-
following it repeatedly visits every match once and terminates. Raising `limit` is not the way to
|
|
147
|
-
read a large set; advancing `offset` is.
|
|
148
|
-
|
|
149
|
-
When far more rows match than one window could hold, the response also carries a `breakdown` of how
|
|
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`, `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.
|
|
154
|
-
|
|
155
|
-
### Naming the path up front
|
|
156
|
-
|
|
157
116
|
If you would rather never see the prompt — a CI job, a shared image, or simply a preference — put
|
|
158
117
|
the path in a gitignored `.env` in the working directory. Copy the template:
|
|
159
118
|
|
|
@@ -167,7 +126,9 @@ Then set your local path in `.env`:
|
|
|
167
126
|
EA_QEA_PATH=C:\EA\exports\model.qea
|
|
168
127
|
```
|
|
169
128
|
|
|
170
|
-
|
|
129
|
+
If the path points to a **directory** instead of a file, the server automatically picks the newest
|
|
130
|
+
`.qea` file in it by modification time — point it at your export folder and new exports are picked up
|
|
131
|
+
without reconfiguring anything:
|
|
171
132
|
|
|
172
133
|
```ini
|
|
173
134
|
EA_QEA_PATH=C:\EA\exports\
|
|
@@ -177,15 +138,33 @@ The `.env` file is gitignored — each developer sets their own path without aff
|
|
|
177
138
|
config. It is also never committed, which is why it is the one route every new user has to set up by
|
|
178
139
|
hand; answering the prompt once is what makes that unnecessary.
|
|
179
140
|
|
|
141
|
+
A source naming a path that cannot be opened is **skipped** rather than fatal, so the next source gets
|
|
142
|
+
its turn. The reason goes to the server log, and once some later source opens, `ea_get_model_info`
|
|
143
|
+
lists it under `skipped`. That is deliberate — a sample value left in an `env` block would otherwise
|
|
144
|
+
outrank every answer you could give, and answering the prompt would never help. The cost is that a
|
|
145
|
+
genuine typo is demoted quietly, so check `ea_get_model_info` if the server opens a different model
|
|
146
|
+
than you expected.
|
|
147
|
+
|
|
148
|
+
Skipping is only worth it when an answer can take the skipped source's place, so two cases stay
|
|
149
|
+
fatal: a path you passed **on the command line** (that is this run's explicit instruction, not a
|
|
150
|
+
stale default), and any broken source in a client that **cannot show a prompt** — falling through
|
|
151
|
+
there would quietly open some other model instead of telling you.
|
|
152
|
+
|
|
153
|
+
Answers are remembered per machine, in `%APPDATA%\enterprise-architect-mcp\` on Windows,
|
|
154
|
+
`~/Library/Application Support/enterprise-architect-mcp/` on macOS, and `$XDG_CONFIG_HOME` (or
|
|
155
|
+
`~/.config`) elsewhere; set `EA_MCP_CONFIG_DIR` to keep that file somewhere else. A path that does
|
|
156
|
+
not open is never remembered, so asking again is enough to correct a mistyped answer.
|
|
157
|
+
|
|
180
158
|
## Available Tools
|
|
181
159
|
|
|
182
160
|
| Tool | Description |
|
|
183
161
|
|------|-------------|
|
|
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. |
|
|
162
|
+
| `ea_search` | Full-text search across elements, attributes, operations, and constraints. Takes `requiredTerms`, a list of terms every one of which must occur somewhere in an element's searchable text (conjunction) — terms need not share a field, but sharing one ranks higher. `boostAnyOf` is an optional list of further terms that promote a result's rank without ever excluding on that basis. 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. |
|
|
163
|
+
| `ea_search_and_any_of` | Same matching as `ea_search`, plus `andAnyOf`: an optional list of terms where a result must satisfy `requiredTerms` **and** at least one `andAnyOf` term — narrowing rather than reordering. Use this instead of `ea_search` when an alternative term should exclude, not just promote. |
|
|
185
164
|
| `ea_get_element` | Full element detail — attributes, operations, diagrams it appears on, constraints (pre/post/invariant/process). Flags whether attribute multiplicity is contrastive. |
|
|
186
165
|
| `ea_list_elements` | List elements in a package, optionally filtered by type. Windowed: reports the total and pages with `offset`. |
|
|
187
|
-
| `ea_get_connectors` | Relationships for an element — includes feature-link resolution (which attribute/operation each end attaches to). |
|
|
188
|
-
| `ea_get_diagram_elements` | Elements and connectors on a diagram, including implied connectors and feature links. |
|
|
166
|
+
| `ea_get_connectors` | Relationships for an element — includes feature-link resolution (which attribute/operation each end attaches to). `Generalization` connectors carry a `role` (`child`/`parent`) on each end; filter `connectorType: "Generalization"` with `direction: "incoming"`/`"outgoing"` to list an element's direct children/parent(s) without a diagram. |
|
|
167
|
+
| `ea_get_diagram_elements` | Elements and connectors on a diagram, including implied connectors and feature links. `elements` includes free-text `Note` diagram objects, which often carry a legend or abbreviation definitions. |
|
|
189
168
|
| `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. |
|
|
190
169
|
| `ea_get_package_tree` | Navigate the package hierarchy with recursive depth. |
|
|
191
170
|
| `ea_list_diagrams` | Search diagrams by name, type and package. Windowed like the tools above. |
|
|
@@ -207,21 +186,41 @@ Two fields exist to stop an inexact answer from being read as a confirmed one:
|
|
|
207
186
|
- `ea_resolve` — `match` is always present; only `prefix` is an inexact match
|
|
208
187
|
- `ea_get_element` — `_meta.attributes.multiplicityIsUniform: true` means the element's attributes show no multiplicity contrast, so `1..1` is not evidence of requiredness
|
|
209
188
|
|
|
210
|
-
##
|
|
189
|
+
## How Results Are Ordered, Paged, and Narrowed
|
|
211
190
|
|
|
212
|
-
|
|
191
|
+
### Name ordering
|
|
213
192
|
|
|
214
|
-
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
193
|
+
Matching is locale-independent — search folds case and diacritics across European Latin alphabets,
|
|
194
|
+
so `Straße`, `Łódź` and `Győr` are found however they are typed or entity-encoded.
|
|
195
|
+
|
|
196
|
+
The enumeration tools — `ea_search`, `ea_list_elements`, `ea_list_diagrams` — do not order by name
|
|
197
|
+
at all. They order by the model's internal identity: stable and repeatable, but artificial, so
|
|
198
|
+
nothing should be read into which row follows which. That is a deliberate trade. Alphabetical
|
|
199
|
+
ordering under SQLite's binary collation sorts every accented initial after `Z`, and since these
|
|
200
|
+
tools return a window rather than the whole set, it does not merely reorder the list — it pushes
|
|
201
|
+
accented names out of the window entirely. Measured on a real export, names with an accented
|
|
202
|
+
initial filled 1.3% of visible slots under binary ordering against 3.0% under identity order, in a
|
|
203
|
+
model where they make up 3.9% of all names.
|
|
204
|
+
|
|
205
|
+
Only `ea_get_scenarios` still orders names by locale, where the whole set is always returned and no
|
|
206
|
+
name can be cut off. `EA_LOCALE` pins that ordering to a BCP 47 tag (`sk`, `pl`, `hu`, `de`, …) in
|
|
207
|
+
an `env` block or system env; unset, the host default applies. It does not affect matching.
|
|
208
|
+
|
|
209
|
+
### Paging and narrowing
|
|
210
|
+
|
|
211
|
+
The enumeration tools return a window, not a sample. Each response carries `totalMatched`,
|
|
212
|
+
`returned`, `offset` and `truncated`, and when rows remain, a `continuation` naming the next call —
|
|
213
|
+
following it repeatedly visits every match once and terminates. Raising `limit` is not the way to
|
|
214
|
+
read a large set; advancing `offset` is.
|
|
215
|
+
|
|
216
|
+
When far more rows match than one window could hold, the response also carries a `breakdown` of how
|
|
217
|
+
they distribute. Its keys are parameter names and its values are argument values, so a breakdown is
|
|
218
|
+
a prompt to narrow — by `objectType`, `stereotype`, `diagramType`, or, for `ea_search` when the
|
|
219
|
+
result isn't already scoped, by `packageScope` (reported as the matching package's id, which the
|
|
220
|
+
next call can pass straight back) — rather than to page through thousands of rows.
|
|
221
221
|
|
|
222
222
|
## License
|
|
223
223
|
|
|
224
224
|
Copyright (c) 2026 Michal Mracka
|
|
225
225
|
|
|
226
226
|
Licensed under the EUPL — see [LICENSE](LICENSE) for the full text.
|
|
227
|
-
|
package/dist/tools/connectors.js
CHANGED
|
@@ -46,7 +46,7 @@ function parseFeatureLinks(db, styleEx) {
|
|
|
46
46
|
return { sourceFeature, targetFeature };
|
|
47
47
|
}
|
|
48
48
|
export function configureConnectorTools(server, model) {
|
|
49
|
-
server.tool("ea_get_connectors", "Get all relationships (connectors) for a given element. `connectors` lists what the element is connected to and how (Realisation, Dependency, Association, etc.), each entry naming its `source` and `dest` ends. Feature links show which specific attribute or operation each end attaches to.", {
|
|
49
|
+
server.tool("ea_get_connectors", "Get all relationships (connectors) for a given element. `connectors` lists what the element is connected to and how (Realisation, Dependency, Association, etc.), each entry naming its `source` and `dest` ends. For Generalization connectors, `source` is always the specific (child) type and `dest` the general (parent) type \u2014 each end also carries a `role` making this explicit without needing to reason about direction. Filter by `connectorType` and `direction` to list an element's direct children (incoming Generalization) or direct parent(s) (outgoing Generalization) without a diagram. Feature links show which specific attribute or operation each end attaches to.", {
|
|
50
50
|
elementId: z.coerce.number().describe("The Object_ID of the element to get connectors for"),
|
|
51
51
|
connectorType: z
|
|
52
52
|
.string()
|
|
@@ -113,8 +113,9 @@ export function configureConnectorTools(server, model) {
|
|
|
113
113
|
destCard: r.DestCard,
|
|
114
114
|
sourceRole: r.SourceRole || null,
|
|
115
115
|
destRole: r.DestRole || null,
|
|
116
|
-
|
|
117
|
-
|
|
116
|
+
// Generalization: Start_Object_ID is always the specific (child) type, End_Object_ID the general (parent) type.
|
|
117
|
+
source: { id: r.Start_Object_ID, name: r.SourceName, type: r.SourceType, stereotype: r.SourceStereotype, ...(r.Connector_Type === "Generalization" ? { role: "child" } : {}) },
|
|
118
|
+
dest: { id: r.End_Object_ID, name: r.DestName, type: r.DestType, stereotype: r.DestStereotype, ...(r.Connector_Type === "Generalization" ? { role: "parent" } : {}) },
|
|
118
119
|
sourceFeature,
|
|
119
120
|
targetFeature,
|
|
120
121
|
};
|
package/dist/tools/diagrams.js
CHANGED
|
@@ -44,7 +44,7 @@ function parseFeatureLinks(db, styleEx) {
|
|
|
44
44
|
return { sourceFeature, targetFeature };
|
|
45
45
|
}
|
|
46
46
|
export function configureDiagramTools(server, model) {
|
|
47
|
-
server.tool("ea_get_diagram_elements", "Get all elements and connectors placed on a specific diagram: the `diagram` itself, plus `elements` and `connectors`. Connectors include feature-link resolution showing which attribute or operation each end attaches to. The connector list is the union of explicit t_diagramlinks rows and implied connectors (both ends on the diagram).", {
|
|
47
|
+
server.tool("ea_get_diagram_elements", "Get all elements and connectors placed on a specific diagram: the `diagram` itself, plus `elements` and `connectors`. `elements` already includes free-text `Note` diagram objects, which often carry a legend or abbreviation definitions an agent would otherwise miss. Connectors include feature-link resolution showing which attribute or operation each end attaches to. The connector list is the union of explicit t_diagramlinks rows and implied connectors (both ends on the diagram).", {
|
|
48
48
|
diagramId: z.coerce.number().describe("The Diagram_ID to get elements for"),
|
|
49
49
|
}, READ_ONLY, async ({ diagramId }) => {
|
|
50
50
|
const db = await model.database();
|
package/dist/tools/elements.js
CHANGED
|
@@ -39,7 +39,7 @@ export function configureElementTools(server, model) {
|
|
|
39
39
|
type: a.Type,
|
|
40
40
|
scope: a.Scope,
|
|
41
41
|
stereotype: a.Stereotype,
|
|
42
|
-
notes: a.Notes,
|
|
42
|
+
notes: decodeEntities(a.Notes),
|
|
43
43
|
multiplicity: formatMultiplicity(a),
|
|
44
44
|
default: a.Default,
|
|
45
45
|
}));
|
|
@@ -66,12 +66,12 @@ export function configureElementTools(server, model) {
|
|
|
66
66
|
returnType: op.Type,
|
|
67
67
|
scope: op.Scope,
|
|
68
68
|
stereotype: op.Stereotype,
|
|
69
|
-
notes: op.Notes,
|
|
69
|
+
notes: decodeEntities(op.Notes),
|
|
70
70
|
parameters: params.map((p) => ({
|
|
71
71
|
name: p.Name,
|
|
72
72
|
type: p.Type,
|
|
73
73
|
kind: p.Kind,
|
|
74
|
-
notes: p.Notes,
|
|
74
|
+
notes: decodeEntities(p.Notes),
|
|
75
75
|
})),
|
|
76
76
|
};
|
|
77
77
|
});
|
package/dist/tools/search.js
CHANGED
|
@@ -49,10 +49,16 @@ function excerptAround(original, folded, foldedQuery, budget) {
|
|
|
49
49
|
const tail = hi < words.length - 1 ? "…" : "";
|
|
50
50
|
return { text: head + original.slice(words[lo][0], words[hi][1]) + tail, truncated: head !== "" || tail !== "" };
|
|
51
51
|
}
|
|
52
|
-
/**
|
|
52
|
+
/**
|
|
53
|
+
* True when the query begins somewhere other than mid-word — including at the very start of
|
|
54
|
+
* the text. Single-term ranking never reaches this for a position-0 match (rank 1, `startsWith`,
|
|
55
|
+
* already wins first), so widening it to treat idx 0 as a boundary is safe there; it matters
|
|
56
|
+
* once several terms are checked independently (R7), where an earlier term's own position-0
|
|
57
|
+
* match must not be misread as failing the boundary test.
|
|
58
|
+
*/
|
|
53
59
|
function startsAtWordBoundary(text, query) {
|
|
54
|
-
for (let idx = text.indexOf(query); idx
|
|
55
|
-
if (!/[\p{L}\p{N}]/u.test(text[idx - 1]))
|
|
60
|
+
for (let idx = text.indexOf(query); idx >= 0; idx = text.indexOf(query, idx + 1)) {
|
|
61
|
+
if (idx === 0 || !/[\p{L}\p{N}]/u.test(text[idx - 1]))
|
|
56
62
|
return true;
|
|
57
63
|
}
|
|
58
64
|
return false;
|
|
@@ -87,6 +93,136 @@ function scoreMatch(entry, foldedQuery) {
|
|
|
87
93
|
return { rank: entry.sourceField === "Name" ? 7 : 9, coverage: 0 };
|
|
88
94
|
return { rank: 10, coverage: 0 };
|
|
89
95
|
}
|
|
96
|
+
/** Below every single-field ladder rank (0-10), so R1's cross-field match never outranks one. */
|
|
97
|
+
const SPREAD_RANK = 11;
|
|
98
|
+
/**
|
|
99
|
+
* Whether every supplied term occurs in `text`, each immediately after the previous one save
|
|
100
|
+
* for a run of non-alphanumeric characters — i.e. adjacent in the caller's own order (R8).
|
|
101
|
+
* Meaningless for a single term (there is nothing to be adjacent to), so it is always false
|
|
102
|
+
* then, which is what keeps this out of the single-term path entirely.
|
|
103
|
+
*/
|
|
104
|
+
function isPhraseGrade(text, foldedTerms) {
|
|
105
|
+
if (foldedTerms.length < 2)
|
|
106
|
+
return false;
|
|
107
|
+
const escaped = foldedTerms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
108
|
+
return new RegExp(escaped.join("[^\\p{L}\\p{N}]*"), "u").test(text);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* How far apart several required terms sit in the text that carried all of them — the smallest
|
|
112
|
+
* span containing at least one occurrence of every term. Smaller is more proximate (R10). Always
|
|
113
|
+
* 0 for a single term, so it never perturbs the single-term freeze (R3): every candidate ties
|
|
114
|
+
* at 0 and the comparison falls straight through to the next tiebreak, exactly as it does today.
|
|
115
|
+
* A term repeated elsewhere in the text must not make a genuinely tight cluster look far apart,
|
|
116
|
+
* so every occurrence of every term is a candidate, not just each term's first (the classic
|
|
117
|
+
* "smallest range covering every list" problem: merge all occurrences by position, then slide a
|
|
118
|
+
* window until it covers every term, shrinking from the left while it still does).
|
|
119
|
+
*/
|
|
120
|
+
function termProximity(text, foldedTerms) {
|
|
121
|
+
if (foldedTerms.length < 2)
|
|
122
|
+
return 0;
|
|
123
|
+
const occurrences = [];
|
|
124
|
+
foldedTerms.forEach((t, term) => {
|
|
125
|
+
for (let at = text.indexOf(t); at >= 0; at = text.indexOf(t, at + 1)) {
|
|
126
|
+
occurrences.push({ pos: at, term });
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
if (occurrences.length === 0)
|
|
130
|
+
return 0;
|
|
131
|
+
occurrences.sort((a, b) => a.pos - b.pos);
|
|
132
|
+
const counts = new Array(foldedTerms.length).fill(0);
|
|
133
|
+
let distinct = 0;
|
|
134
|
+
let left = 0;
|
|
135
|
+
let best = Infinity;
|
|
136
|
+
for (let right = 0; right < occurrences.length; right++) {
|
|
137
|
+
if (counts[occurrences[right].term]++ === 0)
|
|
138
|
+
distinct++;
|
|
139
|
+
while (distinct === foldedTerms.length) {
|
|
140
|
+
best = Math.min(best, occurrences[right].pos - occurrences[left].pos);
|
|
141
|
+
if (--counts[occurrences[left].term] === 0)
|
|
142
|
+
distinct--;
|
|
143
|
+
left++;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return best === Infinity ? 0 : best;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Generalises scoreMatch to several required terms, reducing exactly to it for one term —
|
|
150
|
+
* that identity is what lets R3's freeze hold by construction rather than by a parallel code
|
|
151
|
+
* path. Exact and prefix (ranks 0-1) stay single-term concepts: neither has a natural meaning
|
|
152
|
+
* once more than one distinct required term is involved. Word-boundary (rank 2) generalises
|
|
153
|
+
* cleanly instead (R7): it holds only when every term independently starts at a boundary,
|
|
154
|
+
* otherwise the match settles at the ladder's infix rank (3). Every other bucket depends only
|
|
155
|
+
* on (sourceTable, sourceField), so it is unaffected by term count. A phrase-grade match (R8)
|
|
156
|
+
* then promotes half a rank within whichever bucket it already landed in — never crossing into
|
|
157
|
+
* a neighbouring field's territory, which is what keeps R9's resolvable-to-one-field guarantee
|
|
158
|
+
* intact. Coverage becomes the summed term length over the text length, capped at 1 — how this
|
|
159
|
+
* should really work for a multi-term match is an open question (see Outstanding Questions),
|
|
160
|
+
* and this is a placeholder, not a final answer.
|
|
161
|
+
*/
|
|
162
|
+
function scoreMultiMatch(entry, foldedTerms) {
|
|
163
|
+
if (foldedTerms.length === 1)
|
|
164
|
+
return { ...scoreMatch(entry, foldedTerms[0]), proximity: 0 };
|
|
165
|
+
const text = entry.foldedText;
|
|
166
|
+
const coverage = text.length > 0
|
|
167
|
+
? Math.min(1, foldedTerms.reduce((sum, t) => sum + t.length, 0) / text.length)
|
|
168
|
+
: 0;
|
|
169
|
+
let rank;
|
|
170
|
+
if (entry.sourceTable === "t_object" && entry.sourceField === "Name") {
|
|
171
|
+
rank = foldedTerms.every((t) => startsAtWordBoundary(text, t)) ? 2 : 3;
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
rank = scoreMatch(entry, foldedTerms[0]).rank;
|
|
175
|
+
}
|
|
176
|
+
if (isPhraseGrade(text, foldedTerms))
|
|
177
|
+
rank -= 0.5;
|
|
178
|
+
return { rank, coverage, proximity: termProximity(text, foldedTerms) };
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Whether every required term occurs somewhere in this object's searchable text (R1), and how
|
|
182
|
+
* it ranks. A single entry carrying every term ranks via the existing ladder (R7); an object
|
|
183
|
+
* whose terms are only found spread across separate entries still matches, but ranks below
|
|
184
|
+
* every single-field tier — R1's "ranking preference, not a condition of matching". Reduces
|
|
185
|
+
* to today's single-term matching and ranking exactly when only one term is supplied, since
|
|
186
|
+
* every entry containing that one term trivially "carries every term", and proximity is always
|
|
187
|
+
* 0 for one term (see termProximity), so the R10/R11 tiebreak this adds is a no-op there too.
|
|
188
|
+
*/
|
|
189
|
+
function matchObject(objEntries, foldedTerms) {
|
|
190
|
+
for (const term of foldedTerms) {
|
|
191
|
+
if (!objEntries.some((e) => e.foldedText.includes(term)))
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
let best;
|
|
195
|
+
for (const e of objEntries) {
|
|
196
|
+
if (!foldedTerms.every((t) => e.foldedText.includes(t)))
|
|
197
|
+
continue;
|
|
198
|
+
const { rank, coverage, proximity } = scoreMultiMatch(e, foldedTerms);
|
|
199
|
+
const better = !best ||
|
|
200
|
+
rank < best.rank ||
|
|
201
|
+
(rank === best.rank && coverage > best.coverage) ||
|
|
202
|
+
(rank === best.rank && coverage === best.coverage && proximity < best.proximity);
|
|
203
|
+
if (better) {
|
|
204
|
+
best = { rank, coverage, proximity, matchedIn: `${e.sourceTable}.${e.sourceField}` };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (best)
|
|
208
|
+
return best;
|
|
209
|
+
// Spread: no single entry carries every term. Coverage is the average of each term's own
|
|
210
|
+
// best-entry coverage — like the rank placeholder above, an interim answer, not a final one.
|
|
211
|
+
// Proximity has no shared text to measure across separate entries, so it stays neutral (0).
|
|
212
|
+
let coverageSum = 0;
|
|
213
|
+
for (const term of foldedTerms) {
|
|
214
|
+
let bestCoverage = 0;
|
|
215
|
+
for (const e of objEntries) {
|
|
216
|
+
if (!e.foldedText.includes(term))
|
|
217
|
+
continue;
|
|
218
|
+
const { coverage } = scoreMatch(e, term);
|
|
219
|
+
if (coverage > bestCoverage)
|
|
220
|
+
bestCoverage = coverage;
|
|
221
|
+
}
|
|
222
|
+
coverageSum += bestCoverage;
|
|
223
|
+
}
|
|
224
|
+
return { rank: SPREAD_RANK, coverage: coverageSum / foldedTerms.length, proximity: 0, matchedIn: null };
|
|
225
|
+
}
|
|
90
226
|
function buildCorpus(db) {
|
|
91
227
|
const cached = corpora.get(db);
|
|
92
228
|
if (cached)
|
|
@@ -156,12 +292,14 @@ function originalFor(entry, src) {
|
|
|
156
292
|
}
|
|
157
293
|
/**
|
|
158
294
|
* 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.
|
|
295
|
+
* bounded by what the response shows rather than by the corpus. Matches an entry against any
|
|
296
|
+
* of the required terms (not all of them, unlike matchObject) since evidence is about what
|
|
297
|
+
* each field contributed, and reduces to single-term behaviour exactly when only one is given.
|
|
160
298
|
*/
|
|
161
|
-
function collectEvidence(db, entries, rows, windowIds,
|
|
299
|
+
function collectEvidence(db, entries, rows, windowIds, foldedTerms) {
|
|
162
300
|
const hits = new Map();
|
|
163
301
|
for (const entry of entries) {
|
|
164
|
-
if (!windowIds.has(entry.objectId) || !entry.foldedText.includes(
|
|
302
|
+
if (!windowIds.has(entry.objectId) || !foldedTerms.some((t) => entry.foldedText.includes(t)))
|
|
165
303
|
continue;
|
|
166
304
|
const list = hits.get(entry.objectId);
|
|
167
305
|
if (list)
|
|
@@ -172,17 +310,20 @@ function collectEvidence(db, entries, rows, windowIds, foldedQuery) {
|
|
|
172
310
|
const kept = new Map();
|
|
173
311
|
for (const [objectId, list] of hits) {
|
|
174
312
|
const ranked = list
|
|
175
|
-
.map((e) =>
|
|
176
|
-
.
|
|
313
|
+
.map((e) => {
|
|
314
|
+
const present = foldedTerms.filter((t) => e.foldedText.includes(t));
|
|
315
|
+
return { e, present, ...scoreMultiMatch(e, present) };
|
|
316
|
+
})
|
|
317
|
+
.sort((a, b) => a.rank - b.rank || b.coverage - a.coverage || a.proximity - b.proximity || a.e.sourceId - b.e.sourceId)
|
|
177
318
|
.slice(0, MAX_INLINE_MATCHES)
|
|
178
|
-
.map((r) => r.e);
|
|
319
|
+
.map((r) => ({ entry: r.e, term: r.present[0] }));
|
|
179
320
|
kept.set(objectId, { entries: ranked, totalMatched: list.length });
|
|
180
321
|
}
|
|
181
322
|
const attributeIds = new Set();
|
|
182
323
|
const operationIds = new Set();
|
|
183
324
|
const constraintOwners = new Set();
|
|
184
325
|
for (const { entries: shown } of kept.values()) {
|
|
185
|
-
for (const e of shown) {
|
|
326
|
+
for (const { entry: e } of shown) {
|
|
186
327
|
if (e.sourceTable === "t_attribute")
|
|
187
328
|
attributeIds.add(e.sourceId);
|
|
188
329
|
else if (e.sourceTable === "t_operation")
|
|
@@ -208,11 +349,11 @@ function collectEvidence(db, entries, rows, windowIds, foldedQuery) {
|
|
|
208
349
|
const evidence = new Map();
|
|
209
350
|
for (const [objectId, { entries: shown, totalMatched }] of kept) {
|
|
210
351
|
const items = [];
|
|
211
|
-
for (const entry of shown) {
|
|
352
|
+
for (const { entry, term } of shown) {
|
|
212
353
|
const original = originalFor(entry, src);
|
|
213
354
|
if (!original)
|
|
214
355
|
continue;
|
|
215
|
-
const excerpt = excerptAround(original.text, entry.foldedText,
|
|
356
|
+
const excerpt = excerptAround(original.text, entry.foldedText, term, SNIPPET_CHARS);
|
|
216
357
|
items.push({
|
|
217
358
|
matchedIn: `${entry.sourceTable}.${entry.sourceField}`,
|
|
218
359
|
sourceId: entry.sourceId,
|
|
@@ -225,186 +366,279 @@ function collectEvidence(db, entries, rows, windowIds, foldedQuery) {
|
|
|
225
366
|
}
|
|
226
367
|
return evidence;
|
|
227
368
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
}
|
|
270
|
-
const entries = buildCorpus(db);
|
|
271
|
-
const foldedQuery = foldText(query).trim();
|
|
272
|
-
if (foldedQuery.length === 0) {
|
|
369
|
+
const REQUIRED_TERMS_PARAM = z
|
|
370
|
+
.array(z.string())
|
|
371
|
+
.min(1)
|
|
372
|
+
.max(10)
|
|
373
|
+
.describe("Terms every one of which must occur somewhere in the element's searchable text (names, notes, aliases, attributes, operations, constraints); terms need not share a field; capped at 10");
|
|
374
|
+
const OBJECT_TYPE_PARAM = z
|
|
375
|
+
.string()
|
|
376
|
+
.optional()
|
|
377
|
+
.describe("Filter by object type (e.g., Class, UseCase, Activity, Screen, Requirement, Interface, Component)");
|
|
378
|
+
const STEREOTYPE_PARAM = z.string().optional().describe("Filter by stereotype");
|
|
379
|
+
const PACKAGE_SCOPE_PARAM = z
|
|
380
|
+
.union([z.number().int(), z.string()])
|
|
381
|
+
.optional()
|
|
382
|
+
.describe("Restrict results to this package and its descendants, given as a package id or name");
|
|
383
|
+
/**
|
|
384
|
+
* Shared by both tools: `ea_search`'s required-term matching, ranking and response shape are
|
|
385
|
+
* identical either way. Only what an alternatives match does differs — boost promotes a tier
|
|
386
|
+
* within the same rank without excluding anything, filter removes an object lacking any
|
|
387
|
+
* alternative entirely — so that is the one thing this takes as a parameter.
|
|
388
|
+
*/
|
|
389
|
+
async function runSearch(db, toolName, altParamName, altMode, args, alternatives) {
|
|
390
|
+
const { requiredTerms, objectType, stereotype, packageScope, limit, offset } = args;
|
|
391
|
+
try {
|
|
392
|
+
let subtree;
|
|
393
|
+
if (packageScope !== undefined) {
|
|
394
|
+
const resolution = resolvePackageScope(db, packageScope);
|
|
395
|
+
if (resolution.kind === "not_found") {
|
|
273
396
|
return {
|
|
274
397
|
content: [{ type: "text", text: JSON.stringify({
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
offset,
|
|
279
|
-
truncated: false,
|
|
280
|
-
_meta: { sourceTables: ["t_object", "t_attribute", "t_operation", "t_objectconstraint", "t_package"] },
|
|
281
|
-
error: "Query is empty after normalization.",
|
|
398
|
+
error: "not_found",
|
|
399
|
+
message: `Package scope "${packageScope}" was not found.`,
|
|
400
|
+
packageScope,
|
|
282
401
|
}, null, 2) }],
|
|
402
|
+
isError: true,
|
|
283
403
|
};
|
|
284
404
|
}
|
|
285
|
-
|
|
286
|
-
const matchMap = new Map();
|
|
287
|
-
for (const entry of entries) {
|
|
288
|
-
if (!entry.foldedText.includes(foldedQuery))
|
|
289
|
-
continue;
|
|
290
|
-
const { rank, coverage } = scoreMatch(entry, foldedQuery);
|
|
291
|
-
const existing = matchMap.get(entry.objectId);
|
|
292
|
-
if (existing && (existing.rank < rank || (existing.rank === rank && existing.coverage >= coverage)))
|
|
293
|
-
continue;
|
|
294
|
-
matchMap.set(entry.objectId, { rank, coverage, matchedIn: `${entry.sourceTable}.${entry.sourceField}` });
|
|
295
|
-
}
|
|
296
|
-
if (matchMap.size === 0) {
|
|
405
|
+
if (resolution.kind === "ambiguous") {
|
|
297
406
|
return {
|
|
298
|
-
content: [{
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
offset,
|
|
305
|
-
truncated: false,
|
|
306
|
-
_meta: { sourceTables: ["t_object", "t_attribute", "t_operation", "t_objectconstraint", "t_package"] },
|
|
307
|
-
}, null, 2),
|
|
308
|
-
}],
|
|
407
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
408
|
+
error: "ambiguous_package",
|
|
409
|
+
message: `Package scope "${packageScope}" matches more than one package; use a package id instead.`,
|
|
410
|
+
candidates: resolution.candidates,
|
|
411
|
+
}, null, 2) }],
|
|
412
|
+
isError: true,
|
|
309
413
|
};
|
|
310
414
|
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
}
|
|
327
|
-
// Fetch all matching elements and apply filters
|
|
328
|
-
const placeholders = sortedIds.map(() => "?").join(",");
|
|
329
|
-
const sql = `
|
|
330
|
-
SELECT o.Object_ID, o.Object_Type, o.Name, o.Alias, o.Stereotype,
|
|
331
|
-
o.Package_ID, p.Name as PackageName, o.Note
|
|
332
|
-
FROM t_object o
|
|
333
|
-
LEFT JOIN t_package p ON o.Package_ID = p.Package_ID
|
|
334
|
-
WHERE o.Object_ID IN (${placeholders})${filterClauses}
|
|
335
|
-
`;
|
|
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;
|
|
339
|
-
// IN (...) returns rows in whatever order the plan produces, so rank order is restored here.
|
|
340
|
-
const rowMap = new Map(scopedRows.map((r) => [r.Object_ID, r]));
|
|
341
|
-
const totalMatched = sortedIds.filter((id) => rowMap.has(id)).length;
|
|
342
|
-
const sorted = sortedIds
|
|
343
|
-
.filter((id) => rowMap.has(id))
|
|
344
|
-
.map((id) => rowMap.get(id));
|
|
345
|
-
const window = sorted.slice(offset, offset + limit);
|
|
346
|
-
const truncated = isTruncated(offset, window.length, totalMatched);
|
|
347
|
-
const evidence = collectEvidence(db, entries, rowMap, new Set(window.map((r) => r.Object_ID)), foldedQuery);
|
|
348
|
-
const results = window.map((r) => {
|
|
349
|
-
const decodedNote = decodeEntities(r.Note);
|
|
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);
|
|
359
|
-
return {
|
|
360
|
-
Object_ID: r.Object_ID,
|
|
361
|
-
Object_Type: r.Object_Type,
|
|
362
|
-
Name: r.Name,
|
|
363
|
-
Alias: r.Alias,
|
|
364
|
-
Stereotype: r.Stereotype,
|
|
365
|
-
Package_ID: r.Package_ID,
|
|
366
|
-
PackageName: r.PackageName,
|
|
367
|
-
NotePreview: notePreview,
|
|
368
|
-
notePreviewTruncated,
|
|
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
|
-
},
|
|
378
|
-
};
|
|
379
|
-
});
|
|
380
|
-
const breakdown = breakdownApplies(totalMatched, limit)
|
|
381
|
-
? buildBreakdown({
|
|
382
|
-
objectType: objectType ? undefined : countBy(sorted, (r) => r.Object_Type),
|
|
383
|
-
stereotype: stereotype ? undefined : countBy(sorted, (r) => r.Stereotype),
|
|
384
|
-
packageScope: packageScope !== undefined ? undefined : countBy(sorted, (r) => r.Package_ID),
|
|
385
|
-
})
|
|
386
|
-
: undefined;
|
|
387
|
-
const continuation = buildContinuation("ea_search", { query, objectType, stereotype, packageScope, limit }, offset, results.length, totalMatched);
|
|
388
|
-
const response = {
|
|
389
|
-
results,
|
|
390
|
-
totalMatched,
|
|
391
|
-
returned: results.length,
|
|
392
|
-
offset,
|
|
393
|
-
truncated,
|
|
394
|
-
...(breakdown ? { breakdown } : {}),
|
|
395
|
-
...(continuation ? { continuation } : {}),
|
|
396
|
-
_meta: { sourceTables: ["t_object", "t_attribute", "t_operation", "t_objectconstraint", "t_package"] },
|
|
415
|
+
subtree = getPackageSubtree(db, resolution.packageId);
|
|
416
|
+
}
|
|
417
|
+
const entries = buildCorpus(db);
|
|
418
|
+
const normalizedTerms = requiredTerms.map((t) => foldText(t).trim());
|
|
419
|
+
if (normalizedTerms.some((t) => t.length === 0)) {
|
|
420
|
+
return {
|
|
421
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
422
|
+
results: [],
|
|
423
|
+
totalMatched: 0,
|
|
424
|
+
returned: 0,
|
|
425
|
+
offset,
|
|
426
|
+
truncated: false,
|
|
427
|
+
_meta: { sourceTables: ["t_object", "t_attribute", "t_operation", "t_objectconstraint", "t_package"] },
|
|
428
|
+
error: "At least one requiredTerms entry is empty after normalization (whitespace-only or blank).",
|
|
429
|
+
}, null, 2) }],
|
|
397
430
|
};
|
|
431
|
+
}
|
|
432
|
+
// Duplicate terms would otherwise double-count coverage and cross the phrase-grade check
|
|
433
|
+
// against itself; a caller-supplied duplicate is a no-op, not a stronger requirement.
|
|
434
|
+
const foldedTerms = [...new Set(normalizedTerms)];
|
|
435
|
+
// Group once so a term found via one entry and another via a different entry of the
|
|
436
|
+
// same object still counts as a match (R1): terms need not share a field.
|
|
437
|
+
const byObject = new Map();
|
|
438
|
+
for (const entry of entries) {
|
|
439
|
+
const list = byObject.get(entry.objectId);
|
|
440
|
+
if (list)
|
|
441
|
+
list.push(entry);
|
|
442
|
+
else
|
|
443
|
+
byObject.set(entry.objectId, [entry]);
|
|
444
|
+
}
|
|
445
|
+
const matchMap = new Map();
|
|
446
|
+
for (const [objectId, objEntries] of byObject) {
|
|
447
|
+
const match = matchObject(objEntries, foldedTerms);
|
|
448
|
+
if (match)
|
|
449
|
+
matchMap.set(objectId, { ...match, boosted: false });
|
|
450
|
+
}
|
|
451
|
+
if (matchMap.size === 0) {
|
|
452
|
+
// R6: a caller guessing at stems cannot tell which one emptied the result without
|
|
453
|
+
// this — report each supplied term's own corpus-wide presence, independent of the
|
|
454
|
+
// others, rather than leaving them to re-guess the whole call.
|
|
455
|
+
const termMatches = requiredTerms.map((term) => {
|
|
456
|
+
const folded = foldText(term).trim();
|
|
457
|
+
return { term, matchedAnywhere: folded.length > 0 && entries.some((e) => e.foldedText.includes(folded)) };
|
|
458
|
+
});
|
|
398
459
|
return {
|
|
399
|
-
content: [{
|
|
460
|
+
content: [{
|
|
461
|
+
type: "text",
|
|
462
|
+
text: JSON.stringify({
|
|
463
|
+
results: [],
|
|
464
|
+
totalMatched: 0,
|
|
465
|
+
returned: 0,
|
|
466
|
+
offset,
|
|
467
|
+
truncated: false,
|
|
468
|
+
termMatches,
|
|
469
|
+
_meta: { sourceTables: ["t_object", "t_attribute", "t_operation", "t_objectconstraint", "t_package"] },
|
|
470
|
+
}, null, 2),
|
|
471
|
+
}],
|
|
400
472
|
};
|
|
401
473
|
}
|
|
402
|
-
|
|
403
|
-
|
|
474
|
+
// R4: the alternatives list is never required, and an empty one behaves as if omitted.
|
|
475
|
+
const foldedAlt = alternatives.map((t) => foldText(t).trim()).filter((t) => t.length > 0);
|
|
476
|
+
if (foldedAlt.length > 0) {
|
|
477
|
+
for (const [objectId, match] of matchMap) {
|
|
478
|
+
const objEntries = byObject.get(objectId);
|
|
479
|
+
const satisfied = objEntries.some((e) => foldedAlt.some((a) => e.foldedText.includes(a)));
|
|
480
|
+
if (altMode === "filter" && !satisfied)
|
|
481
|
+
matchMap.delete(objectId);
|
|
482
|
+
else if (altMode === "boost" && satisfied)
|
|
483
|
+
match.boosted = true;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
if (matchMap.size === 0) {
|
|
487
|
+
// Filtering removed every required-term match; this is not R6's case (the required
|
|
488
|
+
// terms did match something), so no termMatches — just the plain empty shape.
|
|
404
489
|
return {
|
|
405
|
-
content: [{
|
|
406
|
-
|
|
490
|
+
content: [{
|
|
491
|
+
type: "text",
|
|
492
|
+
text: JSON.stringify({
|
|
493
|
+
results: [],
|
|
494
|
+
totalMatched: 0,
|
|
495
|
+
returned: 0,
|
|
496
|
+
offset,
|
|
497
|
+
truncated: false,
|
|
498
|
+
_meta: { sourceTables: ["t_object", "t_attribute", "t_operation", "t_objectconstraint", "t_package"] },
|
|
499
|
+
}, null, 2),
|
|
500
|
+
}],
|
|
407
501
|
};
|
|
408
502
|
}
|
|
503
|
+
// Strongest first, then identity: without the final tiebreak, paging a large tie could
|
|
504
|
+
// show the same row twice and never show another. `boosted` (R4's promotion) sits right
|
|
505
|
+
// after rank — ahead of coverage — since it is a within-rank preference, not a new tier;
|
|
506
|
+
// proximity (R10) then sits between coverage and identity, per R11. Both are no-ops when
|
|
507
|
+
// there is nothing to distinguish (a single required term, or no alternatives supplied).
|
|
508
|
+
const sortedIds = [...matchMap.entries()]
|
|
509
|
+
.sort((a, b) => a[1].rank - b[1].rank ||
|
|
510
|
+
Number(b[1].boosted) - Number(a[1].boosted) ||
|
|
511
|
+
b[1].coverage - a[1].coverage ||
|
|
512
|
+
a[1].proximity - b[1].proximity ||
|
|
513
|
+
a[0] - b[0])
|
|
514
|
+
.map(([id]) => id);
|
|
515
|
+
// Build SQL to fetch matched elements with filters
|
|
516
|
+
let filterClauses = "";
|
|
517
|
+
const filterParams = [];
|
|
518
|
+
if (objectType) {
|
|
519
|
+
filterClauses += " AND o.Object_Type = ?";
|
|
520
|
+
filterParams.push(objectType);
|
|
521
|
+
}
|
|
522
|
+
if (stereotype) {
|
|
523
|
+
filterClauses += " AND o.Stereotype = ?";
|
|
524
|
+
filterParams.push(stereotype);
|
|
525
|
+
}
|
|
526
|
+
// Fetch all matching elements and apply filters
|
|
527
|
+
const placeholders = sortedIds.map(() => "?").join(",");
|
|
528
|
+
const sql = `
|
|
529
|
+
SELECT o.Object_ID, o.Object_Type, o.Name, o.Alias, o.Stereotype,
|
|
530
|
+
o.Package_ID, p.Name as PackageName, o.Note
|
|
531
|
+
FROM t_object o
|
|
532
|
+
LEFT JOIN t_package p ON o.Package_ID = p.Package_ID
|
|
533
|
+
WHERE o.Object_ID IN (${placeholders})${filterClauses}
|
|
534
|
+
`;
|
|
535
|
+
const allRows = db.prepare(sql).all(...sortedIds, ...filterParams);
|
|
536
|
+
// Package_ID is on every fetched row, so scoping is a subtree membership check, not a query change.
|
|
537
|
+
const scopedRows = subtree ? allRows.filter((r) => subtree.has(r.Package_ID)) : allRows;
|
|
538
|
+
// IN (...) returns rows in whatever order the plan produces, so rank order is restored here.
|
|
539
|
+
const rowMap = new Map(scopedRows.map((r) => [r.Object_ID, r]));
|
|
540
|
+
const totalMatched = sortedIds.filter((id) => rowMap.has(id)).length;
|
|
541
|
+
const sorted = sortedIds
|
|
542
|
+
.filter((id) => rowMap.has(id))
|
|
543
|
+
.map((id) => rowMap.get(id));
|
|
544
|
+
const window = sorted.slice(offset, offset + limit);
|
|
545
|
+
const truncated = isTruncated(offset, window.length, totalMatched);
|
|
546
|
+
const evidence = collectEvidence(db, entries, rowMap, new Set(window.map((r) => r.Object_ID)), foldedTerms);
|
|
547
|
+
const results = window.map((r) => {
|
|
548
|
+
const decodedNote = decodeEntities(r.Note);
|
|
549
|
+
const matchedIn = matchMap.get(r.Object_ID)?.matchedIn ?? null;
|
|
550
|
+
// Previewing from the start hides the reason for a match that lies deeper in the note.
|
|
551
|
+
// matchedIn is only "t_object.Note" when the note itself carried every required term
|
|
552
|
+
// (matchObject's single-field branch), so centring on the first term is always valid here.
|
|
553
|
+
const notePreview = !decodedNote
|
|
554
|
+
? null
|
|
555
|
+
: matchedIn === "t_object.Note"
|
|
556
|
+
? excerptAround(decodedNote, foldText(decodedNote), foldedTerms[0], NOTE_PREVIEW_CHARS).text
|
|
557
|
+
: decodedNote.slice(0, NOTE_PREVIEW_CHARS);
|
|
558
|
+
const notePreviewTruncated = decodedNote != null && decodedNote.length > NOTE_PREVIEW_CHARS;
|
|
559
|
+
const matches = evidence.get(r.Object_ID);
|
|
560
|
+
return {
|
|
561
|
+
Object_ID: r.Object_ID,
|
|
562
|
+
Object_Type: r.Object_Type,
|
|
563
|
+
Name: r.Name,
|
|
564
|
+
Alias: r.Alias,
|
|
565
|
+
Stereotype: r.Stereotype,
|
|
566
|
+
Package_ID: r.Package_ID,
|
|
567
|
+
PackageName: r.PackageName,
|
|
568
|
+
NotePreview: notePreview,
|
|
569
|
+
notePreviewTruncated,
|
|
570
|
+
matchedIn,
|
|
571
|
+
matches: matches?.items ?? [],
|
|
572
|
+
_meta: {
|
|
573
|
+
matches: {
|
|
574
|
+
totalMatched: matches?.totalMatched ?? 0,
|
|
575
|
+
returned: matches?.items.length ?? 0,
|
|
576
|
+
truncated: (matches?.totalMatched ?? 0) > (matches?.items.length ?? 0),
|
|
577
|
+
},
|
|
578
|
+
},
|
|
579
|
+
};
|
|
580
|
+
});
|
|
581
|
+
const breakdown = breakdownApplies(totalMatched, limit)
|
|
582
|
+
? buildBreakdown({
|
|
583
|
+
objectType: objectType ? undefined : countBy(sorted, (r) => r.Object_Type),
|
|
584
|
+
stereotype: stereotype ? undefined : countBy(sorted, (r) => r.Stereotype),
|
|
585
|
+
packageScope: packageScope !== undefined ? undefined : countBy(sorted, (r) => r.Package_ID),
|
|
586
|
+
})
|
|
587
|
+
: undefined;
|
|
588
|
+
const continuation = buildContinuation(toolName, { requiredTerms, objectType, stereotype, packageScope, limit, [altParamName]: alternatives.length > 0 ? alternatives : undefined }, offset, results.length, totalMatched);
|
|
589
|
+
const response = {
|
|
590
|
+
results,
|
|
591
|
+
totalMatched,
|
|
592
|
+
returned: results.length,
|
|
593
|
+
offset,
|
|
594
|
+
truncated,
|
|
595
|
+
...(breakdown ? { breakdown } : {}),
|
|
596
|
+
...(continuation ? { continuation } : {}),
|
|
597
|
+
_meta: { sourceTables: ["t_object", "t_attribute", "t_operation", "t_objectconstraint", "t_package"] },
|
|
598
|
+
};
|
|
599
|
+
return {
|
|
600
|
+
content: [{ type: "text", text: JSON.stringify(response, null, 2) }],
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
catch (error) {
|
|
604
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
605
|
+
return {
|
|
606
|
+
content: [{ type: "text", text: `Error searching elements: ${msg}` }],
|
|
607
|
+
isError: true,
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
export function configureSearchTools(server, model) {
|
|
612
|
+
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. `requiredTerms` is a list of terms every one of which must occur somewhere in an element's searchable text (conjunction) — terms need not share a field, but sharing one ranks higher; each term is matched as a contiguous substring exactly as a single term is, so a term carrying whitespace is a phrase and is never split. A one-entry list behaves exactly as a single search term always has. `boostAnyOf` is an optional list of further terms: a result also matching at least one of them ranks ahead of one that does not, but nothing is ever excluded on that basis — for narrowing to only elements matching an alternative too, use `ea_search_and_any_of` instead. When no element matches, `termMatches` reports, per supplied term, whether that term matched anywhere in the corpus at all — so a caller can tell which term emptied the result rather than re-guessing the whole call. 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.", {
|
|
613
|
+
requiredTerms: REQUIRED_TERMS_PARAM,
|
|
614
|
+
boostAnyOf: z
|
|
615
|
+
.array(z.string())
|
|
616
|
+
.max(10)
|
|
617
|
+
.optional()
|
|
618
|
+
.describe("Terms that promote a result's rank when also present; never excludes. Use ea_search_and_any_of to narrow instead."),
|
|
619
|
+
objectType: OBJECT_TYPE_PARAM,
|
|
620
|
+
stereotype: STEREOTYPE_PARAM,
|
|
621
|
+
packageScope: PACKAGE_SCOPE_PARAM,
|
|
622
|
+
limit: limitParam(25),
|
|
623
|
+
offset: offsetParam,
|
|
624
|
+
}, READ_ONLY, async ({ requiredTerms, boostAnyOf, objectType, stereotype, packageScope, limit, offset }) => {
|
|
625
|
+
const db = await model.database();
|
|
626
|
+
return runSearch(db, "ea_search", "boostAnyOf", "boost", { requiredTerms, objectType, stereotype, packageScope, limit, offset }, boostAnyOf ?? []);
|
|
627
|
+
});
|
|
628
|
+
server.tool("ea_search_and_any_of", "Search Enterprise Architect model elements the same way `ea_search` does, plus `andAnyOf`: a required-and-alternative filter. `requiredTerms` still works exactly as it does on `ea_search` (conjunction, contiguous substring, terms need not share a field). `andAnyOf` is an optional list of further terms; when supplied, a result must contain `requiredTerms` AND at least one `andAnyOf` term, so it can only ever narrow — it never returns a result `requiredTerms` alone would not. An empty `andAnyOf` array applies no filter. For promoting rather than narrowing, use `ea_search` instead, which offers the same alternatives idea as a rank boost. When no element matches the required terms at all, `termMatches` reports, per supplied term, whether it matched anywhere in the corpus. Matching elements are returned in `results`, strongest first, each with a decoded note preview, a truncation flag, and `matches` — the evidence for why it was returned, capped and strongest-first, with `_meta.matches` reporting how many were found and withheld. `packageScope` restricts results to a package and its descendants. Walk a large result set with `offset`; 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 `packageScope`.", {
|
|
629
|
+
requiredTerms: REQUIRED_TERMS_PARAM,
|
|
630
|
+
andAnyOf: z
|
|
631
|
+
.array(z.string())
|
|
632
|
+
.max(10)
|
|
633
|
+
.optional()
|
|
634
|
+
.describe("Terms a result must also contain at least one of, in addition to requiredTerms; never adds results. Use ea_search's boostAnyOf to only reorder instead."),
|
|
635
|
+
objectType: OBJECT_TYPE_PARAM,
|
|
636
|
+
stereotype: STEREOTYPE_PARAM,
|
|
637
|
+
packageScope: PACKAGE_SCOPE_PARAM,
|
|
638
|
+
limit: limitParam(25),
|
|
639
|
+
offset: offsetParam,
|
|
640
|
+
}, READ_ONLY, async ({ requiredTerms, andAnyOf, objectType, stereotype, packageScope, limit, offset }) => {
|
|
641
|
+
const db = await model.database();
|
|
642
|
+
return runSearch(db, "ea_search_and_any_of", "andAnyOf", "filter", { requiredTerms, objectType, stereotype, packageScope, limit, offset }, andAnyOf ?? []);
|
|
409
643
|
});
|
|
410
644
|
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const packageVersion = "
|
|
1
|
+
export declare const packageVersion = "3.0.0+gf2d8478";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const packageVersion = "
|
|
1
|
+
export const packageVersion = "3.0.0+gf2d8478";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "enterprise-architect-mcp",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.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": [
|