@stratta/mcp 1.16.0 → 1.16.1

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
@@ -209,42 +209,57 @@ serves the same 58, everything minus `add_attachment`).
209
209
  | `list_site_photos` | The photos uploaded on the sheet, with position, direction, instant and note. No image, no link. |
210
210
  | `list_site_neighbours` | The organisation's other sheets within a radius, with the distance and the geological unit their scan established. |
211
211
 
212
- **Dossier** (25 tools — keep what was decided on a project, and everything the dossier page can do to a question):
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
- | `list_skills` | The procedures the agent follows for a repeatable task: the ones Stratta ships and the ones the office wrote. |
232
- | `get_skill` | Read one procedure in full; the office version wins over the Stratta one of the same name. |
233
- | `list_questions` | The questions of a dossier, paginated, with status, assignee, due date, options and decision title. |
234
- | `get_question` | One question in full: options, decision, evidence with every citation field, comments with authors. |
235
- | `get_dossier_activity` | The history of a dossier, most recent first, paginated: who did what, from the app or an agent. |
236
- | `list_exports` | The verification notes and journals exported from a dossier, with hash and trusted timestamp. |
237
- | `update_question` | Reword a question, assign it by e-mail address, set or clear its due date. |
238
- | `add_option` | Add one way of settling a question. Idempotent on the name. |
239
- | `update_option` | Rename or describe an option, or mark it retained (the others are released). |
240
- | `delete_option` | Remove an option; its evidence stays on the question. Asks the user first. |
241
- | `delete_question` | Delete a question opened by mistake; its evidence stays, unfiled. Asks the user first. |
242
- | `add_comment` | Leave a signed remark on a dossier, a question or an entry. |
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. |
212
+ **Dossier** (27 tools — what a project reads, retains and produces: questions, evidence, decisions, attachments, templates, and the deliverable written section by section):
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
+ | `list_questions` | The questions of a dossier, paginated, with status, assignee, due date, options and decision title. |
232
+ | `get_question` | One question in full: options, decision, evidence with every citation field, comments with authors. |
233
+ | `get_dossier_activity` | The history of a dossier, most recent first, paginated: who did what, from the app or an agent. |
234
+ | `list_exports` | The verification notes and journals exported from a dossier, with hash and trusted timestamp. |
235
+ | `update_question` | Reword a question, assign it by e-mail address, set or clear its due date. |
236
+ | `add_option` | Add one way of settling a question. Idempotent on the name. |
237
+ | `update_option` | Rename or describe an option, or mark it retained (the others are released). |
238
+ | `delete_option` | Remove an option; its evidence stays on the question. Asks the user first. |
239
+ | `delete_question` | Delete a question opened by mistake; its evidence stays, unfiled. Asks the user first. |
240
+ | `add_comment` | Leave a signed remark on a dossier, a question or an entry. |
241
+ | `update_entry` | Correct an entry's wording, value, confidence or citation in place. |
242
+ | `attach_entry` | File an entry under a question and optionally an option, or unfile it. |
245
243
 
246
244
  A dossier is read, annotated, reviewed and exported from
247
- [stratta.ch/dossiers](https://stratta.ch/dossiers).
245
+ [stratta.ch/dossiers](https://stratta.ch/dossiers): four tabs (Terrain,
246
+ Lectures, Décisions, Livrables). Every section the agent reads for a named
247
+ project is logged under Lectures without a gesture; a decision can be recorded
248
+ in one sentence (`record_decision` with `dossierId` and `question`); the
249
+ deliverable is draft, in review or signed, exported to Word inside the office's
250
+ own report, and signing never goes through an agent.
251
+
252
+ **Library** (2 tools — the written procedures an agent follows for a repeatable task):
253
+
254
+ | Tool | Purpose |
255
+ | ------------- | ------------------------------------------------------------------------------------------------------------- |
256
+ | `list_skills` | The procedures the agent follows for a repeatable task: the ones Stratta ships and the ones the office wrote. |
257
+ | `get_skill` | Read one procedure in full; the office version wins over the Stratta one of the same name. |
258
+
259
+ An office skill with the same name as a shipped one replaces it for every agent
260
+ of the organisation. A report template learned from an office Word file on
261
+ [stratta.ch/bibliotheque](https://stratta.ch/bibliotheque) is served as a skill
262
+ too, and its file is the layout the Word export is rendered into.
248
263
 
249
264
  **Ingest** (10 tools — add YOUR licensed norms; driven by the bundled `ingest-norm` skill; owner or admin role):
250
265
 
@@ -273,18 +288,25 @@ For querying:
273
288
  6. Follow `crossRefs` for compound questions (e.g. SIA 261 → SIA 263 → EC).
274
289
  7. Call `get_figure` when the section references a figure relevant to the answer.
275
290
 
276
- Two skills ship in the package: **`consult-stratta`** (the working method an agent
277
- follows to answer from the norms, generated from the same text the server serves
278
- through `get_methodology`) and **`ingest-norm`**.
291
+ Nine skills ship in the package, under `skills/`: **`consult-stratta`** (the
292
+ working method an agent follows to answer from the norms, generated from the
293
+ same text the server serves through `get_methodology`), **`ingest-norm`**,
294
+ **`project-basis`** and **`use-agreement`** (the two SIA 260 documents, written
295
+ with `draft_deliverable` and `write_section`), **`site-chapter`**,
296
+ **`instruct-structure`**, **`review-open-questions`**, **`handoff-to-word`** and
297
+ **`verification-note`**. The same nine are served to every client by
298
+ `list_skills` and `get_skill`, so a client that installs no skills folder reads
299
+ them anyway; an office skill of the same name replaces the shipped one.
279
300
 
280
301
  ## Resources and prompts
281
302
 
282
- Four `stratta://` resources can be pinned to a conversation or read without a
303
+ Five `stratta://` resources can be pinned to a conversation or read without a
283
304
  tool call, each scoped to your workspace: `stratta://norms` (JSON),
284
305
  `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`) and
286
- `stratta://dossier/{id}` (a project dossier as Markdown). The same four are
287
- served by the remote connector.
306
+ contents to depth 2, code URL-encoded, e.g. `stratta://norm/SIA%20267/toc`),
307
+ `stratta://dossier/{id}` (a project dossier as Markdown) and
308
+ `stratta://skills/{name}` (one skill as Markdown, the office version when there
309
+ is one). The same five are served by the remote connector.
288
310
 
289
311
  Three prompts appear as slash commands in clients that support them:
290
312
  `investigate-question` (question, project?), `resume-dossier` (name) and
@@ -332,10 +354,15 @@ current count and the plan limit. Retrying will fail identically.
332
354
  | Figures | 60 | 750 | 3,000 | 3,750 |
333
355
  | Queries / month | 500 | 15,000 | 60,000 | 75,000 |
334
356
  | Members | 1 | 1 | 1 | 5 |
357
+ | Site sheet | – | – | ✓ | ✓ |
335
358
 
336
359
  Beyond the included queries, paid plans bill the overage per thousand. An
337
- Enterprise contract scales seats, norms and queries further; the calculator is
338
- at https://stratta.ch/tarifs
360
+ Enterprise contract scales seats, norms and queries further and includes the
361
+ site sheet; the calculator is at https://stratta.ch/tarifs. Dossiers,
362
+ deliverables and the library are on every plan; `scan_site` and the site tools
363
+ answer `PLAN_REQUIRED` below Max. A tool call weighs a whole number of queries:
364
+ a light read 1, a section or a sheet 2, a search 5, a borehole PDF read 10, a
365
+ site scan 100.
339
366
 
340
367
  Stock limits free up when you delete a norm (`ingest_delete`). The monthly query
341
368
  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';
@@ -62,6 +62,7 @@ async function call(def, args) {
62
62
  let resolvedKey = null;
63
63
  let authenticated = false;
64
64
  let status = 'success';
65
+ let failure;
65
66
  const reportUsage = () => {
66
67
  if (!authenticated || !resolvedKey)
67
68
  return;
@@ -73,6 +74,8 @@ async function call(def, args) {
73
74
  durationMs: Date.now() - startedAt,
74
75
  sessionId: SESSION_ID,
75
76
  argsDigest: usageDigest(args),
77
+ errorCode: failure?.code,
78
+ errorMessage: failure?.message,
76
79
  })
77
80
  .catch((e) => console.error('[stratta-mcp] usage report failed:', e));
78
81
  };
@@ -97,6 +100,7 @@ async function call(def, args) {
97
100
  }
98
101
  catch (err) {
99
102
  status = 'error';
103
+ failure = digestToolError(err);
100
104
  reportUsage();
101
105
  return {
102
106
  content: [{ type: 'text', text: `Error: ${formatToolError(err)}` }],
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stratta/mcp",
3
3
  "mcpName": "ch.stratta/mcp",
4
- "version": "1.16.0",
4
+ "version": "1.16.1",
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)