@stratta/mcp 0.10.0 → 0.12.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.
@@ -1,40 +1,51 @@
1
1
  import { z } from 'zod';
2
2
  import { api } from '../client.js';
3
3
  import { requireApiKey } from '../auth.js';
4
+ import { catalogEntry, param } from './catalog.gen.js';
4
5
  import { defineTool } from './define.js';
5
6
  /**
6
- * The eight read tools: how an agent navigates a norm.
7
+ * The read tools: how an agent navigates a norm.
7
8
  *
8
- * All of them are `readOnlyHint: true` — they never write, so a client is free
9
- * to retry them and an agent is free to explore.
9
+ * Names, descriptions and annotations come from the generated catalogue, which
10
+ * the remote transport reads too: the two transports cannot describe the same
11
+ * tool differently any more (ADR 22 § 2.4). What stays here is the argument
12
+ * schema and the call.
13
+ *
14
+ * ⚠️ The norm argument is `code` on both transports since 0.12.0. It used to
15
+ * be `norm` here and `code` remotely; an agent reads the schema on every
16
+ * session, so the rename costs it nothing, and one name is one less thing a
17
+ * skill can get wrong.
10
18
  */
11
- const norm = z
12
- .string()
13
- .min(1)
14
- .describe('Norm code, e.g. "SIA 261", "SIA 263", "EN 1992-1-1".');
15
- const sectionPath = z
16
- .string()
17
- .min(1)
18
- .describe('Section path as it appears in the document, e.g. "4.2.1" or "Annexe A".');
19
- const readOnly = { readOnlyHint: true, openWorldHint: false };
19
+ const headOf = (name) => {
20
+ const entry = catalogEntry(name);
21
+ return {
22
+ name,
23
+ title: entry.title,
24
+ description: entry.description,
25
+ annotations: entry.annotations,
26
+ };
27
+ };
28
+ const code = (tool) => z.string().min(1).describe(param(tool, 'code'));
29
+ const path = (tool) => z.string().min(1).describe(param(tool, 'path'));
30
+ const edition = (tool) => z.string().optional().describe(param(tool, 'edition'));
20
31
  export const getMethodology = defineTool({
21
- name: 'get_methodology',
22
- title: 'Get methodology',
23
- description: 'MANDATORY FIRST CALL when answering any technical question about Swiss civil engineering norms via Stratta. Returns the canonical persona, navigation workflow, meta-routing hints (which SIA norms cover which topics), tree-navigation rules (where to look for formulas vs coefficients vs definitions), citation format and answer rules. Adopt these rules verbatim for the rest of the consultation. If you skip this call you WILL produce lower-quality answers (wrong citation format, missing cross-norm dependencies, hallucinated values).',
24
- inputSchema: {
25
- norm: norm
26
- .optional()
27
- .describe('Optional: norm code the user is asking about, if already known. Used to scope methodology hints (currently informational only).'),
28
- },
29
- annotations: readOnly,
30
- run: (client, args) => client.query(api._mcp.getMethodology, { norm: args.norm }),
32
+ ...headOf('get_methodology'),
33
+ inputSchema: {},
34
+ run: (client) => client.action(api._mcp.getMethodology, { apiKey: requireApiKey() }),
35
+ // One Markdown document, read at the start of every conversation.
36
+ toContent: (result) => ({
37
+ content: [{ type: 'text', text: result.text }],
38
+ structuredContent: result,
39
+ }),
40
+ });
41
+ export const whoami = defineTool({
42
+ ...headOf('whoami'),
43
+ inputSchema: {},
44
+ run: (client) => client.action(api._mcp.whoami, { apiKey: requireApiKey() }),
31
45
  });
32
46
  export const listNorms = defineTool({
33
- name: 'list_norms',
34
- title: 'List norms',
35
- description: "List all engineering norms (SIA, Eurocodes, etc.) available in YOUR workspace. Returns code, year, title, and language for each. Workflow: call get_methodology FIRST (persona + rules), then list_norms to know what is queryable, then meta-route the user's question to the relevant norms before drilling in.",
47
+ ...headOf('list_norms'),
36
48
  inputSchema: {},
37
- annotations: readOnly,
38
49
  run: async (client) => ({
39
50
  norms: await client.action(api._mcp.listPublishedNorms, {
40
51
  apiKey: requireApiKey(),
@@ -42,24 +53,23 @@ export const listNorms = defineTool({
42
53
  }),
43
54
  });
44
55
  export const getToc = defineTool({
45
- name: 'get_toc',
46
- title: 'Get table of contents',
47
- description: "Get the high-level table of contents for a norm. By default returns only top-level chapters (depth=1) to stay light. Call get_subtree on a specific chapter's path to drill into sections + subsections. Increase maxDepth if you need a wider overview (cost: response size grows fast). Each node has path, title, summary, pageStart, pageEnd, depth, and children (empty at the maxDepth boundary).",
56
+ ...headOf('get_toc'),
48
57
  inputSchema: {
49
- norm,
58
+ code: code('get_toc'),
59
+ edition: edition('get_toc'),
50
60
  maxDepth: z
51
61
  .number()
52
62
  .int()
53
63
  .min(1)
54
64
  .max(6)
55
65
  .optional()
56
- .describe('Maximum nesting depth to include. Defaults to 1 (chapters only). depth=2 includes sections X.Y. depth=3 includes sub-subsections X.Y.Z (may exceed response size limit on large norms).'),
66
+ .describe(param('get_toc', 'maxDepth')),
57
67
  },
58
- annotations: readOnly,
59
68
  run: async (client, args) => {
60
69
  const tree = await client.action(api._mcp.getDocumentToc, {
61
70
  apiKey: requireApiKey(),
62
- code: args.norm,
71
+ code: args.code,
72
+ edition: args.edition,
63
73
  maxDepth: args.maxDepth,
64
74
  });
65
75
  // An unknown or unpublished norm comes back empty. Saying so beats
@@ -67,132 +77,195 @@ export const getToc = defineTool({
67
77
  // chapters" and then answer from memory.
68
78
  if (!tree || (Array.isArray(tree) && tree.length === 0)) {
69
79
  return {
70
- error: `Norm "${args.norm}" not found in your workspace, or not published yet. Call list_norms to see what is available.`,
80
+ error: `Norm "${args.code}" not found in your workspace, or not published yet. Call list_norms to see what is available.`,
71
81
  };
72
82
  }
73
- return { norm: args.norm, tree };
83
+ return { code: args.code, tree };
74
84
  },
75
85
  });
76
86
  export const getSubtree = defineTool({
77
- name: 'get_subtree',
78
- title: 'Get subtree',
79
- description: 'Drill down into a specific chapter or section. Returns the subtree rooted at `path` with optional depth limit (relative to the root). Use this after get_toc to explore one chapter in detail without fetching the entire TOC. Each node has path, title, summary, depth, pageStart, pageEnd, and recursive children.',
87
+ ...headOf('get_subtree'),
80
88
  inputSchema: {
81
- norm,
82
- path: sectionPath.describe('Section path to root the subtree at, e.g. "14" or "14.2".'),
89
+ code: code('get_subtree'),
90
+ edition: edition('get_subtree'),
91
+ path: path('get_subtree'),
83
92
  maxDepth: z
84
93
  .number()
85
94
  .int()
86
95
  .min(1)
87
96
  .max(6)
88
97
  .optional()
89
- .describe('Max nesting depth relative to the root. Default: unlimited. depth=1 returns the root + direct children only.'),
98
+ .describe(param('get_subtree', 'maxDepth')),
99
+ maxNodes: z
100
+ .number()
101
+ .int()
102
+ .min(20)
103
+ .max(2000)
104
+ .optional()
105
+ .describe(param('get_subtree', 'maxNodes')),
90
106
  },
91
- annotations: readOnly,
92
107
  run: async (client, args) => {
93
108
  const tree = await client.action(api._mcp.getSubtree, {
94
109
  apiKey: requireApiKey(),
95
- code: args.norm,
110
+ code: args.code,
111
+ edition: args.edition,
96
112
  path: args.path,
97
113
  maxDepth: args.maxDepth,
114
+ maxNodes: args.maxNodes,
98
115
  });
99
116
  if (tree === null) {
100
117
  return {
101
- error: `Section "${args.path}" not found in norm "${args.norm}".`,
118
+ error: `Section "${args.path}" not found in norm "${args.code}", or the norm is not in your workspace. Call list_norms, then get_toc on the norm.`,
102
119
  };
103
120
  }
104
- return { norm: args.norm, root: args.path, tree };
121
+ return { code: args.code, root: args.path, tree };
105
122
  },
106
123
  });
107
124
  export const getSection = defineTool({
108
- name: 'get_section',
109
- title: 'Get section',
110
- description: "Fetch the full enriched content of a section: markdown text with formulas in LaTeX and tables inline, pageStart/pageEnd, figures (call get_figure for the image), attached tables/formulas, and cross-references to other norms. ALL technical claims in your answer MUST be backed by a [<norm> <path>, p. <pageStart>] citation pointing to a section you actually fetched via get_section — never cite from memory. When a section's crossRefs list non-empty targets, follow them with another get_section call if the answer depends on them.",
111
- inputSchema: { norm, path: sectionPath },
112
- annotations: readOnly,
125
+ ...headOf('get_section'),
126
+ inputSchema: {
127
+ code: code('get_section'),
128
+ edition: edition('get_section'),
129
+ path: path('get_section'),
130
+ maxChars: z
131
+ .number()
132
+ .int()
133
+ .min(500)
134
+ .max(40_000)
135
+ .optional()
136
+ .describe(param('get_section', 'maxChars')),
137
+ offset: z
138
+ .number()
139
+ .int()
140
+ .min(0)
141
+ .optional()
142
+ .describe(param('get_section', 'offset')),
143
+ },
113
144
  run: async (client, args) => {
114
- const section = await client.action(api._mcp.getSection, {
145
+ const section = (await client.action(api._mcp.getSection, {
115
146
  apiKey: requireApiKey(),
116
- code: args.norm,
147
+ code: args.code,
148
+ edition: args.edition,
117
149
  path: args.path,
118
- });
150
+ maxChars: args.maxChars,
151
+ offset: args.offset,
152
+ }));
119
153
  if (section === null) {
120
154
  return {
121
- error: `Section "${args.path}" not found in norm "${args.norm}".`,
155
+ error: `Section "${args.path}" not found in norm "${args.code}", or the norm is not in your workspace. Call list_norms, then get_toc on the norm to read its section paths.`,
122
156
  };
123
157
  }
124
- return { norm: args.norm, section };
158
+ return { code: args.code, section };
159
+ },
160
+ // The section as one document. The JSON travels as `structuredContent` for
161
+ // a client that wants the fields; the model reads the Markdown.
162
+ toContent: (result) => {
163
+ const r = result;
164
+ if (r.error || !r.section) {
165
+ return {
166
+ content: [{ type: 'text', text: r.error ?? 'Section unavailable.' }],
167
+ isError: true,
168
+ };
169
+ }
170
+ return {
171
+ content: [{ type: 'text', text: r.section.markdown }],
172
+ structuredContent: r.section,
173
+ };
125
174
  },
126
175
  });
127
176
  export const searchInNorm = defineTool({
128
- name: 'search_in_norm',
129
- title: 'Search in norm',
130
- description: 'Full-text search within a norm. Returns up to `limit` matches in relevance order, with path, title and a snippet. Matching is by term, not substring: "charges variables" finds sections containing those words, and a hit in a section TITLE ranks above one in its body. Use this when you do not yet know which section to read.',
177
+ ...headOf('search_in_norm'),
131
178
  inputSchema: {
132
- norm,
179
+ code: code('search_in_norm'),
180
+ edition: edition('search_in_norm'),
133
181
  keyword: z
134
182
  .string()
135
183
  .min(1)
136
184
  .max(200)
137
- .describe('Search terms. Searches both section titles and content.'),
185
+ .describe(param('search_in_norm', 'keyword')),
138
186
  limit: z
139
187
  .number()
140
188
  .int()
141
189
  .min(1)
142
190
  .max(50)
143
191
  .optional()
144
- .describe('Max results to return (default 20).'),
192
+ .describe(param('search_in_norm', 'limit')),
145
193
  },
146
- annotations: readOnly,
147
194
  run: async (client, args) => ({
148
- norm: args.norm,
195
+ code: args.code,
196
+ edition: args.edition,
149
197
  keyword: args.keyword,
150
198
  hits: await client.action(api._mcp.searchInNorm, {
151
199
  apiKey: requireApiKey(),
152
- code: args.norm,
200
+ code: args.code,
201
+ edition: args.edition,
202
+ keyword: args.keyword,
203
+ limit: args.limit,
204
+ }),
205
+ }),
206
+ });
207
+ export const searchCorpus = defineTool({
208
+ ...headOf('search_corpus'),
209
+ inputSchema: {
210
+ keyword: z
211
+ .string()
212
+ .min(1)
213
+ .max(200)
214
+ .describe(param('search_corpus', 'keyword')),
215
+ limit: z
216
+ .number()
217
+ .int()
218
+ .min(1)
219
+ .max(40)
220
+ .optional()
221
+ .describe(param('search_corpus', 'limit')),
222
+ },
223
+ run: async (client, args) => ({
224
+ keyword: args.keyword,
225
+ norms: await client.action(api._mcp.searchCorpus, {
226
+ apiKey: requireApiKey(),
153
227
  keyword: args.keyword,
154
228
  limit: args.limit,
155
229
  }),
156
230
  }),
157
231
  });
158
232
  export const getCrossRefs = defineTool({
159
- name: 'get_cross_refs',
160
- title: 'Get cross-references',
161
- description: 'List outgoing cross-references from a section to other norms (e.g. SIA 261 §4.2 → SIA 263). For compound questions you MUST follow these refs: fetch each target via get_section before concluding. Skipping cross-refs is the #1 cause of incomplete answers — SIA norms intentionally distribute the rule, the coefficient, and the action across separate norms (260 / 261 / domain).',
162
- inputSchema: { norm, path: sectionPath },
163
- annotations: readOnly,
233
+ ...headOf('get_cross_refs'),
234
+ inputSchema: {
235
+ code: code('get_cross_refs'),
236
+ edition: edition('get_cross_refs'),
237
+ path: path('get_cross_refs'),
238
+ },
164
239
  run: async (client, args) => ({
165
- norm: args.norm,
240
+ code: args.code,
241
+ edition: args.edition,
166
242
  path: args.path,
167
243
  crossRefs: await client.action(api._mcp.getCrossRefs, {
168
244
  apiKey: requireApiKey(),
169
- code: args.norm,
245
+ code: args.code,
246
+ edition: args.edition,
170
247
  path: args.path,
171
248
  }),
172
249
  }),
173
250
  });
174
251
  export const getFigure = defineTool({
175
- name: 'get_figure',
176
- title: 'Get figure',
177
- description: 'Retrieve a figure (image) referenced in a section. Returns the image inline (base64) so you can see and reason about it. Use the `id` returned by get_section in its `figures` array.',
252
+ ...headOf('get_figure'),
178
253
  inputSchema: {
179
- norm,
180
- figureId: z
181
- .string()
182
- .min(1)
183
- .describe('Figure ID from get_section response (figures[].id).'),
254
+ code: code('get_figure'),
255
+ edition: edition('get_figure'),
256
+ figureId: z.string().min(1).describe(param('get_figure', 'figureId')),
184
257
  },
185
- annotations: readOnly,
186
258
  run: async (client, args) => {
187
259
  const apiKey = requireApiKey();
188
260
  const meta = (await client.action(api._mcp.getFigureMeta, {
189
261
  apiKey,
190
- code: args.norm,
262
+ code: args.code,
263
+ edition: args.edition,
191
264
  figureId: args.figureId,
192
265
  }));
193
266
  if (!meta)
194
267
  return { error: 'Figure not found.' };
195
- // The storage URL is used server-side, here, only to download the bytes —
268
+ // The storage URL is used server-side, here, only to download the bytes;
196
269
  // it is never returned to the agent. Convex `getUrl` hands back a bare,
197
270
  // unsigned, non-expiring public link, and the norm figures are licensed,
198
271
  // per-org content: surfacing that link would drop a permanent, tenant-
@@ -200,7 +273,8 @@ export const getFigure = defineTool({
200
273
  // logs). The agent gets the image inline instead.
201
274
  const url = (await client.action(api._mcp.getStorageUrl, {
202
275
  apiKey,
203
- code: args.norm,
276
+ code: args.code,
277
+ edition: args.edition,
204
278
  figureId: args.figureId,
205
279
  }));
206
280
  if (!url)
@@ -230,18 +304,20 @@ export const getFigure = defineTool({
230
304
  return {
231
305
  content: [
232
306
  { type: 'image', data: r.image.base64, mimeType: r.image.mimeType },
233
- { type: 'text', text: `${r.figureNumber} — ${r.caption}` },
307
+ { type: 'text', text: `${r.figureNumber}: ${r.caption}` },
234
308
  ],
235
309
  };
236
310
  },
237
311
  });
238
312
  export const readTools = [
239
313
  getMethodology,
314
+ whoami,
240
315
  listNorms,
241
316
  getToc,
242
317
  getSubtree,
243
318
  getSection,
244
319
  searchInNorm,
320
+ searchCorpus,
245
321
  getCrossRefs,
246
322
  getFigure,
247
323
  ];
@@ -0,0 +1,3 @@
1
+ export declare const USAGE_DIGEST_MAX = 80;
2
+ export declare const SESSION_ID: `${string}-${string}-${string}-${string}-${string}`;
3
+ export declare function usageDigest(args: unknown): string | undefined;
package/dist/usage.js ADDED
@@ -0,0 +1,50 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ /**
3
+ * Session and argument digest reported with every tool call.
4
+ *
5
+ * A stdio server lives exactly as long as the agent's process, so one id per
6
+ * process is the session: everything an agent did in one conversation lands
7
+ * under the same `sessionId`, and the dashboard can read the path it took.
8
+ *
9
+ * The digest is a short, fixed address of what was asked for (norm code,
10
+ * section path, search terms, identifiers), never the free-form fields. Both
11
+ * strings are telemetry for the key's own organisation and nothing else: the
12
+ * server bounds them and never lets them decide anything.
13
+ *
14
+ * ⚠️ Mirror of `convex/usageDigest.ts`. The package is published alone and
15
+ * cannot import from the workspace; keep the two in step.
16
+ */
17
+ const DIGEST_KEYS = [
18
+ 'code',
19
+ 'norm',
20
+ 'path',
21
+ 'keyword',
22
+ 'figureId',
23
+ 'documentId',
24
+ 'sectionId',
25
+ 'dossierId',
26
+ 'questionId',
27
+ 'name',
28
+ ];
29
+ export const USAGE_DIGEST_MAX = 80;
30
+ export const SESSION_ID = randomUUID();
31
+ export function usageDigest(args) {
32
+ if (typeof args !== 'object' || args === null)
33
+ return undefined;
34
+ const record = args;
35
+ const parts = [];
36
+ for (const key of DIGEST_KEYS) {
37
+ const value = record[key];
38
+ if (typeof value === 'string') {
39
+ const trimmed = value.trim();
40
+ if (trimmed.length > 0)
41
+ parts.push(trimmed);
42
+ }
43
+ else if (typeof value === 'number') {
44
+ parts.push(String(value));
45
+ }
46
+ }
47
+ if (parts.length === 0)
48
+ return undefined;
49
+ return parts.join('|').slice(0, USAGE_DIGEST_MAX);
50
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stratta/mcp",
3
3
  "mcpName": "ch.stratta/mcp",
4
- "version": "0.10.0",
4
+ "version": "0.12.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>",