@stratta/mcp 0.10.0 → 0.12.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
@@ -9,34 +9,41 @@ mcp-name: ch.stratta/mcp
9
9
 
10
10
  # @stratta/mcp
11
11
 
12
- MCP server exposing Swiss engineering norms (SIA / Eurocodes) to Claude clients via Stratta TreeRAG.
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. Sign up at https://stratta.ch and generate an API key at https://stratta.ch/api-keys.
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 at
21
- > https://stratta.ch/api-keys.
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
- Often not. Stratta also runs as a **remote connector** — one address, a browser
26
- sign-in, no key and no Node:
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, and the only one that works in an agent running in the
33
- cloud (claude.ai). See https://stratta.ch/docs/en/guides/connect-remote.
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 32 of the 33 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
- - you are **ingesting a norm** — it reads a PDF from your disk and runs a Python
38
- pre-pass, neither of which a remote connector can reach;
39
- - your agent **cannot open a browser** — CI, a scheduled task, a server;
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/api-keys instead, without echoing it.
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.
@@ -152,50 +159,76 @@ settings come from environment variables — see [`.env.example`](./.env.example
152
159
  | `STRATTA_API_KEY` | no¹ | – | API key from https://stratta.ch. ¹If unset, the server falls back to `~/.stratta/config.json`, or prompts you on first use (clients that support elicitation). |
153
160
  | `STRATTA_CONVEX_URL` | no | Stratta prod backend | Override only if you self-host. |
154
161
 
162
+ ## Tests
163
+
164
+ `npm test` runs the unit tests. The two integration suites
165
+ (`tests/handlers.test.ts`, `tests/dossier-flow.e2e.test.ts`) run this checkout's
166
+ built server against the deployment you name, and are skipped when either
167
+ variable is missing:
168
+
169
+ ```bash
170
+ npm run build
171
+ STRATTA_E2E_URL=https://<dev-deployment>.convex.cloud STRATTA_E2E_API_KEY=sk_strt_... npm test
172
+ ```
173
+
174
+ Point them at a development deployment: the dossier flow creates a dossier
175
+ named `E2E dossier <timestamp>` and does not delete it.
176
+
155
177
  ## Tools exposed
156
178
 
157
- **Read** (8 tools — query norms in your workspace):
158
-
159
- | Tool | Purpose |
160
- | ----------------- | -------------------------------------------------------------------------------------- |
161
- | `get_methodology` | Behavioural contract: persona, workflow, meta-routing hints, answer rules. Call first. |
162
- | `list_norms` | List all norms published in your workspace (code, year, title, language). |
163
- | `get_toc` | Hierarchical TOC for a norm (default `maxDepth=1` = chapters). |
164
- | `get_subtree` | Drill into a chapter/section subtree (`path` + `maxDepth`). |
165
- | `get_section` | Full enriched content of a section (formulas, tables, figures, cross-refs). |
166
- | `search_in_norm` | Keyword search inside a norm. |
167
- | `get_figure` | Retrieve a figure inline (base64 ImageContent) + public URL. |
168
- | `get_cross_refs` | Outgoing cross-refs from a section to other norms. |
169
-
170
- **Dossier** (7 tools — keep what was decided on a project):
171
-
172
- | Tool | Purpose |
173
- | ------------------ | ---------------------------------------------------------------------------------------- |
174
- | `list_dossiers` | Your organisation's dossiers, most recently touched first, with open-question counts. |
175
- | `open_dossier` | Open a project's dossier, creating it if needed. Idempotent on the name. |
176
- | `open_question` | Open one question to settle, with optional named options. Idempotent on the title. |
177
- | `save_finding` | Record one piece of evidence: a cited article, a retained value and why, an observation. |
178
- | `record_decision` | Settle a question with a decision the engineer has confirmed, and the retained option. |
179
- | `load_dossier` | Reload everything: questions with their evidence and decisions, open ones first. |
180
- | `resolve_question` | Close a question without a decision, or reopen one. The evidence stays. |
179
+ 33 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
+ **Dossier** (13 tools — keep what was decided on a project):
198
+
199
+ | Tool | Purpose |
200
+ | ------------------- | ------------------------------------------------------------------------------------------------------ |
201
+ | `list_dossiers` | Your organisation's dossiers, most recently touched first, with open-question counts. |
202
+ | `open_dossier` | Open a project's dossier, creating it if needed. Idempotent on the name. |
203
+ | `open_question` | Open one question to settle, with optional named options. Idempotent on the title. |
204
+ | `save_finding` | Record one piece of evidence: a cited article, a retained value and why, an observation. |
205
+ | `record_decision` | Settle a question with a decision the engineer has confirmed, and the retained option. |
206
+ | `load_dossier` | Reload everything: questions with their evidence and decisions, open ones first. |
207
+ | `resolve_question` | Close a question without a decision, or reopen one. The evidence stays. |
208
+ | `list_attachments` | The project attachments of a dossier: site reports, borehole logs, minutes, data sheets. |
209
+ | `read_attachment` | Read an attachment's text as Markdown, page by page. |
210
+ | `search_in_dossier` | Full-text search over a dossier's attachments, with the page of each hit. |
211
+ | `add_attachment` | Upload a file from the user's machine to a dossier (PDF, DOCX, XLSX, images, text). Local server only. |
212
+ | `list_templates` | The checklists the organisation wrote for its types of structure. |
213
+ | `apply_template` | Open a template's questions in a dossier and file the clauses that resolve in the corpus. |
181
214
 
182
215
  A dossier is read, annotated, reviewed and exported from
183
216
  [stratta.ch/dossiers](https://stratta.ch/dossiers).
184
217
 
185
- **Ingest** (10 tools — add YOUR licensed norms; driven by the bundled `ingest-norm` skill):
186
-
187
- | Tool | Purpose |
188
- | ----------------------------- | ------------------------------------------------------------ |
189
- | `ingest_status` | Check if a norm already exists in your workspace. |
190
- | `ingest_create_document` | Create a draft norm document. |
191
- | `ingest_create_sections` | Bulk-insert sections (returns `nodeId → sectionId` map). |
192
- | `ingest_attach_formula` | Attach a LaTeX formula to a section. |
193
- | `ingest_attach_table` | Attach a structured table `{headers, rows}` to a section. |
194
- | `ingest_attach_cross_ref` | Attach an explicit cross-ref to another norm. |
195
- | `ingest_upload_figure` | Upload a figure (base64 PNG/JPEG/WebP, ≤ 8 MB) to a section. |
196
- | `ingest_normalize_cross_refs` | Auto-detect and rebuild cross-refs from section content. |
197
- | `ingest_publish` | Flip a draft to published — visible via the read tools. |
198
- | `ingest_delete` | Delete a document and all its children. |
218
+ **Ingest** (10 tools — add YOUR licensed norms; driven by the bundled `ingest-norm` skill; owner or admin role):
219
+
220
+ | Tool | Purpose |
221
+ | ----------------------------- | -------------------------------------------------------------------------------- |
222
+ | `ingest_status` | Check if a norm already exists in your workspace, and its coverage. |
223
+ | `ingest_create_document` | Create a draft norm document. |
224
+ | `ingest_create_sections` | Bulk-insert sections (returns `nodeId → sectionId` map). |
225
+ | `ingest_attach_formula` | Attach a LaTeX formula to a section. |
226
+ | `ingest_attach_table` | Attach a structured table `{headers, rows}` to a section. |
227
+ | `ingest_attach_cross_ref` | Attach an explicit cross-ref to another norm. |
228
+ | `ingest_upload_figure` | Upload a figure (base64 PNG/JPEG/WebP, ≤ 8 MB) to a section. |
229
+ | `ingest_normalize_cross_refs` | Auto-detect and rebuild cross-refs from section content. |
230
+ | `ingest_publish` | Score the document and flip it to published; refuses below 30/100 unless forced. |
231
+ | `ingest_delete` | Delete a document and all its children. |
199
232
 
200
233
  ## How agents should use it
201
234
 
@@ -205,10 +238,27 @@ For querying:
205
238
  2. Call `list_norms` to see what's available in your workspace.
206
239
  3. Call `get_toc(norm)` to navigate the structure; `get_subtree` to drill in.
207
240
  4. Call `get_section(norm, path)` to read specific content.
208
- 5. Use `search_in_norm` when the section path is unknown.
241
+ 5. Use `search_corpus` when the norm is unknown, `search_in_norm` when the section path is.
209
242
  6. Follow `crossRefs` for compound questions (e.g. SIA 261 → SIA 263 → EC).
210
243
  7. Call `get_figure` when the section references a figure relevant to the answer.
211
244
 
245
+ Two skills ship in the package: **`consult-stratta`** (the working method an agent
246
+ follows to answer from the norms, generated from the same text the server serves
247
+ through `get_methodology`) and **`ingest-norm`**.
248
+
249
+ ## Resources and prompts
250
+
251
+ Four `stratta://` resources can be pinned to a conversation or read without a
252
+ tool call, each scoped to your workspace: `stratta://norms` (JSON),
253
+ `stratta://methodology` (Markdown), `stratta://norm/{code}/toc` (the table of
254
+ contents to depth 2, code URL-encoded, e.g. `stratta://norm/SIA%20267/toc`) and
255
+ `stratta://dossier/{id}` (a project dossier as Markdown). The same four are
256
+ served by the remote connector.
257
+
258
+ Three prompts appear as slash commands in clients that support them:
259
+ `investigate-question` (question, project?), `resume-dossier` (name) and
260
+ `check-value` (value, norm?).
261
+
212
262
  For ingesting your own licensed norms, see the bundled **`ingest-norm` skill** —
213
263
  a 2-phase hybrid pipeline (since 0.4.0): a Python pre-pass
214
264
  (`scripts/ingest-prepass.py`, PyMuPDF) extracts the hierarchical tree and
@@ -220,7 +270,7 @@ Requires Python ≥ 3.10 with PyMuPDF (`python -m pip install --user pymupdf`).
220
270
 
221
271
  ### `Authentication failed` / `Invalid API key`
222
272
 
223
- - Verify the key starts with `sk_strt_` and is not revoked at https://stratta.ch/api-keys.
273
+ - Verify the key starts with `sk_strt_` and is not revoked at https://stratta.ch/settings?tab=keys.
224
274
  - Check the env var is reaching the process: `echo $STRATTA_API_KEY` (or `$env:STRATTA_API_KEY` on Windows PowerShell).
225
275
  - If you copied from the UI, make sure no leading/trailing whitespace was added.
226
276
 
@@ -229,7 +279,7 @@ Requires Python ≥ 3.10 with PyMuPDF (`python -m pip install --user pymupdf`).
229
279
  - Confirm outbound HTTPS to `*.convex.cloud` is allowed by your firewall/VPN.
230
280
  - Try `curl -I https://stratta.ch` to verify general internet reachability.
231
281
 
232
- ### Tools don't appear in Claude
282
+ ### Tools don't appear in your agent
233
283
 
234
284
  - Restart your Claude client after editing the config.
235
285
  - Check the MCP server logs (Claude Code: `claude mcp logs stratta`; Claude Desktop: `~/Library/Logs/Claude/mcp-server-stratta.log` on macOS).
@@ -244,16 +294,17 @@ Requires Python ≥ 3.10 with PyMuPDF (`python -m pip install --user pymupdf`).
244
294
  Your organization reached one of its limits. The error names the dimension, your
245
295
  current count and the plan limit. Retrying will fail identically.
246
296
 
247
- | Limit | Free | Pro | Max |
248
- | --------------- | ---- | ------ | ------- |
249
- | Norms | 1 | 15 | 60 |
250
- | Sections | 500 | 5,000 | 21,000 |
251
- | Figures | 60 | 750 | 3,000 |
252
- | Queries / month | 500 | 15,000 | 100,000 |
253
- | Members | 1 | 1 | 5 |
254
-
255
- Beyond Max, an Enterprise plan scales to 100 members, 300 norms and a million
256
- monthly queries; the calculator is at https://stratta.ch/tarifs
297
+ | Limit | Free | Pro | Max | Team |
298
+ | --------------- | ----- | ------ | ------ | ------ |
299
+ | Norms | 1 | 15 | 60 | 75 |
300
+ | Sections | 1,000 | 15,000 | 60,000 | 75,000 |
301
+ | Figures | 60 | 750 | 3,000 | 3,750 |
302
+ | Queries / month | 500 | 15,000 | 60,000 | 75,000 |
303
+ | Members | 1 | 1 | 1 | 5 |
304
+
305
+ Beyond the included queries, paid plans bill the overage per thousand. An
306
+ Enterprise contract scales seats, norms and queries further; the calculator is
307
+ at https://stratta.ch/tarifs
257
308
 
258
309
  Stock limits free up when you delete a norm (`ingest_delete`). The monthly query
259
310
  counter resets on its own. Gauges live on the Workspace page of your dashboard,
@@ -0,0 +1,15 @@
1
+ import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
3
+ /**
4
+ * The confirmation step before a decision is written, on stdio.
5
+ *
6
+ * Same contract as the remote gate: `confirmed: true` in the arguments means
7
+ * the agent already asked the user; otherwise the client is asked through an
8
+ * elicitation when it supports one, and told to come back with
9
+ * `confirmed: true` when it does not. The Convex function never sees the
10
+ * difference.
11
+ */
12
+ export declare const CONFIRMED_TOOLS: Record<string, (args: Record<string, unknown>) => string>;
13
+ export declare const CONFIRMATION_HINT: string;
14
+ /** `null` when the call may proceed, a tool result to return otherwise. */
15
+ export declare function confirmWithUser(server: Pick<Server, 'getClientCapabilities' | 'elicitInput'>, toolName: string, args: Record<string, unknown>): Promise<CallToolResult | null>;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The confirmation step before a decision is written, on stdio.
3
+ *
4
+ * Same contract as the remote gate: `confirmed: true` in the arguments means
5
+ * the agent already asked the user; otherwise the client is asked through an
6
+ * elicitation when it supports one, and told to come back with
7
+ * `confirmed: true` when it does not. The Convex function never sees the
8
+ * difference.
9
+ */
10
+ export const CONFIRMED_TOOLS = {
11
+ record_decision: (args) => [
12
+ args.title,
13
+ args.value ? `= ${String(args.value)}` : null,
14
+ args.retainedOption ? `(option ${String(args.retainedOption)})` : null,
15
+ ]
16
+ .filter(Boolean)
17
+ .map(String)
18
+ .join(' '),
19
+ resolve_question: (args) => args.resolved === false
20
+ ? 'reopen this question'
21
+ : 'close this question without a decision',
22
+ };
23
+ export const CONFIRMATION_HINT = 'Not recorded: this client cannot ask the user directly. Confirm the ' +
24
+ 'decision with the user in the conversation, then call again with ' +
25
+ 'confirmed: true.';
26
+ /** `null` when the call may proceed, a tool result to return otherwise. */
27
+ export async function confirmWithUser(server, toolName, args) {
28
+ const summary = CONFIRMED_TOOLS[toolName];
29
+ if (!summary || args.confirmed === true)
30
+ return null;
31
+ if (!server.getClientCapabilities()?.elicitation) {
32
+ return {
33
+ content: [{ type: 'text', text: CONFIRMATION_HINT }],
34
+ structuredContent: { outcome: 'confirmation_required' },
35
+ isError: true,
36
+ };
37
+ }
38
+ const result = await server.elicitInput({
39
+ mode: 'form',
40
+ message: `${toolName === 'record_decision' ? 'Decision' : 'Question'}: ${summary(args)}\n\nRecord it in the dossier?`,
41
+ requestedSchema: {
42
+ type: 'object',
43
+ properties: { confirm: { type: 'boolean', title: 'Record it' } },
44
+ required: ['confirm'],
45
+ },
46
+ });
47
+ if (result.action !== 'accept' || result.content?.confirm === false) {
48
+ return {
49
+ content: [{ type: 'text', text: 'Not recorded: the user declined.' }],
50
+ structuredContent: { outcome: 'declined' },
51
+ };
52
+ }
53
+ return null;
54
+ }
package/dist/errors.js CHANGED
@@ -4,6 +4,22 @@ function isQuotaPayload(data) {
4
4
  data !== null &&
5
5
  data.code === 'QUOTA_EXCEEDED');
6
6
  }
7
+ function isQualityPayload(data) {
8
+ return (typeof data === 'object' &&
9
+ data !== null &&
10
+ data.code === 'QUALITY_TOO_LOW');
11
+ }
12
+ /** What each coverage flag means, in words the agent can relay. */
13
+ const FLAG_TEXT = {
14
+ 'sparse-text': 'trop peu de texte par page : le contenu des sections est un résumé, pas le texte de la norme',
15
+ 'light-text': 'peu de texte par page',
16
+ 'few-sections': "moins de 5 sections : la détection des titres n'a rien trouvé",
17
+ 'thin-sections': 'sections presque vides (moins de 200 caractères en moyenne)',
18
+ 'low-coverage': 'les chapitres couvrent moins de la moitié des pages',
19
+ 'empty-leaves': 'plus de 20 % des sections feuilles sont vides',
20
+ 'duplicate-paths': 'plusieurs sections portent le même chemin',
21
+ 'no-enrichment': 'aucune formule ni tableau attaché',
22
+ };
7
23
  function humanDuration(ms) {
8
24
  const days = Math.floor(ms / 86_400_000);
9
25
  if (days >= 1)
@@ -22,6 +38,7 @@ const DOCS = 'https://stratta.ch/docs/fr';
22
38
  */
23
39
  const HELP = {
24
40
  QUOTA_EXCEEDED: `${DOCS}/account/plans#quand-une-limite-est-atteinte`,
41
+ QUALITY_TOO_LOW: `${DOCS}/guides/ingest-a-norm`,
25
42
  UNAUTHORIZED: `${DOCS}/guides/get-api-key`,
26
43
  NO_ORGANIZATION: `${DOCS}/guides/manage-organization`,
27
44
  DOCUMENT_NOT_FOUND: `${DOCS}/guides/ingest-a-norm`,
@@ -59,6 +76,16 @@ export function formatToolError(err) {
59
76
  lines.push('Ne relancez pas la même opération : elle échouera à l’identique tant que la limite est atteinte.');
60
77
  return withHelp('QUOTA_EXCEEDED', lines);
61
78
  }
79
+ if (isQualityPayload(data)) {
80
+ const lines = [
81
+ `Document non publié : score de couverture ${data.score}/100, sous le plancher de ${data.minimum}.`,
82
+ `Mesures : ${data.charsPerPage} caractères par page, ${Math.round(data.pagesCovered * 100)} % des pages sous un chapitre, ${data.sectionCount} sections.`,
83
+ ...data.flags.map((f) => `- ${FLAG_TEXT[f] ?? f}`),
84
+ 'Un agent qui lirait ce document répondrait depuis des résumés. Corrigez l’ingestion (le champ `content` de chaque section doit porter le texte de la norme, chaque chapitre ses pages) plutôt que de relancer la publication.',
85
+ 'Si le document est réellement aussi court, demandez à l’utilisateur, puis rappelez `ingest_publish` avec `force: true`.',
86
+ ];
87
+ return withHelp('QUALITY_TOO_LOW', lines);
88
+ }
62
89
  if (typeof data === 'string') {
63
90
  switch (data) {
64
91
  case 'UNAUTHORIZED':
package/dist/index.js CHANGED
@@ -8,9 +8,14 @@ import { createConvexClient, api } from './client.js';
8
8
  import { formatToolError } from './errors.js';
9
9
  import { ensureAuthenticated, resolveApiKey } from './auth.js';
10
10
  import { elicitApiKey } from './elicit.js';
11
+ import { confirmWithUser } from './confirm.js';
11
12
  import { readTools } from './tools/read.js';
12
13
  import { ingestTools } from './tools/ingest.js';
13
14
  import { dossierTools } from './tools/dossier.js';
15
+ import { SESSION_ID, usageDigest } from './usage.js';
16
+ import { SERVER_INSTRUCTIONS } from './tools/catalog.gen.js';
17
+ import { registerResources } from './resources.js';
18
+ import { registerPrompts } from './prompts.js';
14
19
  /**
15
20
  * Read from package.json rather than repeated in a literal. The previous
16
21
  * version announced `0.3.0` while the published package was `0.6.0`, so every
@@ -32,7 +37,14 @@ function packageVersion() {
32
37
  }
33
38
  return '0.0.0';
34
39
  }
35
- const server = new McpServer({ name: 'stratta-mcp', version: packageVersion() }, { capabilities: { tools: {} } });
40
+ const server = new McpServer({ name: 'stratta-mcp', version: packageVersion() },
41
+ // The same sentence the remote transport sends at initialisation. Not every
42
+ // client shows it to the model, which is why each tool description stands
43
+ // on its own; where it is shown, the two transports say the same thing.
44
+ {
45
+ capabilities: { tools: {}, resources: {}, prompts: {} },
46
+ instructions: SERVER_INSTRUCTIONS,
47
+ });
36
48
  const client = createConvexClient();
37
49
  /**
38
50
  * Everything every tool call needs, in one place: resolve the key (env, then
@@ -57,6 +69,8 @@ async function call(def, args) {
57
69
  toolName: def.name,
58
70
  status,
59
71
  durationMs: Date.now() - startedAt,
72
+ sessionId: SESSION_ID,
73
+ argsDigest: usageDigest(args),
60
74
  })
61
75
  .catch((e) => console.error('[stratta-mcp] usage report failed:', e));
62
76
  };
@@ -64,12 +78,19 @@ async function call(def, args) {
64
78
  resolvedKey = resolveApiKey() ?? (await elicitApiKey(server.server, client));
65
79
  await ensureAuthenticated(client, resolvedKey);
66
80
  authenticated = true;
81
+ // A decision is written only once the user confirmed it: through the
82
+ // client when it can ask, through the agent otherwise (`confirm.ts`).
83
+ const gate = await confirmWithUser(server.server, def.name, (args ?? {}));
84
+ if (gate)
85
+ return gate;
67
86
  const result = await def.run(client, args);
68
87
  reportUsage();
69
88
  if (def.toContent)
70
89
  return def.toContent(result);
90
+ // Compact: the two-space indentation was a third more tokens on every
91
+ // section an agent read, for a reader that never looks at whitespace.
71
92
  return {
72
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
93
+ content: [{ type: 'text', text: JSON.stringify(result) }],
73
94
  };
74
95
  }
75
96
  catch (err) {
@@ -92,6 +113,10 @@ for (const def of [...readTools, ...dossierTools, ...ingestTools]) {
92
113
  // handler receives values that already match the declared shape.
93
114
  (args) => call(def, args));
94
115
  }
116
+ // Documents an agent can read without a tool call, and the three ways a
117
+ // user starts a conversation. Neither exists on the remote transport yet.
118
+ registerResources(server, client);
119
+ registerPrompts(server);
95
120
  /**
96
121
  * The help nobody had.
97
122
  *
@@ -0,0 +1,4 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { type PromptEntry } from './tools/catalog.gen.js';
3
+ export declare function renderPrompt(p: PromptEntry, args: Record<string, unknown>): string;
4
+ export declare function registerPrompts(server: McpServer): void;
@@ -0,0 +1,40 @@
1
+ import { z } from 'zod';
2
+ import { PROMPTS } from './tools/catalog.gen.js';
3
+ /**
4
+ * Prompts: the three ways a user starts a conversation with Stratta, as
5
+ * slash commands in the clients that support them (Claude Code, Claude
6
+ * Desktop, Cursor). stdio only: the remote gateway does not carry prompts yet.
7
+ *
8
+ * Text and arguments come from the catalogue, so a wording change is made
9
+ * once. `{{arg}}` is filled with the argument; an `optional` line is appended
10
+ * only when its argument was given.
11
+ */
12
+ const fill = (template, args) => template.replace(/\{\{(\w+)\}\}/g, (_match, key) => String(args[key] ?? ''));
13
+ export function renderPrompt(p, args) {
14
+ let text = fill(p.text, args);
15
+ for (const [arg, line] of Object.entries(p.optional ?? {})) {
16
+ if (args[arg])
17
+ text += `\n${fill(line, args)}`;
18
+ }
19
+ return text;
20
+ }
21
+ export function registerPrompts(server) {
22
+ for (const p of PROMPTS) {
23
+ const shape = {};
24
+ for (const a of p.args) {
25
+ const s = z.string().describe(a.description);
26
+ shape[a.name] = a.required ? s : s.optional();
27
+ }
28
+ server.registerPrompt(p.name, { title: p.title, description: p.description, argsSchema: shape }, (args) => ({
29
+ messages: [
30
+ {
31
+ role: 'user',
32
+ content: {
33
+ type: 'text',
34
+ text: renderPrompt(p, args),
35
+ },
36
+ },
37
+ ],
38
+ }));
39
+ }
40
+ }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { ConvexHttpClient } from 'convex/browser';
3
+ export declare function registerResources(server: McpServer, client: ConvexHttpClient): void;
@@ -0,0 +1,35 @@
1
+ import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { api } from './client.js';
3
+ import { requireApiKey } from './auth.js';
4
+ async function read(client, uri) {
5
+ const content = (await client.action(api._mcp.readResource, {
6
+ apiKey: requireApiKey(),
7
+ uri,
8
+ }));
9
+ if (!content) {
10
+ throw new Error(`Nothing at ${uri}: the norm or dossier is not in this workspace. Read stratta://norms or call list_dossiers.`);
11
+ }
12
+ return { contents: [content] };
13
+ }
14
+ export function registerResources(server, client) {
15
+ server.registerResource('norms', 'stratta://norms', {
16
+ title: 'Readable norms',
17
+ description: 'The norms this workspace can read, with code, year, title, language, scope and coverage. JSON.',
18
+ mimeType: 'application/json',
19
+ }, (uri) => read(client, uri.href));
20
+ server.registerResource('methodology', 'stratta://methodology', {
21
+ title: 'Working method',
22
+ description: 'The working method served by get_methodology, as one Markdown document built for this workspace.',
23
+ mimeType: 'text/markdown',
24
+ }, (uri) => read(client, uri.href));
25
+ server.registerResource('norm-toc', new ResourceTemplate('stratta://norm/{code}/toc', { list: undefined }), {
26
+ title: 'Table of contents of a norm',
27
+ description: 'Chapters and X.Y sections of one norm with their summaries and pages, as Markdown. {code} is the norm code, URL-encoded (SIA%20267).',
28
+ mimeType: 'text/markdown',
29
+ }, (uri) => read(client, uri.href));
30
+ server.registerResource('dossier', new ResourceTemplate('stratta://dossier/{id}', { list: undefined }), {
31
+ title: 'Project dossier',
32
+ description: 'One project dossier as Markdown: its questions with evidence, options and decisions, then the evidence not yet filed. {id} is the dossierId from list_dossiers.',
33
+ mimeType: 'text/markdown',
34
+ }, (uri) => read(client, uri.href));
35
+ }
@@ -0,0 +1,34 @@
1
+ export type Transport = 'stdio' | 'remote';
2
+ export type ToolAnnotations = {
3
+ readOnlyHint: boolean;
4
+ destructiveHint: boolean;
5
+ idempotentHint: boolean;
6
+ openWorldHint: boolean;
7
+ };
8
+ export type CatalogEntry = {
9
+ name: string;
10
+ title: string;
11
+ description: string;
12
+ annotations: ToolAnnotations;
13
+ transports: Transport[];
14
+ params: Record<string, string>;
15
+ };
16
+ export declare const SERVER_INSTRUCTIONS = "Stratta serves the construction norms of the signed-in organization, and only those. Call `get_methodology` before the first technical question of a conversation: it carries the navigation order, the citation rules and the dossier policy. Never answer a technical question from memory; a norm absent from `list_norms` cannot be read.";
17
+ export declare const CATALOG: CatalogEntry[];
18
+ export type PromptArg = {
19
+ name: string;
20
+ description: string;
21
+ required: boolean;
22
+ };
23
+ export type PromptEntry = {
24
+ name: string;
25
+ title: string;
26
+ description: string;
27
+ args: PromptArg[];
28
+ text: string;
29
+ optional?: Record<string, string>;
30
+ };
31
+ export declare const PROMPTS: PromptEntry[];
32
+ export declare function catalogEntry(name: string): CatalogEntry;
33
+ /** Description of one parameter, as the catalogue words it. */
34
+ export declare function param(tool: string, name: string): string;