@stratta/mcp 0.5.0 → 0.7.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 CHANGED
@@ -87,23 +87,52 @@ settings come from environment variables — see [`.env.example`](./.env.example
87
87
 
88
88
  ## Tools exposed
89
89
 
90
+ **Read** (8 tools — query norms in your workspace):
91
+
92
+ | Tool | Purpose |
93
+ |---|---|
94
+ | `get_methodology` | Behavioural contract: persona, workflow, meta-routing hints, answer rules. Call first. |
95
+ | `list_norms` | List all norms published in your workspace (code, year, title, language). |
96
+ | `get_toc` | Hierarchical TOC for a norm (default `maxDepth=1` = chapters). |
97
+ | `get_subtree` | Drill into a chapter/section subtree (`path` + `maxDepth`). |
98
+ | `get_section` | Full enriched content of a section (formulas, tables, figures, cross-refs). |
99
+ | `search_in_norm` | Keyword search inside a norm. |
100
+ | `get_figure` | Retrieve a figure inline (base64 ImageContent) + public URL. |
101
+ | `get_cross_refs` | Outgoing cross-refs from a section to other norms. |
102
+
103
+ **Ingest** (10 tools — add YOUR licensed norms; driven by the bundled `ingest-norm` skill):
104
+
90
105
  | Tool | Purpose |
91
106
  |---|---|
92
- | `list_norms` | List all available norms (code, year, title, language). |
93
- | `get_toc` | Get the hierarchical table of contents for a norm. |
94
- | `get_section` | Fetch the full enriched content of a section (with formulas, tables, cross-refs). |
95
- | `search_in_norm` | Search sections within a norm by keyword. |
96
- | `get_figure` | Retrieve a figure (image) inline (base64) to reason about diagrams. |
97
- | `get_cross_refs` | List outgoing cross-references from a section to other norms. |
107
+ | `ingest_status` | Check if a norm already exists in your workspace. |
108
+ | `ingest_create_document` | Create a draft norm document. |
109
+ | `ingest_create_sections` | Bulk-insert sections (returns `nodeId → sectionId` map). |
110
+ | `ingest_attach_formula` | Attach a LaTeX formula to a section. |
111
+ | `ingest_attach_table` | Attach a structured table `{headers, rows}` to a section. |
112
+ | `ingest_attach_cross_ref` | Attach an explicit cross-ref to another norm. |
113
+ | `ingest_upload_figure` | Upload a figure (base64 PNG/JPEG/WebP, ≤ 8 MB) to a section. |
114
+ | `ingest_normalize_cross_refs` | Auto-detect and rebuild cross-refs from section content. |
115
+ | `ingest_publish` | Flip a draft to published — visible via the read tools. |
116
+ | `ingest_delete` | Delete a document and all its children. |
98
117
 
99
118
  ## How agents should use it
100
119
 
101
- 1. Call `list_norms` to see what's available.
102
- 2. Call `get_toc(norm)` to navigate the structure.
103
- 3. Call `get_section(norm, path)` to read specific content.
104
- 4. Use `search_in_norm` when the section path is unknown.
105
- 5. Follow `crossRefs` for compound questions (e.g. SIA 261 → SIA 263 → EC).
106
- 6. Call `get_figure` when the section references a figure relevant to the answer.
120
+ For querying:
121
+
122
+ 1. Call `get_methodology` first — load the behavioural contract.
123
+ 2. Call `list_norms` to see what's available in your workspace.
124
+ 3. Call `get_toc(norm)` to navigate the structure; `get_subtree` to drill in.
125
+ 4. Call `get_section(norm, path)` to read specific content.
126
+ 5. Use `search_in_norm` when the section path is unknown.
127
+ 6. Follow `crossRefs` for compound questions (e.g. SIA 261 → SIA 263 → EC).
128
+ 7. Call `get_figure` when the section references a figure relevant to the answer.
129
+
130
+ For ingesting your own licensed norms, see the bundled **`ingest-norm` skill** —
131
+ a 2-phase hybrid pipeline (since 0.4.0): a Python pre-pass
132
+ (`scripts/ingest-prepass.py`, PyMuPDF) extracts the hierarchical tree and
133
+ rasterizes figures deterministically, then the agent enriches sections with
134
+ summaries, LaTeX formulas, tables and cross-refs via targeted visual reading.
135
+ Requires Python ≥ 3.10 with PyMuPDF (`python -m pip install --user pymupdf`).
107
136
 
108
137
  ## Troubleshooting
109
138
 
@@ -128,8 +157,26 @@ settings come from environment variables — see [`.env.example`](./.env.example
128
157
 
129
158
  - Each user can create up to 50 active keys and 20 new keys per 24h. Revoke unused keys in the dashboard.
130
159
 
160
+ ### `QUOTA_EXCEEDED`
161
+
162
+ Your organization reached one of its limits. The error names the dimension, your
163
+ current count and the plan limit. Retrying will fail identically.
164
+
165
+ | Limit | Free | Pro | Team |
166
+ |---|---|---|---|
167
+ | Norms | 2 | 15 | 60 |
168
+ | Sections | 500 | 5,000 | 20,000 |
169
+ | Figures | 60 | 750 | 3,000 |
170
+ | Queries / month | 500 | 15,000 | 75,000 |
171
+ | Members | 1 | 3 | 15 |
172
+
173
+ Stock limits free up when you delete a norm (`ingest_delete`). The monthly query
174
+ counter resets on its own. Gauges live on the Workspace page of your dashboard,
175
+ and an org admin can also set caps below the plan. Full details:
176
+ https://docs.stratta.ch/en/account/plans
177
+
131
178
  ## License
132
179
 
133
- Proprietary — © SmartFlow (Hugo Gebel). All rights reserved. This package is the
180
+ Proprietary — © SmartFlow, Lausanne. All rights reserved. This package is the
134
181
  official Stratta MCP client; redistribution, modification, or reuse of the source
135
182
  is not permitted without prior written consent.
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Turns a server error into something an agent can act on. A quota rejection
3
+ * is not a bug: the agent should stop retrying and tell the user which limit
4
+ * was hit, so it gets the numbers instead of a stack trace.
5
+ */
6
+ export declare function formatToolError(err: unknown): string;
package/dist/errors.js ADDED
@@ -0,0 +1,51 @@
1
+ import { ConvexError } from 'convex/values';
2
+ function isQuotaPayload(data) {
3
+ return (typeof data === 'object' &&
4
+ data !== null &&
5
+ data.code === 'QUOTA_EXCEEDED');
6
+ }
7
+ function humanDuration(ms) {
8
+ const days = Math.floor(ms / 86_400_000);
9
+ if (days >= 1)
10
+ return `${days} jour${days > 1 ? 's' : ''}`;
11
+ const hours = Math.ceil(ms / 3_600_000);
12
+ return `${hours} heure${hours > 1 ? 's' : ''}`;
13
+ }
14
+ /**
15
+ * Turns a server error into something an agent can act on. A quota rejection
16
+ * is not a bug: the agent should stop retrying and tell the user which limit
17
+ * was hit, so it gets the numbers instead of a stack trace.
18
+ */
19
+ export function formatToolError(err) {
20
+ if (err instanceof ConvexError) {
21
+ const data = err.data;
22
+ if (isQuotaPayload(data)) {
23
+ const lines = [
24
+ data.message,
25
+ `Dimension : ${data.label} (${data.current}/${data.limit}, demandé : ${data.requested}).`,
26
+ `Plan : ${data.plan}.`,
27
+ ];
28
+ if (data.retryAfter) {
29
+ lines.push(`Réinitialisation dans ${humanDuration(data.retryAfter)}.`);
30
+ }
31
+ lines.push('Ne relancez pas la même opération : elle échouera à l’identique tant que la limite est atteinte.');
32
+ return lines.join('\n');
33
+ }
34
+ if (typeof data === 'string') {
35
+ switch (data) {
36
+ case 'UNAUTHORIZED':
37
+ return 'Clé API invalide ou révoquée. Relancez `npx @stratta/mcp login` pour en enregistrer une autre.';
38
+ case 'NO_ORGANIZATION':
39
+ return "Cette clé n'est rattachée à aucune organisation. Ouvrez stratta.ch et recréez une clé depuis votre espace.";
40
+ case 'DOCUMENT_NOT_FOUND':
41
+ return "Cette norme n'existe pas dans votre organisation, ou elle appartient à une autre.";
42
+ case 'SECTION_NOT_FOUND':
43
+ return "Cette section n'existe pas dans votre organisation.";
44
+ default:
45
+ return data;
46
+ }
47
+ }
48
+ return JSON.stringify(data);
49
+ }
50
+ return err instanceof Error ? err.message : String(err);
51
+ }
package/dist/index.js CHANGED
@@ -1,155 +1,105 @@
1
1
  #!/usr/bin/env node
2
- import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ import { readFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
- import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
5
7
  import { createConvexClient, api } from './client.js';
8
+ import { formatToolError } from './errors.js';
6
9
  import { ensureAuthenticated, resolveApiKey } from './auth.js';
7
10
  import { elicitApiKey } from './elicit.js';
8
- import { getMethodologyTool, handleGetMethodology } from './tools/get-methodology.js';
9
- import { listNormsTool, handleListNorms } from './tools/list-norms.js';
10
- import { getTocTool, handleGetToc } from './tools/get-toc.js';
11
- import { getSubtreeTool, handleGetSubtree } from './tools/get-subtree.js';
12
- import { getSectionTool, handleGetSection } from './tools/get-section.js';
13
- import { searchInNormTool, handleSearchInNorm } from './tools/search-in-norm.js';
14
- import { getFigureTool, handleGetFigure } from './tools/get-figure.js';
15
- import { getCrossRefsTool, handleGetCrossRefs } from './tools/get-cross-refs.js';
16
- import { ingestTools, isIngestTool, handleIngestTool } from './tools/ingest.js';
17
- const tools = [
18
- getMethodologyTool,
19
- listNormsTool,
20
- getTocTool,
21
- getSubtreeTool,
22
- getSectionTool,
23
- searchInNormTool,
24
- getFigureTool,
25
- getCrossRefsTool,
26
- ...ingestTools,
27
- ];
28
- const server = new Server({ name: 'stratta-mcp', version: '0.3.0' }, { capabilities: { tools: {} } });
11
+ import { readTools } from './tools/read.js';
12
+ import { ingestTools } from './tools/ingest.js';
13
+ /**
14
+ * Read from package.json rather than repeated in a literal. The previous
15
+ * version announced `0.3.0` while the published package was `0.6.0`, so every
16
+ * client's logs and every bug report carried the wrong number.
17
+ */
18
+ function packageVersion() {
19
+ const here = dirname(fileURLToPath(import.meta.url));
20
+ for (const candidate of ['../package.json', '../../package.json']) {
21
+ try {
22
+ const raw = readFileSync(join(here, candidate), 'utf8');
23
+ const version = JSON.parse(raw).version;
24
+ if (version)
25
+ return version;
26
+ }
27
+ catch {
28
+ // Try the next candidate: the file sits one level up from `dist/`, but
29
+ // two when running from source.
30
+ }
31
+ }
32
+ return '0.0.0';
33
+ }
34
+ const server = new McpServer({ name: 'stratta-mcp', version: packageVersion() }, { capabilities: { tools: {} } });
29
35
  const client = createConvexClient();
30
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
31
- tools,
32
- }));
33
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
34
- const { name, arguments: args } = request.params;
36
+ /**
37
+ * Everything every tool call needs, in one place: resolve the key (env, then
38
+ * stored config, then prompt the user), validate it, run the tool, report
39
+ * usage, and turn a thrown error into a message the agent can act on.
40
+ *
41
+ * Usage is reported with the plaintext key as proof of possession — the server
42
+ * derives the identity from it, so a call cannot be attributed to somebody
43
+ * else's key.
44
+ */
45
+ async function call(def, args) {
35
46
  const startedAt = Date.now();
36
- let apiKeyId = null;
37
47
  let resolvedKey = null;
48
+ let authenticated = false;
38
49
  let status = 'success';
39
- const recordUsage = () => {
40
- // Only record once authenticated. Send the plaintext key (proof of
41
- // possession) — the server derives the identity, so usage can't be forged
42
- // for another user's key.
43
- if (!apiKeyId || !resolvedKey)
50
+ const reportUsage = () => {
51
+ if (!authenticated || !resolvedKey)
44
52
  return;
45
53
  void client
46
54
  .action(api.apiKeys.recordToolUsage, {
47
55
  apiKey: resolvedKey,
48
- toolName: name,
56
+ toolName: def.name,
49
57
  status,
50
58
  durationMs: Date.now() - startedAt,
51
59
  })
52
- .catch((e) => console.error('[stratta-mcp] track failed:', e));
60
+ .catch((e) => console.error('[stratta-mcp] usage report failed:', e));
53
61
  };
54
62
  try {
55
- resolvedKey = resolveApiKey();
56
- if (!resolvedKey) {
57
- resolvedKey = await elicitApiKey(server, client);
58
- }
59
- const auth = await ensureAuthenticated(client, resolvedKey);
60
- apiKeyId = auth.apiKeyId;
61
- let result;
62
- if (isIngestTool(name)) {
63
- result = await handleIngestTool(client, name, (args ?? {}));
64
- recordUsage();
65
- return {
66
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
67
- };
68
- }
69
- switch (name) {
70
- case 'get_methodology':
71
- result = await handleGetMethodology(client, args);
72
- break;
73
- case 'list_norms':
74
- result = await handleListNorms(client);
75
- break;
76
- case 'get_toc':
77
- result = await handleGetToc(client, args);
78
- break;
79
- case 'get_subtree':
80
- result = await handleGetSubtree(client, args);
81
- break;
82
- case 'get_section':
83
- result = await handleGetSection(client, args);
84
- break;
85
- case 'search_in_norm':
86
- result = await handleSearchInNorm(client, args);
87
- break;
88
- case 'get_figure':
89
- result = await handleGetFigure(client, args);
90
- break;
91
- case 'get_cross_refs':
92
- result = await handleGetCrossRefs(client, args);
93
- break;
94
- default:
95
- return {
96
- content: [{ type: 'text', text: `Unknown tool: ${name}` }],
97
- isError: true,
98
- };
99
- }
100
- // Special handling for get_figure: return ImageContent inline
101
- if (name === 'get_figure' &&
102
- typeof result === 'object' &&
103
- result !== null &&
104
- 'image' in result) {
105
- const r = result;
106
- recordUsage();
107
- const lines = [`${r.figureNumber} — ${r.caption}`];
108
- if (r.url) {
109
- lines.push(`Image URL: ${r.url}`);
110
- lines.push(`To show this figure to the user, embed it inline in your reply as Markdown: ![${r.figureNumber}](${r.url})`);
111
- }
112
- return {
113
- content: [
114
- {
115
- type: 'image',
116
- data: r.image.base64,
117
- mimeType: r.image.mimeType,
118
- },
119
- {
120
- type: 'text',
121
- text: lines.join('\n'),
122
- },
123
- ],
124
- };
125
- }
126
- recordUsage();
63
+ resolvedKey = resolveApiKey() ?? (await elicitApiKey(server.server, client));
64
+ await ensureAuthenticated(client, resolvedKey);
65
+ authenticated = true;
66
+ const result = await def.run(client, args);
67
+ reportUsage();
68
+ if (def.toContent)
69
+ return def.toContent(result);
127
70
  return {
128
- content: [
129
- { type: 'text', text: JSON.stringify(result, null, 2) },
130
- ],
71
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
131
72
  };
132
73
  }
133
74
  catch (err) {
134
75
  status = 'error';
135
- recordUsage();
136
- const message = err instanceof Error ? err.message : String(err);
76
+ reportUsage();
137
77
  return {
138
- content: [{ type: 'text', text: `Error: ${message}` }],
78
+ content: [{ type: 'text', text: `Error: ${formatToolError(err)}` }],
139
79
  isError: true,
140
80
  };
141
81
  }
142
- });
82
+ }
83
+ for (const def of [...readTools, ...ingestTools]) {
84
+ server.registerTool(def.name, {
85
+ title: def.title,
86
+ description: def.description,
87
+ inputSchema: def.inputSchema,
88
+ annotations: def.annotations,
89
+ },
90
+ // The SDK validates `args` against `inputSchema` before this runs, so the
91
+ // handler receives values that already match the declared shape.
92
+ (args) => call(def, args));
93
+ }
143
94
  async function main() {
144
95
  if (process.argv[2] === 'login') {
145
96
  const { runLogin } = await import('./login.js');
146
97
  await runLogin();
147
98
  return;
148
99
  }
149
- const transport = new StdioServerTransport();
150
- await server.connect(transport);
151
- // Stderr-only logging; stdout is reserved for MCP protocol.
152
- console.error('[stratta-mcp] started, listening on stdio');
100
+ await server.connect(new StdioServerTransport());
101
+ // Stderr only; stdout carries the MCP protocol.
102
+ console.error(`[stratta-mcp] v${packageVersion()} listening on stdio`);
153
103
  }
154
104
  main().catch((err) => {
155
105
  console.error('[stratta-mcp] fatal:', err);
@@ -0,0 +1,45 @@
1
+ import type { ConvexHttpClient } from 'convex/browser';
2
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
3
+ import type { z, ZodRawShape } from 'zod';
4
+ /**
5
+ * A tool, described once.
6
+ *
7
+ * The previous shape kept three things apart that always change together: a
8
+ * hand-written JSON Schema literal, a `handleX` function that re-declared the
9
+ * same argument types with `as` casts, and a `case 'x':` in a switch that
10
+ * wired them up. Nothing checked that the three agreed — and they did not:
11
+ * `args.year as number` happily accepted the string `"2020"` and failed later,
12
+ * inside Convex, with an error the agent could not act on.
13
+ *
14
+ * Here the Zod shape IS the schema (the SDK converts it to JSON Schema for the
15
+ * client), IS the runtime validation (the SDK rejects bad arguments before the
16
+ * handler runs), and IS the argument type (`z.infer`). One source, three uses.
17
+ */
18
+ export type ToolDef<S extends ZodRawShape = ZodRawShape> = {
19
+ name: string;
20
+ /** Human-facing label shown by clients that display one. */
21
+ title: string;
22
+ description: string;
23
+ inputSchema: S;
24
+ /**
25
+ * Behaviour hints for the client. `readOnlyHint` lets an agent know a call
26
+ * is safe to retry or run speculatively; `destructiveHint` is what makes a
27
+ * client ask before running `ingest_delete`. Absent before — every tool
28
+ * looked equally dangerous, which means none did.
29
+ */
30
+ annotations?: {
31
+ readOnlyHint?: boolean;
32
+ destructiveHint?: boolean;
33
+ idempotentHint?: boolean;
34
+ openWorldHint?: boolean;
35
+ };
36
+ run: (client: ConvexHttpClient, args: z.infer<z.ZodObject<S>>) => Promise<unknown>;
37
+ /**
38
+ * Optional override for turning the result into MCP content. Only
39
+ * `get_figure` needs it, to return the image as an `ImageContent` block
40
+ * rather than as JSON.
41
+ */
42
+ toContent?: (result: unknown) => CallToolResult;
43
+ };
44
+ /** Identity helper that keeps the generic bound to the literal shape. */
45
+ export declare function defineTool<S extends ZodRawShape>(def: ToolDef<S>): ToolDef<S>;
@@ -0,0 +1,4 @@
1
+ /** Identity helper that keeps the generic bound to the literal shape. */
2
+ export function defineTool(def) {
3
+ return def;
4
+ }