@ontrails/mcp 1.0.0-beta.32 → 1.0.0-beta.39

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,33 @@
1
1
  # @ontrails/mcp
2
2
 
3
+ ## 1.0.0-beta.39
4
+
5
+ ### Patch Changes
6
+
7
+ - [`81373bc`](https://github.com/outfitter-dev/trails/commit/81373bc5e980bb06d56fb06af4f0986f72e318c7): Wave-2 MCP cutover to the app-authored `surfaces` overlay. The overlay's `mcp` bindings are now the authored, lockable default for the MCP surface: a list binding derives one grouped trailhead tool (member selection in `{ trail, input }`, member identity preserved in `{ trail, output }`, deterministic derived description), and a scalar binding derives an additional tool synonym whose MCP-safe name is published verbatim and must expand to exactly one trail. `deriveMcpTools`/`createServer` accept the new `overlays` option; `@ontrails/core` gains `expandMcpSurfaceBindings` and `deriveMcpTrailheadDescription`.
8
+
9
+ The call-site `CreateServerOptions.trailheads` map survives as permanent override-in-context design, not a compatibility bridge: when both channels are present, the call-site map wins at runtime. Warden's new `trailhead-override-divergence` rule (warn) names both sides when a call-site map's binding names or member selectors diverge from the authored overlay default.
10
+
11
+ Topographer now derives `graph.trailheads` from the overlay's `mcp` list bindings in both `deriveTopoGraph` and the store-side graph build, so trailhead facts flow from compiled locks into Wayfinder reads for the first time. The never-wired `DeriveTopoGraphOptions.trailheads` option and the `TopoGraphTrailheadDeclaration`/`TopoGraphTrailheadTrailSelector` types are removed — a beta-window hard cutover of an option no caller could reach; author the equivalent `mcp` list binding in `surfaceOverlay({ mcp })` instead.
12
+
13
+ - [`820b4ad`](https://github.com/outfitter-dev/trails/commit/820b4ad9c40ea383b3c489a05fe7e4b2328e324f): Add `surfaceOverlay` — the shared surface-naming schema (scalar binding = synonym, list binding = grouped entry, singleton list stays a group) with app-authored/adapter-derived overlay provenance enforced at collection and consumption, and the `surface-overlay-coherence` Warden rule. MCP tool-name derivation moves to `@ontrails/core` (`deriveMcpToolName`) so the surface and governance read one projection; `@ontrails/mcp`'s `deriveToolName` now delegates to it. The coherence rule activates on standard warden runs once fresh derivations collect app-module overlays through the shared compile channel (TRL-1209, next in this stack).
14
+
15
+ ## 1.0.0-beta.38
16
+
17
+ ## 1.0.0-beta.37
18
+
19
+ ## 1.0.0-beta.36
20
+
21
+ ### Patch Changes
22
+
23
+ - [`6e63e48`](https://github.com/outfitter-dev/trails/commit/6e63e483617b84cb6868d0c4d58d5b5a8d3b9ed2): Complete the v1 grouped surface-entry vocabulary cutover from facet to trailhead, including Regrade dogfood support for governed string literal renames and composed AST rewrite application.
24
+
25
+ ## 1.0.0-beta.35
26
+
27
+ ## 1.0.0-beta.34
28
+
29
+ ## 1.0.0-beta.33
30
+
3
31
  ## 1.0.0-beta.32
4
32
 
5
33
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ontrails/mcp",
3
- "version": "1.0.0-beta.32",
3
+ "version": "1.0.0-beta.39",
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.32"
25
+ "@ontrails/core": "^1.0.0-beta.39"
26
26
  },
27
27
  "peerDependencies": {
28
28
  "@modelcontextprotocol/sdk": "^1.28.0",
package/src/build.ts CHANGED
@@ -8,12 +8,15 @@
8
8
 
9
9
  import {
10
10
  AuthError,
11
+ InternalError,
11
12
  Result,
12
13
  ValidationError,
13
14
  collectAttachedTypedLayers,
15
+ deriveMcpTrailheadDescription,
14
16
  deriveSurfaceTrailVersionProjections,
15
17
  deriveStructuredTrailExamples,
16
18
  executeTrail,
19
+ expandMcpSurfaceBindings,
17
20
  filterSurfaceTrails,
18
21
  isBlobRef,
19
22
  isTrailsError,
@@ -21,6 +24,7 @@ import {
21
24
  matchesTrailPattern,
22
25
  projectLayerFieldName,
23
26
  projectPublicSurfaceError,
27
+ resolveSurfaceOverlayBindings,
24
28
  toBlobRefDescriptor,
25
29
  validateSurfaceTopo,
26
30
  withSurfaceLayerNames,
@@ -32,6 +36,8 @@ import type {
32
36
  BaseSurfaceOptions,
33
37
  BlobRef,
34
38
  Layer,
39
+ McpSurfaceBindingExpansion,
40
+ OverlayEnvelopeLike,
35
41
  ResourceOverrideMap,
36
42
  SurfaceErrorProjection,
37
43
  SurfaceTrailVersionProjection,
@@ -71,20 +77,20 @@ export const MCP_TOOL_EXAMPLES_META_KEY = 'ontrails/examples';
71
77
  export const MCP_TOOL_ERROR_META_KEY = 'ontrails/error';
72
78
 
73
79
  /**
74
- * Metadata key used to identify MCP tools derived from surface facets.
80
+ * Metadata key used to identify MCP tools derived from surface trailheads.
75
81
  *
76
- * Surface facets preserve member trail identity rather than merging member
77
- * contracts. The metadata names the facet and its member trail IDs so clients
82
+ * Surface trailheads preserve member trail identity rather than merging member
83
+ * contracts. The metadata names the trailhead and its member trail IDs so clients
78
84
  * can inspect the grouped entry before choosing a selected trail.
79
85
  *
80
86
  * @example
81
87
  * ```ts
82
- * import { MCP_TOOL_FACET_META_KEY } from '@ontrails/mcp';
88
+ * import { MCP_TOOL_TRAILHEAD_META_KEY } from '@ontrails/mcp';
83
89
  *
84
- * const facet = tool._meta?.[MCP_TOOL_FACET_META_KEY];
90
+ * const trailhead = tool._meta?.[MCP_TOOL_TRAILHEAD_META_KEY];
85
91
  * ```
86
92
  */
87
- export const MCP_TOOL_FACET_META_KEY = 'ontrails/facet';
93
+ export const MCP_TOOL_TRAILHEAD_META_KEY = 'ontrails/trailhead';
88
94
 
89
95
  /**
90
96
  * Metadata key used as a compatibility hint for clients that support
@@ -107,17 +113,29 @@ export interface DeriveMcpToolsOptions extends BaseSurfaceOptions {
107
113
  readonly createContext?:
108
114
  | (() => TrailContextInit | Promise<TrailContextInit>)
109
115
  | undefined;
110
- readonly facets?: McpSurfaceFacetMap | undefined;
116
+ /**
117
+ * App-authored overlay envelopes (the same collection compile embeds in
118
+ * `trails.lock`). The `surfaces` overlay's `mcp` bindings are the authored,
119
+ * lockable default: list bindings become grouped trailhead tools and scalar
120
+ * bindings become tool synonyms.
121
+ */
122
+ readonly overlays?: readonly OverlayEnvelopeLike[] | undefined;
123
+ /**
124
+ * Call-site trailhead map. Override-in-context by design: when both this
125
+ * map and overlay `mcp` list bindings are present, the call-site map wins
126
+ * at runtime.
127
+ */
128
+ readonly trailheads?: McpSurfaceTrailheadMap | undefined;
111
129
  readonly layers?: readonly Layer[] | undefined;
112
130
  readonly resources?: ResourceOverrideMap | undefined;
113
131
  readonly resolvePermit?: ResolveMcpPermit | undefined;
114
132
  }
115
133
 
116
- export type McpSurfaceFacetTrailSelector = string | readonly string[];
134
+ export type McpSurfaceTrailheadTrailSelector = string | readonly string[];
117
135
 
118
136
  /** Surface-side grouped entry over existing trails. */
119
- export interface McpSurfaceFacetDefinition {
120
- readonly trails: McpSurfaceFacetTrailSelector;
137
+ export interface McpSurfaceTrailheadDefinition {
138
+ readonly trails: McpSurfaceTrailheadTrailSelector;
121
139
  readonly description: string;
122
140
  readonly visibility?: 'public' | 'internal' | undefined;
123
141
  readonly descriptionStableThrough?: string | undefined;
@@ -129,8 +147,8 @@ export interface McpSurfaceFacetDefinition {
129
147
  | undefined;
130
148
  }
131
149
 
132
- export type McpSurfaceFacetMap = Readonly<
133
- Record<string, McpSurfaceFacetDefinition>
150
+ export type McpSurfaceTrailheadMap = Readonly<
151
+ Record<string, McpSurfaceTrailheadDefinition>
134
152
  >;
135
153
 
136
154
  export interface ResolveMcpPermitInput {
@@ -149,7 +167,7 @@ export interface McpToolDefinition {
149
167
  readonly _meta?: Record<string, unknown> | undefined;
150
168
  readonly annotations: McpAnnotations | undefined;
151
169
  readonly description: string | undefined;
152
- readonly facetId?: string | undefined;
170
+ readonly trailheadId?: string | undefined;
153
171
  readonly handler: (
154
172
  args: Record<string, unknown>,
155
173
  extra: McpExtra
@@ -1025,19 +1043,19 @@ const buildToolDefinition = (
1025
1043
  };
1026
1044
  };
1027
1045
 
1028
- const facetSelectors = (
1029
- selector: McpSurfaceFacetTrailSelector
1046
+ const trailheadSelectors = (
1047
+ selector: McpSurfaceTrailheadTrailSelector
1030
1048
  ): readonly string[] => (typeof selector === 'string' ? [selector] : selector);
1031
1049
 
1032
- const matchesFacetSelector = (
1050
+ const matchesTrailheadSelector = (
1033
1051
  trailId: string,
1034
- selector: McpSurfaceFacetTrailSelector
1052
+ selector: McpSurfaceTrailheadTrailSelector
1035
1053
  ): boolean =>
1036
- facetSelectors(selector).some((pattern) =>
1054
+ trailheadSelectors(selector).some((pattern) =>
1037
1055
  matchesTrailPattern(trailId, pattern)
1038
1056
  );
1039
1057
 
1040
- interface FacetMemberTool {
1058
+ interface TrailheadMemberTool {
1041
1059
  readonly tool: McpToolDefinition;
1042
1060
  readonly trail: Trail<unknown, unknown, unknown>;
1043
1061
  }
@@ -1045,8 +1063,8 @@ interface FacetMemberTool {
1045
1063
  const isRecord = (value: unknown): value is Record<string, unknown> =>
1046
1064
  value !== null && typeof value === 'object' && !Array.isArray(value);
1047
1065
 
1048
- const buildFacetInputSchema = (
1049
- members: readonly FacetMemberTool[]
1066
+ const buildTrailheadInputSchema = (
1067
+ members: readonly TrailheadMemberTool[]
1050
1068
  ): Record<string, unknown> => ({
1051
1069
  anyOf: members.map(({ tool, trail }) => ({
1052
1070
  properties: {
@@ -1067,8 +1085,8 @@ const buildFacetInputSchema = (
1067
1085
  type: 'object',
1068
1086
  });
1069
1087
 
1070
- const buildFacetOutputSchema = (
1071
- members: readonly FacetMemberTool[]
1088
+ const buildTrailheadOutputSchema = (
1089
+ members: readonly TrailheadMemberTool[]
1072
1090
  ): Record<string, unknown> => {
1073
1091
  const outputSchemas = members.map(({ tool }) => tool.outputSchema ?? {});
1074
1092
  return {
@@ -1101,7 +1119,7 @@ const parseJsonTextContent = (
1101
1119
  }
1102
1120
  };
1103
1121
 
1104
- const wrapFacetResult = (
1122
+ const wrapTrailheadResult = (
1105
1123
  trailId: string,
1106
1124
  result: McpToolResult
1107
1125
  ): McpToolResult => {
@@ -1121,9 +1139,9 @@ const wrapFacetResult = (
1121
1139
  };
1122
1140
  };
1123
1141
 
1124
- const createFacetHandler = (
1125
- facetId: string,
1126
- members: readonly FacetMemberTool[]
1142
+ const createTrailheadHandler = (
1143
+ trailheadId: string,
1144
+ members: readonly TrailheadMemberTool[]
1127
1145
  ): McpToolDefinition['handler'] => {
1128
1146
  const byTrailId = new Map(
1129
1147
  members.map((member) => [member.trail.id, member.tool])
@@ -1135,7 +1153,7 @@ const createFacetHandler = (
1135
1153
  if (tool === undefined) {
1136
1154
  return mcpError(
1137
1155
  new ValidationError(
1138
- `MCP facet "${facetId}" received unknown trail selector "${trailId || '(missing)'}"`
1156
+ `MCP trailhead "${trailheadId}" received unknown trail selector "${trailId || '(missing)'}"`
1139
1157
  )
1140
1158
  );
1141
1159
  }
@@ -1144,17 +1162,17 @@ const createFacetHandler = (
1144
1162
  if (!isRecord(input)) {
1145
1163
  return mcpError(
1146
1164
  new ValidationError(
1147
- `MCP facet "${facetId}" expects an object input for trail "${trailId}"`
1165
+ `MCP trailhead "${trailheadId}" expects an object input for trail "${trailId}"`
1148
1166
  )
1149
1167
  );
1150
1168
  }
1151
1169
 
1152
- return wrapFacetResult(trailId, await tool.handler(input, extra));
1170
+ return wrapTrailheadResult(trailId, await tool.handler(input, extra));
1153
1171
  };
1154
1172
  };
1155
1173
 
1156
- const deriveFacetIntent = (
1157
- members: readonly FacetMemberTool[]
1174
+ const deriveTrailheadIntent = (
1175
+ members: readonly TrailheadMemberTool[]
1158
1176
  ): Pick<Trail<unknown, unknown, unknown>, 'intent'>['intent'] => {
1159
1177
  if (members.every(({ trail }) => trail.intent === 'read')) {
1160
1178
  return 'read';
@@ -1165,14 +1183,14 @@ const deriveFacetIntent = (
1165
1183
  return 'write';
1166
1184
  };
1167
1185
 
1168
- const deriveFacetAnnotations = (
1169
- definition: McpSurfaceFacetDefinition,
1170
- members: readonly FacetMemberTool[]
1186
+ const deriveTrailheadAnnotations = (
1187
+ definition: McpSurfaceTrailheadDefinition,
1188
+ members: readonly TrailheadMemberTool[]
1171
1189
  ): McpAnnotations | undefined => {
1172
1190
  const annotations = deriveAnnotations({
1173
1191
  description: definition.description,
1174
1192
  idempotent: false,
1175
- intent: deriveFacetIntent(members),
1193
+ intent: deriveTrailheadIntent(members),
1176
1194
  } as Pick<
1177
1195
  Trail<unknown, unknown, unknown>,
1178
1196
  'description' | 'idempotent' | 'intent'
@@ -1180,15 +1198,15 @@ const deriveFacetAnnotations = (
1180
1198
  return Object.keys(annotations).length > 0 ? annotations : undefined;
1181
1199
  };
1182
1200
 
1183
- const buildFacetMeta = (
1184
- facetId: string,
1185
- definition: McpSurfaceFacetDefinition,
1201
+ const buildTrailheadMeta = (
1202
+ trailheadId: string,
1203
+ definition: McpSurfaceTrailheadDefinition,
1186
1204
  memberTrailIds: readonly string[]
1187
1205
  ): Record<string, unknown> | undefined =>
1188
1206
  mergeMeta(
1189
1207
  {
1190
- [MCP_TOOL_FACET_META_KEY]: {
1191
- id: facetId,
1208
+ [MCP_TOOL_TRAILHEAD_META_KEY]: {
1209
+ id: trailheadId,
1192
1210
  memberTrailIds,
1193
1211
  },
1194
1212
  },
@@ -1197,23 +1215,23 @@ const buildFacetMeta = (
1197
1215
  : undefined
1198
1216
  );
1199
1217
 
1200
- const buildFacetToolDefinition = (
1218
+ const buildTrailheadToolDefinition = (
1201
1219
  graph: Topo,
1202
- facetId: string,
1203
- definition: McpSurfaceFacetDefinition,
1204
- members: readonly FacetMemberTool[]
1220
+ trailheadId: string,
1221
+ definition: McpSurfaceTrailheadDefinition,
1222
+ members: readonly TrailheadMemberTool[]
1205
1223
  ): McpToolDefinition => {
1206
1224
  const memberTrailIds = members.map(({ trail }) => trail.id);
1207
1225
  return {
1208
- _meta: buildFacetMeta(facetId, definition, memberTrailIds),
1209
- annotations: deriveFacetAnnotations(definition, members),
1226
+ _meta: buildTrailheadMeta(trailheadId, definition, memberTrailIds),
1227
+ annotations: deriveTrailheadAnnotations(definition, members),
1210
1228
  description: definition.description,
1211
- facetId,
1212
- handler: createFacetHandler(facetId, members),
1213
- inputSchema: buildFacetInputSchema(members),
1229
+ handler: createTrailheadHandler(trailheadId, members),
1230
+ inputSchema: buildTrailheadInputSchema(members),
1214
1231
  memberTrailIds,
1215
- name: deriveToolName(graph.name, facetId),
1216
- outputSchema: buildFacetOutputSchema(members),
1232
+ name: deriveToolName(graph.name, trailheadId),
1233
+ outputSchema: buildTrailheadOutputSchema(members),
1234
+ trailheadId,
1217
1235
  };
1218
1236
  };
1219
1237
 
@@ -1256,71 +1274,135 @@ const validateToolBuild = (
1256
1274
  options: DeriveMcpToolsOptions
1257
1275
  ): Result<void, Error> => validateSurfaceTopo(graph, options);
1258
1276
 
1259
- const collectFacetMembers = (
1277
+ const collectTrailheadMembers = (
1260
1278
  graph: Topo,
1261
- definition: McpSurfaceFacetDefinition,
1279
+ definition: McpSurfaceTrailheadDefinition,
1262
1280
  availableTrails: readonly Trail<unknown, unknown, unknown>[],
1263
1281
  layers: readonly Layer[],
1264
1282
  options: DeriveMcpToolsOptions
1265
- ): readonly FacetMemberTool[] =>
1283
+ ): readonly TrailheadMemberTool[] =>
1266
1284
  availableTrails
1267
1285
  .filter((trailItem) =>
1268
- matchesFacetSelector(trailItem.id, definition.trails)
1286
+ matchesTrailheadSelector(trailItem.id, definition.trails)
1269
1287
  )
1270
1288
  .map((trailItem) => ({
1271
1289
  tool: buildToolDefinition(graph, trailItem, layers, options),
1272
1290
  trail: trailItem,
1273
1291
  }));
1274
1292
 
1275
- const registerFacet = (
1293
+ const registerTrailhead = (
1276
1294
  graph: Topo,
1277
- facetId: string,
1278
- definition: McpSurfaceFacetDefinition,
1279
- members: readonly FacetMemberTool[],
1295
+ trailheadId: string,
1296
+ definition: McpSurfaceTrailheadDefinition,
1297
+ members: readonly TrailheadMemberTool[],
1280
1298
  nameToSourceId: Map<string, string>,
1281
1299
  tools: McpToolDefinition[]
1282
1300
  ): Result<void, Error> => {
1283
1301
  if (members.length === 0) {
1284
1302
  return Result.err(
1285
1303
  new ValidationError(
1286
- `MCP facet "${facetId}" did not match any surface-eligible trails`
1304
+ `MCP trailhead "${trailheadId}" did not match any surface-eligible trails`
1287
1305
  )
1288
1306
  );
1289
1307
  }
1290
1308
 
1291
- const toolName = deriveToolName(graph.name, facetId);
1309
+ const toolName = deriveToolName(graph.name, trailheadId);
1292
1310
  const existingId = nameToSourceId.get(toolName);
1293
1311
  if (existingId !== undefined) {
1294
1312
  return Result.err(
1295
1313
  new ValidationError(
1296
- `MCP tool-name collision: "${existingId}" and "facet:${facetId}" both derive the tool name "${toolName}"`
1314
+ `MCP tool-name collision: "${existingId}" and "trailhead:${trailheadId}" both derive the tool name "${toolName}"`
1297
1315
  )
1298
1316
  );
1299
1317
  }
1300
1318
 
1301
- nameToSourceId.set(toolName, `facet:${facetId}`);
1302
- tools.push(buildFacetToolDefinition(graph, facetId, definition, members));
1319
+ nameToSourceId.set(toolName, `trailhead:${trailheadId}`);
1320
+ tools.push(
1321
+ buildTrailheadToolDefinition(graph, trailheadId, definition, members)
1322
+ );
1303
1323
  return Result.ok();
1304
1324
  };
1305
1325
 
1306
- const registerFacets = (
1326
+ /**
1327
+ * Resolve the `surfaces` overlay's `mcp` bindings against the
1328
+ * surface-eligible trails.
1329
+ *
1330
+ * Framework overlay/binding validation failures are represented as
1331
+ * `Result.err` so `deriveMcpTools` keeps its no-throw contract.
1332
+ */
1333
+ const resolveOverlayBindingExpansion = (
1334
+ options: DeriveMcpToolsOptions,
1335
+ availableTrails: readonly Trail<unknown, unknown, unknown>[]
1336
+ ): Result<McpSurfaceBindingExpansion | undefined, Error> => {
1337
+ try {
1338
+ const bindings = resolveSurfaceOverlayBindings(options.overlays);
1339
+ return Result.ok(
1340
+ expandMcpSurfaceBindings(
1341
+ bindings?.mcp,
1342
+ availableTrails.map((trailItem) => trailItem.id)
1343
+ )
1344
+ );
1345
+ } catch (error) {
1346
+ return Result.err(
1347
+ error instanceof Error
1348
+ ? error
1349
+ : new InternalError(
1350
+ `MCP surface overlay resolution failed: ${String(error)}`
1351
+ )
1352
+ );
1353
+ }
1354
+ };
1355
+
1356
+ /**
1357
+ * Construct call-site-equivalent trailhead definitions from the overlay's
1358
+ * `mcp` list bindings.
1359
+ *
1360
+ * Each grouped binding becomes one definition with the expanded member trail
1361
+ * ids as exact selectors and the shared derived default description, so the
1362
+ * existing trailhead machinery builds the tool exactly as a call-site map
1363
+ * would.
1364
+ */
1365
+ const trailheadDefinitionsFromOverlay = (
1366
+ expansion: McpSurfaceBindingExpansion | undefined
1367
+ ): McpSurfaceTrailheadMap | undefined => {
1368
+ if (expansion === undefined) {
1369
+ return undefined;
1370
+ }
1371
+ const groups = Object.entries(expansion.groups);
1372
+ if (groups.length === 0) {
1373
+ return undefined;
1374
+ }
1375
+ return Object.fromEntries(
1376
+ groups.map(([name, memberIds]) => [
1377
+ name,
1378
+ {
1379
+ description: deriveMcpTrailheadDescription(memberIds),
1380
+ trails: memberIds,
1381
+ },
1382
+ ])
1383
+ );
1384
+ };
1385
+
1386
+ const registerTrailheads = (
1307
1387
  graph: Topo,
1388
+ trailheads: McpSurfaceTrailheadMap | undefined,
1308
1389
  options: DeriveMcpToolsOptions,
1309
1390
  layers: readonly Layer[],
1310
1391
  availableTrails: readonly Trail<unknown, unknown, unknown>[],
1311
1392
  nameToSourceId: Map<string, string>,
1312
1393
  tools: McpToolDefinition[]
1313
1394
  ): Result<ReadonlySet<string>, Error> => {
1314
- const { facets } = options;
1315
1395
  const consumedTrailIds = new Set<string>();
1316
1396
  const ownerByTrailId = new Map<string, string>();
1317
1397
 
1318
- if (facets === undefined || Object.keys(facets).length === 0) {
1398
+ if (trailheads === undefined || Object.keys(trailheads).length === 0) {
1319
1399
  return Result.ok(consumedTrailIds);
1320
1400
  }
1321
1401
 
1322
- for (const [facetId, definition] of Object.entries(facets).toSorted()) {
1323
- const members = collectFacetMembers(
1402
+ for (const [trailheadId, definition] of Object.entries(
1403
+ trailheads
1404
+ ).toSorted()) {
1405
+ const members = collectTrailheadMembers(
1324
1406
  graph,
1325
1407
  definition,
1326
1408
  availableTrails,
@@ -1332,17 +1414,17 @@ const registerFacets = (
1332
1414
  if (previous !== undefined) {
1333
1415
  return Result.err(
1334
1416
  new ValidationError(
1335
- `MCP facet overlap: trail "${memberTrail.id}" is selected by facets "${previous}" and "${facetId}"`
1417
+ `MCP trailhead overlap: trail "${memberTrail.id}" is selected by trailheads "${previous}" and "${trailheadId}"`
1336
1418
  )
1337
1419
  );
1338
1420
  }
1339
- ownerByTrailId.set(memberTrail.id, facetId);
1421
+ ownerByTrailId.set(memberTrail.id, trailheadId);
1340
1422
  consumedTrailIds.add(memberTrail.id);
1341
1423
  }
1342
1424
 
1343
- const registered = registerFacet(
1425
+ const registered = registerTrailhead(
1344
1426
  graph,
1345
- facetId,
1427
+ trailheadId,
1346
1428
  definition,
1347
1429
  members,
1348
1430
  nameToSourceId,
@@ -1356,6 +1438,49 @@ const registerFacets = (
1356
1438
  return Result.ok(consumedTrailIds);
1357
1439
  };
1358
1440
 
1441
+ /**
1442
+ * Register overlay tool synonyms: additional MCP tools whose names are the
1443
+ * scalar binding names, sharing the target trail's schema, annotations, and
1444
+ * handler.
1445
+ */
1446
+ const registerSynonymTools = (
1447
+ graph: Topo,
1448
+ options: DeriveMcpToolsOptions,
1449
+ layers: readonly Layer[],
1450
+ availableTrails: readonly Trail<unknown, unknown, unknown>[],
1451
+ synonyms: Readonly<Record<string, string>>,
1452
+ nameToSourceId: Map<string, string>,
1453
+ tools: McpToolDefinition[]
1454
+ ): Result<void, Error> => {
1455
+ const trailById = new Map(
1456
+ availableTrails.map((trailItem) => [trailItem.id, trailItem])
1457
+ );
1458
+ for (const [name, trailId] of Object.entries(synonyms)) {
1459
+ const trailItem = trailById.get(trailId);
1460
+ if (trailItem === undefined) {
1461
+ return Result.err(
1462
+ new ValidationError(
1463
+ `MCP overlay binding "${name}" targets trail "${trailId}", which is not surface-eligible`
1464
+ )
1465
+ );
1466
+ }
1467
+ const existingId = nameToSourceId.get(name);
1468
+ if (existingId !== undefined) {
1469
+ return Result.err(
1470
+ new ValidationError(
1471
+ `MCP tool-name collision: "${existingId}" and "binding:${name}" both use the tool name "${name}"`
1472
+ )
1473
+ );
1474
+ }
1475
+ nameToSourceId.set(name, `binding:${name}`);
1476
+ tools.push({
1477
+ ...buildToolDefinition(graph, trailItem, layers, options),
1478
+ name,
1479
+ });
1480
+ }
1481
+ return Result.ok();
1482
+ };
1483
+
1359
1484
  const registerTools = (
1360
1485
  graph: Topo,
1361
1486
  options: DeriveMcpToolsOptions,
@@ -1364,18 +1489,27 @@ const registerTools = (
1364
1489
  const tools: McpToolDefinition[] = [];
1365
1490
  const nameToSourceId = new Map<string, string>();
1366
1491
  const availableTrails = eligibleTrails(graph, options);
1367
- const registeredFacets = registerFacets(
1492
+ const expansion = resolveOverlayBindingExpansion(options, availableTrails);
1493
+ if (expansion.isErr()) {
1494
+ return expansion;
1495
+ }
1496
+ // Override-in-context: the call-site trailhead map wins over the authored
1497
+ // overlay default whenever the caller supplies one.
1498
+ const trailheads =
1499
+ options.trailheads ?? trailheadDefinitionsFromOverlay(expansion.value);
1500
+ const registeredTrailheads = registerTrailheads(
1368
1501
  graph,
1502
+ trailheads,
1369
1503
  options,
1370
1504
  layers,
1371
1505
  availableTrails,
1372
1506
  nameToSourceId,
1373
1507
  tools
1374
1508
  );
1375
- if (registeredFacets.isErr()) {
1376
- return registeredFacets;
1509
+ if (registeredTrailheads.isErr()) {
1510
+ return registeredTrailheads;
1377
1511
  }
1378
- const consumedTrailIds = registeredFacets.value;
1512
+ const consumedTrailIds = registeredTrailheads.value;
1379
1513
 
1380
1514
  for (const trailItem of availableTrails) {
1381
1515
  if (consumedTrailIds.has(trailItem.id)) {
@@ -1394,6 +1528,19 @@ const registerTools = (
1394
1528
  }
1395
1529
  }
1396
1530
 
1531
+ const registeredSynonyms = registerSynonymTools(
1532
+ graph,
1533
+ options,
1534
+ layers,
1535
+ availableTrails,
1536
+ expansion.value?.synonyms ?? {},
1537
+ nameToSourceId,
1538
+ tools
1539
+ );
1540
+ if (registeredSynonyms.isErr()) {
1541
+ return registeredSynonyms;
1542
+ }
1543
+
1397
1544
  return Result.ok(tools);
1398
1545
  };
1399
1546
 
package/src/index.ts CHANGED
@@ -3,12 +3,12 @@ export {
3
3
  MCP_TOOL_ERROR_META_KEY,
4
4
  MCP_TOOL_EXAMPLES_META_KEY,
5
5
  MCP_TOOL_DEFERRED_META_KEY,
6
- MCP_TOOL_FACET_META_KEY,
6
+ MCP_TOOL_TRAILHEAD_META_KEY,
7
7
  deriveMcpTools,
8
8
  type DeriveMcpToolsOptions,
9
- type McpSurfaceFacetDefinition,
10
- type McpSurfaceFacetMap,
11
- type McpSurfaceFacetTrailSelector,
9
+ type McpSurfaceTrailheadDefinition,
10
+ type McpSurfaceTrailheadMap,
11
+ type McpSurfaceTrailheadTrailSelector,
12
12
  type McpToolDefinition,
13
13
  type McpToolResult,
14
14
  type McpToolErrorMeta,
@@ -24,7 +24,7 @@ export {
24
24
  MCP_SURFACE_MAP_RESOURCE_URI,
25
25
  MCP_TRAIL_RESOURCE_PREFIX,
26
26
  buildMcpResources,
27
- isMcpFacetTool,
27
+ isMcpTrailheadTool,
28
28
  type BuiltMcpResources,
29
29
  type McpResourceContent,
30
30
  type McpResourceDefinition,
package/src/resources.ts CHANGED
@@ -7,7 +7,7 @@ import type { Topo, Trail } from '@ontrails/core';
7
7
 
8
8
  import {
9
9
  MCP_TOOL_DEFERRED_META_KEY,
10
- MCP_TOOL_FACET_META_KEY,
10
+ MCP_TOOL_TRAILHEAD_META_KEY,
11
11
  } from './build.js';
12
12
  import type { McpToolDefinition } from './build.js';
13
13
 
@@ -74,7 +74,7 @@ export interface BuiltMcpResources {
74
74
  interface McpSurfaceMapTool {
75
75
  readonly annotations: McpToolDefinition['annotations'];
76
76
  readonly description: string | undefined;
77
- readonly facetId?: string | undefined;
77
+ readonly trailheadId?: string | undefined;
78
78
  readonly inputSchema: Record<string, unknown>;
79
79
  readonly memberTrailIds?: readonly string[] | undefined;
80
80
  readonly name: string;
@@ -112,7 +112,7 @@ const projectSurfaceMapTool = (tool: McpToolDefinition): McpSurfaceMapTool => ({
112
112
  description: tool.description,
113
113
  inputSchema: tool.inputSchema,
114
114
  name: tool.name,
115
- ...(tool.facetId === undefined ? {} : { facetId: tool.facetId }),
115
+ ...(tool.trailheadId === undefined ? {} : { trailheadId: tool.trailheadId }),
116
116
  ...(tool.memberTrailIds === undefined
117
117
  ? {}
118
118
  : { memberTrailIds: tool.memberTrailIds }),
@@ -323,14 +323,14 @@ export const buildMcpResources = (
323
323
  };
324
324
 
325
325
  /**
326
- * Return whether an MCP tool was projected from a surface facet.
326
+ * Return whether an MCP tool was projected from a surface trailhead.
327
327
  *
328
328
  * @example
329
329
  * ```ts
330
- * import { isMcpFacetTool } from '@ontrails/mcp';
330
+ * import { isMcpTrailheadTool } from '@ontrails/mcp';
331
331
  *
332
- * const facetTools = tools.filter(isMcpFacetTool);
332
+ * const trailheadTools = tools.filter(isMcpTrailheadTool);
333
333
  * ```
334
334
  */
335
- export const isMcpFacetTool = (tool: McpToolDefinition): boolean =>
336
- tool._meta?.[MCP_TOOL_FACET_META_KEY] !== undefined;
335
+ export const isMcpTrailheadTool = (tool: McpToolDefinition): boolean =>
336
+ tool._meta?.[MCP_TOOL_TRAILHEAD_META_KEY] !== undefined;
package/src/surface.ts CHANGED
@@ -14,13 +14,14 @@ import {
14
14
  import type {
15
15
  BaseSurfaceOptions,
16
16
  Layer,
17
+ OverlayEnvelopeLike,
17
18
  ResourceOverrideMap,
18
19
  Topo,
19
20
  TrailContextInit,
20
21
  } from '@ontrails/core';
21
22
 
22
23
  import type {
23
- McpSurfaceFacetMap,
24
+ McpSurfaceTrailheadMap,
24
25
  McpToolDefinition,
25
26
  ResolveMcpPermit,
26
27
  } from './build.js';
@@ -38,7 +39,19 @@ export interface CreateServerOptions extends BaseSurfaceOptions {
38
39
  | (() => TrailContextInit | Promise<TrailContextInit>)
39
40
  | undefined;
40
41
  readonly description?: string | undefined;
41
- readonly facets?: McpSurfaceFacetMap | undefined;
42
+ /**
43
+ * App-authored overlay envelopes (the same collection compile embeds in
44
+ * `trails.lock`). The `surfaces` overlay's `mcp` bindings are the authored,
45
+ * lockable default: list bindings become grouped trailhead tools and scalar
46
+ * bindings become tool synonyms.
47
+ */
48
+ readonly overlays?: readonly OverlayEnvelopeLike[] | undefined;
49
+ /**
50
+ * Call-site trailhead map. Override-in-context by design: when both this
51
+ * map and overlay `mcp` list bindings are present, the call-site map wins
52
+ * at runtime.
53
+ */
54
+ readonly trailheads?: McpSurfaceTrailheadMap | undefined;
42
55
  readonly layers?: readonly Layer[] | undefined;
43
56
  readonly mcpResources?: McpResourcesConfig | false | undefined;
44
57
  readonly name?: string | undefined;
@@ -220,12 +233,13 @@ export const createServer = (
220
233
  configValues: options.configValues,
221
234
  createContext: options.createContext,
222
235
  exclude: options.exclude,
223
- facets: options.facets,
224
236
  include: options.include,
225
237
  intent: options.intent,
226
238
  layers: options.layers,
239
+ overlays: options.overlays,
227
240
  resolvePermit: options.resolvePermit,
228
241
  resources: options.resources,
242
+ trailheads: options.trailheads,
229
243
  validate: options.validate,
230
244
  });
231
245
 
package/src/tool-name.ts CHANGED
@@ -1,10 +1,13 @@
1
1
  /**
2
2
  * Derive MCP-safe tool names from app name + trail ID.
3
3
  *
4
- * MCP tool names must be [a-z0-9_]+. We prefix with the app name,
5
- * replace dots and hyphens with underscores, and lowercase everything.
4
+ * The derivation itself lives in `@ontrails/core` (`deriveMcpToolName`) so
5
+ * governance readers such as Warden's `surface-overlay-coherence` rule check
6
+ * collisions against the exact projection the MCP surface renders.
6
7
  */
7
8
 
9
+ import { deriveMcpToolName } from '@ontrails/core';
10
+
8
11
  /**
9
12
  * Convert app name + trail ID to an MCP-safe tool name.
10
13
  *
@@ -12,8 +15,4 @@
12
15
  * deriveToolName("myapp", "entity.show") // "myapp_entity_show"
13
16
  * deriveToolName("dispatch", "patch.search") // "dispatch_patch_search"
14
17
  */
15
- export const deriveToolName = (appName: string, trailId: string): string => {
16
- const prefix = appName.toLowerCase().replaceAll(/[.-]/g, '_');
17
- const suffix = trailId.toLowerCase().replaceAll(/[.-]/g, '_');
18
- return `${prefix}_${suffix}`;
19
- };
18
+ export const deriveToolName = deriveMcpToolName;