@stratta/mcp 1.16.0 → 1.17.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 +92 -50
- package/dist/errors.d.ts +8 -0
- package/dist/errors.js +32 -0
- package/dist/index.js +17 -2
- package/dist/mode.d.ts +16 -0
- package/dist/mode.js +32 -0
- package/dist/prompts.js +6 -0
- package/dist/tools/read.js +6 -1
- package/dist/tools/toolsets.gen.d.ts +49 -0
- package/dist/tools/toolsets.gen.js +130 -0
- package/package.json +1 -1
- package/scripts/ingest-prepass.py +52 -0
- package/skills/consult-stratta/SKILL.md +1 -1
package/README.md
CHANGED
|
@@ -46,6 +46,21 @@ This package is what you want when:
|
|
|
46
46
|
uploads a file from your disk to a project dossier;
|
|
47
47
|
- you would simply rather run the server yourself.
|
|
48
48
|
|
|
49
|
+
## Lighter setup: `--simple`
|
|
50
|
+
|
|
51
|
+
By default the server announces all 59 tools (about 67,000 characters of
|
|
52
|
+
schemas). `--simple` announces only the 10 norm-reading tools (about 11,000),
|
|
53
|
+
which is enough for an agent that just reads norms:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
claude mcp add stratta --scope user -- npx -y @stratta/mcp --simple
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`--toolsets dossiers,sites` adds families (`norms` is always on; `dossiers`,
|
|
60
|
+
`sites`, `ingest`), `--full` restores everything, and the `STRATTA_TOOLSETS`
|
|
61
|
+
environment variable does the same. An unknown family is ignored. For the
|
|
62
|
+
remote connector, use the **Tools** menu on https://stratta.ch/connections.
|
|
63
|
+
|
|
49
64
|
## Install
|
|
50
65
|
|
|
51
66
|
### Claude Code (recommended)
|
|
@@ -209,42 +224,57 @@ serves the same 58, everything minus `add_attachment`).
|
|
|
209
224
|
| `list_site_photos` | The photos uploaded on the sheet, with position, direction, instant and note. No image, no link. |
|
|
210
225
|
| `list_site_neighbours` | The organisation's other sheets within a radius, with the distance and the geological unit their scan established. |
|
|
211
226
|
|
|
212
|
-
**Dossier** (
|
|
213
|
-
|
|
214
|
-
| Tool | Purpose
|
|
215
|
-
| ---------------------- |
|
|
216
|
-
| `list_dossiers` | Your organisation's dossiers, most recently touched first, with open-question counts and site id.
|
|
217
|
-
| `open_dossier` | Open a project's dossier, creating it if needed. Idempotent on the name.
|
|
218
|
-
| `open_question` | Open one question to settle, with optional named options. Idempotent on the title.
|
|
219
|
-
| `save_finding` | Record one piece of evidence: a cited article, a retained value and why, an observation.
|
|
220
|
-
| `record_decision` | Settle a question with a decision the engineer has confirmed, and the retained option.
|
|
221
|
-
| `load_dossier` | Reload everything: questions with their evidence and decisions, open ones first.
|
|
222
|
-
| `resolve_question` | Close a question without a decision, or reopen one. The evidence stays.
|
|
223
|
-
| `list_attachments` | The project attachments of a dossier: site reports, borehole logs, minutes, data sheets.
|
|
224
|
-
| `read_attachment` | Read an attachment's text as Markdown, page by page.
|
|
225
|
-
| `search_in_dossier` | Full-text search over a dossier's attachments, with the page of each hit.
|
|
226
|
-
| `add_attachment` | Upload a file from the user's machine to a dossier (PDF, DOCX, XLSX, images, text). Local server only.
|
|
227
|
-
| `list_templates` | The checklists the organisation wrote for its types of structure.
|
|
228
|
-
| `apply_template` | Open a template's questions in a dossier and file the clauses that resolve in the corpus.
|
|
229
|
-
| `draft_deliverable` | Open the document a dossier produces (project basis, use agreement, report) and get its plan and sources.
|
|
230
|
-
| `write_section` | Write one section of a deliverable with what it rests on; a section without a source is refused.
|
|
231
|
-
| `
|
|
232
|
-
| `
|
|
233
|
-
| `
|
|
234
|
-
| `
|
|
235
|
-
| `
|
|
236
|
-
| `
|
|
237
|
-
| `
|
|
238
|
-
| `
|
|
239
|
-
| `
|
|
240
|
-
| `
|
|
241
|
-
| `
|
|
242
|
-
| `
|
|
243
|
-
| `update_entry` | Correct an entry's wording, value, confidence or citation in place. |
|
|
244
|
-
| `attach_entry` | File an entry under a question and optionally an option, or unfile it. |
|
|
227
|
+
**Dossier** (27 tools — what a project reads, retains and produces: questions, evidence, decisions, attachments, templates, and the deliverable written section by section):
|
|
228
|
+
|
|
229
|
+
| Tool | Purpose |
|
|
230
|
+
| ---------------------- | --------------------------------------------------------------------------------------------------------- |
|
|
231
|
+
| `list_dossiers` | Your organisation's dossiers, most recently touched first, with open-question counts and site id. |
|
|
232
|
+
| `open_dossier` | Open a project's dossier, creating it if needed. Idempotent on the name. |
|
|
233
|
+
| `open_question` | Open one question to settle, with optional named options. Idempotent on the title. |
|
|
234
|
+
| `save_finding` | Record one piece of evidence: a cited article, a retained value and why, an observation. |
|
|
235
|
+
| `record_decision` | Settle a question with a decision the engineer has confirmed, and the retained option. |
|
|
236
|
+
| `load_dossier` | Reload everything: questions with their evidence and decisions, open ones first. |
|
|
237
|
+
| `resolve_question` | Close a question without a decision, or reopen one. The evidence stays. |
|
|
238
|
+
| `list_attachments` | The project attachments of a dossier: site reports, borehole logs, minutes, data sheets. |
|
|
239
|
+
| `read_attachment` | Read an attachment's text as Markdown, page by page. |
|
|
240
|
+
| `search_in_dossier` | Full-text search over a dossier's attachments, with the page of each hit. |
|
|
241
|
+
| `add_attachment` | Upload a file from the user's machine to a dossier (PDF, DOCX, XLSX, images, text). Local server only. |
|
|
242
|
+
| `list_templates` | The checklists the organisation wrote for its types of structure. |
|
|
243
|
+
| `apply_template` | Open a template's questions in a dossier and file the clauses that resolve in the corpus. |
|
|
244
|
+
| `draft_deliverable` | Open the document a dossier produces (project basis, use agreement, report) and get its plan and sources. |
|
|
245
|
+
| `write_section` | Write one section of a deliverable with what it rests on; a section without a source is refused. |
|
|
246
|
+
| `list_questions` | The questions of a dossier, paginated, with status, assignee, due date, options and decision title. |
|
|
247
|
+
| `get_question` | One question in full: options, decision, evidence with every citation field, comments with authors. |
|
|
248
|
+
| `get_dossier_activity` | The history of a dossier, most recent first, paginated: who did what, from the app or an agent. |
|
|
249
|
+
| `list_exports` | The verification notes and journals exported from a dossier, with hash and trusted timestamp. |
|
|
250
|
+
| `update_question` | Reword a question, assign it by e-mail address, set or clear its due date. |
|
|
251
|
+
| `add_option` | Add one way of settling a question. Idempotent on the name. |
|
|
252
|
+
| `update_option` | Rename or describe an option, or mark it retained (the others are released). |
|
|
253
|
+
| `delete_option` | Remove an option; its evidence stays on the question. Asks the user first. |
|
|
254
|
+
| `delete_question` | Delete a question opened by mistake; its evidence stays, unfiled. Asks the user first. |
|
|
255
|
+
| `add_comment` | Leave a signed remark on a dossier, a question or an entry. |
|
|
256
|
+
| `update_entry` | Correct an entry's wording, value, confidence or citation in place. |
|
|
257
|
+
| `attach_entry` | File an entry under a question and optionally an option, or unfile it. |
|
|
245
258
|
|
|
246
259
|
A dossier is read, annotated, reviewed and exported from
|
|
247
|
-
[stratta.ch/dossiers](https://stratta.ch/dossiers)
|
|
260
|
+
[stratta.ch/dossiers](https://stratta.ch/dossiers): four tabs (Terrain,
|
|
261
|
+
Lectures, Décisions, Livrables). Every section the agent reads for a named
|
|
262
|
+
project is logged under Lectures without a gesture; a decision can be recorded
|
|
263
|
+
in one sentence (`record_decision` with `dossierId` and `question`); the
|
|
264
|
+
deliverable is draft, in review or signed, exported to Word inside the office's
|
|
265
|
+
own report, and signing never goes through an agent.
|
|
266
|
+
|
|
267
|
+
**Library** (2 tools — the written procedures an agent follows for a repeatable task):
|
|
268
|
+
|
|
269
|
+
| Tool | Purpose |
|
|
270
|
+
| ------------- | ------------------------------------------------------------------------------------------------------------- |
|
|
271
|
+
| `list_skills` | The procedures the agent follows for a repeatable task: the ones Stratta ships and the ones the office wrote. |
|
|
272
|
+
| `get_skill` | Read one procedure in full; the office version wins over the Stratta one of the same name. |
|
|
273
|
+
|
|
274
|
+
An office skill with the same name as a shipped one replaces it for every agent
|
|
275
|
+
of the organisation. A report template learned from an office Word file on
|
|
276
|
+
[stratta.ch/bibliotheque](https://stratta.ch/bibliotheque) is served as a skill
|
|
277
|
+
too, and its file is the layout the Word export is rendered into.
|
|
248
278
|
|
|
249
279
|
**Ingest** (10 tools — add YOUR licensed norms; driven by the bundled `ingest-norm` skill; owner or admin role):
|
|
250
280
|
|
|
@@ -273,18 +303,25 @@ For querying:
|
|
|
273
303
|
6. Follow `crossRefs` for compound questions (e.g. SIA 261 → SIA 263 → EC).
|
|
274
304
|
7. Call `get_figure` when the section references a figure relevant to the answer.
|
|
275
305
|
|
|
276
|
-
|
|
277
|
-
follows to answer from the norms, generated from the
|
|
278
|
-
through `get_methodology`)
|
|
306
|
+
Nine skills ship in the package, under `skills/`: **`consult-stratta`** (the
|
|
307
|
+
working method an agent follows to answer from the norms, generated from the
|
|
308
|
+
same text the server serves through `get_methodology`), **`ingest-norm`**,
|
|
309
|
+
**`project-basis`** and **`use-agreement`** (the two SIA 260 documents, written
|
|
310
|
+
with `draft_deliverable` and `write_section`), **`site-chapter`**,
|
|
311
|
+
**`instruct-structure`**, **`review-open-questions`**, **`handoff-to-word`** and
|
|
312
|
+
**`verification-note`**. The same nine are served to every client by
|
|
313
|
+
`list_skills` and `get_skill`, so a client that installs no skills folder reads
|
|
314
|
+
them anyway; an office skill of the same name replaces the shipped one.
|
|
279
315
|
|
|
280
316
|
## Resources and prompts
|
|
281
317
|
|
|
282
|
-
|
|
318
|
+
Five `stratta://` resources can be pinned to a conversation or read without a
|
|
283
319
|
tool call, each scoped to your workspace: `stratta://norms` (JSON),
|
|
284
320
|
`stratta://methodology` (Markdown), `stratta://norm/{code}/toc` (the table of
|
|
285
|
-
contents to depth 2, code URL-encoded, e.g. `stratta://norm/SIA%20267/toc`)
|
|
286
|
-
`stratta://dossier/{id}` (a project dossier as Markdown)
|
|
287
|
-
|
|
321
|
+
contents to depth 2, code URL-encoded, e.g. `stratta://norm/SIA%20267/toc`),
|
|
322
|
+
`stratta://dossier/{id}` (a project dossier as Markdown) and
|
|
323
|
+
`stratta://skills/{name}` (one skill as Markdown, the office version when there
|
|
324
|
+
is one). The same five are served by the remote connector.
|
|
288
325
|
|
|
289
326
|
Three prompts appear as slash commands in clients that support them:
|
|
290
327
|
`investigate-question` (question, project?), `resume-dossier` (name) and
|
|
@@ -325,17 +362,22 @@ Requires Python ≥ 3.10 with PyMuPDF (`python -m pip install --user pymupdf`).
|
|
|
325
362
|
Your organization reached one of its limits. The error names the dimension, your
|
|
326
363
|
current count and the plan limit. Retrying will fail identically.
|
|
327
364
|
|
|
328
|
-
| Limit | Free | Pro | Max |
|
|
329
|
-
| --------------- | ----- | ------ | ------ |
|
|
330
|
-
| Norms | 1 | 15 | 60 |
|
|
331
|
-
| Sections | 1,000 | 15,000 | 60,000 |
|
|
332
|
-
| Figures | 60 | 750 | 3,000 |
|
|
333
|
-
| Queries / month | 500 |
|
|
334
|
-
| Members | 1 | 1 | 1 |
|
|
365
|
+
| Limit | Free | Pro | Max |
|
|
366
|
+
| --------------- | ----- | ------ | ------ |
|
|
367
|
+
| Norms | 1 | 15 | 60 |
|
|
368
|
+
| Sections | 1,000 | 15,000 | 60,000 |
|
|
369
|
+
| Figures | 60 | 750 | 3,000 |
|
|
370
|
+
| Queries / month | 500 | 10,000 | 40,000 |
|
|
371
|
+
| Members | 1 | 1 | 1 |
|
|
372
|
+
| Site sheet | – | – | ✓ |
|
|
335
373
|
|
|
336
374
|
Beyond the included queries, paid plans bill the overage per thousand. An
|
|
337
|
-
Enterprise contract scales seats, norms and queries further
|
|
338
|
-
at https://stratta.ch/tarifs
|
|
375
|
+
Enterprise contract scales seats, norms and queries further and includes the
|
|
376
|
+
site sheet; the calculator is at https://stratta.ch/tarifs. Dossiers,
|
|
377
|
+
deliverables and the library are on every plan; `scan_site` and the site tools
|
|
378
|
+
answer `PLAN_REQUIRED` below Max. A tool call weighs a whole number of queries:
|
|
379
|
+
a light read 1, a section or a sheet 2, a search 5, a borehole PDF read 10, a
|
|
380
|
+
site scan 100.
|
|
339
381
|
|
|
340
382
|
Stock limits free up when you delete a norm (`ingest_delete`). The monthly query
|
|
341
383
|
counter resets on its own. Gauges live on the Workspace page of your dashboard,
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The code and the sentence the usage report carries for a failed call, so
|
|
3
|
+
* the admin console can say why (the server bounds and scrubs both again).
|
|
4
|
+
*/
|
|
5
|
+
export declare function digestToolError(err: unknown): {
|
|
6
|
+
code: string;
|
|
7
|
+
message: string;
|
|
8
|
+
};
|
|
1
9
|
/**
|
|
2
10
|
* Turns a server error into something an agent can act on. A quota rejection
|
|
3
11
|
* is not a bug: the agent should stop retrying and tell the user which limit
|
package/dist/errors.js
CHANGED
|
@@ -57,6 +57,38 @@ function withHelp(code, lines) {
|
|
|
57
57
|
const url = HELP[code];
|
|
58
58
|
return (url ? [...lines, `Détails : ${url}`] : lines).join('\n');
|
|
59
59
|
}
|
|
60
|
+
const CODE_RE = /^[A-Z][A-Z0-9_]{1,63}$/;
|
|
61
|
+
const MESSAGE_MAX = 300;
|
|
62
|
+
/**
|
|
63
|
+
* The code and the sentence the usage report carries for a failed call, so
|
|
64
|
+
* the admin console can say why (the server bounds and scrubs both again).
|
|
65
|
+
*/
|
|
66
|
+
export function digestToolError(err) {
|
|
67
|
+
const clamp = (text) => text.replace(/\s+/g, ' ').trim().slice(0, MESSAGE_MAX);
|
|
68
|
+
if (err instanceof ConvexError) {
|
|
69
|
+
const data = err.data;
|
|
70
|
+
if (typeof data === 'string') {
|
|
71
|
+
return {
|
|
72
|
+
code: CODE_RE.test(data) ? data : 'APPLICATION_ERROR',
|
|
73
|
+
message: clamp(data),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (data && typeof data === 'object') {
|
|
77
|
+
const { code, message } = data;
|
|
78
|
+
return {
|
|
79
|
+
code: typeof code === 'string' && CODE_RE.test(code)
|
|
80
|
+
? code
|
|
81
|
+
: 'APPLICATION_ERROR',
|
|
82
|
+
message: clamp(typeof message === 'string' ? message : JSON.stringify(data)),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
87
|
+
return {
|
|
88
|
+
code: CODE_RE.test(message) ? message : 'UNEXPECTED',
|
|
89
|
+
message: clamp(message),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
60
92
|
/**
|
|
61
93
|
* Turns a server error into something an agent can act on. A quota rejection
|
|
62
94
|
* is not a bug: the agent should stop retrying and tell the user which limit
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
5
5
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
6
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
7
7
|
import { createConvexClient, api } from './client.js';
|
|
8
|
-
import { formatToolError } from './errors.js';
|
|
8
|
+
import { digestToolError, formatToolError } from './errors.js';
|
|
9
9
|
import { ensureAuthenticated, resolveApiKey } from './auth.js';
|
|
10
10
|
import { elicitApiKey } from './elicit.js';
|
|
11
11
|
import { confirmWithUser } from './confirm.js';
|
|
@@ -18,6 +18,8 @@ import { SESSION_ID, usageDigest } from './usage.js';
|
|
|
18
18
|
import { SERVER_INSTRUCTIONS } from './tools/catalog.gen.js';
|
|
19
19
|
import { registerResources } from './resources.js';
|
|
20
20
|
import { registerPrompts } from './prompts.js';
|
|
21
|
+
import { TOOLSET_CHOICE } from './mode.js';
|
|
22
|
+
import { toolInChoice } from './tools/toolsets.gen.js';
|
|
21
23
|
/**
|
|
22
24
|
* Read from package.json rather than repeated in a literal. The previous
|
|
23
25
|
* version announced `0.3.0` while the published package was `0.6.0`, so every
|
|
@@ -62,6 +64,7 @@ async function call(def, args) {
|
|
|
62
64
|
let resolvedKey = null;
|
|
63
65
|
let authenticated = false;
|
|
64
66
|
let status = 'success';
|
|
67
|
+
let failure;
|
|
65
68
|
const reportUsage = () => {
|
|
66
69
|
if (!authenticated || !resolvedKey)
|
|
67
70
|
return;
|
|
@@ -73,6 +76,8 @@ async function call(def, args) {
|
|
|
73
76
|
durationMs: Date.now() - startedAt,
|
|
74
77
|
sessionId: SESSION_ID,
|
|
75
78
|
argsDigest: usageDigest(args),
|
|
79
|
+
errorCode: failure?.code,
|
|
80
|
+
errorMessage: failure?.message,
|
|
76
81
|
})
|
|
77
82
|
.catch((e) => console.error('[stratta-mcp] usage report failed:', e));
|
|
78
83
|
};
|
|
@@ -97,6 +102,7 @@ async function call(def, args) {
|
|
|
97
102
|
}
|
|
98
103
|
catch (err) {
|
|
99
104
|
status = 'error';
|
|
105
|
+
failure = digestToolError(err);
|
|
100
106
|
reportUsage();
|
|
101
107
|
return {
|
|
102
108
|
content: [{ type: 'text', text: `Error: ${formatToolError(err)}` }],
|
|
@@ -104,13 +110,15 @@ async function call(def, args) {
|
|
|
104
110
|
};
|
|
105
111
|
}
|
|
106
112
|
}
|
|
113
|
+
// `--simple` or `--toolsets` (ADR 57): an agent that only reads norms is
|
|
114
|
+
// shown ten tools instead of fifty-nine.
|
|
107
115
|
for (const def of [
|
|
108
116
|
...readTools,
|
|
109
117
|
...siteTools,
|
|
110
118
|
...dossierTools,
|
|
111
119
|
...libraryTools,
|
|
112
120
|
...ingestTools,
|
|
113
|
-
]) {
|
|
121
|
+
].filter((d) => toolInChoice(d.name, TOOLSET_CHOICE))) {
|
|
114
122
|
server.registerTool(def.name, {
|
|
115
123
|
title: def.title,
|
|
116
124
|
description: def.description,
|
|
@@ -150,10 +158,17 @@ COMMANDES
|
|
|
150
158
|
--version Version installée
|
|
151
159
|
--help Cet écran
|
|
152
160
|
|
|
161
|
+
OPTIONS DU SERVEUR
|
|
162
|
+
--simple Normes seules : dix outils, démarrage plus léger
|
|
163
|
+
--toolsets dossiers,sites Normes plus les familles choisies
|
|
164
|
+
(dossiers, sites, ingest)
|
|
165
|
+
STRATTA_TOOLSETS=... Même réglage par variable d'environnement
|
|
166
|
+
|
|
153
167
|
Sans commande, le serveur MCP démarre sur stdio : c'est ce que fait votre
|
|
154
168
|
agent, vous n'avez pas à le lancer vous-même.
|
|
155
169
|
|
|
156
170
|
Installation claude mcp add stratta --scope user -- npx -y @stratta/mcp
|
|
171
|
+
Mode simple claude mcp add stratta --scope user -- npx -y @stratta/mcp --simple
|
|
157
172
|
Raccourci npm install -g @stratta/mcp puis stratta doctor
|
|
158
173
|
Documentation https://stratta.ch/docs`);
|
|
159
174
|
}
|
package/dist/mode.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type ToolsetChoice } from './tools/toolsets.gen.js';
|
|
2
|
+
/**
|
|
3
|
+
* Which tool families this server shows (ADR 57).
|
|
4
|
+
*
|
|
5
|
+
* npx -y @stratta/mcp --simple norms only, ten tools
|
|
6
|
+
* npx -y @stratta/mcp --toolsets dossiers norms and dossiers
|
|
7
|
+
* STRATTA_TOOLSETS=sites npx -y @stratta/mcp
|
|
8
|
+
*
|
|
9
|
+
* The flag wins over the variable. Nothing set: every tool, as before 1.17.
|
|
10
|
+
* An unknown word is dropped, never fatal: a server that refuses to start on
|
|
11
|
+
* a typo looks, from the agent, exactly like a server that hangs.
|
|
12
|
+
*/
|
|
13
|
+
export declare function readToolsetChoice(argv: ReadonlyArray<string>, env: Readonly<Record<string, string | undefined>>): ToolsetChoice;
|
|
14
|
+
/** The choice as the server's `get_methodology` reads it; absent for full. */
|
|
15
|
+
export declare function toolsetsParam(choice: ToolsetChoice): string | undefined;
|
|
16
|
+
export declare const TOOLSET_CHOICE: ToolsetChoice;
|
package/dist/mode.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { parseToolsets } from './tools/toolsets.gen.js';
|
|
2
|
+
/**
|
|
3
|
+
* Which tool families this server shows (ADR 57).
|
|
4
|
+
*
|
|
5
|
+
* npx -y @stratta/mcp --simple norms only, ten tools
|
|
6
|
+
* npx -y @stratta/mcp --toolsets dossiers norms and dossiers
|
|
7
|
+
* STRATTA_TOOLSETS=sites npx -y @stratta/mcp
|
|
8
|
+
*
|
|
9
|
+
* The flag wins over the variable. Nothing set: every tool, as before 1.17.
|
|
10
|
+
* An unknown word is dropped, never fatal: a server that refuses to start on
|
|
11
|
+
* a typo looks, from the agent, exactly like a server that hangs.
|
|
12
|
+
*/
|
|
13
|
+
export function readToolsetChoice(argv, env) {
|
|
14
|
+
if (argv.includes('--simple'))
|
|
15
|
+
return parseToolsets('simple');
|
|
16
|
+
if (argv.includes('--full'))
|
|
17
|
+
return null;
|
|
18
|
+
for (let i = 0; i < argv.length; i++) {
|
|
19
|
+
const arg = argv[i];
|
|
20
|
+
if (arg.startsWith('--toolsets=')) {
|
|
21
|
+
return parseToolsets(arg.slice('--toolsets='.length));
|
|
22
|
+
}
|
|
23
|
+
if (arg === '--toolsets')
|
|
24
|
+
return parseToolsets(argv[i + 1]);
|
|
25
|
+
}
|
|
26
|
+
return parseToolsets(env.STRATTA_TOOLSETS);
|
|
27
|
+
}
|
|
28
|
+
/** The choice as the server's `get_methodology` reads it; absent for full. */
|
|
29
|
+
export function toolsetsParam(choice) {
|
|
30
|
+
return choice === null ? undefined : choice.join(',');
|
|
31
|
+
}
|
|
32
|
+
export const TOOLSET_CHOICE = readToolsetChoice(process.argv.slice(2), process.env);
|
package/dist/prompts.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { PROMPTS } from './tools/catalog.gen.js';
|
|
3
|
+
import { choiceHas } from './tools/toolsets.gen.js';
|
|
4
|
+
import { TOOLSET_CHOICE } from './mode.js';
|
|
3
5
|
/**
|
|
4
6
|
* Prompts: the three ways a user starts a conversation with Stratta, as
|
|
5
7
|
* slash commands in the clients that support them (Claude Code, Claude
|
|
@@ -20,6 +22,10 @@ export function renderPrompt(p, args) {
|
|
|
20
22
|
}
|
|
21
23
|
export function registerPrompts(server) {
|
|
22
24
|
for (const p of PROMPTS) {
|
|
25
|
+
// A prompt that drives dossier tools is not offered without them.
|
|
26
|
+
if (p.name === 'resume-dossier' && !choiceHas(TOOLSET_CHOICE, 'dossiers')) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
23
29
|
const shape = {};
|
|
24
30
|
for (const a of p.args) {
|
|
25
31
|
const s = z.string().describe(a.description);
|
package/dist/tools/read.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { TOOLSET_CHOICE, toolsetsParam } from '../mode.js';
|
|
1
2
|
import { z } from 'zod';
|
|
2
3
|
import { api } from '../client.js';
|
|
3
4
|
import { requireApiKey } from '../auth.js';
|
|
@@ -31,7 +32,11 @@ const edition = (tool) => z.string().optional().describe(param(tool, 'edition'))
|
|
|
31
32
|
export const getMethodology = defineTool({
|
|
32
33
|
...headOf('get_methodology'),
|
|
33
34
|
inputSchema: {},
|
|
34
|
-
run: (client) => client.action(api._mcp.getMethodology, {
|
|
35
|
+
run: (client) => client.action(api._mcp.getMethodology, {
|
|
36
|
+
apiKey: requireApiKey(),
|
|
37
|
+
// The dossier policy only when the dossier tools are shown (ADR 57).
|
|
38
|
+
toolsets: toolsetsParam(TOOLSET_CHOICE),
|
|
39
|
+
}),
|
|
35
40
|
// One Markdown document, read at the start of every conversation.
|
|
36
41
|
toContent: (result) => ({
|
|
37
42
|
content: [{ type: 'text', text: result.text }],
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool families: which MCP tools an agent is shown (ADR 57).
|
|
3
|
+
*
|
|
4
|
+
* Fifty-nine tools cost a client that loads every schema up front about
|
|
5
|
+
* 13 500 tokens before the first question, and a long menu makes a model
|
|
6
|
+
* pick worse. Most conversations only read norms. So the catalogue is cut
|
|
7
|
+
* into families, the way GitHub's MCP server cuts its own into toolsets:
|
|
8
|
+
*
|
|
9
|
+
* - `norms` is always there: the method, the account, and the norm reader.
|
|
10
|
+
* - `dossiers` adds the project work: questions, findings, decisions,
|
|
11
|
+
* attachments, templates, deliverables and the office's methods.
|
|
12
|
+
* - `sites` adds the site scan and its readings.
|
|
13
|
+
* - `ingest` adds the ingestion tools (stdio only: they read a local PDF).
|
|
14
|
+
*
|
|
15
|
+
* "Simple" is `norms` alone. "Full" is everything the transport serves, and
|
|
16
|
+
* stays the default: the published ChatGPT plugin is a snapshot of the full
|
|
17
|
+
* list, and changing what a connection sees is the user's choice, never ours.
|
|
18
|
+
*
|
|
19
|
+
* ⚠️ Read by the browser (the connections page) and by the generator of the
|
|
20
|
+
* npm package's catalogue: imports nothing, erasable TypeScript only. The
|
|
21
|
+
* npm package gets a verbatim copy (`toolsets.gen.ts`).
|
|
22
|
+
* `mcpToolsets.test.ts` checks that every catalogue tool has a family.
|
|
23
|
+
*/
|
|
24
|
+
export type Toolset = 'norms' | 'dossiers' | 'sites' | 'ingest';
|
|
25
|
+
export declare const TOOLSETS: ReadonlyArray<Toolset>;
|
|
26
|
+
/** The families a user can turn on or off; `norms` cannot be turned off. */
|
|
27
|
+
export declare const OPTIONAL_TOOLSETS: ReadonlyArray<Toolset>;
|
|
28
|
+
export declare const TOOLSET_OF: Readonly<Record<string, Toolset>>;
|
|
29
|
+
/** A tool with no family is shown only in full mode, never hidden by mistake. */
|
|
30
|
+
export declare function toolsetOf(name: string): Toolset | null;
|
|
31
|
+
/**
|
|
32
|
+
* What a connection was set to. `null` means full: every tool the transport
|
|
33
|
+
* serves, the behaviour of every connection made before families existed.
|
|
34
|
+
*/
|
|
35
|
+
export type ToolsetChoice = ReadonlyArray<Toolset> | null;
|
|
36
|
+
export declare const SIMPLE_TOOLSETS: ReadonlyArray<Toolset>;
|
|
37
|
+
/**
|
|
38
|
+
* Read a choice written by a person or a flag: `simple`, `full`, `all`, or a
|
|
39
|
+
* comma list such as `dossiers,sites`. Unknown words are dropped rather than
|
|
40
|
+
* refused, so a typo degrades to fewer tools, never to a server that does not
|
|
41
|
+
* start. `norms` is always added back.
|
|
42
|
+
*/
|
|
43
|
+
export declare function parseToolsets(raw: string | null | undefined): ToolsetChoice;
|
|
44
|
+
/** Whether a tool is shown under a choice. */
|
|
45
|
+
export declare function toolInChoice(name: string, choice: ToolsetChoice): boolean;
|
|
46
|
+
/** Whether the choice carries a family (a full choice carries all of them). */
|
|
47
|
+
export declare function choiceHas(choice: ToolsetChoice, family: Toolset): boolean;
|
|
48
|
+
/** The stored form: a sorted list, or `undefined` for full. */
|
|
49
|
+
export declare function normalizeChoice(choice: ToolsetChoice): Toolset[] | undefined;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Generated by scripts/build-mcp-catalog.mjs from convex/mcpToolsets.ts.
|
|
2
|
+
// Do not edit: change the source and run the script.
|
|
3
|
+
export const TOOLSETS = [
|
|
4
|
+
'norms',
|
|
5
|
+
'dossiers',
|
|
6
|
+
'sites',
|
|
7
|
+
'ingest',
|
|
8
|
+
];
|
|
9
|
+
/** The families a user can turn on or off; `norms` cannot be turned off. */
|
|
10
|
+
export const OPTIONAL_TOOLSETS = [
|
|
11
|
+
'dossiers',
|
|
12
|
+
'sites',
|
|
13
|
+
'ingest',
|
|
14
|
+
];
|
|
15
|
+
export const TOOLSET_OF = {
|
|
16
|
+
get_methodology: 'norms',
|
|
17
|
+
whoami: 'norms',
|
|
18
|
+
list_norms: 'norms',
|
|
19
|
+
get_toc: 'norms',
|
|
20
|
+
get_subtree: 'norms',
|
|
21
|
+
get_section: 'norms',
|
|
22
|
+
search_in_norm: 'norms',
|
|
23
|
+
search_corpus: 'norms',
|
|
24
|
+
get_cross_refs: 'norms',
|
|
25
|
+
get_figure: 'norms',
|
|
26
|
+
list_dossiers: 'dossiers',
|
|
27
|
+
open_dossier: 'dossiers',
|
|
28
|
+
load_dossier: 'dossiers',
|
|
29
|
+
open_question: 'dossiers',
|
|
30
|
+
save_finding: 'dossiers',
|
|
31
|
+
record_decision: 'dossiers',
|
|
32
|
+
resolve_question: 'dossiers',
|
|
33
|
+
list_attachments: 'dossiers',
|
|
34
|
+
read_attachment: 'dossiers',
|
|
35
|
+
search_in_dossier: 'dossiers',
|
|
36
|
+
add_attachment: 'dossiers',
|
|
37
|
+
list_templates: 'dossiers',
|
|
38
|
+
apply_template: 'dossiers',
|
|
39
|
+
draft_deliverable: 'dossiers',
|
|
40
|
+
write_section: 'dossiers',
|
|
41
|
+
list_skills: 'dossiers',
|
|
42
|
+
get_skill: 'dossiers',
|
|
43
|
+
list_questions: 'dossiers',
|
|
44
|
+
get_question: 'dossiers',
|
|
45
|
+
get_dossier_activity: 'dossiers',
|
|
46
|
+
list_exports: 'dossiers',
|
|
47
|
+
update_question: 'dossiers',
|
|
48
|
+
add_option: 'dossiers',
|
|
49
|
+
update_option: 'dossiers',
|
|
50
|
+
delete_option: 'dossiers',
|
|
51
|
+
delete_question: 'dossiers',
|
|
52
|
+
add_comment: 'dossiers',
|
|
53
|
+
update_entry: 'dossiers',
|
|
54
|
+
attach_entry: 'dossiers',
|
|
55
|
+
scan_site: 'sites',
|
|
56
|
+
get_boreholes: 'sites',
|
|
57
|
+
get_site_context: 'sites',
|
|
58
|
+
list_sites: 'sites',
|
|
59
|
+
get_site: 'sites',
|
|
60
|
+
get_site_section: 'sites',
|
|
61
|
+
list_site_overlays: 'sites',
|
|
62
|
+
get_groundwater_contours: 'sites',
|
|
63
|
+
list_site_photos: 'sites',
|
|
64
|
+
list_site_neighbours: 'sites',
|
|
65
|
+
ingest_status: 'ingest',
|
|
66
|
+
ingest_create_document: 'ingest',
|
|
67
|
+
ingest_create_sections: 'ingest',
|
|
68
|
+
ingest_attach_formula: 'ingest',
|
|
69
|
+
ingest_attach_table: 'ingest',
|
|
70
|
+
ingest_attach_cross_ref: 'ingest',
|
|
71
|
+
ingest_upload_figure: 'ingest',
|
|
72
|
+
ingest_normalize_cross_refs: 'ingest',
|
|
73
|
+
ingest_publish: 'ingest',
|
|
74
|
+
ingest_delete: 'ingest',
|
|
75
|
+
};
|
|
76
|
+
/** A tool with no family is shown only in full mode, never hidden by mistake. */
|
|
77
|
+
export function toolsetOf(name) {
|
|
78
|
+
return TOOLSET_OF[name] ?? null;
|
|
79
|
+
}
|
|
80
|
+
export const SIMPLE_TOOLSETS = ['norms'];
|
|
81
|
+
/**
|
|
82
|
+
* Read a choice written by a person or a flag: `simple`, `full`, `all`, or a
|
|
83
|
+
* comma list such as `dossiers,sites`. Unknown words are dropped rather than
|
|
84
|
+
* refused, so a typo degrades to fewer tools, never to a server that does not
|
|
85
|
+
* start. `norms` is always added back.
|
|
86
|
+
*/
|
|
87
|
+
export function parseToolsets(raw) {
|
|
88
|
+
const text = (raw ?? '').trim().toLowerCase();
|
|
89
|
+
if (!text || text === 'full' || text === 'all')
|
|
90
|
+
return null;
|
|
91
|
+
if (text === 'simple')
|
|
92
|
+
return SIMPLE_TOOLSETS;
|
|
93
|
+
const picked = new Set(['norms']);
|
|
94
|
+
for (const word of text.split(/[\s,]+/)) {
|
|
95
|
+
const alias = {
|
|
96
|
+
norm: 'norms',
|
|
97
|
+
normes: 'norms',
|
|
98
|
+
dossier: 'dossiers',
|
|
99
|
+
deliverables: 'dossiers',
|
|
100
|
+
livrables: 'dossiers',
|
|
101
|
+
site: 'sites',
|
|
102
|
+
ingestion: 'ingest',
|
|
103
|
+
};
|
|
104
|
+
const name = (alias[word] ?? word);
|
|
105
|
+
if (TOOLSETS.includes(name))
|
|
106
|
+
picked.add(name);
|
|
107
|
+
}
|
|
108
|
+
return TOOLSETS.filter((t) => picked.has(t));
|
|
109
|
+
}
|
|
110
|
+
/** Whether a tool is shown under a choice. */
|
|
111
|
+
export function toolInChoice(name, choice) {
|
|
112
|
+
if (choice === null)
|
|
113
|
+
return true;
|
|
114
|
+
const family = toolsetOf(name);
|
|
115
|
+
return family !== null && choice.includes(family);
|
|
116
|
+
}
|
|
117
|
+
/** Whether the choice carries a family (a full choice carries all of them). */
|
|
118
|
+
export function choiceHas(choice, family) {
|
|
119
|
+
return choice === null || choice.includes(family);
|
|
120
|
+
}
|
|
121
|
+
/** The stored form: a sorted list, or `undefined` for full. */
|
|
122
|
+
export function normalizeChoice(choice) {
|
|
123
|
+
if (choice === null)
|
|
124
|
+
return undefined;
|
|
125
|
+
const set = new Set(['norms', ...choice]);
|
|
126
|
+
// Every family turned on is the same as full, and full is how it is stored.
|
|
127
|
+
if (TOOLSETS.every((t) => set.has(t)))
|
|
128
|
+
return undefined;
|
|
129
|
+
return TOOLSETS.filter((t) => set.has(t));
|
|
130
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stratta/mcp",
|
|
3
3
|
"mcpName": "ch.stratta/mcp",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.17.0",
|
|
5
5
|
"description": "MCP server exposing the engineering norms your firm is licensed for (SIA / Eurocodes) to any MCP client, via Stratta TreeRAG.",
|
|
6
6
|
"license": "UNLICENSED",
|
|
7
7
|
"author": "SmartFlow <hello@stratta.ch>",
|
|
@@ -77,6 +77,46 @@ NUMBER_TOKEN_RE = re.compile(r"^(?:\d{1,2}(?:\.\d{1,3}){0,4}\.?|[A-Z](?:\.\d{1,2
|
|
|
77
77
|
OCR_DOTTED_NUMBER_RE = re.compile(r"^(\d+(?:\.\d+)*) \.(\d)")
|
|
78
78
|
|
|
79
79
|
|
|
80
|
+
# Above this share of a page's words, vertical text is content, not a watermark.
|
|
81
|
+
VERTICAL_WATERMARK_SHARE = 0.25
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _vertical_lines(page: "fitz.Page") -> list[tuple[tuple[float, float, float, float], set[str]]]:
|
|
85
|
+
"""Box and words of each line not written left to right.
|
|
86
|
+
|
|
87
|
+
Matched to the words by position and text, never by (block, line)
|
|
88
|
+
numbers: `get_text("dict")` counts image blocks and `get_text("words")`
|
|
89
|
+
does not, so the numbers of one extraction can point at a clause line of
|
|
90
|
+
the other.
|
|
91
|
+
"""
|
|
92
|
+
out: list[tuple[tuple[float, float, float, float], set[str]]] = []
|
|
93
|
+
try:
|
|
94
|
+
blocks = page.get_text("dict").get("blocks", [])
|
|
95
|
+
except Exception: # a page the dict extraction cannot read keeps its words
|
|
96
|
+
return out
|
|
97
|
+
for block in blocks:
|
|
98
|
+
for line in block.get("lines", []):
|
|
99
|
+
dx, dy = line.get("dir", (1.0, 0.0))
|
|
100
|
+
if abs(dy) <= 0.1 and dx >= 0:
|
|
101
|
+
continue
|
|
102
|
+
text = " ".join(span.get("text", "") for span in line.get("spans", []))
|
|
103
|
+
words = {w for w in text.split() if w}
|
|
104
|
+
if words:
|
|
105
|
+
out.append((tuple(line["bbox"]), words))
|
|
106
|
+
return out
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _in_vertical_line(
|
|
110
|
+
word: tuple, vertical: list[tuple[tuple[float, float, float, float], set[str]]]
|
|
111
|
+
) -> bool:
|
|
112
|
+
cx = (word[0] + word[2]) / 2
|
|
113
|
+
cy = (word[1] + word[3]) / 2
|
|
114
|
+
for (x0, y0, x1, y1), texts in vertical:
|
|
115
|
+
if x0 - 1 <= cx <= x1 + 1 and y0 - 1 <= cy <= y1 + 1 and word[4] in texts:
|
|
116
|
+
return True
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
|
|
80
120
|
def _rows(page: "fitz.Page") -> list[list[tuple]]:
|
|
81
121
|
words = page.get_text("words") # x0, y0, x1, y1, text, block, line, word
|
|
82
122
|
if not words:
|
|
@@ -86,7 +126,19 @@ def _rows(page: "fitz.Page") -> list[list[tuple]]:
|
|
|
86
126
|
# A word set vertically (a library watermark such as "Ecole Polytechnique
|
|
87
127
|
# Fédérale de Lausanne" running up the margin) has a box far taller than
|
|
88
128
|
# the text. Left in, its words land on every line they cross.
|
|
129
|
+
# ⚠️ The height alone lets its short words through ("Ecole", "EPFL,",
|
|
130
|
+
# "SNV", "de", "/"): 310 sections of 16 norms carried them (2026-09-30).
|
|
131
|
+
# A vertical line is also told by its writing direction.
|
|
89
132
|
words = [w for w in words if (w[3] - w[1]) <= 2.5 * median]
|
|
133
|
+
# ⚠️ A watermark is a thin minority of a page. When vertical lines carry
|
|
134
|
+
# a large share of the words, the page itself is rotated (a landscape
|
|
135
|
+
# table in SIA 197-1, p. 45) and dropping them would empty it.
|
|
136
|
+
vertical = _vertical_lines(page)
|
|
137
|
+
if vertical:
|
|
138
|
+
flagged = [w for w in words if _in_vertical_line(w, vertical)]
|
|
139
|
+
if len(flagged) <= VERTICAL_WATERMARK_SHARE * len(words):
|
|
140
|
+
dropped = set(map(id, flagged))
|
|
141
|
+
words = [w for w in words if id(w) not in dropped]
|
|
90
142
|
if not words:
|
|
91
143
|
return []
|
|
92
144
|
tol = max(2.0, 0.45 * median)
|
|
@@ -68,4 +68,4 @@ Before drafting a document (project basis, use agreement, terrain chapter, verif
|
|
|
68
68
|
- Do not expose internal identifiers unless the next Stratta tool call requires them.
|
|
69
69
|
- Ingesting a new norm starts from a PDF on the user's own machine: it goes through the local `@stratta/mcp` server and its `ingest-norm` skill, never through the remote connector.
|
|
70
70
|
|
|
71
|
-
methodology:
|
|
71
|
+
methodology: v4
|