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 CHANGED
@@ -5,6 +5,7 @@
5
5
  [![License: EUPL-1.2](https://img.shields.io/badge/license-EUPL--1.2-blue.svg)](LICENSE)
6
6
  [![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_server-0098FF?logo=visualstudiocode&logoColor=white)](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
  [![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_server-24bfa5?logo=visualstudiocode&logoColor=white)](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
+ [![Compound Engineering](https://img.shields.io/badge/Built_with-Compound_Engineering-6366f1)](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
- A directory works too the newest `.qea` file in it is used:
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
- ## Example Prompts
189
+ ## How Results Are Ordered, Paged, and Narrowed
211
190
 
212
- Once connected, try prompts like:
191
+ ### Name ordering
213
192
 
214
- - "Search for elements related to 'legal entity'"
215
- - "Show me the package structure under the root"
216
- - "What are the use case scenarios for UC_SUBMIT_APPLICATION?"
217
- - "What elements and connectors are on diagram 0103 Application Processing?"
218
- - "Resolve the reference {3F2A7C10-5B4D-4e8a-9C1F-27D6E8B0A4F3}"
219
- - "What columns does t_connector have?"
220
- - "Which diagrams does element a7680 appear on?"
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
-
@@ -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
- source: { id: r.Start_Object_ID, name: r.SourceName, type: r.SourceType, stereotype: r.SourceStereotype },
117
- dest: { id: r.End_Object_ID, name: r.DestName, type: r.DestType, stereotype: r.DestStereotype },
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
  };
@@ -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();
@@ -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
  });
@@ -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
- /** True when the query begins somewhere other than mid-word. */
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 > 0; idx = text.indexOf(query, idx + 1)) {
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, foldedQuery) {
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(foldedQuery))
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) => ({ e, ...scoreMatch(e, foldedQuery) }))
176
- .sort((a, b) => a.rank - b.rank || b.coverage - a.coverage || a.e.sourceId - b.e.sourceId)
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, foldedQuery, SNIPPET_CHARS);
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
- export function configureSearchTools(server, model) {
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.", {
230
- query: z.string().describe("Search term to find across all model text (names, notes, aliases, attributes, operations, constraints)"),
231
- objectType: z
232
- .string()
233
- .optional()
234
- .describe("Filter by object type (e.g., Class, UseCase, Activity, Screen, Requirement, Interface, Component)"),
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"),
240
- limit: limitParam(25),
241
- offset: offsetParam,
242
- }, READ_ONLY, async ({ query, objectType, stereotype, packageScope, limit, offset }) => {
243
- const db = await model.database();
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
- }
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
- results: [],
276
- totalMatched: 0,
277
- returned: 0,
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
- // Find matching object IDs with match quality ranking
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
- type: "text",
300
- text: JSON.stringify({
301
- results: [],
302
- totalMatched: 0,
303
- returned: 0,
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
- // Strongest first, then identity: without the final tiebreak, paging a large
312
- // tie could show the same row twice and never show another.
313
- const sortedIds = [...matchMap.entries()]
314
- .sort((a, b) => a[1].rank - b[1].rank || b[1].coverage - a[1].coverage || a[0] - b[0])
315
- .map(([id]) => id);
316
- // Build SQL to fetch matched elements with filters
317
- let filterClauses = "";
318
- const filterParams = [];
319
- if (objectType) {
320
- filterClauses += " AND o.Object_Type = ?";
321
- filterParams.push(objectType);
322
- }
323
- if (stereotype) {
324
- filterClauses += " AND o.Stereotype = ?";
325
- filterParams.push(stereotype);
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: [{ type: "text", text: JSON.stringify(response, null, 2) }],
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
- catch (error) {
403
- const msg = error instanceof Error ? error.message : String(error);
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: [{ type: "text", text: `Error searching elements: ${msg}` }],
406
- isError: true,
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 = "2.3.0+gf6910be";
1
+ export declare const packageVersion = "3.0.0+gf2d8478";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const packageVersion = "2.3.0+gf6910be";
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": "2.3.0",
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": [