@stratta/mcp 0.12.0 → 0.13.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 +89 -64
- package/dist/index.js +7 -1
- package/dist/tools/catalog.gen.js +78 -10
- package/dist/tools/site.d.ts +53 -0
- package/dist/tools/site.js +100 -0
- package/package.json +1 -1
- package/scripts/ingest-prepass.py +82 -0
- package/skills/ingest-norm/SKILL.md +11 -2
package/README.md
CHANGED
|
@@ -9,34 +9,41 @@ mcp-name: ch.stratta/mcp
|
|
|
9
9
|
|
|
10
10
|
# @stratta/mcp
|
|
11
11
|
|
|
12
|
-
MCP server
|
|
12
|
+
The local MCP server for [Stratta](https://stratta.ch): the engineering norms
|
|
13
|
+
your bureau licensed (SIA, Eurocodes), cited to the section and page, inside
|
|
14
|
+
any agent that speaks MCP.
|
|
13
15
|
|
|
14
|
-
> Requires a free Stratta account
|
|
16
|
+
> Requires a free Stratta account: https://stratta.ch/sign-up.
|
|
15
17
|
|
|
16
18
|
> [!IMPORTANT]
|
|
17
19
|
> Your `STRATTA_API_KEY` is a **secret** — it grants read/write access to your
|
|
18
20
|
> Stratta workspace. Never commit it to a repository, paste it into a shared/
|
|
19
21
|
> project-scoped MCP config, or share it in logs. Prefer a user-scoped config or
|
|
20
|
-
> a shell environment variable. If a key leaks, revoke it immediately
|
|
21
|
-
> https://stratta.ch/
|
|
22
|
+
> a shell environment variable. If a key leaks, revoke it immediately under
|
|
23
|
+
> Settings › API keys (https://stratta.ch/settings?tab=keys).
|
|
22
24
|
|
|
23
25
|
## Do you need this package?
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
sign-in, no key
|
|
27
|
+
Usually not. Stratta also runs as a **remote connector** — one address, a
|
|
28
|
+
browser sign-in, no key, no Node:
|
|
27
29
|
|
|
28
30
|
```
|
|
29
31
|
https://stratta.ch/mcp
|
|
30
32
|
```
|
|
31
33
|
|
|
32
|
-
That is the shorter path,
|
|
33
|
-
|
|
34
|
+
That is the shorter path, the one the dashboard walks you through for Claude,
|
|
35
|
+
ChatGPT, Claude Code, Codex, Cursor, VS Code, Gemini CLI and Windsurf, and the
|
|
36
|
+
only one that works in an agent running in the cloud (claude.ai, ChatGPT). It
|
|
37
|
+
serves 35 of the 36 tools below, **ingestion included**: the pre-pass script is
|
|
38
|
+
downloaded from https://stratta.ch/ingest-prepass.py when it is not on disk.
|
|
39
|
+
See https://stratta.ch/docs/en/guides/connect-remote.
|
|
34
40
|
|
|
35
41
|
This package is what you want when:
|
|
36
42
|
|
|
37
|
-
-
|
|
38
|
-
|
|
39
|
-
-
|
|
43
|
+
- your agent **cannot open a browser** — CI, a scheduled task, a server — and
|
|
44
|
+
authenticates with an API key instead;
|
|
45
|
+
- you want `add_attachment`, the one tool the connector does not serve: it
|
|
46
|
+
uploads a file from your disk to a project dossier;
|
|
40
47
|
- you would simply rather run the server yourself.
|
|
41
48
|
|
|
42
49
|
## Install
|
|
@@ -63,7 +70,7 @@ npx -y @stratta/mcp login
|
|
|
63
70
|
```
|
|
64
71
|
|
|
65
72
|
No browser on this machine — remote server, SSH, CI? `login --paste` asks for a
|
|
66
|
-
key from https://stratta.ch/
|
|
73
|
+
key from https://stratta.ch/settings?tab=keys instead, without echoing it.
|
|
67
74
|
|
|
68
75
|
If you skip the step entirely, Claude Code prompts you for a key on the first
|
|
69
76
|
tool call.
|
|
@@ -169,50 +176,67 @@ named `E2E dossier <timestamp>` and does not delete it.
|
|
|
169
176
|
|
|
170
177
|
## Tools exposed
|
|
171
178
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
|
178
|
-
|
|
|
179
|
-
| `
|
|
180
|
-
| `
|
|
181
|
-
| `
|
|
182
|
-
| `
|
|
183
|
-
| `
|
|
184
|
-
| `
|
|
185
|
-
| `
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
|
193
|
-
|
|
|
194
|
-
| `
|
|
195
|
-
| `
|
|
196
|
-
| `
|
|
197
|
-
|
|
179
|
+
36 tools, generated from one catalogue shared with the remote connector (which
|
|
180
|
+
serves the same 33 minus `add_attachment`).
|
|
181
|
+
|
|
182
|
+
**Read** (10 tools — query the norms of your workspace):
|
|
183
|
+
|
|
184
|
+
| Tool | Purpose |
|
|
185
|
+
| ----------------- | --------------------------------------------------------------------------------------- |
|
|
186
|
+
| `get_methodology` | Behavioural contract: persona, workflow, meta-routing hints, answer rules. Call first. |
|
|
187
|
+
| `whoami` | Which organisation, role and plan this connection reads as, and how many norms it sees. |
|
|
188
|
+
| `list_norms` | List all norms published in your workspace (code, year, title, language, coverage). |
|
|
189
|
+
| `get_toc` | Hierarchical TOC for a norm (default `maxDepth=1` = chapters). |
|
|
190
|
+
| `get_subtree` | Drill into a chapter/section subtree (`path` + `maxDepth`). |
|
|
191
|
+
| `get_section` | Full enriched content of a section (formulas, tables, figures, cross-refs). |
|
|
192
|
+
| `search_in_norm` | Keyword search inside a norm. |
|
|
193
|
+
| `search_corpus` | Keyword search across every norm of the workspace, grouped by norm. |
|
|
194
|
+
| `get_figure` | Retrieve a figure inline (base64 ImageContent). |
|
|
195
|
+
| `get_cross_refs` | Outgoing cross-refs from a section to other norms. |
|
|
196
|
+
|
|
197
|
+
**Site** (3 tools — what public Swiss registers know about a plot):
|
|
198
|
+
|
|
199
|
+
| Tool | Purpose |
|
|
200
|
+
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
|
|
201
|
+
| `scan_site` | Collect municipality, parcel, elevation, geology, nearby boreholes, polluted sites, hazards and noise class around a Swiss address. |
|
|
202
|
+
| `get_site_context` | Read what the scan settled, and — separately — what it could not, with the reason. |
|
|
203
|
+
| `get_boreholes` | Read the boreholes nearest the site with their logged strata, SIA 261 ground class, water table, and links to cantonal documents. |
|
|
204
|
+
|
|
205
|
+
**Dossier** (13 tools — keep what was decided on a project):
|
|
206
|
+
|
|
207
|
+
| Tool | Purpose |
|
|
208
|
+
| ------------------- | ------------------------------------------------------------------------------------------------------ |
|
|
209
|
+
| `list_dossiers` | Your organisation's dossiers, most recently touched first, with open-question counts. |
|
|
210
|
+
| `open_dossier` | Open a project's dossier, creating it if needed. Idempotent on the name. |
|
|
211
|
+
| `open_question` | Open one question to settle, with optional named options. Idempotent on the title. |
|
|
212
|
+
| `save_finding` | Record one piece of evidence: a cited article, a retained value and why, an observation. |
|
|
213
|
+
| `record_decision` | Settle a question with a decision the engineer has confirmed, and the retained option. |
|
|
214
|
+
| `load_dossier` | Reload everything: questions with their evidence and decisions, open ones first. |
|
|
215
|
+
| `resolve_question` | Close a question without a decision, or reopen one. The evidence stays. |
|
|
216
|
+
| `list_attachments` | The project attachments of a dossier: site reports, borehole logs, minutes, data sheets. |
|
|
217
|
+
| `read_attachment` | Read an attachment's text as Markdown, page by page. |
|
|
218
|
+
| `search_in_dossier` | Full-text search over a dossier's attachments, with the page of each hit. |
|
|
219
|
+
| `add_attachment` | Upload a file from the user's machine to a dossier (PDF, DOCX, XLSX, images, text). Local server only. |
|
|
220
|
+
| `list_templates` | The checklists the organisation wrote for its types of structure. |
|
|
221
|
+
| `apply_template` | Open a template's questions in a dossier and file the clauses that resolve in the corpus. |
|
|
198
222
|
|
|
199
223
|
A dossier is read, annotated, reviewed and exported from
|
|
200
224
|
[stratta.ch/dossiers](https://stratta.ch/dossiers).
|
|
201
225
|
|
|
202
|
-
**Ingest** (10 tools — add YOUR licensed norms; driven by the bundled `ingest-norm` skill):
|
|
203
|
-
|
|
204
|
-
| Tool | Purpose
|
|
205
|
-
| ----------------------------- |
|
|
206
|
-
| `ingest_status` | Check if a norm already exists in your workspace.
|
|
207
|
-
| `ingest_create_document` | Create a draft norm document.
|
|
208
|
-
| `ingest_create_sections` | Bulk-insert sections (returns `nodeId → sectionId` map).
|
|
209
|
-
| `ingest_attach_formula` | Attach a LaTeX formula to a section.
|
|
210
|
-
| `ingest_attach_table` | Attach a structured table `{headers, rows}` to a section.
|
|
211
|
-
| `ingest_attach_cross_ref` | Attach an explicit cross-ref to another norm.
|
|
212
|
-
| `ingest_upload_figure` | Upload a figure (base64 PNG/JPEG/WebP, ≤ 8 MB) to a section.
|
|
213
|
-
| `ingest_normalize_cross_refs` | Auto-detect and rebuild cross-refs from section content.
|
|
214
|
-
| `ingest_publish` |
|
|
215
|
-
| `ingest_delete` | Delete a document and all its children.
|
|
226
|
+
**Ingest** (10 tools — add YOUR licensed norms; driven by the bundled `ingest-norm` skill; owner or admin role):
|
|
227
|
+
|
|
228
|
+
| Tool | Purpose |
|
|
229
|
+
| ----------------------------- | -------------------------------------------------------------------------------- |
|
|
230
|
+
| `ingest_status` | Check if a norm already exists in your workspace, and its coverage. |
|
|
231
|
+
| `ingest_create_document` | Create a draft norm document. |
|
|
232
|
+
| `ingest_create_sections` | Bulk-insert sections (returns `nodeId → sectionId` map). |
|
|
233
|
+
| `ingest_attach_formula` | Attach a LaTeX formula to a section. |
|
|
234
|
+
| `ingest_attach_table` | Attach a structured table `{headers, rows}` to a section. |
|
|
235
|
+
| `ingest_attach_cross_ref` | Attach an explicit cross-ref to another norm. |
|
|
236
|
+
| `ingest_upload_figure` | Upload a figure (base64 PNG/JPEG/WebP, ≤ 8 MB) to a section. |
|
|
237
|
+
| `ingest_normalize_cross_refs` | Auto-detect and rebuild cross-refs from section content. |
|
|
238
|
+
| `ingest_publish` | Score the document and flip it to published; refuses below 30/100 unless forced. |
|
|
239
|
+
| `ingest_delete` | Delete a document and all its children. |
|
|
216
240
|
|
|
217
241
|
## How agents should use it
|
|
218
242
|
|
|
@@ -254,7 +278,7 @@ Requires Python ≥ 3.10 with PyMuPDF (`python -m pip install --user pymupdf`).
|
|
|
254
278
|
|
|
255
279
|
### `Authentication failed` / `Invalid API key`
|
|
256
280
|
|
|
257
|
-
- Verify the key starts with `sk_strt_` and is not revoked at https://stratta.ch/
|
|
281
|
+
- Verify the key starts with `sk_strt_` and is not revoked at https://stratta.ch/settings?tab=keys.
|
|
258
282
|
- Check the env var is reaching the process: `echo $STRATTA_API_KEY` (or `$env:STRATTA_API_KEY` on Windows PowerShell).
|
|
259
283
|
- If you copied from the UI, make sure no leading/trailing whitespace was added.
|
|
260
284
|
|
|
@@ -263,7 +287,7 @@ Requires Python ≥ 3.10 with PyMuPDF (`python -m pip install --user pymupdf`).
|
|
|
263
287
|
- Confirm outbound HTTPS to `*.convex.cloud` is allowed by your firewall/VPN.
|
|
264
288
|
- Try `curl -I https://stratta.ch` to verify general internet reachability.
|
|
265
289
|
|
|
266
|
-
### Tools don't appear in
|
|
290
|
+
### Tools don't appear in your agent
|
|
267
291
|
|
|
268
292
|
- Restart your Claude client after editing the config.
|
|
269
293
|
- Check the MCP server logs (Claude Code: `claude mcp logs stratta`; Claude Desktop: `~/Library/Logs/Claude/mcp-server-stratta.log` on macOS).
|
|
@@ -278,16 +302,17 @@ Requires Python ≥ 3.10 with PyMuPDF (`python -m pip install --user pymupdf`).
|
|
|
278
302
|
Your organization reached one of its limits. The error names the dimension, your
|
|
279
303
|
current count and the plan limit. Retrying will fail identically.
|
|
280
304
|
|
|
281
|
-
| Limit | Free
|
|
282
|
-
| --------------- |
|
|
283
|
-
| Norms | 1
|
|
284
|
-
| Sections |
|
|
285
|
-
| Figures | 60
|
|
286
|
-
| Queries / month | 500
|
|
287
|
-
| Members | 1
|
|
288
|
-
|
|
289
|
-
Beyond
|
|
290
|
-
|
|
305
|
+
| Limit | Free | Pro | Max | Team |
|
|
306
|
+
| --------------- | ----- | ------ | ------ | ------ |
|
|
307
|
+
| Norms | 1 | 15 | 60 | 75 |
|
|
308
|
+
| Sections | 1,000 | 15,000 | 60,000 | 75,000 |
|
|
309
|
+
| Figures | 60 | 750 | 3,000 | 3,750 |
|
|
310
|
+
| Queries / month | 500 | 15,000 | 60,000 | 75,000 |
|
|
311
|
+
| Members | 1 | 1 | 1 | 5 |
|
|
312
|
+
|
|
313
|
+
Beyond the included queries, paid plans bill the overage per thousand. An
|
|
314
|
+
Enterprise contract scales seats, norms and queries further; the calculator is
|
|
315
|
+
at https://stratta.ch/tarifs
|
|
291
316
|
|
|
292
317
|
Stock limits free up when you delete a norm (`ingest_delete`). The monthly query
|
|
293
318
|
counter resets on its own. Gauges live on the Workspace page of your dashboard,
|
package/dist/index.js
CHANGED
|
@@ -12,6 +12,7 @@ import { confirmWithUser } from './confirm.js';
|
|
|
12
12
|
import { readTools } from './tools/read.js';
|
|
13
13
|
import { ingestTools } from './tools/ingest.js';
|
|
14
14
|
import { dossierTools } from './tools/dossier.js';
|
|
15
|
+
import { siteTools } from './tools/site.js';
|
|
15
16
|
import { SESSION_ID, usageDigest } from './usage.js';
|
|
16
17
|
import { SERVER_INSTRUCTIONS } from './tools/catalog.gen.js';
|
|
17
18
|
import { registerResources } from './resources.js';
|
|
@@ -102,7 +103,12 @@ async function call(def, args) {
|
|
|
102
103
|
};
|
|
103
104
|
}
|
|
104
105
|
}
|
|
105
|
-
for (const def of [
|
|
106
|
+
for (const def of [
|
|
107
|
+
...readTools,
|
|
108
|
+
...siteTools,
|
|
109
|
+
...dossierTools,
|
|
110
|
+
...ingestTools,
|
|
111
|
+
]) {
|
|
106
112
|
server.registerTool(def.name, {
|
|
107
113
|
title: def.title,
|
|
108
114
|
description: def.description,
|
|
@@ -194,6 +194,64 @@ export const CATALOG = [
|
|
|
194
194
|
"figureId": "Figure identifier from the figures array of get_section."
|
|
195
195
|
}
|
|
196
196
|
},
|
|
197
|
+
{
|
|
198
|
+
"name": "scan_site",
|
|
199
|
+
"title": "Scan a site",
|
|
200
|
+
"description": "Collect what public Swiss registers know about a building site: municipality, parcel, ground elevation, geology, nearby boreholes with their strata, polluted sites, groundwater protection, natural hazards, noise sensitivity. Give a Swiss address; it is geocoded server-side against swisstopo, so do not pass coordinates you inferred. Open the dossier first with open_dossier: one site per dossier. The scan runs in the background and takes a few seconds; read the result with get_site_context. It is a survey of public registers, never a geotechnical study.",
|
|
201
|
+
"annotations": {
|
|
202
|
+
"readOnlyHint": false,
|
|
203
|
+
"destructiveHint": false,
|
|
204
|
+
"idempotentHint": false,
|
|
205
|
+
"openWorldHint": true
|
|
206
|
+
},
|
|
207
|
+
"transports": [
|
|
208
|
+
"stdio",
|
|
209
|
+
"remote"
|
|
210
|
+
],
|
|
211
|
+
"params": {
|
|
212
|
+
"dossierId": "Dossier id returned by open_dossier or list_dossiers.",
|
|
213
|
+
"address": "The street address in Switzerland, as the user wrote it: \"Av. de Rhodanie 58, Lausanne\".",
|
|
214
|
+
"radius": "How far around the site to look for boreholes and constraints, in metres (50 to 500, default 300)."
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
"name": "get_boreholes",
|
|
219
|
+
"title": "Read the boreholes of a site",
|
|
220
|
+
"description": "Read the boreholes a site scan found, nearest first, with their logged strata (depth interval, description, period), depth, year, SIA 261 ground class when logged, water table when logged, and links to the cantonal documents. Call it after get_site_context when the question turns on what the ground is actually made of. Each borehole is one observation at one point: quote it with its distance, never interpolate between two of them, and never derive a design value from a layer description.",
|
|
221
|
+
"annotations": {
|
|
222
|
+
"readOnlyHint": true,
|
|
223
|
+
"destructiveHint": false,
|
|
224
|
+
"idempotentHint": true,
|
|
225
|
+
"openWorldHint": false
|
|
226
|
+
},
|
|
227
|
+
"transports": [
|
|
228
|
+
"stdio",
|
|
229
|
+
"remote"
|
|
230
|
+
],
|
|
231
|
+
"params": {
|
|
232
|
+
"siteId": "Site id returned by scan_site.",
|
|
233
|
+
"limit": "How many boreholes to return, nearest first (1 to 40, default 8). Ask for more only when the nearest ones did not answer.",
|
|
234
|
+
"withStrataOnly": "Return only the boreholes whose strata were read. Use it when the layers are the point; the count of what was left out still travels in `total`."
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
"name": "get_site_context",
|
|
239
|
+
"title": "Read a site scan",
|
|
240
|
+
"description": "Read what a site scan found, grouped by topic, each value with its source and the date THE DATA was current. Returns two separate lists: what the registers settled, and what they could NOT settle, with the reason. Report the second list to the user in full: it is what a bid has to budget for. Never derive a design value (friction angle, bearing capacity, modulus) from a geological description returned here; state what the register says and that a local investigation is required.",
|
|
241
|
+
"annotations": {
|
|
242
|
+
"readOnlyHint": true,
|
|
243
|
+
"destructiveHint": false,
|
|
244
|
+
"idempotentHint": true,
|
|
245
|
+
"openWorldHint": false
|
|
246
|
+
},
|
|
247
|
+
"transports": [
|
|
248
|
+
"stdio",
|
|
249
|
+
"remote"
|
|
250
|
+
],
|
|
251
|
+
"params": {
|
|
252
|
+
"siteId": "Site id returned by scan_site."
|
|
253
|
+
}
|
|
254
|
+
},
|
|
197
255
|
{
|
|
198
256
|
"name": "list_dossiers",
|
|
199
257
|
"title": "Project dossiers",
|
|
@@ -470,7 +528,8 @@ export const CATALOG = [
|
|
|
470
528
|
"openWorldHint": false
|
|
471
529
|
},
|
|
472
530
|
"transports": [
|
|
473
|
-
"stdio"
|
|
531
|
+
"stdio",
|
|
532
|
+
"remote"
|
|
474
533
|
],
|
|
475
534
|
"params": {
|
|
476
535
|
"code": "Norm code, e.g. \"SIA 261\"."
|
|
@@ -487,7 +546,8 @@ export const CATALOG = [
|
|
|
487
546
|
"openWorldHint": false
|
|
488
547
|
},
|
|
489
548
|
"transports": [
|
|
490
|
-
"stdio"
|
|
549
|
+
"stdio",
|
|
550
|
+
"remote"
|
|
491
551
|
],
|
|
492
552
|
"params": {
|
|
493
553
|
"code": "Norm code as printed on the cover, e.g. \"SIA 118/262\".",
|
|
@@ -510,7 +570,8 @@ export const CATALOG = [
|
|
|
510
570
|
"openWorldHint": false
|
|
511
571
|
},
|
|
512
572
|
"transports": [
|
|
513
|
-
"stdio"
|
|
573
|
+
"stdio",
|
|
574
|
+
"remote"
|
|
514
575
|
],
|
|
515
576
|
"params": {
|
|
516
577
|
"documentId": "Document id from ingest_create_document.",
|
|
@@ -528,7 +589,8 @@ export const CATALOG = [
|
|
|
528
589
|
"openWorldHint": false
|
|
529
590
|
},
|
|
530
591
|
"transports": [
|
|
531
|
-
"stdio"
|
|
592
|
+
"stdio",
|
|
593
|
+
"remote"
|
|
532
594
|
],
|
|
533
595
|
"params": {
|
|
534
596
|
"sectionId": "Section id from ingest_create_sections.",
|
|
@@ -548,7 +610,8 @@ export const CATALOG = [
|
|
|
548
610
|
"openWorldHint": false
|
|
549
611
|
},
|
|
550
612
|
"transports": [
|
|
551
|
-
"stdio"
|
|
613
|
+
"stdio",
|
|
614
|
+
"remote"
|
|
552
615
|
],
|
|
553
616
|
"params": {
|
|
554
617
|
"sectionId": "Section id from ingest_create_sections.",
|
|
@@ -568,7 +631,8 @@ export const CATALOG = [
|
|
|
568
631
|
"openWorldHint": false
|
|
569
632
|
},
|
|
570
633
|
"transports": [
|
|
571
|
-
"stdio"
|
|
634
|
+
"stdio",
|
|
635
|
+
"remote"
|
|
572
636
|
],
|
|
573
637
|
"params": {
|
|
574
638
|
"sourceSectionId": "Section id the reference is made from.",
|
|
@@ -589,7 +653,8 @@ export const CATALOG = [
|
|
|
589
653
|
"openWorldHint": false
|
|
590
654
|
},
|
|
591
655
|
"transports": [
|
|
592
|
-
"stdio"
|
|
656
|
+
"stdio",
|
|
657
|
+
"remote"
|
|
593
658
|
],
|
|
594
659
|
"params": {
|
|
595
660
|
"sectionId": "Section id from ingest_create_sections.",
|
|
@@ -610,7 +675,8 @@ export const CATALOG = [
|
|
|
610
675
|
"openWorldHint": false
|
|
611
676
|
},
|
|
612
677
|
"transports": [
|
|
613
|
-
"stdio"
|
|
678
|
+
"stdio",
|
|
679
|
+
"remote"
|
|
614
680
|
],
|
|
615
681
|
"params": {
|
|
616
682
|
"documentId": "Document id from ingest_create_document."
|
|
@@ -627,7 +693,8 @@ export const CATALOG = [
|
|
|
627
693
|
"openWorldHint": false
|
|
628
694
|
},
|
|
629
695
|
"transports": [
|
|
630
|
-
"stdio"
|
|
696
|
+
"stdio",
|
|
697
|
+
"remote"
|
|
631
698
|
],
|
|
632
699
|
"params": {
|
|
633
700
|
"documentId": "Document id from ingest_create_document.",
|
|
@@ -645,7 +712,8 @@ export const CATALOG = [
|
|
|
645
712
|
"openWorldHint": false
|
|
646
713
|
},
|
|
647
714
|
"transports": [
|
|
648
|
-
"stdio"
|
|
715
|
+
"stdio",
|
|
716
|
+
"remote"
|
|
649
717
|
],
|
|
650
718
|
"params": {
|
|
651
719
|
"documentId": "Document id from ingest_create_document."
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const scanSite: import("./define.js").ToolDef<{
|
|
3
|
+
dossierId: z.ZodString;
|
|
4
|
+
address: z.ZodString;
|
|
5
|
+
/**
|
|
6
|
+
* ⚠️ Bounded here as well as on the server. The server clamps rather than
|
|
7
|
+
* refusing, so a model asking for five kilometres would silently get five
|
|
8
|
+
* hundred metres and describe a radius it did not receive.
|
|
9
|
+
*/
|
|
10
|
+
radius: z.ZodOptional<z.ZodNumber>;
|
|
11
|
+
}>;
|
|
12
|
+
export declare const getSiteContext: import("./define.js").ToolDef<{
|
|
13
|
+
siteId: z.ZodString;
|
|
14
|
+
}>;
|
|
15
|
+
/**
|
|
16
|
+
* The boreholes, with their logs.
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ A separate call rather than a bigger `get_site_context`: a Geneva radius
|
|
19
|
+
* holds a hundred and eight boreholes, and pouring their logs into every read
|
|
20
|
+
* of the fiche would make the cheap call expensive for every question that
|
|
21
|
+
* never looks at a log.
|
|
22
|
+
*/
|
|
23
|
+
export declare const getBoreholes: import("./define.js").ToolDef<{
|
|
24
|
+
siteId: z.ZodString;
|
|
25
|
+
/**
|
|
26
|
+
* ⚠️ Bounded here as well as on the server. The server clamps rather than
|
|
27
|
+
* refusing, so a model asking for two hundred would silently get forty and
|
|
28
|
+
* describe a list it did not receive.
|
|
29
|
+
*/
|
|
30
|
+
limit: z.ZodOptional<z.ZodNumber>;
|
|
31
|
+
withStrataOnly: z.ZodOptional<z.ZodBoolean>;
|
|
32
|
+
}>;
|
|
33
|
+
export declare const siteTools: (import("./define.js").ToolDef<{
|
|
34
|
+
dossierId: z.ZodString;
|
|
35
|
+
address: z.ZodString;
|
|
36
|
+
/**
|
|
37
|
+
* ⚠️ Bounded here as well as on the server. The server clamps rather than
|
|
38
|
+
* refusing, so a model asking for five kilometres would silently get five
|
|
39
|
+
* hundred metres and describe a radius it did not receive.
|
|
40
|
+
*/
|
|
41
|
+
radius: z.ZodOptional<z.ZodNumber>;
|
|
42
|
+
}> | import("./define.js").ToolDef<{
|
|
43
|
+
siteId: z.ZodString;
|
|
44
|
+
}> | import("./define.js").ToolDef<{
|
|
45
|
+
siteId: z.ZodString;
|
|
46
|
+
/**
|
|
47
|
+
* ⚠️ Bounded here as well as on the server. The server clamps rather than
|
|
48
|
+
* refusing, so a model asking for two hundred would silently get forty and
|
|
49
|
+
* describe a list it did not receive.
|
|
50
|
+
*/
|
|
51
|
+
limit: z.ZodOptional<z.ZodNumber>;
|
|
52
|
+
withStrataOnly: z.ZodOptional<z.ZodBoolean>;
|
|
53
|
+
}>)[];
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { api } from '../client.js';
|
|
3
|
+
import { requireApiKey } from '../auth.js';
|
|
4
|
+
import { catalogEntry, param } from './catalog.gen.js';
|
|
5
|
+
import { defineTool } from './define.js';
|
|
6
|
+
/**
|
|
7
|
+
* The three site tools: what public Swiss registers know about a plot.
|
|
8
|
+
*
|
|
9
|
+
* `scan_site` starts the collection, `get_site_context` reads what came back.
|
|
10
|
+
* They are split because the scan queries a dozen federal and cantonal
|
|
11
|
+
* services and takes a few seconds; a single blocking tool would either time
|
|
12
|
+
* out or return half a fiche.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ `get_site_context` returns what could NOT be established as a separate,
|
|
15
|
+
* first-class list. That list is the point: "no borehole within three hundred
|
|
16
|
+
* metres, molasse likely but unconfirmed, budget for three drillings" is the
|
|
17
|
+
* sentence worth money when pricing a bid, and an agent that only reads the
|
|
18
|
+
* settled facts will present the fiche as complete.
|
|
19
|
+
*/
|
|
20
|
+
const headOf = (name) => {
|
|
21
|
+
const entry = catalogEntry(name);
|
|
22
|
+
return {
|
|
23
|
+
name,
|
|
24
|
+
title: entry.title,
|
|
25
|
+
description: entry.description,
|
|
26
|
+
annotations: entry.annotations,
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
export const scanSite = defineTool({
|
|
30
|
+
...headOf('scan_site'),
|
|
31
|
+
inputSchema: {
|
|
32
|
+
dossierId: z.string().min(1).describe(param('scan_site', 'dossierId')),
|
|
33
|
+
address: z.string().min(3).max(200).describe(param('scan_site', 'address')),
|
|
34
|
+
/**
|
|
35
|
+
* ⚠️ Bounded here as well as on the server. The server clamps rather than
|
|
36
|
+
* refusing, so a model asking for five kilometres would silently get five
|
|
37
|
+
* hundred metres and describe a radius it did not receive.
|
|
38
|
+
*/
|
|
39
|
+
radius: z
|
|
40
|
+
.number()
|
|
41
|
+
.int()
|
|
42
|
+
.min(50)
|
|
43
|
+
.max(500)
|
|
44
|
+
.optional()
|
|
45
|
+
.describe(param('scan_site', 'radius')),
|
|
46
|
+
},
|
|
47
|
+
run: (client, args) => client.action(api.sitesApi.scanSite, {
|
|
48
|
+
apiKey: requireApiKey(),
|
|
49
|
+
dossierId: args.dossierId,
|
|
50
|
+
address: args.address,
|
|
51
|
+
radius: args.radius,
|
|
52
|
+
}),
|
|
53
|
+
});
|
|
54
|
+
export const getSiteContext = defineTool({
|
|
55
|
+
...headOf('get_site_context'),
|
|
56
|
+
inputSchema: {
|
|
57
|
+
siteId: z.string().min(1).describe(param('get_site_context', 'siteId')),
|
|
58
|
+
},
|
|
59
|
+
run: (client, args) => client.action(api.sitesApi.getSiteContext, {
|
|
60
|
+
apiKey: requireApiKey(),
|
|
61
|
+
siteId: args.siteId,
|
|
62
|
+
}),
|
|
63
|
+
});
|
|
64
|
+
/**
|
|
65
|
+
* The boreholes, with their logs.
|
|
66
|
+
*
|
|
67
|
+
* ⚠️ A separate call rather than a bigger `get_site_context`: a Geneva radius
|
|
68
|
+
* holds a hundred and eight boreholes, and pouring their logs into every read
|
|
69
|
+
* of the fiche would make the cheap call expensive for every question that
|
|
70
|
+
* never looks at a log.
|
|
71
|
+
*/
|
|
72
|
+
export const getBoreholes = defineTool({
|
|
73
|
+
...headOf('get_boreholes'),
|
|
74
|
+
inputSchema: {
|
|
75
|
+
siteId: z.string().min(1).describe(param('get_boreholes', 'siteId')),
|
|
76
|
+
/**
|
|
77
|
+
* ⚠️ Bounded here as well as on the server. The server clamps rather than
|
|
78
|
+
* refusing, so a model asking for two hundred would silently get forty and
|
|
79
|
+
* describe a list it did not receive.
|
|
80
|
+
*/
|
|
81
|
+
limit: z
|
|
82
|
+
.number()
|
|
83
|
+
.int()
|
|
84
|
+
.min(1)
|
|
85
|
+
.max(40)
|
|
86
|
+
.optional()
|
|
87
|
+
.describe(param('get_boreholes', 'limit')),
|
|
88
|
+
withStrataOnly: z
|
|
89
|
+
.boolean()
|
|
90
|
+
.optional()
|
|
91
|
+
.describe(param('get_boreholes', 'withStrataOnly')),
|
|
92
|
+
},
|
|
93
|
+
run: (client, args) => client.action(api.sitesApi.getBoreholes, {
|
|
94
|
+
apiKey: requireApiKey(),
|
|
95
|
+
siteId: args.siteId,
|
|
96
|
+
limit: args.limit,
|
|
97
|
+
withStrataOnly: args.withStrataOnly,
|
|
98
|
+
}),
|
|
99
|
+
});
|
|
100
|
+
export const siteTools = [scanSite, getSiteContext, getBoreholes];
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stratta/mcp",
|
|
3
3
|
"mcpName": "ch.stratta/mcp",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.13.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>",
|
|
@@ -412,6 +412,14 @@ def extract_chapters(
|
|
|
412
412
|
per_page[meta["pageStart"]] += 1
|
|
413
413
|
return {p - 1 for p, n in per_page.items() if n >= 3}
|
|
414
414
|
|
|
415
|
+
# A règlement (SIA 144) is cut in articles, "Art. 7 Titre", a Eurocode
|
|
416
|
+
# in "Section 7 Titre". The passes below would take table rows for
|
|
417
|
+
# chapters and lose the whole text outside any section; a labelled
|
|
418
|
+
# layout is unambiguous, so it is read first and wins outright.
|
|
419
|
+
labelled = infer_labelled_chapters(doc, skip)
|
|
420
|
+
if len(labelled) >= 3:
|
|
421
|
+
return recover_missing_chapters(doc, prune_chapters(labelled), skip)
|
|
422
|
+
|
|
415
423
|
for pattern in (CHAP_UPPER_RE, CHAP_MIXED_RE):
|
|
416
424
|
chapters = scan(pattern, skip)
|
|
417
425
|
extra = toc_like_pages(chapters)
|
|
@@ -422,10 +430,77 @@ def extract_chapters(
|
|
|
422
430
|
|
|
423
431
|
for num, meta in infer_unnumbered_chapters(doc, skip).items():
|
|
424
432
|
chapters.setdefault(num, meta)
|
|
433
|
+
|
|
425
434
|
chapters = prune_chapters(chapters)
|
|
426
435
|
return recover_missing_chapters(doc, chapters, skip)
|
|
427
436
|
|
|
428
437
|
|
|
438
|
+
LABEL_RE = re.compile(
|
|
439
|
+
r"^(Art\.?|Section|Chapitre|Kapitel|Abschnitt)\s*(\d{1,3})\b[\s:_.\u2013-]*(.*?)(?:\s+(\d{1,3}))?$"
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def infer_labelled_chapters(doc: "fitz.Document", skip: set[int]) -> dict[str, dict]:
|
|
444
|
+
"""Chapters from labelled headings: "Art. 7 Titre" (a règlement, SIA 144)
|
|
445
|
+
or "Section 7 Titre" (a Eurocode adopted as SIA 267.001).
|
|
446
|
+
|
|
447
|
+
Two sources, because neither is complete on its own:
|
|
448
|
+
|
|
449
|
+
- the **contents page** (three or more labelled lines on one page) gives
|
|
450
|
+
clean titles, and for a règlement the printed page number
|
|
451
|
+
("Art. 1 But et modalités de l'appel d'offres 6");
|
|
452
|
+
- the **body** gives where each heading really starts. In a règlement the
|
|
453
|
+
OCR merges the margin column with the text ("Art. 1 1.1 L'enjeu…"), so
|
|
454
|
+
the body knows the page but not the title; in a Eurocode the body line
|
|
455
|
+
is "Section 2 _ Bases du calcul geotechnique", usable but often without
|
|
456
|
+
its accents.
|
|
457
|
+
|
|
458
|
+
A number seen in both takes its title from the contents and its page from
|
|
459
|
+
the body. A number seen only in the contents takes the printed page plus
|
|
460
|
+
the offset the pairs establish; only in the body, the body's title.
|
|
461
|
+
"""
|
|
462
|
+
contents: dict[str, tuple[str, int | None]] = {}
|
|
463
|
+
body: dict[str, tuple[str, int]] = {}
|
|
464
|
+
for pageno in range(doc.page_count):
|
|
465
|
+
if pageno in skip:
|
|
466
|
+
continue
|
|
467
|
+
hits: list[tuple[str, str, int | None]] = []
|
|
468
|
+
for line in (l.strip() for l in page_lines(doc, pageno)):
|
|
469
|
+
m = LABEL_RE.match(line)
|
|
470
|
+
if not m:
|
|
471
|
+
continue
|
|
472
|
+
title = clean(m.group(3))
|
|
473
|
+
printed = int(m.group(4)) if m.group(4) else None
|
|
474
|
+
hits.append((m.group(2), title, printed))
|
|
475
|
+
if len({h[0] for h in hits}) >= 3:
|
|
476
|
+
for num, title, printed in hits:
|
|
477
|
+
if title and plausible_title(title):
|
|
478
|
+
contents.setdefault(num, (title, printed))
|
|
479
|
+
else:
|
|
480
|
+
for num, title, printed in hits:
|
|
481
|
+
body.setdefault(num, (title, pageno + 1))
|
|
482
|
+
|
|
483
|
+
offsets = sorted(
|
|
484
|
+
body[num][1] - printed
|
|
485
|
+
for num, (_t, printed) in contents.items()
|
|
486
|
+
if printed is not None and num in body and body[num][1] > printed
|
|
487
|
+
)
|
|
488
|
+
offset = offsets[len(offsets) // 2] if offsets else None
|
|
489
|
+
|
|
490
|
+
found: dict[str, dict] = {}
|
|
491
|
+
for num in sorted(set(contents) | set(body), key=int):
|
|
492
|
+
c_title, printed = contents.get(num, ("", None))
|
|
493
|
+
b_title, b_page = body.get(num, ("", None))
|
|
494
|
+
title = c_title or b_title
|
|
495
|
+
page = b_page
|
|
496
|
+
if page is None and printed is not None and offset is not None:
|
|
497
|
+
page = printed + offset
|
|
498
|
+
if not title or page is None or not (1 <= page <= doc.page_count):
|
|
499
|
+
continue
|
|
500
|
+
found[num] = {"title": title, "pageStart": page}
|
|
501
|
+
return found
|
|
502
|
+
|
|
503
|
+
|
|
429
504
|
SCOPE_TITLE_RE = re.compile(
|
|
430
505
|
r"^(DOMAINE D.APPLICATION|GELTUNGSBEREICH|CAMPO D.APPLICAZIONE|SCOPE)\b", re.I
|
|
431
506
|
)
|
|
@@ -721,6 +796,13 @@ def locate_heading(lines: list[str], node: dict) -> int:
|
|
|
721
796
|
if line.strip().lower().startswith(needle):
|
|
722
797
|
return i
|
|
723
798
|
return -1
|
|
799
|
+
if path.isdigit():
|
|
800
|
+
labelled = re.compile(
|
|
801
|
+
r"^(?:Art\.?|Section|Chapitre|Kapitel|Abschnitt)\s*" + path + r"\b"
|
|
802
|
+
)
|
|
803
|
+
for i, line in enumerate(lines):
|
|
804
|
+
if labelled.match(line.strip()):
|
|
805
|
+
return i
|
|
724
806
|
prefix = path + " "
|
|
725
807
|
for i, line in enumerate(lines):
|
|
726
808
|
s = line.strip()
|
|
@@ -17,8 +17,10 @@ writes go through the Stratta MCP `ingest_*` tools, scoped to your org.
|
|
|
17
17
|
|
|
18
18
|
## Prerequisites
|
|
19
19
|
|
|
20
|
-
-
|
|
21
|
-
(
|
|
20
|
+
- Stratta is connected, either way: the remote connector
|
|
21
|
+
(`https://stratta.ch/mcp`, authorised in the browser, no key) or the local
|
|
22
|
+
package with a key (`npx -y @stratta/mcp login`). Both serve the same
|
|
23
|
+
`ingest_*` tools.
|
|
22
24
|
- **Python ≥ 3.10 with PyMuPDF**. Install once:
|
|
23
25
|
`python -m pip install --user pymupdf` (or `uv pip install pymupdf`).
|
|
24
26
|
- The norm PDF is available locally.
|
|
@@ -57,6 +59,13 @@ node -e "console.log(require.resolve('@stratta/mcp/package.json'))"
|
|
|
57
59
|
If the user is working in the Stratta monorepo, the script also lives at
|
|
58
60
|
`packages/mcp/scripts/ingest-prepass.py`.
|
|
59
61
|
|
|
62
|
+
If neither is on disk (the remote connector installs nothing), download the
|
|
63
|
+
same script from the site and keep it next to the PDF:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
curl -fsSLO https://stratta.ch/ingest-prepass.py
|
|
67
|
+
```
|
|
68
|
+
|
|
60
69
|
### 2. Check for an existing copy
|
|
61
70
|
|
|
62
71
|
`ingest_status { code }` (e.g. `"SIA 261"`). To re-ingest, call
|