@stratta/mcp 0.7.1 → 0.9.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 +53 -39
- package/dist/errors.js +82 -7
- package/dist/index.js +3 -2
- package/dist/login.d.ts +1 -1
- package/dist/login.js +153 -9
- package/dist/tools/dossier.d.ts +63 -0
- package/dist/tools/dossier.js +174 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Ownership marker for the official MCP registry. It must match `mcpName` in
|
|
3
|
+
package.json exactly, and it must be present in the *published* tarball —
|
|
4
|
+
the registry reads the README from npm, not from this repository. Without it
|
|
5
|
+
`mcp-publisher publish` rejects the server as unverified.
|
|
6
|
+
|
|
7
|
+
mcp-name: io.github.hugogebel-boop/stratta
|
|
8
|
+
-->
|
|
9
|
+
|
|
1
10
|
# @stratta/mcp
|
|
2
11
|
|
|
3
12
|
MCP server exposing Swiss engineering norms (SIA / Eurocodes) to Claude clients via Stratta TreeRAG.
|
|
@@ -21,17 +30,22 @@ Add the server — no key needed up front:
|
|
|
21
30
|
claude mcp add stratta -- npx -y @stratta/mcp
|
|
22
31
|
```
|
|
23
32
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
Prefer to set it up ahead of time? Log in via the CLI:
|
|
33
|
+
Then sign in. This opens stratta.ch in your browser, where you confirm which
|
|
34
|
+
machine and organisation to authorise; the key comes back to the terminal on
|
|
35
|
+
its own and is saved to `~/.stratta/config.json` (owner-only, `0600`). You
|
|
36
|
+
never see it, and you only do this once:
|
|
29
37
|
|
|
30
38
|
```bash
|
|
31
39
|
npx -y @stratta/mcp login
|
|
32
40
|
```
|
|
33
41
|
|
|
34
|
-
|
|
42
|
+
No browser on this machine — remote server, SSH, CI? `login --paste` asks for a
|
|
43
|
+
key from https://stratta.ch/api-keys instead, without echoing it.
|
|
44
|
+
|
|
45
|
+
If you skip the step entirely, Claude Code prompts you for a key on the first
|
|
46
|
+
tool call.
|
|
47
|
+
|
|
48
|
+
You can also pass the key explicitly as an environment variable (it then takes
|
|
35
49
|
precedence over the saved key):
|
|
36
50
|
|
|
37
51
|
```bash
|
|
@@ -40,7 +54,7 @@ claude mcp add stratta --scope user --env STRATTA_API_KEY=sk_strt_xxx -- npx -y
|
|
|
40
54
|
|
|
41
55
|
### Claude Desktop
|
|
42
56
|
|
|
43
|
-
|
|
57
|
+
Sign in once from a terminal — the desktop app cannot prompt you interactively:
|
|
44
58
|
|
|
45
59
|
```bash
|
|
46
60
|
npx -y @stratta/mcp login
|
|
@@ -80,40 +94,40 @@ The API key is resolved in this order: the `STRATTA_API_KEY` env var, then
|
|
|
80
94
|
`~/.stratta/config.json` (written by `login` or the first-run prompt). Other
|
|
81
95
|
settings come from environment variables — see [`.env.example`](./.env.example).
|
|
82
96
|
|
|
83
|
-
| Variable
|
|
84
|
-
|
|
85
|
-
| `STRATTA_API_KEY`
|
|
86
|
-
| `STRATTA_CONVEX_URL` | no
|
|
97
|
+
| Variable | Required | Default | Purpose |
|
|
98
|
+
| -------------------- | -------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
99
|
+
| `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). |
|
|
100
|
+
| `STRATTA_CONVEX_URL` | no | Stratta prod backend | Override only if you self-host. |
|
|
87
101
|
|
|
88
102
|
## Tools exposed
|
|
89
103
|
|
|
90
104
|
**Read** (8 tools — query norms in your workspace):
|
|
91
105
|
|
|
92
|
-
| Tool
|
|
93
|
-
|
|
106
|
+
| Tool | Purpose |
|
|
107
|
+
| ----------------- | -------------------------------------------------------------------------------------- |
|
|
94
108
|
| `get_methodology` | Behavioural contract: persona, workflow, meta-routing hints, answer rules. Call first. |
|
|
95
|
-
| `list_norms`
|
|
96
|
-
| `get_toc`
|
|
97
|
-
| `get_subtree`
|
|
98
|
-
| `get_section`
|
|
99
|
-
| `search_in_norm`
|
|
100
|
-
| `get_figure`
|
|
101
|
-
| `get_cross_refs`
|
|
109
|
+
| `list_norms` | List all norms published in your workspace (code, year, title, language). |
|
|
110
|
+
| `get_toc` | Hierarchical TOC for a norm (default `maxDepth=1` = chapters). |
|
|
111
|
+
| `get_subtree` | Drill into a chapter/section subtree (`path` + `maxDepth`). |
|
|
112
|
+
| `get_section` | Full enriched content of a section (formulas, tables, figures, cross-refs). |
|
|
113
|
+
| `search_in_norm` | Keyword search inside a norm. |
|
|
114
|
+
| `get_figure` | Retrieve a figure inline (base64 ImageContent) + public URL. |
|
|
115
|
+
| `get_cross_refs` | Outgoing cross-refs from a section to other norms. |
|
|
102
116
|
|
|
103
117
|
**Ingest** (10 tools — add YOUR licensed norms; driven by the bundled `ingest-norm` skill):
|
|
104
118
|
|
|
105
|
-
| Tool
|
|
106
|
-
|
|
107
|
-
| `ingest_status`
|
|
108
|
-
| `ingest_create_document`
|
|
109
|
-
| `ingest_create_sections`
|
|
110
|
-
| `ingest_attach_formula`
|
|
111
|
-
| `ingest_attach_table`
|
|
112
|
-
| `ingest_attach_cross_ref`
|
|
113
|
-
| `ingest_upload_figure`
|
|
114
|
-
| `ingest_normalize_cross_refs` | Auto-detect and rebuild cross-refs from section content.
|
|
115
|
-
| `ingest_publish`
|
|
116
|
-
| `ingest_delete`
|
|
119
|
+
| Tool | Purpose |
|
|
120
|
+
| ----------------------------- | ------------------------------------------------------------ |
|
|
121
|
+
| `ingest_status` | Check if a norm already exists in your workspace. |
|
|
122
|
+
| `ingest_create_document` | Create a draft norm document. |
|
|
123
|
+
| `ingest_create_sections` | Bulk-insert sections (returns `nodeId → sectionId` map). |
|
|
124
|
+
| `ingest_attach_formula` | Attach a LaTeX formula to a section. |
|
|
125
|
+
| `ingest_attach_table` | Attach a structured table `{headers, rows}` to a section. |
|
|
126
|
+
| `ingest_attach_cross_ref` | Attach an explicit cross-ref to another norm. |
|
|
127
|
+
| `ingest_upload_figure` | Upload a figure (base64 PNG/JPEG/WebP, ≤ 8 MB) to a section. |
|
|
128
|
+
| `ingest_normalize_cross_refs` | Auto-detect and rebuild cross-refs from section content. |
|
|
129
|
+
| `ingest_publish` | Flip a draft to published — visible via the read tools. |
|
|
130
|
+
| `ingest_delete` | Delete a document and all its children. |
|
|
117
131
|
|
|
118
132
|
## How agents should use it
|
|
119
133
|
|
|
@@ -162,13 +176,13 @@ Requires Python ≥ 3.10 with PyMuPDF (`python -m pip install --user pymupdf`).
|
|
|
162
176
|
Your organization reached one of its limits. The error names the dimension, your
|
|
163
177
|
current count and the plan limit. Retrying will fail identically.
|
|
164
178
|
|
|
165
|
-
| Limit
|
|
166
|
-
|
|
167
|
-
| Norms
|
|
168
|
-
| Sections
|
|
169
|
-
| Figures
|
|
170
|
-
| Queries / month | 500
|
|
171
|
-
| Members
|
|
179
|
+
| Limit | Free | Pro | Max |
|
|
180
|
+
| --------------- | ---- | ------ | ------- |
|
|
181
|
+
| Norms | 1 | 15 | 60 |
|
|
182
|
+
| Sections | 500 | 5,000 | 21,000 |
|
|
183
|
+
| Figures | 60 | 750 | 3,000 |
|
|
184
|
+
| Queries / month | 500 | 15,000 | 100,000 |
|
|
185
|
+
| Members | 1 | 1 | 5 |
|
|
172
186
|
|
|
173
187
|
Beyond Max, an Enterprise plan scales to 100 members, 300 norms and a million
|
|
174
188
|
monthly queries; the calculator is at https://stratta.ch/tarifs
|
package/dist/errors.js
CHANGED
|
@@ -11,6 +11,34 @@ function humanDuration(ms) {
|
|
|
11
11
|
const hours = Math.ceil(ms / 3_600_000);
|
|
12
12
|
return `${hours} heure${hours > 1 ? 's' : ''}`;
|
|
13
13
|
}
|
|
14
|
+
const DOCS = 'https://stratta.ch/docs/fr';
|
|
15
|
+
/**
|
|
16
|
+
* Where an agent should read next, per failure.
|
|
17
|
+
*
|
|
18
|
+
* A message that only states what went wrong leaves the agent guessing at the
|
|
19
|
+
* fix; one that carries an address lets it read the page and recover on its
|
|
20
|
+
* own. Anchors are kept ASCII so the URL survives being copied into a terminal
|
|
21
|
+
* or a chat client that mangles percent-encoding.
|
|
22
|
+
*/
|
|
23
|
+
const HELP = {
|
|
24
|
+
QUOTA_EXCEEDED: `${DOCS}/account/plans#quand-une-limite-est-atteinte`,
|
|
25
|
+
UNAUTHORIZED: `${DOCS}/guides/get-api-key`,
|
|
26
|
+
NO_ORGANIZATION: `${DOCS}/guides/manage-organization`,
|
|
27
|
+
DOCUMENT_NOT_FOUND: `${DOCS}/guides/ingest-a-norm`,
|
|
28
|
+
SECTION_NOT_FOUND: `${DOCS}/mcp/read-tools`,
|
|
29
|
+
INSUFFICIENT_ROLE: `${DOCS}/guides/manage-organization`,
|
|
30
|
+
RATE_LIMITED: `${DOCS}/resources/troubleshooting`,
|
|
31
|
+
DOSSIER_NOT_FOUND: `${DOCS}/mcp/dossier-tools`,
|
|
32
|
+
DOSSIER_CLOSED: `${DOCS}/guides/suivre-un-projet`,
|
|
33
|
+
ENTRY_NOT_FOUND: `${DOCS}/mcp/dossier-tools`,
|
|
34
|
+
TOO_MANY_DOSSIERS: `${DOCS}/mcp/dossier-tools`,
|
|
35
|
+
TOO_MANY_ENTRIES: `${DOCS}/mcp/dossier-tools`,
|
|
36
|
+
TOO_MANY_COMMENTS: `${DOCS}/mcp/dossier-tools`,
|
|
37
|
+
};
|
|
38
|
+
function withHelp(code, lines) {
|
|
39
|
+
const url = HELP[code];
|
|
40
|
+
return (url ? [...lines, `Détails : ${url}`] : lines).join('\n');
|
|
41
|
+
}
|
|
14
42
|
/**
|
|
15
43
|
* Turns a server error into something an agent can act on. A quota rejection
|
|
16
44
|
* is not a bug: the agent should stop retrying and tell the user which limit
|
|
@@ -29,22 +57,69 @@ export function formatToolError(err) {
|
|
|
29
57
|
lines.push(`Réinitialisation dans ${humanDuration(data.retryAfter)}.`);
|
|
30
58
|
}
|
|
31
59
|
lines.push('Ne relancez pas la même opération : elle échouera à l’identique tant que la limite est atteinte.');
|
|
32
|
-
return
|
|
60
|
+
return withHelp('QUOTA_EXCEEDED', lines);
|
|
33
61
|
}
|
|
34
62
|
if (typeof data === 'string') {
|
|
35
63
|
switch (data) {
|
|
36
64
|
case 'UNAUTHORIZED':
|
|
37
|
-
return
|
|
65
|
+
return withHelp(data, [
|
|
66
|
+
'Clé API invalide ou révoquée. Relancez `npx @stratta/mcp login` pour en enregistrer une autre.',
|
|
67
|
+
]);
|
|
38
68
|
case 'NO_ORGANIZATION':
|
|
39
|
-
return
|
|
69
|
+
return withHelp(data, [
|
|
70
|
+
"Cette clé n'est rattachée à aucune organisation. Ouvrez stratta.ch et recréez une clé depuis votre espace.",
|
|
71
|
+
]);
|
|
40
72
|
case 'DOCUMENT_NOT_FOUND':
|
|
41
|
-
return
|
|
73
|
+
return withHelp(data, [
|
|
74
|
+
"Cette norme n'existe pas dans votre organisation, ou elle appartient à une autre.",
|
|
75
|
+
'Appelez `list_norms` pour voir ce qui est disponible avant de réessayer.',
|
|
76
|
+
]);
|
|
42
77
|
case 'SECTION_NOT_FOUND':
|
|
43
|
-
return
|
|
78
|
+
return withHelp(data, [
|
|
79
|
+
"Cette section n'existe pas dans votre organisation.",
|
|
80
|
+
'Appelez `get_toc` sur la norme pour lire les chemins de section réels.',
|
|
81
|
+
]);
|
|
44
82
|
case 'INSUFFICIENT_ROLE':
|
|
45
|
-
return
|
|
83
|
+
return withHelp(data, [
|
|
84
|
+
"Cette action modifie le corpus de normes et demande un rôle propriétaire ou administrateur de l'organisation. Demandez à un administrateur de votre espace de l'exécuter (ou de vous accorder ce rôle).",
|
|
85
|
+
]);
|
|
46
86
|
case 'RATE_LIMITED':
|
|
47
|
-
return
|
|
87
|
+
return withHelp(data, [
|
|
88
|
+
'Trop de tentatives en peu de temps. Patientez une minute avant de réessayer.',
|
|
89
|
+
]);
|
|
90
|
+
case 'DOSSIER_NOT_FOUND':
|
|
91
|
+
return withHelp(data, [
|
|
92
|
+
"Ce dossier n'existe pas dans votre organisation.",
|
|
93
|
+
'Appelez `list_dossiers` pour voir les dossiers disponibles, ou `open_dossier` pour en créer un.',
|
|
94
|
+
]);
|
|
95
|
+
case 'DOSSIER_CLOSED':
|
|
96
|
+
return withHelp(data, [
|
|
97
|
+
'Ce dossier est visé : ses entrées ne peuvent plus être modifiées.',
|
|
98
|
+
"Ne réessayez pas. Dites à l'utilisateur de le repasser « en cours » sur stratta.ch/dossiers s'il veut le reprendre. Les commentaires, eux, restent possibles.",
|
|
99
|
+
]);
|
|
100
|
+
case 'ENTRY_NOT_FOUND':
|
|
101
|
+
return withHelp(data, [
|
|
102
|
+
"Cette entrée n'existe pas, ou elle appartient à un autre dossier.",
|
|
103
|
+
'Rechargez le dossier avec `load_dossier` pour obtenir des identifiants à jour.',
|
|
104
|
+
]);
|
|
105
|
+
case 'TOO_MANY_DOSSIERS':
|
|
106
|
+
return withHelp(data, [
|
|
107
|
+
'Cette organisation a atteint son plafond de dossiers.',
|
|
108
|
+
"Ne réessayez pas. Écrivez plutôt dans un dossier existant, ou dites à l'utilisateur d'en supprimer un.",
|
|
109
|
+
]);
|
|
110
|
+
case 'TOO_MANY_ENTRIES':
|
|
111
|
+
return withHelp(data, [
|
|
112
|
+
'Ce dossier a atteint son plafond d’entrées.',
|
|
113
|
+
"Ne réessayez pas. Un dossier aussi rempli est le signe qu'on y consigne trop : ouvrez-en un second pour la phase en cours.",
|
|
114
|
+
]);
|
|
115
|
+
case 'TOO_MANY_COMMENTS':
|
|
116
|
+
return withHelp(data, [
|
|
117
|
+
'Ce dossier a atteint son plafond de commentaires. Ne réessayez pas.',
|
|
118
|
+
]);
|
|
119
|
+
case 'ACCOUNT_SUSPENDED':
|
|
120
|
+
return withHelp(data, [
|
|
121
|
+
'Ce compte est suspendu. Ne réessayez pas ; contactez hello@stratta.ch.',
|
|
122
|
+
]);
|
|
48
123
|
default:
|
|
49
124
|
return data;
|
|
50
125
|
}
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,7 @@ import { ensureAuthenticated, resolveApiKey } from './auth.js';
|
|
|
10
10
|
import { elicitApiKey } from './elicit.js';
|
|
11
11
|
import { readTools } from './tools/read.js';
|
|
12
12
|
import { ingestTools } from './tools/ingest.js';
|
|
13
|
+
import { dossierTools } from './tools/dossier.js';
|
|
13
14
|
/**
|
|
14
15
|
* Read from package.json rather than repeated in a literal. The previous
|
|
15
16
|
* version announced `0.3.0` while the published package was `0.6.0`, so every
|
|
@@ -80,7 +81,7 @@ async function call(def, args) {
|
|
|
80
81
|
};
|
|
81
82
|
}
|
|
82
83
|
}
|
|
83
|
-
for (const def of [...readTools, ...ingestTools]) {
|
|
84
|
+
for (const def of [...readTools, ...dossierTools, ...ingestTools]) {
|
|
84
85
|
server.registerTool(def.name, {
|
|
85
86
|
title: def.title,
|
|
86
87
|
description: def.description,
|
|
@@ -94,7 +95,7 @@ for (const def of [...readTools, ...ingestTools]) {
|
|
|
94
95
|
async function main() {
|
|
95
96
|
if (process.argv[2] === 'login') {
|
|
96
97
|
const { runLogin } = await import('./login.js');
|
|
97
|
-
await runLogin();
|
|
98
|
+
await runLogin(process.argv.slice(3));
|
|
98
99
|
return;
|
|
99
100
|
}
|
|
100
101
|
await server.connect(new StdioServerTransport());
|
package/dist/login.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare function runLogin(): Promise<void>;
|
|
1
|
+
export declare function runLogin(argv?: Array<string>): Promise<void>;
|
package/dist/login.js
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { hostname } from 'node:os';
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
2
6
|
import { createConvexClient, api } from './client.js';
|
|
3
7
|
import { writeStoredApiKey, CONFIG_PATH } from './config.js';
|
|
8
|
+
const SITE_URL = process.env.STRATTA_SITE_URL ?? 'https://stratta.ch';
|
|
9
|
+
/** How long the loopback server waits before giving up on the browser. */
|
|
10
|
+
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
4
11
|
// Prompt for a secret on the TTY without echoing it back.
|
|
5
12
|
function promptSecret(query) {
|
|
6
13
|
return new Promise((resolve) => {
|
|
@@ -19,22 +26,159 @@ function promptSecret(query) {
|
|
|
19
26
|
muted = true;
|
|
20
27
|
});
|
|
21
28
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Open a URL in the user's default browser, best effort.
|
|
31
|
+
*
|
|
32
|
+
* Deliberately not a dependency: the three platform commands are three lines,
|
|
33
|
+
* and a login flow should not pull an install-time package into a server that
|
|
34
|
+
* otherwise has none. Failure is expected and survivable — headless boxes and
|
|
35
|
+
* SSH sessions have no browser, so the caller always prints the URL too.
|
|
36
|
+
*/
|
|
37
|
+
function openBrowser(url) {
|
|
38
|
+
const [cmd, args] = process.platform === 'win32'
|
|
39
|
+
? // `start` is a cmd builtin, not an executable, and it treats the first
|
|
40
|
+
// quoted argument as a window title — hence the empty one.
|
|
41
|
+
['cmd', ['/c', 'start', '', url]]
|
|
42
|
+
: process.platform === 'darwin'
|
|
43
|
+
? ['open', [url]]
|
|
44
|
+
: ['xdg-open', [url]];
|
|
45
|
+
try {
|
|
46
|
+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
47
|
+
child.on('error', () => { });
|
|
48
|
+
child.unref();
|
|
28
49
|
}
|
|
50
|
+
catch {
|
|
51
|
+
// Printed URL is the fallback.
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function page(title, body) {
|
|
55
|
+
return `<!doctype html><html lang="fr"><meta charset="utf-8">
|
|
56
|
+
<title>${title}</title>
|
|
57
|
+
<style>
|
|
58
|
+
body{font:16px/1.6 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
|
|
59
|
+
background:#12110f;color:#f5f1e8;display:grid;place-items:center;
|
|
60
|
+
min-height:100vh;margin:0;padding:2rem;text-align:center}
|
|
61
|
+
.c{max-width:26rem} h1{font-size:1.25rem;margin:0 0 .5rem}
|
|
62
|
+
p{margin:0;color:#a8a196}
|
|
63
|
+
</style>
|
|
64
|
+
<div class="c"><h1>${title}</h1><p>${body}</p></div>`;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Receive the key on 127.0.0.1 after the user approves in the browser.
|
|
68
|
+
*
|
|
69
|
+
* The loopback redirect is the same shape `firebase login` and `vercel login`
|
|
70
|
+
* use, and it is chosen over a device code for one reason: no server-side
|
|
71
|
+
* state. Nothing to store, expire, or poll — the page hands the key to a port
|
|
72
|
+
* that only this process is listening on.
|
|
73
|
+
*
|
|
74
|
+
* Two things keep that port honest. It binds to `127.0.0.1` explicitly, never
|
|
75
|
+
* `0.0.0.0`, so nothing off-machine can reach it. And it rejects any callback
|
|
76
|
+
* whose `state` does not match the one just generated, so another local
|
|
77
|
+
* process that guesses the port cannot feed us a key of its choosing.
|
|
78
|
+
*/
|
|
79
|
+
function awaitCallback(state, onPort) {
|
|
80
|
+
return new Promise((resolve) => {
|
|
81
|
+
let settled = false;
|
|
82
|
+
const finish = (result) => {
|
|
83
|
+
if (settled)
|
|
84
|
+
return;
|
|
85
|
+
settled = true;
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
server.close();
|
|
88
|
+
resolve(result);
|
|
89
|
+
};
|
|
90
|
+
const server = createServer((req, res) => {
|
|
91
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
92
|
+
if (url.pathname !== '/callback') {
|
|
93
|
+
res.writeHead(404).end();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const reply = (status, title, body) => {
|
|
97
|
+
res.writeHead(status, { 'content-type': 'text/html; charset=utf-8' });
|
|
98
|
+
res.end(page(title, body));
|
|
99
|
+
};
|
|
100
|
+
if (url.searchParams.get('state') !== state) {
|
|
101
|
+
reply(400, 'Requête refusée', 'Le jeton de session ne correspond pas. Relancez la commande de connexion.');
|
|
102
|
+
// Not fatal: keep listening. A mismatched callback is either a stale
|
|
103
|
+
// tab from a previous attempt or something we should ignore, and
|
|
104
|
+
// neither should cancel the login the user is in the middle of.
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
// The page reports a refusal rather than closing the tab and leaving the
|
|
108
|
+
// terminal to sit through the five-minute timeout. A user who clicks
|
|
109
|
+
// "Refuser" gets their prompt back immediately, which is the whole point
|
|
110
|
+
// of offering the button.
|
|
111
|
+
const denied = url.searchParams.get('error');
|
|
112
|
+
if (denied) {
|
|
113
|
+
reply(200, 'Autorisation refusée', 'Aucune clé n’a été créée. Vous pouvez fermer cet onglet.');
|
|
114
|
+
finish({ error: 'refusée depuis le navigateur' });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const key = url.searchParams.get('key');
|
|
118
|
+
if (!key) {
|
|
119
|
+
reply(400, 'Clé manquante', 'La page n’a transmis aucune clé.');
|
|
120
|
+
finish({ error: 'callback sans clé' });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
reply(200, 'Stratta est connecté', 'Vous pouvez fermer cet onglet et retourner à votre terminal.');
|
|
124
|
+
finish({ key });
|
|
125
|
+
});
|
|
126
|
+
const timer = setTimeout(() => finish({ error: 'délai dépassé' }), LOGIN_TIMEOUT_MS);
|
|
127
|
+
// `unref` so a forgotten timer never keeps the process alive on its own.
|
|
128
|
+
timer.unref?.();
|
|
129
|
+
server.on('error', (err) => finish({ error: err.message ?? 'port indisponible' }));
|
|
130
|
+
// Port 0 asks the OS for any free port, which avoids both a collision with
|
|
131
|
+
// a real service and a fixed port an attacker could camp on in advance.
|
|
132
|
+
server.listen(0, '127.0.0.1', () => {
|
|
133
|
+
onPort(server.address().port);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
async function saveValidated(apiKey) {
|
|
29
138
|
const client = createConvexClient();
|
|
30
139
|
const res = (await client.action(api.apiKeys.validateApiKey, {
|
|
31
140
|
plaintext: apiKey,
|
|
32
141
|
}));
|
|
33
142
|
if (!res?.valid) {
|
|
34
|
-
console.error('That API key is invalid. Generate a new one at https://stratta.ch and try again.');
|
|
35
|
-
|
|
36
|
-
return;
|
|
143
|
+
console.error('That API key is invalid. Generate a new one at https://stratta.ch/api-keys and try again.');
|
|
144
|
+
return false;
|
|
37
145
|
}
|
|
38
146
|
writeStoredApiKey(apiKey);
|
|
39
147
|
console.log(`Saved. Stratta MCP will use this key (stored at ${CONFIG_PATH}).`);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
/** The old flow, kept behind `--paste` for CI and headless machines. */
|
|
151
|
+
async function loginByPaste() {
|
|
152
|
+
const apiKey = await promptSecret('Paste your Stratta API key (from https://stratta.ch/api-keys): ');
|
|
153
|
+
if (!apiKey) {
|
|
154
|
+
console.error('No key entered. Aborting.');
|
|
155
|
+
process.exitCode = 1;
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (!(await saveValidated(apiKey)))
|
|
159
|
+
process.exitCode = 1;
|
|
160
|
+
}
|
|
161
|
+
async function loginByBrowser() {
|
|
162
|
+
const state = randomBytes(24).toString('base64url');
|
|
163
|
+
// Named after the machine so the key is recognisable in the dashboard a year
|
|
164
|
+
// from now, when the question is which laptop to revoke.
|
|
165
|
+
const label = `CLI ${hostname()}`.slice(0, 60);
|
|
166
|
+
const result = await awaitCallback(state, (port) => {
|
|
167
|
+
const url = `${SITE_URL}/cli-auth?port=${port}&state=${state}&label=${encodeURIComponent(label)}`;
|
|
168
|
+
console.log('Opening your browser to authorise this machine…');
|
|
169
|
+
console.log(`If it does not open, visit:\n\n ${url}\n`);
|
|
170
|
+
openBrowser(url);
|
|
171
|
+
});
|
|
172
|
+
if ('error' in result) {
|
|
173
|
+
console.error(`Browser login did not complete (${result.error}).\nRun \`npx @stratta/mcp login --paste\` to enter a key by hand instead.`);
|
|
174
|
+
process.exitCode = 1;
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (!(await saveValidated(result.key)))
|
|
178
|
+
process.exitCode = 1;
|
|
179
|
+
}
|
|
180
|
+
export async function runLogin(argv = []) {
|
|
181
|
+
if (argv.includes('--paste'))
|
|
182
|
+
return loginByPaste();
|
|
183
|
+
return loginByBrowser();
|
|
40
184
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const listDossiers: import("./define.js").ToolDef<{}>;
|
|
3
|
+
export declare const openDossier: import("./define.js").ToolDef<{
|
|
4
|
+
name: z.ZodString;
|
|
5
|
+
reference: z.ZodOptional<z.ZodString>;
|
|
6
|
+
}>;
|
|
7
|
+
export declare const saveFinding: import("./define.js").ToolDef<{
|
|
8
|
+
dossierId: z.ZodString;
|
|
9
|
+
kind: z.ZodEnum<{
|
|
10
|
+
reference: "reference";
|
|
11
|
+
hypothesis: "hypothesis";
|
|
12
|
+
observation: "observation";
|
|
13
|
+
question: "question";
|
|
14
|
+
}>;
|
|
15
|
+
title: z.ZodString;
|
|
16
|
+
detail: z.ZodOptional<z.ZodString>;
|
|
17
|
+
value: z.ZodOptional<z.ZodString>;
|
|
18
|
+
confidence: z.ZodOptional<z.ZodEnum<{
|
|
19
|
+
established: "established";
|
|
20
|
+
judgement: "judgement";
|
|
21
|
+
to_confirm: "to_confirm";
|
|
22
|
+
}>>;
|
|
23
|
+
normCode: z.ZodOptional<z.ZodString>;
|
|
24
|
+
sectionPath: z.ZodOptional<z.ZodString>;
|
|
25
|
+
page: z.ZodOptional<z.ZodNumber>;
|
|
26
|
+
}>;
|
|
27
|
+
export declare const loadDossier: import("./define.js").ToolDef<{
|
|
28
|
+
dossierId: z.ZodOptional<z.ZodString>;
|
|
29
|
+
name: z.ZodOptional<z.ZodString>;
|
|
30
|
+
}>;
|
|
31
|
+
export declare const resolveQuestion: import("./define.js").ToolDef<{
|
|
32
|
+
entryId: z.ZodString;
|
|
33
|
+
resolved: z.ZodOptional<z.ZodBoolean>;
|
|
34
|
+
}>;
|
|
35
|
+
export declare const dossierTools: (import("./define.js").ToolDef<{}> | import("./define.js").ToolDef<{
|
|
36
|
+
name: z.ZodString;
|
|
37
|
+
reference: z.ZodOptional<z.ZodString>;
|
|
38
|
+
}> | import("./define.js").ToolDef<{
|
|
39
|
+
dossierId: z.ZodString;
|
|
40
|
+
kind: z.ZodEnum<{
|
|
41
|
+
reference: "reference";
|
|
42
|
+
hypothesis: "hypothesis";
|
|
43
|
+
observation: "observation";
|
|
44
|
+
question: "question";
|
|
45
|
+
}>;
|
|
46
|
+
title: z.ZodString;
|
|
47
|
+
detail: z.ZodOptional<z.ZodString>;
|
|
48
|
+
value: z.ZodOptional<z.ZodString>;
|
|
49
|
+
confidence: z.ZodOptional<z.ZodEnum<{
|
|
50
|
+
established: "established";
|
|
51
|
+
judgement: "judgement";
|
|
52
|
+
to_confirm: "to_confirm";
|
|
53
|
+
}>>;
|
|
54
|
+
normCode: z.ZodOptional<z.ZodString>;
|
|
55
|
+
sectionPath: z.ZodOptional<z.ZodString>;
|
|
56
|
+
page: z.ZodOptional<z.ZodNumber>;
|
|
57
|
+
}> | import("./define.js").ToolDef<{
|
|
58
|
+
dossierId: z.ZodOptional<z.ZodString>;
|
|
59
|
+
name: z.ZodOptional<z.ZodString>;
|
|
60
|
+
}> | import("./define.js").ToolDef<{
|
|
61
|
+
entryId: z.ZodString;
|
|
62
|
+
resolved: z.ZodOptional<z.ZodBoolean>;
|
|
63
|
+
}>)[];
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { api } from '../client.js';
|
|
3
|
+
import { requireApiKey } from '../auth.js';
|
|
4
|
+
import { defineTool } from './define.js';
|
|
5
|
+
/**
|
|
6
|
+
* The five dossier tools: how the work survives the conversation.
|
|
7
|
+
*
|
|
8
|
+
* Everything else here reads a norm. These write down what was decided with
|
|
9
|
+
* it, so the next conversation starts where the last one stopped and a human
|
|
10
|
+
* can review it in the app.
|
|
11
|
+
*
|
|
12
|
+
* The descriptions carry more instruction than the read tools do, on purpose.
|
|
13
|
+
* An agent left to guess will either record nothing — and the feature is dead
|
|
14
|
+
* — or record every sentence it produces, which buries the three decisions
|
|
15
|
+
* that mattered. `get_methodology` tells it when to open a dossier; these tell
|
|
16
|
+
* it what is worth putting in one.
|
|
17
|
+
*/
|
|
18
|
+
const write = { readOnlyHint: false, openWorldHint: false };
|
|
19
|
+
const readOnly = { readOnlyHint: true, openWorldHint: false };
|
|
20
|
+
const dossierId = z
|
|
21
|
+
.string()
|
|
22
|
+
.min(1)
|
|
23
|
+
.describe('Dossier id returned by open_dossier or list_dossiers.');
|
|
24
|
+
export const listDossiers = defineTool({
|
|
25
|
+
name: 'list_dossiers',
|
|
26
|
+
title: 'List dossiers',
|
|
27
|
+
description: 'List the dossiers of YOUR organisation, most recently touched first. A dossier is a project record: what was decided, what it rests on, and what is still open. Call this when the user mentions a project by name, or asks what they were working on. Each row carries the count of entries and of UNRESOLVED questions — a dossier with open questions is the one to resume.',
|
|
28
|
+
inputSchema: {},
|
|
29
|
+
annotations: readOnly,
|
|
30
|
+
run: async (client) => ({
|
|
31
|
+
dossiers: await client.action(api.dossiersApi.listDossiers, {
|
|
32
|
+
apiKey: requireApiKey(),
|
|
33
|
+
}),
|
|
34
|
+
}),
|
|
35
|
+
});
|
|
36
|
+
export const openDossier = defineTool({
|
|
37
|
+
name: 'open_dossier',
|
|
38
|
+
title: 'Open a dossier',
|
|
39
|
+
description: "Open the dossier for a project, creating it if it does not exist yet. IDEMPOTENT on the name: calling it twice with the same name returns the same dossier rather than splitting a project's history in two. Call this when the user starts working on a named project and there is something worth keeping — a retained value, an assumption, a site observation, an unanswered question. Do not open one for a passing lookup. Returns { dossierId, created }.",
|
|
40
|
+
inputSchema: {
|
|
41
|
+
name: z
|
|
42
|
+
.string()
|
|
43
|
+
.min(1)
|
|
44
|
+
.max(120)
|
|
45
|
+
.describe('How the user refers to the project, e.g. "Villa Morges — toiture". Reuse their words: this is the name they will search for.'),
|
|
46
|
+
reference: z
|
|
47
|
+
.string()
|
|
48
|
+
.max(60)
|
|
49
|
+
.optional()
|
|
50
|
+
.describe("The bureau's own project number, if the user gave one."),
|
|
51
|
+
},
|
|
52
|
+
annotations: { ...write, idempotentHint: true },
|
|
53
|
+
run: (client, args) => client.action(api.dossiersApi.openDossier, {
|
|
54
|
+
apiKey: requireApiKey(),
|
|
55
|
+
name: args.name,
|
|
56
|
+
reference: args.reference,
|
|
57
|
+
}),
|
|
58
|
+
});
|
|
59
|
+
export const saveFinding = defineTool({
|
|
60
|
+
name: 'save_finding',
|
|
61
|
+
title: 'Save a finding to a dossier',
|
|
62
|
+
description: `Record ONE decision in a dossier. Call it as you work, not in a batch at the end — a finding saved when it is made carries the reasoning that produced it.
|
|
63
|
+
|
|
64
|
+
WHAT TO RECORD, by kind:
|
|
65
|
+
- reference: what a norm says, that the project relies on. ALWAYS fill normCode + sectionPath (+ page). If you cannot cite it, it is not a reference.
|
|
66
|
+
- hypothesis: a value the engineer RETAINS, and why. This is the one that matters most in geotechnics, where a retained value is a judgement between disagreeing measurements rather than the output of a formula. Put the number in \`value\` and the reasoning in \`detail\`.
|
|
67
|
+
- observation: what the site, a borehole, or a survey showed. Include the date in \`detail\` when known.
|
|
68
|
+
- question: something not settled. These surface FIRST when the dossier is reloaded, so record them even when you cannot answer — especially then.
|
|
69
|
+
|
|
70
|
+
WHAT NOT TO RECORD: your own prose, intermediate steps, anything the user did not treat as a decision. A dossier of forty entries where three mattered is worse than a dossier of three.
|
|
71
|
+
|
|
72
|
+
Set \`confidence\` whenever the entry is a value: established (computed or read directly), judgement (the engineer chose it), to_confirm (provisional, needs a test or a check). Confusing those three is the professional fault this field exists to prevent.`,
|
|
73
|
+
inputSchema: {
|
|
74
|
+
dossierId,
|
|
75
|
+
kind: z
|
|
76
|
+
.enum(['reference', 'hypothesis', 'observation', 'question'])
|
|
77
|
+
.describe('See the tool description: pick by what the entry IS.'),
|
|
78
|
+
title: z
|
|
79
|
+
.string()
|
|
80
|
+
.min(1)
|
|
81
|
+
.max(200)
|
|
82
|
+
.describe('The decision in one line, as an engineer would write it in a report.'),
|
|
83
|
+
detail: z
|
|
84
|
+
.string()
|
|
85
|
+
.max(4000)
|
|
86
|
+
.optional()
|
|
87
|
+
.describe('The reasoning: why this value, what it rests on, what disagreed. Required in practice for a hypothesis — without it the entry cannot be reviewed.'),
|
|
88
|
+
value: z
|
|
89
|
+
.string()
|
|
90
|
+
.max(120)
|
|
91
|
+
.optional()
|
|
92
|
+
.describe('The retained value WITH its unit, e.g. "30°", "1,25 kN/m²".'),
|
|
93
|
+
confidence: z
|
|
94
|
+
.enum(['established', 'judgement', 'to_confirm'])
|
|
95
|
+
.optional()
|
|
96
|
+
.describe('How firm the value is. See the tool description.'),
|
|
97
|
+
normCode: z
|
|
98
|
+
.string()
|
|
99
|
+
.max(40)
|
|
100
|
+
.optional()
|
|
101
|
+
.describe('Norm this comes from, e.g. "SIA 261".'),
|
|
102
|
+
sectionPath: z
|
|
103
|
+
.string()
|
|
104
|
+
.max(60)
|
|
105
|
+
.optional()
|
|
106
|
+
.describe('Exact section path, e.g. "14.2".'),
|
|
107
|
+
page: z
|
|
108
|
+
.number()
|
|
109
|
+
.int()
|
|
110
|
+
.positive()
|
|
111
|
+
.optional()
|
|
112
|
+
.describe('Page in the norm, so a human can verify it in their PDF.'),
|
|
113
|
+
},
|
|
114
|
+
annotations: write,
|
|
115
|
+
run: (client, args) => client.action(api.dossiersApi.saveFinding, {
|
|
116
|
+
apiKey: requireApiKey(),
|
|
117
|
+
...args,
|
|
118
|
+
}),
|
|
119
|
+
});
|
|
120
|
+
export const loadDossier = defineTool({
|
|
121
|
+
name: 'load_dossier',
|
|
122
|
+
title: 'Load a dossier',
|
|
123
|
+
description: 'Reload everything a dossier holds: retained values, what they rest on, site observations, open questions and the comments a colleague left. UNRESOLVED QUESTIONS COME FIRST — read them before anything else and tell the user what is still open. Call this whenever the user returns to a project ("reprends le dossier X", "on en était où sur Y") BEFORE answering anything about it, so you build on what was decided instead of deciding it again. Accepts either the id or the name the user uses.',
|
|
124
|
+
inputSchema: {
|
|
125
|
+
dossierId: z
|
|
126
|
+
.string()
|
|
127
|
+
.min(1)
|
|
128
|
+
.optional()
|
|
129
|
+
.describe('Preferred when you have it.'),
|
|
130
|
+
name: z
|
|
131
|
+
.string()
|
|
132
|
+
.min(1)
|
|
133
|
+
.max(120)
|
|
134
|
+
.optional()
|
|
135
|
+
.describe('The project name, when the user names it rather than giving an id. Matched exactly; call list_dossiers first if unsure.'),
|
|
136
|
+
},
|
|
137
|
+
annotations: readOnly,
|
|
138
|
+
run: (client, args) => client.action(api.dossiersApi.loadDossier, {
|
|
139
|
+
apiKey: requireApiKey(),
|
|
140
|
+
dossierId: args.dossierId,
|
|
141
|
+
name: args.name,
|
|
142
|
+
}),
|
|
143
|
+
});
|
|
144
|
+
export const resolveQuestion = defineTool({
|
|
145
|
+
name: 'resolve_question',
|
|
146
|
+
title: 'Close an open question',
|
|
147
|
+
description: 'Mark a question entry as settled, once it actually is. The entry stays in the dossier — the trail of what was once uncertain is part of the record — but it stops surfacing at the top on reload. Only call this when the user has confirmed the answer, never on your own reasoning.',
|
|
148
|
+
inputSchema: {
|
|
149
|
+
entryId: z
|
|
150
|
+
.string()
|
|
151
|
+
.min(1)
|
|
152
|
+
.describe('Entry id of the question, from load_dossier.'),
|
|
153
|
+
resolved: z
|
|
154
|
+
.boolean()
|
|
155
|
+
.optional()
|
|
156
|
+
.describe('Defaults to true. Pass false to reopen a question.'),
|
|
157
|
+
},
|
|
158
|
+
annotations: { ...write, idempotentHint: true },
|
|
159
|
+
run: async (client, args) => {
|
|
160
|
+
await client.action(api.dossiersApi.resolveQuestion, {
|
|
161
|
+
apiKey: requireApiKey(),
|
|
162
|
+
entryId: args.entryId,
|
|
163
|
+
resolved: args.resolved,
|
|
164
|
+
});
|
|
165
|
+
return { ok: true };
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
export const dossierTools = [
|
|
169
|
+
listDossiers,
|
|
170
|
+
openDossier,
|
|
171
|
+
saveFinding,
|
|
172
|
+
loadDossier,
|
|
173
|
+
resolveQuestion,
|
|
174
|
+
];
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stratta/mcp",
|
|
3
3
|
"mcpName": "io.github.hugogebel-boop/stratta",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.9.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>",
|