@ontrails/mcp 1.0.0-beta.19 → 1.0.0-beta.21

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/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # @ontrails/mcp
2
2
 
3
+ ## 1.0.0-beta.21
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [99523f2]
8
+ - @ontrails/core@1.0.0-beta.21
9
+
10
+ ## 1.0.0-beta.20
11
+
12
+ ### Minor Changes
13
+
14
+ - accb9ec: Add MCP surface facets, MCP resource projection for cold context, and deferred-loading metadata hints.
15
+
16
+ ### Patch Changes
17
+
18
+ - 9bec01c: Document MCP resource projection and deferred-loading options for cold surface context.
19
+ - Updated dependencies [851a2a3]
20
+ - @ontrails/core@1.0.0-beta.20
21
+
3
22
  ## 1.0.0-beta.19
4
23
 
5
24
  ### Patch Changes
package/README.md CHANGED
@@ -54,6 +54,7 @@ for (const tool of result.value) {
54
54
  | --- | --- |
55
55
  | `surface(graph, options?)` | Start an MCP server with all trails as tools |
56
56
  | `deriveMcpTools(graph, options?)` | Build tool definitions without starting a server |
57
+ | `buildMcpResources(graph, tools, config?)` | Build MCP resource listings and read handlers for cold context |
57
58
  | `deriveToolName(appName, trailId)` | Compute the MCP tool name from app and trail IDs |
58
59
  | `deriveAnnotations(trail)` | Extract MCP annotations from trail intent, idempotency, and description |
59
60
  | `createMcpProgressCallback(server)` | Bridge `ctx.progress` to MCP `notifications/progress` |
@@ -79,6 +80,29 @@ MCP tool definitions include the trail's input schema, and trails with an `outpu
79
80
 
80
81
  Trail examples are projected as structured metadata under `_meta["ontrails/examples"]`. Each projected example preserves its input, expected output or error, a success/error kind, and provenance pointing back to the authored `trail.examples` field.
81
82
 
83
+ ## MCP resources and deferred loading
84
+
85
+ Cold context is projected through MCP resources, not extra Trails resources. `surface(graph)` and `createServer(graph)` expose MCP resources by default:
86
+
87
+ - `trails://surface-map` lists the resolved MCP tool projection, including ordinary tools, facet tools, schemas, versions, deferred hints, and member trail IDs.
88
+ - `trails://examples/<trailId>` exposes structured examples for exposed trails that define examples.
89
+
90
+ Disable resource projection only when the host needs a minimal MCP capability surface:
91
+
92
+ ```typescript
93
+ await surface(graph, { mcpResources: false });
94
+ ```
95
+
96
+ Or choose a narrower resource set:
97
+
98
+ ```typescript
99
+ await surface(graph, {
100
+ mcpResources: { examples: false, surfaceMap: true },
101
+ });
102
+ ```
103
+
104
+ Facet definitions may set `mcp: { loading: 'deferred' }`. In this release, deferred loading is a compatibility hint under `_meta["ontrails/deferred"]`; the MCP tool schema remains present so clients that do not understand deferred loading continue to work.
105
+
82
106
  ## Tool naming
83
107
 
84
108
  Trail IDs become MCP tool names with the app prefix: `entity.show` in app `myapp` becomes `myapp_entity_show`. Dots and hyphens become underscores, everything lowercase.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ontrails/mcp",
3
- "version": "1.0.0-beta.19",
3
+ "version": "1.0.0-beta.21",
4
4
  "files": [
5
5
  "src/**/*.ts",
6
6
  "!src/**/__tests__/**",
@@ -22,7 +22,7 @@
22
22
  "clean": "rm -rf dist *.tsbuildinfo"
23
23
  },
24
24
  "dependencies": {
25
- "@ontrails/core": "^1.0.0-beta.19"
25
+ "@ontrails/core": "^1.0.0-beta.21"
26
26
  },
27
27
  "peerDependencies": {
28
28
  "@modelcontextprotocol/sdk": "^1.28.0",
package/src/build.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  isBlobRef,
19
19
  isTrailsError,
20
20
  LAYER_FIELD_RESERVED_NAMES,
21
+ matchesTrailPattern,
21
22
  projectLayerFieldName,
22
23
  projectPublicSurfaceError,
23
24
  toBlobRefDescriptor,
@@ -69,6 +70,31 @@ export const MCP_TOOL_EXAMPLES_META_KEY = 'ontrails/examples';
69
70
  */
70
71
  export const MCP_TOOL_ERROR_META_KEY = 'ontrails/error';
71
72
 
73
+ /**
74
+ * Metadata key used to identify MCP tools derived from surface facets.
75
+ *
76
+ * @example
77
+ * ```ts
78
+ * import { MCP_TOOL_FACET_META_KEY } from '@ontrails/mcp';
79
+ *
80
+ * const facet = tool._meta?.[MCP_TOOL_FACET_META_KEY];
81
+ * ```
82
+ */
83
+ export const MCP_TOOL_FACET_META_KEY = 'ontrails/facet';
84
+
85
+ /**
86
+ * Metadata key used as a compatibility hint for clients that support
87
+ * deferred MCP tool loading.
88
+ *
89
+ * @example
90
+ * ```ts
91
+ * import { MCP_TOOL_DEFERRED_META_KEY } from '@ontrails/mcp';
92
+ *
93
+ * const isDeferred = tool._meta?.[MCP_TOOL_DEFERRED_META_KEY] === true;
94
+ * ```
95
+ */
96
+ export const MCP_TOOL_DEFERRED_META_KEY = 'ontrails/deferred';
97
+
72
98
  // ---------------------------------------------------------------------------
73
99
  // Public types
74
100
  // ---------------------------------------------------------------------------
@@ -77,11 +103,31 @@ export interface DeriveMcpToolsOptions extends BaseSurfaceOptions {
77
103
  readonly createContext?:
78
104
  | (() => TrailContextInit | Promise<TrailContextInit>)
79
105
  | undefined;
106
+ readonly facets?: McpSurfaceFacetMap | undefined;
80
107
  readonly layers?: readonly Layer[] | undefined;
81
108
  readonly resources?: ResourceOverrideMap | undefined;
82
109
  readonly resolvePermit?: ResolveMcpPermit | undefined;
83
110
  }
84
111
 
112
+ export type McpSurfaceFacetTrailSelector = string | readonly string[];
113
+
114
+ export interface McpSurfaceFacetDefinition {
115
+ readonly trails: McpSurfaceFacetTrailSelector;
116
+ readonly description: string;
117
+ readonly visibility?: 'public' | 'internal' | undefined;
118
+ readonly descriptionStableThrough?: string | undefined;
119
+ readonly visibilityWideningAccepted?: true | undefined;
120
+ readonly mcp?:
121
+ | {
122
+ readonly loading?: 'deferred' | undefined;
123
+ }
124
+ | undefined;
125
+ }
126
+
127
+ export type McpSurfaceFacetMap = Readonly<
128
+ Record<string, McpSurfaceFacetDefinition>
129
+ >;
130
+
85
131
  export interface ResolveMcpPermitInput {
86
132
  readonly authorization?: string | undefined;
87
133
  readonly bearerToken?: string | undefined;
@@ -98,15 +144,17 @@ export interface McpToolDefinition {
98
144
  readonly _meta?: Record<string, unknown> | undefined;
99
145
  readonly annotations: McpAnnotations | undefined;
100
146
  readonly description: string | undefined;
147
+ readonly facetId?: string | undefined;
101
148
  readonly handler: (
102
149
  args: Record<string, unknown>,
103
150
  extra: McpExtra
104
151
  ) => Promise<McpToolResult>;
105
152
  readonly inputSchema: Record<string, unknown>;
153
+ readonly memberTrailIds?: readonly string[] | undefined;
106
154
  readonly name: string;
107
155
  readonly outputSchema?: Record<string, unknown> | undefined;
108
156
  /** The trail ID this tool was derived from. */
109
- readonly trailId: string;
157
+ readonly trailId?: string | undefined;
110
158
  readonly versions?: readonly SurfaceTrailVersionProjection[] | undefined;
111
159
  }
112
160
 
@@ -923,6 +971,16 @@ const buildMeta = (
923
971
  return { [MCP_TOOL_EXAMPLES_META_KEY]: examples };
924
972
  };
925
973
 
974
+ const mergeMeta = (
975
+ ...entries: readonly (Record<string, unknown> | undefined)[]
976
+ ): Record<string, unknown> | undefined => {
977
+ const merged = Object.assign(
978
+ {},
979
+ ...(entries.filter(Boolean) as Record<string, unknown>[])
980
+ );
981
+ return Object.keys(merged).length > 0 ? merged : undefined;
982
+ };
983
+
926
984
  /** Build a single MCP tool definition from a trail. */
927
985
  const buildToolDefinition = (
928
986
  graph: Topo,
@@ -962,25 +1020,217 @@ const buildToolDefinition = (
962
1020
  };
963
1021
  };
964
1022
 
1023
+ const facetSelectors = (
1024
+ selector: McpSurfaceFacetTrailSelector
1025
+ ): readonly string[] => (typeof selector === 'string' ? [selector] : selector);
1026
+
1027
+ const matchesFacetSelector = (
1028
+ trailId: string,
1029
+ selector: McpSurfaceFacetTrailSelector
1030
+ ): boolean =>
1031
+ facetSelectors(selector).some((pattern) =>
1032
+ matchesTrailPattern(trailId, pattern)
1033
+ );
1034
+
1035
+ interface FacetMemberTool {
1036
+ readonly tool: McpToolDefinition;
1037
+ readonly trail: Trail<unknown, unknown, unknown>;
1038
+ }
1039
+
1040
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
1041
+ value !== null && typeof value === 'object' && !Array.isArray(value);
1042
+
1043
+ const buildFacetInputSchema = (
1044
+ members: readonly FacetMemberTool[]
1045
+ ): Record<string, unknown> => ({
1046
+ anyOf: members.map(({ tool, trail }) => ({
1047
+ properties: {
1048
+ input: tool.inputSchema,
1049
+ trail: { const: trail.id },
1050
+ },
1051
+ required: ['trail', 'input'],
1052
+ type: 'object',
1053
+ })),
1054
+ properties: {
1055
+ input: { type: 'object' },
1056
+ trail: {
1057
+ enum: members.map(({ trail }) => trail.id),
1058
+ type: 'string',
1059
+ },
1060
+ },
1061
+ required: ['trail', 'input'],
1062
+ type: 'object',
1063
+ });
1064
+
1065
+ const buildFacetOutputSchema = (
1066
+ members: readonly FacetMemberTool[]
1067
+ ): Record<string, unknown> => {
1068
+ const outputSchemas = members.map(({ tool }) => tool.outputSchema ?? {});
1069
+ return {
1070
+ properties: {
1071
+ output:
1072
+ outputSchemas.length === 1
1073
+ ? (outputSchemas[0] ?? {})
1074
+ : { anyOf: outputSchemas },
1075
+ trail: {
1076
+ enum: members.map(({ trail }) => trail.id),
1077
+ type: 'string',
1078
+ },
1079
+ },
1080
+ required: ['trail', 'output'],
1081
+ type: 'object',
1082
+ };
1083
+ };
1084
+
1085
+ const parseJsonTextContent = (
1086
+ content: readonly McpContent[]
1087
+ ): unknown | undefined => {
1088
+ const text = content.find((item) => item.type === 'text')?.text;
1089
+ if (text === undefined) {
1090
+ return undefined;
1091
+ }
1092
+ try {
1093
+ return JSON.parse(text) as unknown;
1094
+ } catch {
1095
+ return undefined;
1096
+ }
1097
+ };
1098
+
1099
+ const wrapFacetResult = (
1100
+ trailId: string,
1101
+ result: McpToolResult
1102
+ ): McpToolResult => {
1103
+ if (result.isError === true) {
1104
+ return result;
1105
+ }
1106
+ const output =
1107
+ result.structuredContent ?? parseJsonTextContent(result.content) ?? null;
1108
+ const envelope = { output, trail: trailId };
1109
+ return {
1110
+ ...(result._meta === undefined ? {} : { _meta: result._meta }),
1111
+ content: [
1112
+ { text: JSON.stringify(envelope), type: 'text' },
1113
+ ...result.content.filter((item) => item.type !== 'text'),
1114
+ ],
1115
+ structuredContent: envelope,
1116
+ };
1117
+ };
1118
+
1119
+ const createFacetHandler = (
1120
+ facetId: string,
1121
+ members: readonly FacetMemberTool[]
1122
+ ): McpToolDefinition['handler'] => {
1123
+ const byTrailId = new Map(
1124
+ members.map((member) => [member.trail.id, member.tool])
1125
+ );
1126
+
1127
+ return async (args, extra): Promise<McpToolResult> => {
1128
+ const trailId = typeof args['trail'] === 'string' ? args['trail'] : '';
1129
+ const tool = byTrailId.get(trailId);
1130
+ if (tool === undefined) {
1131
+ return mcpError(
1132
+ new ValidationError(
1133
+ `MCP facet "${facetId}" received unknown trail selector "${trailId || '(missing)'}"`
1134
+ )
1135
+ );
1136
+ }
1137
+
1138
+ const { input } = args;
1139
+ if (!isRecord(input)) {
1140
+ return mcpError(
1141
+ new ValidationError(
1142
+ `MCP facet "${facetId}" expects an object input for trail "${trailId}"`
1143
+ )
1144
+ );
1145
+ }
1146
+
1147
+ return wrapFacetResult(trailId, await tool.handler(input, extra));
1148
+ };
1149
+ };
1150
+
1151
+ const deriveFacetIntent = (
1152
+ members: readonly FacetMemberTool[]
1153
+ ): Pick<Trail<unknown, unknown, unknown>, 'intent'>['intent'] => {
1154
+ if (members.every(({ trail }) => trail.intent === 'read')) {
1155
+ return 'read';
1156
+ }
1157
+ if (members.some(({ trail }) => trail.intent === 'destroy')) {
1158
+ return 'destroy';
1159
+ }
1160
+ return 'write';
1161
+ };
1162
+
1163
+ const deriveFacetAnnotations = (
1164
+ definition: McpSurfaceFacetDefinition,
1165
+ members: readonly FacetMemberTool[]
1166
+ ): McpAnnotations | undefined => {
1167
+ const annotations = deriveAnnotations({
1168
+ description: definition.description,
1169
+ idempotent: false,
1170
+ intent: deriveFacetIntent(members),
1171
+ } as Pick<
1172
+ Trail<unknown, unknown, unknown>,
1173
+ 'description' | 'idempotent' | 'intent'
1174
+ >);
1175
+ return Object.keys(annotations).length > 0 ? annotations : undefined;
1176
+ };
1177
+
1178
+ const buildFacetMeta = (
1179
+ facetId: string,
1180
+ definition: McpSurfaceFacetDefinition,
1181
+ memberTrailIds: readonly string[]
1182
+ ): Record<string, unknown> | undefined =>
1183
+ mergeMeta(
1184
+ {
1185
+ [MCP_TOOL_FACET_META_KEY]: {
1186
+ id: facetId,
1187
+ memberTrailIds,
1188
+ },
1189
+ },
1190
+ definition.mcp?.loading === 'deferred'
1191
+ ? { [MCP_TOOL_DEFERRED_META_KEY]: true }
1192
+ : undefined
1193
+ );
1194
+
1195
+ const buildFacetToolDefinition = (
1196
+ graph: Topo,
1197
+ facetId: string,
1198
+ definition: McpSurfaceFacetDefinition,
1199
+ members: readonly FacetMemberTool[]
1200
+ ): McpToolDefinition => {
1201
+ const memberTrailIds = members.map(({ trail }) => trail.id);
1202
+ return {
1203
+ _meta: buildFacetMeta(facetId, definition, memberTrailIds),
1204
+ annotations: deriveFacetAnnotations(definition, members),
1205
+ description: definition.description,
1206
+ facetId,
1207
+ handler: createFacetHandler(facetId, members),
1208
+ inputSchema: buildFacetInputSchema(members),
1209
+ memberTrailIds,
1210
+ name: deriveToolName(graph.name, facetId),
1211
+ outputSchema: buildFacetOutputSchema(members),
1212
+ };
1213
+ };
1214
+
965
1215
  /** Register a trail as an MCP tool, checking for name collisions. */
966
1216
  const registerTool = (
967
1217
  graph: Topo,
968
1218
  trailItem: Trail<unknown, unknown, unknown>,
969
1219
  layers: readonly Layer[],
970
1220
  options: DeriveMcpToolsOptions,
971
- nameToTrailId: Map<string, string>,
1221
+ nameToSourceId: Map<string, string>,
972
1222
  tools: McpToolDefinition[]
973
1223
  ): Result<void, Error> => {
974
1224
  const toolName = deriveToolName(graph.name, trailItem.id);
975
- const existingId = nameToTrailId.get(toolName);
1225
+ const existingId = nameToSourceId.get(toolName);
976
1226
  if (existingId !== undefined) {
977
1227
  return Result.err(
978
1228
  new ValidationError(
979
- `MCP tool-name collision: trails "${existingId}" and "${trailItem.id}" both derive the tool name "${toolName}"`
1229
+ `MCP tool-name collision: "${existingId}" and "trail:${trailItem.id}" both derive the tool name "${toolName}"`
980
1230
  )
981
1231
  );
982
1232
  }
983
- nameToTrailId.set(toolName, trailItem.id);
1233
+ nameToSourceId.set(toolName, `trail:${trailItem.id}`);
984
1234
  tools.push(buildToolDefinition(graph, trailItem, layers, options));
985
1235
  return Result.ok();
986
1236
  };
@@ -1001,21 +1251,137 @@ const validateToolBuild = (
1001
1251
  options: DeriveMcpToolsOptions
1002
1252
  ): Result<void, Error> => validateSurfaceTopo(graph, options);
1003
1253
 
1254
+ const collectFacetMembers = (
1255
+ graph: Topo,
1256
+ definition: McpSurfaceFacetDefinition,
1257
+ availableTrails: readonly Trail<unknown, unknown, unknown>[],
1258
+ layers: readonly Layer[],
1259
+ options: DeriveMcpToolsOptions
1260
+ ): readonly FacetMemberTool[] =>
1261
+ availableTrails
1262
+ .filter((trailItem) =>
1263
+ matchesFacetSelector(trailItem.id, definition.trails)
1264
+ )
1265
+ .map((trailItem) => ({
1266
+ tool: buildToolDefinition(graph, trailItem, layers, options),
1267
+ trail: trailItem,
1268
+ }));
1269
+
1270
+ const registerFacet = (
1271
+ graph: Topo,
1272
+ facetId: string,
1273
+ definition: McpSurfaceFacetDefinition,
1274
+ members: readonly FacetMemberTool[],
1275
+ nameToSourceId: Map<string, string>,
1276
+ tools: McpToolDefinition[]
1277
+ ): Result<void, Error> => {
1278
+ if (members.length === 0) {
1279
+ return Result.err(
1280
+ new ValidationError(
1281
+ `MCP facet "${facetId}" did not match any surface-eligible trails`
1282
+ )
1283
+ );
1284
+ }
1285
+
1286
+ const toolName = deriveToolName(graph.name, facetId);
1287
+ const existingId = nameToSourceId.get(toolName);
1288
+ if (existingId !== undefined) {
1289
+ return Result.err(
1290
+ new ValidationError(
1291
+ `MCP tool-name collision: "${existingId}" and "facet:${facetId}" both derive the tool name "${toolName}"`
1292
+ )
1293
+ );
1294
+ }
1295
+
1296
+ nameToSourceId.set(toolName, `facet:${facetId}`);
1297
+ tools.push(buildFacetToolDefinition(graph, facetId, definition, members));
1298
+ return Result.ok();
1299
+ };
1300
+
1301
+ const registerFacets = (
1302
+ graph: Topo,
1303
+ options: DeriveMcpToolsOptions,
1304
+ layers: readonly Layer[],
1305
+ availableTrails: readonly Trail<unknown, unknown, unknown>[],
1306
+ nameToSourceId: Map<string, string>,
1307
+ tools: McpToolDefinition[]
1308
+ ): Result<ReadonlySet<string>, Error> => {
1309
+ const { facets } = options;
1310
+ const consumedTrailIds = new Set<string>();
1311
+ const ownerByTrailId = new Map<string, string>();
1312
+
1313
+ if (facets === undefined || Object.keys(facets).length === 0) {
1314
+ return Result.ok(consumedTrailIds);
1315
+ }
1316
+
1317
+ for (const [facetId, definition] of Object.entries(facets).toSorted()) {
1318
+ const members = collectFacetMembers(
1319
+ graph,
1320
+ definition,
1321
+ availableTrails,
1322
+ layers,
1323
+ options
1324
+ );
1325
+ for (const { trail: memberTrail } of members) {
1326
+ const previous = ownerByTrailId.get(memberTrail.id);
1327
+ if (previous !== undefined) {
1328
+ return Result.err(
1329
+ new ValidationError(
1330
+ `MCP facet overlap: trail "${memberTrail.id}" is selected by facets "${previous}" and "${facetId}"`
1331
+ )
1332
+ );
1333
+ }
1334
+ ownerByTrailId.set(memberTrail.id, facetId);
1335
+ consumedTrailIds.add(memberTrail.id);
1336
+ }
1337
+
1338
+ const registered = registerFacet(
1339
+ graph,
1340
+ facetId,
1341
+ definition,
1342
+ members,
1343
+ nameToSourceId,
1344
+ tools
1345
+ );
1346
+ if (registered.isErr()) {
1347
+ return registered;
1348
+ }
1349
+ }
1350
+
1351
+ return Result.ok(consumedTrailIds);
1352
+ };
1353
+
1004
1354
  const registerTools = (
1005
1355
  graph: Topo,
1006
1356
  options: DeriveMcpToolsOptions,
1007
1357
  layers: readonly Layer[]
1008
1358
  ): Result<McpToolDefinition[], Error> => {
1009
1359
  const tools: McpToolDefinition[] = [];
1010
- const nameToTrailId = new Map<string, string>();
1360
+ const nameToSourceId = new Map<string, string>();
1361
+ const availableTrails = eligibleTrails(graph, options);
1362
+ const registeredFacets = registerFacets(
1363
+ graph,
1364
+ options,
1365
+ layers,
1366
+ availableTrails,
1367
+ nameToSourceId,
1368
+ tools
1369
+ );
1370
+ if (registeredFacets.isErr()) {
1371
+ return registeredFacets;
1372
+ }
1373
+ const consumedTrailIds = registeredFacets.value;
1011
1374
 
1012
- for (const trailItem of eligibleTrails(graph, options)) {
1375
+ for (const trailItem of availableTrails) {
1376
+ if (consumedTrailIds.has(trailItem.id)) {
1377
+ continue;
1378
+ }
1013
1379
  const registered = registerTool(
1014
1380
  graph,
1015
1381
  trailItem,
1016
1382
  layers,
1017
1383
  options,
1018
- nameToTrailId,
1384
+ nameToSourceId,
1019
1385
  tools
1020
1386
  );
1021
1387
  if (registered.isErr()) {
package/src/index.ts CHANGED
@@ -2,8 +2,13 @@
2
2
  export {
3
3
  MCP_TOOL_ERROR_META_KEY,
4
4
  MCP_TOOL_EXAMPLES_META_KEY,
5
+ MCP_TOOL_DEFERRED_META_KEY,
6
+ MCP_TOOL_FACET_META_KEY,
5
7
  deriveMcpTools,
6
8
  type DeriveMcpToolsOptions,
9
+ type McpSurfaceFacetDefinition,
10
+ type McpSurfaceFacetMap,
11
+ type McpSurfaceFacetTrailSelector,
7
12
  type McpToolDefinition,
8
13
  type McpToolResult,
9
14
  type McpToolErrorMeta,
@@ -13,6 +18,18 @@ export {
13
18
  type ResolveMcpPermitInput,
14
19
  } from './build.js';
15
20
 
21
+ // MCP resources
22
+ export {
23
+ MCP_EXAMPLES_RESOURCE_PREFIX,
24
+ MCP_SURFACE_MAP_RESOURCE_URI,
25
+ buildMcpResources,
26
+ isMcpFacetTool,
27
+ type BuiltMcpResources,
28
+ type McpResourceContent,
29
+ type McpResourceDefinition,
30
+ type McpResourcesConfig,
31
+ } from './resources.js';
32
+
16
33
  // Tool naming
17
34
  export { deriveToolName } from './tool-name.js';
18
35
 
@@ -0,0 +1,228 @@
1
+ /**
2
+ * MCP resource projection 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_FACET_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
+ export interface McpResourceDefinition {
39
+ readonly uri: string;
40
+ readonly mimeType: string;
41
+ readonly name: string;
42
+ readonly description?: string | undefined;
43
+ }
44
+
45
+ export interface McpResourceContent {
46
+ readonly uri: string;
47
+ readonly mimeType: string;
48
+ readonly text: string;
49
+ }
50
+
51
+ export interface McpResourcesConfig {
52
+ readonly surfaceMap?: boolean | undefined;
53
+ readonly examples?: boolean | undefined;
54
+ }
55
+
56
+ export interface BuiltMcpResources {
57
+ readonly list: readonly McpResourceDefinition[];
58
+ readonly read: (uri: string) => McpResourceContent | undefined;
59
+ }
60
+
61
+ interface McpSurfaceMapTool {
62
+ readonly annotations: McpToolDefinition['annotations'];
63
+ readonly description: string | undefined;
64
+ readonly facetId?: string | undefined;
65
+ readonly inputSchema: Record<string, unknown>;
66
+ readonly memberTrailIds?: readonly string[] | undefined;
67
+ readonly name: string;
68
+ readonly outputSchema?: Record<string, unknown> | undefined;
69
+ readonly trailId?: string | undefined;
70
+ readonly versions?: McpToolDefinition['versions'];
71
+ readonly deferred?: true | undefined;
72
+ }
73
+
74
+ interface McpSurfaceMap {
75
+ readonly surface: 'mcp';
76
+ readonly tools: readonly McpSurfaceMapTool[];
77
+ }
78
+
79
+ const asJson = (value: unknown): string =>
80
+ `${JSON.stringify(value, null, 2)}\n`;
81
+
82
+ const projectSurfaceMapTool = (tool: McpToolDefinition): McpSurfaceMapTool => ({
83
+ annotations: tool.annotations,
84
+ description: tool.description,
85
+ inputSchema: tool.inputSchema,
86
+ name: tool.name,
87
+ ...(tool.facetId === undefined ? {} : { facetId: tool.facetId }),
88
+ ...(tool.memberTrailIds === undefined
89
+ ? {}
90
+ : { memberTrailIds: tool.memberTrailIds }),
91
+ ...(tool.outputSchema === undefined
92
+ ? {}
93
+ : { outputSchema: tool.outputSchema }),
94
+ ...(tool.trailId === undefined ? {} : { trailId: tool.trailId }),
95
+ ...(tool.versions === undefined ? {} : { versions: tool.versions }),
96
+ ...(tool._meta?.[MCP_TOOL_DEFERRED_META_KEY] === true
97
+ ? { deferred: true }
98
+ : {}),
99
+ });
100
+
101
+ const buildSurfaceMap = (
102
+ tools: readonly McpToolDefinition[]
103
+ ): McpSurfaceMap => ({
104
+ surface: 'mcp',
105
+ tools: tools.map(projectSurfaceMapTool),
106
+ });
107
+
108
+ const exposedTrailIds = (
109
+ tools: readonly McpToolDefinition[]
110
+ ): ReadonlySet<string> =>
111
+ new Set(
112
+ tools.flatMap((tool) => [
113
+ ...(tool.trailId === undefined ? [] : [tool.trailId]),
114
+ ...(tool.memberTrailIds ?? []),
115
+ ])
116
+ );
117
+
118
+ const examplesUriForTrail = (trailId: string): string =>
119
+ `${MCP_EXAMPLES_RESOURCE_PREFIX}${encodeURIComponent(trailId)}`;
120
+
121
+ const buildExampleResource = (
122
+ trailItem: Trail<unknown, unknown, unknown>
123
+ ):
124
+ | {
125
+ readonly content: McpResourceContent;
126
+ readonly listing: McpResourceDefinition;
127
+ }
128
+ | undefined => {
129
+ const examples = deriveStructuredTrailExamples(trailItem.examples);
130
+ if (examples === undefined) {
131
+ return undefined;
132
+ }
133
+
134
+ const uri = examplesUriForTrail(trailItem.id);
135
+ return {
136
+ content: {
137
+ mimeType: 'application/json',
138
+ text: asJson({
139
+ examples,
140
+ trailId: trailItem.id,
141
+ }),
142
+ uri,
143
+ },
144
+ listing: {
145
+ description: `Structured examples for trail "${trailItem.id}".`,
146
+ mimeType: 'application/json',
147
+ name: `Trail examples: ${trailItem.id}`,
148
+ uri,
149
+ },
150
+ };
151
+ };
152
+
153
+ const buildExampleResources = (
154
+ graph: Topo,
155
+ tools: readonly McpToolDefinition[]
156
+ ): readonly {
157
+ readonly content: McpResourceContent;
158
+ readonly listing: McpResourceDefinition;
159
+ }[] => {
160
+ const visibleTrailIds = exposedTrailIds(tools);
161
+ return graph
162
+ .list()
163
+ .filter((trailItem) => visibleTrailIds.has(trailItem.id))
164
+ .map((trailItem) =>
165
+ buildExampleResource(trailItem as Trail<unknown, unknown, unknown>)
166
+ )
167
+ .filter((resource) => resource !== undefined);
168
+ };
169
+
170
+ /**
171
+ * Build the cold-context MCP resources for a Trails graph and tool set.
172
+ *
173
+ * @example
174
+ * ```ts
175
+ * import { buildMcpResources, deriveMcpTools } from '@ontrails/mcp';
176
+ *
177
+ * const tools = deriveMcpTools(app).value;
178
+ * const resources = buildMcpResources(app, tools);
179
+ * ```
180
+ */
181
+ export const buildMcpResources = (
182
+ graph: Topo,
183
+ tools: readonly McpToolDefinition[],
184
+ config: McpResourcesConfig = {}
185
+ ): BuiltMcpResources => {
186
+ const listings: McpResourceDefinition[] = [];
187
+ const contents = new Map<string, McpResourceContent>();
188
+
189
+ if (config.surfaceMap !== false) {
190
+ const surfaceMapListing = {
191
+ description: 'Resolved MCP surface projection for this Trails app.',
192
+ mimeType: 'application/json',
193
+ name: 'Trails MCP surface map',
194
+ uri: MCP_SURFACE_MAP_RESOURCE_URI,
195
+ };
196
+ listings.push(surfaceMapListing);
197
+ contents.set(MCP_SURFACE_MAP_RESOURCE_URI, {
198
+ mimeType: 'application/json',
199
+ text: asJson(buildSurfaceMap(tools)),
200
+ uri: MCP_SURFACE_MAP_RESOURCE_URI,
201
+ });
202
+ }
203
+
204
+ if (config.examples !== false) {
205
+ for (const resource of buildExampleResources(graph, tools)) {
206
+ listings.push(resource.listing);
207
+ contents.set(resource.content.uri, resource.content);
208
+ }
209
+ }
210
+
211
+ return {
212
+ list: listings,
213
+ read: (uri) => contents.get(uri),
214
+ };
215
+ };
216
+
217
+ /**
218
+ * Return whether an MCP tool was projected from a surface facet.
219
+ *
220
+ * @example
221
+ * ```ts
222
+ * import { isMcpFacetTool } from '@ontrails/mcp';
223
+ *
224
+ * const facetTools = tools.filter(isMcpFacetTool);
225
+ * ```
226
+ */
227
+ export const isMcpFacetTool = (tool: McpToolDefinition): boolean =>
228
+ tool._meta?.[MCP_TOOL_FACET_META_KEY] !== undefined;
package/src/surface.ts CHANGED
@@ -5,7 +5,9 @@
5
5
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
6
6
  import {
7
7
  CallToolRequestSchema,
8
+ ListResourcesRequestSchema,
8
9
  ListToolsRequestSchema,
10
+ ReadResourceRequestSchema,
9
11
  } from '@modelcontextprotocol/sdk/types.js';
10
12
  import type {
11
13
  BaseSurfaceOptions,
@@ -15,8 +17,14 @@ import type {
15
17
  TrailContextInit,
16
18
  } from '@ontrails/core';
17
19
 
18
- import type { McpToolDefinition, ResolveMcpPermit } from './build.js';
20
+ import type {
21
+ McpSurfaceFacetMap,
22
+ McpToolDefinition,
23
+ ResolveMcpPermit,
24
+ } from './build.js';
19
25
  import { deriveMcpTools } from './build.js';
26
+ import { buildMcpResources } from './resources.js';
27
+ import type { BuiltMcpResources, McpResourcesConfig } from './resources.js';
20
28
  import { connectStdio } from './stdio.js';
21
29
 
22
30
  // ---------------------------------------------------------------------------
@@ -28,7 +36,9 @@ export interface CreateServerOptions extends BaseSurfaceOptions {
28
36
  | (() => TrailContextInit | Promise<TrailContextInit>)
29
37
  | undefined;
30
38
  readonly description?: string | undefined;
39
+ readonly facets?: McpSurfaceFacetMap | undefined;
31
40
  readonly layers?: readonly Layer[] | undefined;
41
+ readonly mcpResources?: McpResourcesConfig | false | undefined;
32
42
  readonly name?: string | undefined;
33
43
  readonly resources?: ResourceOverrideMap | undefined;
34
44
  readonly resolvePermit?: ResolveMcpPermit | undefined;
@@ -56,12 +66,16 @@ const createMcpServer = (
56
66
  readonly name: string;
57
67
  readonly version: string;
58
68
  readonly description?: string | undefined;
59
- }
69
+ },
70
+ mcpResources?: BuiltMcpResources | undefined
60
71
  ): Server => {
61
72
  const server = new Server(
62
73
  { name: info.name, version: info.version },
63
74
  {
64
- capabilities: { tools: {} },
75
+ capabilities: {
76
+ ...(mcpResources === undefined ? {} : { resources: {} }),
77
+ tools: {},
78
+ },
65
79
  ...(info.description === undefined
66
80
  ? {}
67
81
  : { instructions: info.description }),
@@ -149,6 +163,31 @@ const createMcpServer = (
149
163
  }
150
164
  );
151
165
 
166
+ if (mcpResources !== undefined) {
167
+ // oxlint-disable-next-line require-await -- MCP SDK requires async handler
168
+ server.setRequestHandler(ListResourcesRequestSchema, async () => ({
169
+ resources: mcpResources.list.map((resource) => ({
170
+ description: resource.description,
171
+ mimeType: resource.mimeType,
172
+ name: resource.name,
173
+ uri: resource.uri,
174
+ })),
175
+ }));
176
+
177
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
178
+ const content = mcpResources.read(request.params.uri);
179
+ return {
180
+ contents: [
181
+ content ?? {
182
+ mimeType: 'text/plain',
183
+ text: `Unknown MCP resource: ${request.params.uri}`,
184
+ uri: request.params.uri,
185
+ },
186
+ ],
187
+ };
188
+ });
189
+ }
190
+
152
191
  return server;
153
192
  };
154
193
 
@@ -179,6 +218,7 @@ export const createServer = (
179
218
  configValues: options.configValues,
180
219
  createContext: options.createContext,
181
220
  exclude: options.exclude,
221
+ facets: options.facets,
182
222
  include: options.include,
183
223
  intent: options.intent,
184
224
  layers: options.layers,
@@ -191,11 +231,20 @@ export const createServer = (
191
231
  throw toolsResult.error;
192
232
  }
193
233
 
194
- return createMcpServer(toolsResult.value, {
195
- description: options.description ?? graph.description,
196
- name: options.name ?? graph.name,
197
- version: options.version ?? graph.version ?? '0.1.0',
198
- });
234
+ const mcpResources =
235
+ options.mcpResources === false
236
+ ? undefined
237
+ : buildMcpResources(graph, toolsResult.value, options.mcpResources);
238
+
239
+ return createMcpServer(
240
+ toolsResult.value,
241
+ {
242
+ description: options.description ?? graph.description,
243
+ name: options.name ?? graph.name,
244
+ version: options.version ?? graph.version ?? '0.1.0',
245
+ },
246
+ mcpResources
247
+ );
199
248
  };
200
249
 
201
250
  // ---------------------------------------------------------------------------