@ontrails/mcp 0.2.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/src/index.ts ADDED
@@ -0,0 +1,52 @@
1
+ // Build
2
+ export {
3
+ MCP_TOOL_ERROR_META_KEY,
4
+ MCP_TOOL_EXAMPLES_META_KEY,
5
+ MCP_TOOL_DEFERRED_META_KEY,
6
+ MCP_TOOL_TRAILHEAD_META_KEY,
7
+ deriveMcpTools,
8
+ type DeriveMcpToolsOptions,
9
+ type McpSurfaceTrailheadDefinition,
10
+ type McpSurfaceTrailheadMap,
11
+ type McpSurfaceTrailheadTrailSelector,
12
+ type McpToolDefinition,
13
+ type McpToolResult,
14
+ type McpToolErrorMeta,
15
+ type McpContent,
16
+ type McpExtra,
17
+ type ResolveMcpPermit,
18
+ type ResolveMcpPermitInput,
19
+ } from './build.js';
20
+
21
+ // MCP resources
22
+ export {
23
+ MCP_EXAMPLES_RESOURCE_PREFIX,
24
+ MCP_SURFACE_MAP_RESOURCE_URI,
25
+ MCP_TRAIL_RESOURCE_PREFIX,
26
+ buildMcpResources,
27
+ isMcpTrailheadTool,
28
+ type BuiltMcpResources,
29
+ type McpResourceContent,
30
+ type McpResourceDefinition,
31
+ type McpResourcesConfig,
32
+ } from './resources.js';
33
+
34
+ // Tool naming
35
+ export { deriveToolName } from './tool-name.js';
36
+
37
+ // Annotations
38
+ export { deriveAnnotations, type McpAnnotations } from './annotations.js';
39
+
40
+ // Progress
41
+ export { createMcpProgressCallback } from './progress.js';
42
+
43
+ // Surface
44
+ export {
45
+ createServer,
46
+ surface,
47
+ type CreateServerOptions,
48
+ type SurfaceMcpResult,
49
+ } from './surface.js';
50
+
51
+ // Transport
52
+ export { connectStdio } from './stdio.js';
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Bridge Trails ProgressCallback to MCP sendProgress notifications.
3
+ */
4
+
5
+ import type { ProgressCallback, ProgressEvent } from '@ontrails/core';
6
+
7
+ import type { McpExtra } from './build.js';
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Event handlers
11
+ // ---------------------------------------------------------------------------
12
+
13
+ type SendFn = (current: number, total: number) => Promise<void>;
14
+
15
+ /** Fire-and-forget a progress send, swallowing transport errors. */
16
+ const fireSend = async (
17
+ send: SendFn,
18
+ current: number,
19
+ total: number
20
+ ): Promise<void> => {
21
+ try {
22
+ await send(current, total);
23
+ } catch {
24
+ /* Transport errors are expected and safe to ignore */
25
+ }
26
+ };
27
+
28
+ const handleProgress = (event: ProgressEvent, send: SendFn): void => {
29
+ if (event.current !== undefined && event.total !== undefined) {
30
+ fireSend(send, event.current, event.total);
31
+ } else if (event.current !== undefined) {
32
+ fireSend(send, event.current, 0);
33
+ }
34
+ };
35
+
36
+ const progressHandlers: Record<
37
+ string,
38
+ (event: ProgressEvent, send: SendFn) => void
39
+ > = {
40
+ complete: (_event, send) => fireSend(send, 1, 1),
41
+ error: () => {
42
+ /* No progress notification for errors */
43
+ },
44
+ progress: handleProgress,
45
+ start: (_event, send) => fireSend(send, 0, 1),
46
+ };
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Factory
50
+ // ---------------------------------------------------------------------------
51
+
52
+ /**
53
+ * Create a ProgressCallback that bridges to MCP's sendProgress.
54
+ *
55
+ * Returns `undefined` if the MCP client did not provide a progressToken
56
+ * (meaning no progress reporting was requested).
57
+ *
58
+ * @example
59
+ * ```ts
60
+ * import { createMcpProgressCallback, type McpExtra } from '@ontrails/mcp';
61
+ *
62
+ * const extra: McpExtra = {
63
+ * progressToken: 'token-123',
64
+ * sendProgress: async (current, total) => {
65
+ * console.log(`progress ${current}/${total}`);
66
+ * },
67
+ * };
68
+ *
69
+ * const progress = createMcpProgressCallback(extra);
70
+ * if (progress !== undefined) {
71
+ * progress({
72
+ * current: 0,
73
+ * ts: new Date().toISOString(),
74
+ * total: 1,
75
+ * type: 'start',
76
+ * });
77
+ * }
78
+ * ```
79
+ */
80
+ export const createMcpProgressCallback = (
81
+ extra: McpExtra
82
+ ): ProgressCallback | undefined => {
83
+ if (extra.progressToken === undefined || extra.progressToken === null) {
84
+ return undefined;
85
+ }
86
+ if (typeof extra.sendProgress !== 'function') {
87
+ return undefined;
88
+ }
89
+
90
+ const send = extra.sendProgress;
91
+ return (event: ProgressEvent): void => {
92
+ const handler = progressHandlers[event.type];
93
+ handler?.(event, send);
94
+ };
95
+ };
@@ -0,0 +1,336 @@
1
+ /**
2
+ * MCP resource rendering for cold Trails context.
3
+ */
4
+
5
+ import { deriveStructuredTrailExamples } from '@ontrails/core';
6
+ import type { Topo, Trail } from '@ontrails/core';
7
+
8
+ import {
9
+ MCP_TOOL_DEFERRED_META_KEY,
10
+ MCP_TOOL_TRAILHEAD_META_KEY,
11
+ } from './build.js';
12
+ import type { McpToolDefinition } from './build.js';
13
+
14
+ /**
15
+ * Resource URI used for the resolved MCP surface map.
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * import { MCP_SURFACE_MAP_RESOURCE_URI } from '@ontrails/mcp';
20
+ *
21
+ * const surfaceMap = resources.read(MCP_SURFACE_MAP_RESOURCE_URI);
22
+ * ```
23
+ */
24
+ export const MCP_SURFACE_MAP_RESOURCE_URI = 'trails://surface-map';
25
+
26
+ /**
27
+ * Prefix used for trail example resources exposed through MCP.
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * import { MCP_EXAMPLES_RESOURCE_PREFIX } from '@ontrails/mcp';
32
+ *
33
+ * const uri = `${MCP_EXAMPLES_RESOURCE_PREFIX}${encodeURIComponent('tasks.create')}`;
34
+ * ```
35
+ */
36
+ export const MCP_EXAMPLES_RESOURCE_PREFIX = 'trails://examples/';
37
+
38
+ /**
39
+ * Prefix used for trail graph fact resources exposed through MCP.
40
+ *
41
+ * @example
42
+ * ```ts
43
+ * import { MCP_TRAIL_RESOURCE_PREFIX } from '@ontrails/mcp';
44
+ *
45
+ * const uri = `${MCP_TRAIL_RESOURCE_PREFIX}${encodeURIComponent('tasks.create')}`;
46
+ * ```
47
+ */
48
+ export const MCP_TRAIL_RESOURCE_PREFIX = 'trails://trail/';
49
+
50
+ export interface McpResourceDefinition {
51
+ readonly uri: string;
52
+ readonly mimeType: string;
53
+ readonly name: string;
54
+ readonly description?: string | undefined;
55
+ }
56
+
57
+ export interface McpResourceContent {
58
+ readonly uri: string;
59
+ readonly mimeType: string;
60
+ readonly text: string;
61
+ }
62
+
63
+ export interface McpResourcesConfig {
64
+ readonly surfaceMap?: boolean | undefined;
65
+ readonly examples?: boolean | undefined;
66
+ readonly graph?: boolean | undefined;
67
+ }
68
+
69
+ export interface BuiltMcpResources {
70
+ readonly list: readonly McpResourceDefinition[];
71
+ readonly read: (uri: string) => McpResourceContent | undefined;
72
+ }
73
+
74
+ interface McpSurfaceMapTool {
75
+ readonly annotations: McpToolDefinition['annotations'];
76
+ readonly description: string | undefined;
77
+ readonly trailheadId?: string | undefined;
78
+ readonly inputSchema: Record<string, unknown>;
79
+ readonly memberTrailIds?: readonly string[] | undefined;
80
+ readonly name: string;
81
+ readonly outputSchema?: Record<string, unknown> | undefined;
82
+ readonly trailId?: string | undefined;
83
+ readonly versions?: McpToolDefinition['versions'];
84
+ readonly deferred?: true | undefined;
85
+ }
86
+
87
+ interface McpSurfaceMap {
88
+ readonly surface: 'mcp';
89
+ readonly tools: readonly McpSurfaceMapTool[];
90
+ }
91
+
92
+ interface McpTrailResource {
93
+ readonly trailId: string;
94
+ readonly description?: string | undefined;
95
+ readonly intent: Trail<unknown, unknown, unknown>['intent'];
96
+ readonly visibility: Trail<unknown, unknown, unknown>['visibility'];
97
+ readonly composes: readonly string[];
98
+ readonly resources: readonly string[];
99
+ readonly signals: {
100
+ readonly fires: readonly string[];
101
+ readonly on: readonly string[];
102
+ };
103
+ readonly surface: 'mcp';
104
+ readonly tools: readonly McpSurfaceMapTool[];
105
+ }
106
+
107
+ const asJson = (value: unknown): string =>
108
+ `${JSON.stringify(value, null, 2)}\n`;
109
+
110
+ const renderSurfaceMapTool = (tool: McpToolDefinition): McpSurfaceMapTool => ({
111
+ annotations: tool.annotations,
112
+ description: tool.description,
113
+ inputSchema: tool.inputSchema,
114
+ name: tool.name,
115
+ ...(tool.trailheadId === undefined ? {} : { trailheadId: tool.trailheadId }),
116
+ ...(tool.memberTrailIds === undefined
117
+ ? {}
118
+ : { memberTrailIds: tool.memberTrailIds }),
119
+ ...(tool.outputSchema === undefined
120
+ ? {}
121
+ : { outputSchema: tool.outputSchema }),
122
+ ...(tool.trailId === undefined ? {} : { trailId: tool.trailId }),
123
+ ...(tool.versions === undefined ? {} : { versions: tool.versions }),
124
+ ...(tool._meta?.[MCP_TOOL_DEFERRED_META_KEY] === true
125
+ ? { deferred: true }
126
+ : {}),
127
+ });
128
+
129
+ const buildSurfaceMap = (
130
+ tools: readonly McpToolDefinition[]
131
+ ): McpSurfaceMap => ({
132
+ surface: 'mcp',
133
+ tools: tools.map(renderSurfaceMapTool),
134
+ });
135
+
136
+ const exposedTrailIds = (
137
+ tools: readonly McpToolDefinition[]
138
+ ): ReadonlySet<string> =>
139
+ new Set(
140
+ tools.flatMap((tool) => [
141
+ ...(tool.trailId === undefined ? [] : [tool.trailId]),
142
+ ...(tool.memberTrailIds ?? []),
143
+ ])
144
+ );
145
+
146
+ const examplesUriForTrail = (trailId: string): string =>
147
+ `${MCP_EXAMPLES_RESOURCE_PREFIX}${encodeURIComponent(trailId)}`;
148
+
149
+ const trailUriForTrail = (trailId: string): string =>
150
+ `${MCP_TRAIL_RESOURCE_PREFIX}${encodeURIComponent(trailId)}`;
151
+
152
+ const buildExampleResource = (
153
+ trailItem: Trail<unknown, unknown, unknown>
154
+ ):
155
+ | {
156
+ readonly content: McpResourceContent;
157
+ readonly listing: McpResourceDefinition;
158
+ }
159
+ | undefined => {
160
+ const examples = deriveStructuredTrailExamples(trailItem.examples);
161
+ if (examples === undefined) {
162
+ return undefined;
163
+ }
164
+
165
+ const uri = examplesUriForTrail(trailItem.id);
166
+ return {
167
+ content: {
168
+ mimeType: 'application/json',
169
+ text: asJson({
170
+ examples,
171
+ trailId: trailItem.id,
172
+ }),
173
+ uri,
174
+ },
175
+ listing: {
176
+ description: `Structured examples for trail "${trailItem.id}".`,
177
+ mimeType: 'application/json',
178
+ name: `Trail examples: ${trailItem.id}`,
179
+ uri,
180
+ },
181
+ };
182
+ };
183
+
184
+ const buildExampleResources = (
185
+ graph: Topo,
186
+ tools: readonly McpToolDefinition[]
187
+ ): readonly {
188
+ readonly content: McpResourceContent;
189
+ readonly listing: McpResourceDefinition;
190
+ }[] => {
191
+ const visibleTrailIds = exposedTrailIds(tools);
192
+ return graph
193
+ .list()
194
+ .filter((trailItem) => visibleTrailIds.has(trailItem.id))
195
+ .map((trailItem) =>
196
+ buildExampleResource(trailItem as Trail<unknown, unknown, unknown>)
197
+ )
198
+ .filter((resource) => resource !== undefined);
199
+ };
200
+
201
+ const resourceId = (
202
+ resource: Trail<unknown, unknown, unknown>['resources'][number]
203
+ ) => resource.id;
204
+
205
+ const buildTrailGraphResource = (
206
+ trailItem: Trail<unknown, unknown, unknown>,
207
+ tools: readonly McpToolDefinition[]
208
+ ): {
209
+ readonly content: McpResourceContent;
210
+ readonly listing: McpResourceDefinition;
211
+ } => {
212
+ const uri = trailUriForTrail(trailItem.id);
213
+ const surfaceTools = tools
214
+ .filter(
215
+ (tool) =>
216
+ tool.trailId === trailItem.id ||
217
+ tool.memberTrailIds?.includes(trailItem.id) === true
218
+ )
219
+ .map(renderSurfaceMapTool);
220
+ const payload: McpTrailResource = {
221
+ composes: trailItem.composes,
222
+ ...(trailItem.description === undefined
223
+ ? {}
224
+ : { description: trailItem.description }),
225
+ intent: trailItem.intent,
226
+ resources: trailItem.resources.map(resourceId),
227
+ signals: {
228
+ fires: trailItem.fires,
229
+ on: trailItem.on,
230
+ },
231
+ surface: 'mcp',
232
+ tools: surfaceTools,
233
+ trailId: trailItem.id,
234
+ visibility: trailItem.visibility,
235
+ };
236
+
237
+ return {
238
+ content: {
239
+ mimeType: 'application/json',
240
+ text: asJson(payload),
241
+ uri,
242
+ },
243
+ listing: {
244
+ description: `MCP-visible graph facts for trail "${trailItem.id}".`,
245
+ mimeType: 'application/json',
246
+ name: `Trail graph fact: ${trailItem.id}`,
247
+ uri,
248
+ },
249
+ };
250
+ };
251
+
252
+ const buildTrailGraphResources = (
253
+ graph: Topo,
254
+ tools: readonly McpToolDefinition[]
255
+ ): readonly {
256
+ readonly content: McpResourceContent;
257
+ readonly listing: McpResourceDefinition;
258
+ }[] => {
259
+ const visibleTrailIds = exposedTrailIds(tools);
260
+ return graph
261
+ .list()
262
+ .filter((trailItem) => visibleTrailIds.has(trailItem.id))
263
+ .map((trailItem) =>
264
+ buildTrailGraphResource(
265
+ trailItem as Trail<unknown, unknown, unknown>,
266
+ tools
267
+ )
268
+ );
269
+ };
270
+
271
+ /**
272
+ * Build the cold-context MCP resources for a Trails graph and tool set.
273
+ *
274
+ * @example
275
+ * ```ts
276
+ * import { buildMcpResources, deriveMcpTools } from '@ontrails/mcp';
277
+ *
278
+ * const tools = deriveMcpTools(app).value;
279
+ * const resources = buildMcpResources(app, tools);
280
+ * ```
281
+ */
282
+ export const buildMcpResources = (
283
+ graph: Topo,
284
+ tools: readonly McpToolDefinition[],
285
+ config: McpResourcesConfig = {}
286
+ ): BuiltMcpResources => {
287
+ const listings: McpResourceDefinition[] = [];
288
+ const contents = new Map<string, McpResourceContent>();
289
+
290
+ if (config.surfaceMap !== false) {
291
+ const surfaceMapListing = {
292
+ description: 'Resolved MCP surface rendering for this Trails app.',
293
+ mimeType: 'application/json',
294
+ name: 'Trails MCP surface map',
295
+ uri: MCP_SURFACE_MAP_RESOURCE_URI,
296
+ };
297
+ listings.push(surfaceMapListing);
298
+ contents.set(MCP_SURFACE_MAP_RESOURCE_URI, {
299
+ mimeType: 'application/json',
300
+ text: asJson(buildSurfaceMap(tools)),
301
+ uri: MCP_SURFACE_MAP_RESOURCE_URI,
302
+ });
303
+ }
304
+
305
+ if (config.examples !== false) {
306
+ for (const resource of buildExampleResources(graph, tools)) {
307
+ listings.push(resource.listing);
308
+ contents.set(resource.content.uri, resource.content);
309
+ }
310
+ }
311
+
312
+ if (config.graph === true) {
313
+ for (const resource of buildTrailGraphResources(graph, tools)) {
314
+ listings.push(resource.listing);
315
+ contents.set(resource.content.uri, resource.content);
316
+ }
317
+ }
318
+
319
+ return {
320
+ list: listings,
321
+ read: (uri) => contents.get(uri),
322
+ };
323
+ };
324
+
325
+ /**
326
+ * Return whether an MCP tool was rendered from a surface trailhead.
327
+ *
328
+ * @example
329
+ * ```ts
330
+ * import { isMcpTrailheadTool } from '@ontrails/mcp';
331
+ *
332
+ * const trailheadTools = tools.filter(isMcpTrailheadTool);
333
+ * ```
334
+ */
335
+ export const isMcpTrailheadTool = (tool: McpToolDefinition): boolean =>
336
+ tool._meta?.[MCP_TOOL_TRAILHEAD_META_KEY] !== undefined;
package/src/stdio.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Thin wrapper around MCP SDK's StdioServerTransport.
3
+ *
4
+ * Exists as a separate function so it can be swapped for other transports
5
+ * (SSE, streamable HTTP) without changing surface().
6
+ */
7
+
8
+ import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
9
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
10
+
11
+ /**
12
+ * Connect an MCP server to stdio transport.
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * import { connectStdio, createServer } from '@ontrails/mcp';
17
+ *
18
+ * const server = createServer(graph);
19
+ * await connectStdio(server);
20
+ * ```
21
+ */
22
+ export const connectStdio = async (server: Server): Promise<void> => {
23
+ const transport = new StdioServerTransport();
24
+ await server.connect(transport);
25
+ };