@stratta/mcp 0.9.8 → 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.
- package/README.md +47 -11
- package/dist/confirm.d.ts +15 -0
- package/dist/confirm.js +54 -0
- package/dist/errors.js +27 -0
- package/dist/index.js +27 -2
- package/dist/prompts.d.ts +4 -0
- package/dist/prompts.js +40 -0
- package/dist/resources.d.ts +3 -0
- package/dist/resources.js +35 -0
- package/dist/tools/catalog.gen.d.ts +34 -0
- package/dist/tools/catalog.gen.js +724 -0
- package/dist/tools/dossier.d.ts +96 -4
- package/dist/tools/dossier.js +291 -90
- package/dist/tools/ingest.d.ts +8 -0
- package/dist/tools/ingest.js +116 -98
- package/dist/tools/read.d.ts +42 -17
- package/dist/tools/read.js +160 -84
- package/dist/usage.d.ts +3 -0
- package/dist/usage.js +50 -0
- package/package.json +1 -1
- package/scripts/ingest-prepass.py +499 -54
- package/scripts/tests/test_prepass.py +211 -0
- package/skills/consult-stratta/SKILL.md +73 -0
- package/skills/ingest-norm/SKILL.md +99 -44
- package/skills/verification-note/SKILL.md +54 -0
package/dist/tools/read.js
CHANGED
|
@@ -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
|
|
7
|
+
* The read tools: how an agent navigates a norm.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
},
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 nodeId, path, title, summary, pageStart, pageEnd, depth, and children (empty at the maxDepth boundary).",
|
|
56
|
+
...headOf('get_toc'),
|
|
48
57
|
inputSchema: {
|
|
49
|
-
|
|
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('
|
|
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.
|
|
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.
|
|
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 {
|
|
83
|
+
return { code: args.code, tree };
|
|
74
84
|
},
|
|
75
85
|
});
|
|
76
86
|
export const getSubtree = defineTool({
|
|
77
|
-
|
|
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 nodeId, path, title, summary, depth, pageStart, pageEnd, and recursive children.',
|
|
87
|
+
...headOf('get_subtree'),
|
|
80
88
|
inputSchema: {
|
|
81
|
-
|
|
82
|
-
|
|
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('
|
|
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.
|
|
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.
|
|
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 {
|
|
121
|
+
return { code: args.code, root: args.path, tree };
|
|
105
122
|
},
|
|
106
123
|
});
|
|
107
124
|
export const getSection = defineTool({
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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.
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
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('
|
|
192
|
+
.describe(param('search_in_norm', 'limit')),
|
|
145
193
|
},
|
|
146
|
-
annotations: readOnly,
|
|
147
194
|
run: async (client, args) => ({
|
|
148
|
-
|
|
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.
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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.
|
|
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.
|
|
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}
|
|
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
|
];
|
package/dist/usage.d.ts
ADDED
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.
|
|
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>",
|