@try-works/dsh-model-as-tool 0.1.1 → 0.1.2

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.
Files changed (79) hide show
  1. package/README.md +51 -0
  2. package/bin/dsh-tool-mode-mcp-stdio.mjs +80 -0
  3. package/catalog/desert-ant-models.json +77 -13
  4. package/lib/adapters/index.d.ts +19 -6
  5. package/lib/adapters/index.d.ts.map +1 -1
  6. package/lib/adapters/index.js +28 -13
  7. package/lib/adapters/tongue.d.ts +11 -3
  8. package/lib/adapters/tongue.d.ts.map +1 -1
  9. package/lib/adapters/tongue.js +12 -4
  10. package/lib/adapters/types.d.ts +11 -0
  11. package/lib/adapters/types.d.ts.map +1 -1
  12. package/lib/adapters/voz.d.ts +7 -1
  13. package/lib/adapters/voz.d.ts.map +1 -1
  14. package/lib/adapters/voz.js +16 -4
  15. package/lib/asset-acquisition.d.ts +137 -0
  16. package/lib/asset-acquisition.d.ts.map +1 -0
  17. package/lib/asset-acquisition.js +161 -0
  18. package/lib/authority.d.ts +153 -0
  19. package/lib/authority.d.ts.map +1 -0
  20. package/lib/authority.js +194 -0
  21. package/lib/bindings.d.ts +115 -0
  22. package/lib/bindings.d.ts.map +1 -0
  23. package/lib/bindings.js +239 -0
  24. package/lib/catalog.d.ts.map +1 -1
  25. package/lib/catalog.js +57 -0
  26. package/lib/contract-payload.d.ts +47 -0
  27. package/lib/contract-payload.d.ts.map +1 -0
  28. package/lib/contract-payload.js +137 -0
  29. package/lib/deployment-coordinator.d.ts +213 -0
  30. package/lib/deployment-coordinator.d.ts.map +1 -0
  31. package/lib/deployment-coordinator.js +274 -0
  32. package/lib/harness-budgets.d.ts +158 -0
  33. package/lib/harness-budgets.d.ts.map +1 -0
  34. package/lib/harness-budgets.js +152 -0
  35. package/lib/index.d.ts +41 -0
  36. package/lib/index.d.ts.map +1 -1
  37. package/lib/index.js +424 -16
  38. package/lib/licensing.d.ts +221 -0
  39. package/lib/licensing.d.ts.map +1 -0
  40. package/lib/licensing.js +188 -0
  41. package/lib/mcp/host-binding.d.ts +91 -0
  42. package/lib/mcp/host-binding.d.ts.map +1 -0
  43. package/lib/mcp/host-binding.js +133 -0
  44. package/lib/mcp/http.d.ts +130 -0
  45. package/lib/mcp/http.d.ts.map +1 -0
  46. package/lib/mcp/http.js +274 -0
  47. package/lib/mcp/protocol.d.ts +261 -0
  48. package/lib/mcp/protocol.d.ts.map +1 -0
  49. package/lib/mcp/protocol.js +387 -0
  50. package/lib/mcp/server.d.ts +115 -0
  51. package/lib/mcp/server.d.ts.map +1 -0
  52. package/lib/mcp/server.js +200 -0
  53. package/lib/mcp/stdio.d.ts +80 -0
  54. package/lib/mcp/stdio.d.ts.map +1 -0
  55. package/lib/mcp/stdio.js +176 -0
  56. package/lib/mcp/subscriptions.d.ts +135 -0
  57. package/lib/mcp/subscriptions.d.ts.map +1 -0
  58. package/lib/mcp/subscriptions.js +207 -0
  59. package/lib/provider-bridge.d.ts +69 -0
  60. package/lib/provider-bridge.d.ts.map +1 -0
  61. package/lib/provider-bridge.js +218 -0
  62. package/lib/publication.d.ts +160 -0
  63. package/lib/publication.d.ts.map +1 -0
  64. package/lib/publication.js +173 -0
  65. package/lib/receipt.d.ts +208 -0
  66. package/lib/receipt.d.ts.map +1 -0
  67. package/lib/receipt.js +150 -0
  68. package/lib/registrar.d.ts +13 -0
  69. package/lib/registrar.d.ts.map +1 -1
  70. package/lib/registrar.js +20 -3
  71. package/lib/resource-governance.d.ts +204 -0
  72. package/lib/resource-governance.d.ts.map +1 -0
  73. package/lib/resource-governance.js +213 -0
  74. package/lib/server-lifecycle.d.ts +175 -0
  75. package/lib/server-lifecycle.d.ts.map +1 -0
  76. package/lib/server-lifecycle.js +269 -0
  77. package/lib/types.d.ts +21 -0
  78. package/lib/types.d.ts.map +1 -1
  79. package/package.json +9 -2
package/lib/index.js CHANGED
@@ -18,18 +18,41 @@ import { loadCatalog } from "./catalog.js";
18
18
  import { resolveCapability } from "./capability.js";
19
19
  import { createAdapter } from "./adapters/index.js";
20
20
  import { registerModelTool } from "./registrar.js";
21
+ import { registerContractTool } from "./bindings.js";
22
+ import { createGrant } from "./authority.js";
23
+ import { ProviderBridge } from "./provider-bridge.js";
24
+ import { buildSurface } from "./publication.js";
25
+ import { createMcpHost } from "./mcp/host-binding.js";
26
+ import { ToolCallId } from '@deepseek-ai/dsh-llm';
27
+ import { STANDARD_CONTRACTS } from '@try-works/tool-mode-contracts';
21
28
  import { renderSkillBody } from "./skills.js";
22
29
  import { loadPlaybooks } from "./playbook.js";
23
30
  import { commandExists, DEFAULT_CLI_COMMAND } from "./transport.js";
24
31
  import { createFileStateStore, createMemoryStateStore, defaultStateFile, resolveSelection, withChoice, } from "./state.js";
25
32
  export const name = 'model-tools';
26
33
  export const inject = ['tools'];
34
+ /**
35
+ * This package's version, stamped into every result as `extensionVersion`.
36
+ * Kept as a literal so the runtime does not depend on reading its own manifest.
37
+ */
38
+ export /** How many invocation receipts are retained in memory (EXT-35). */ const MAX_RETAINED_RECEIPTS = 200;
39
+ const HOST_VERSION = '0.1.1';
27
40
  /** Validate and default the plugin configuration. */
28
41
  export const Config = z.object({
29
42
  catalogFile: z.string(),
30
43
  playbookFile: z.string(),
31
44
  stateFile: z.string(),
32
45
  persist: z.boolean().default(true),
46
+ providers: z.dict(z.string()).default({}),
47
+ legacyAliases: z.boolean().default(false),
48
+ approvedRoots: z.array(z.string()).default([]),
49
+ grantId: z.string().default('local-interactive'),
50
+ recordReceipts: z.boolean().default(true),
51
+ limits: z.object({
52
+ maxInputBytes: z.number(),
53
+ maxAudioSeconds: z.number(),
54
+ maxResultBytes: z.number(),
55
+ }),
33
56
  models: z.array(z.string()).default([]),
34
57
  exclude: z.array(z.string()).default([]),
35
58
  exposeUnavailable: z.boolean().default(false),
@@ -107,11 +130,175 @@ export function apply(ctx, config = {}) {
107
130
  }),
108
131
  }));
109
132
  const byId = new Map(resolved.map(item => [item.entry.id, item]));
133
+ /**
134
+ * Choose the implementation that serves each standard contract (BASE-02).
135
+ *
136
+ * A contract is registered once. Selection is host configuration: an explicit
137
+ * `providers` override wins only if that model can actually run here, and
138
+ * otherwise the first eligible implementation in catalog order is used. The
139
+ * alternatives are kept so discovery can report them without a caller having to
140
+ * choose a model.
141
+ */
142
+ // The host owns provenance and authority: `extension`/`version` name THIS
143
+ // package, the receipt counter lives here, and the grant is built from operator
144
+ // configuration, so an implementation can forge neither (BASE-05, DECL-03).
145
+ let receiptCounter = 0;
146
+ /** The most recent invocation receipts (EXT-35), bounded so retention cannot grow without limit. */
147
+ const invocationReceipts = [];
148
+ const grant = createGrant({
149
+ grantId: config.grantId ?? 'local-interactive',
150
+ // Every registered tool is granted to the interactive operator; an external
151
+ // caller gets a narrower grant of its own rather than reusing this one.
152
+ allowedTools: [...STANDARD_CONTRACTS.map(contract => contract.toolName), 'model_tools'],
153
+ approvedRoots: config.approvedRoots ?? [],
154
+ hostedCode: false,
155
+ recordingPolicy: config.recordReceipts === false ? 'none' : 'receipts',
156
+ ...config.limits === undefined ? {} : { limits: config.limits },
157
+ });
158
+ const host = {
159
+ extension: '@try-works/dsh-model-as-tool',
160
+ extensionVersion: HOST_VERSION,
161
+ activationGeneration: 1,
162
+ nextReceiptId: () => `rcpt_${(++receiptCounter).toString(36).padStart(6, '0')}`,
163
+ grant,
164
+ callerKind: 'interactive',
165
+ // EXT-35: bounded in-memory retention. A receipt is provenance, not a log of
166
+ // content, so retaining the last N costs little and makes the record
167
+ // recoverable after the call rather than only during it.
168
+ recordReceipt: receipt => {
169
+ invocationReceipts.push(receipt);
170
+ if (invocationReceipts.length > MAX_RETAINED_RECEIPTS)
171
+ invocationReceipts.shift();
172
+ },
173
+ };
174
+ /**
175
+ * Extension providers, reached only through the public extension API (BASE-09).
176
+ *
177
+ * The host holds no adapter for any model: a catalog entry names the package
178
+ * that implements it, and this bridge loads it. One bridge per activation, so an
179
+ * extension serving several models is imported once.
180
+ */
181
+ const bridge = new ProviderBridge({
182
+ grant,
183
+ activationGeneration: host.activationGeneration,
184
+ log: (message, fields) => ctx.logger?.debug?.(`[model-as-tool] ${message}`, fields),
185
+ });
186
+ const contractById = new Map(STANDARD_CONTRACTS.map(contract => [contract.contract, contract]));
187
+ const bindings = new Map();
188
+ for (const contract of STANDARD_CONTRACTS) {
189
+ const eligible = resolved.filter(item => item.entry.contract === contract.contract && item.verdict.runnable);
190
+ if (eligible.length === 0)
191
+ continue;
192
+ const override = config.providers?.[contract.contract];
193
+ const chosen = eligible.find(item => item.entry.id === override) ?? eligible[0];
194
+ if (chosen === undefined)
195
+ continue;
196
+ const adapter = createAdapter(chosen.entry, { route: chosen.verdict.route, ...transportOptions }, undefined, bridge);
197
+ if (adapter === undefined)
198
+ continue;
199
+ bindings.set(contract.contract, {
200
+ contract,
201
+ implementation: chosen.entry,
202
+ adapter,
203
+ route: chosen.verdict.route,
204
+ alternatives: eligible.filter(item => item.entry.id !== chosen.entry.id).map(item => item.entry.id),
205
+ });
206
+ }
207
+ /** Contracts already registered, so a second implementation joins rather than duplicates. */
208
+ const registeredContracts = new Map();
209
+ /**
210
+ * The MCP surface over the tools this activation actually published (MCP-08/09).
211
+ *
212
+ * It reads `registeredContracts`, so a tool appears over MCP only if it was
213
+ * REGISTERED here — the publication surface already decided that, and re-deriving
214
+ * it would let MCP advertise something the host had withheld.
215
+ */
216
+ const mcpHost = createMcpHost({
217
+ serverInfo: { name: '@try-works/dsh-model-as-tool', version: HOST_VERSION },
218
+ surface: {
219
+ get published() {
220
+ const out = [];
221
+ for (const [contractId, registered] of registeredContracts) {
222
+ const contract = contractById.get(contractId);
223
+ if (contract === undefined)
224
+ continue;
225
+ out.push({ toolName: registered.toolName, contract });
226
+ }
227
+ return out;
228
+ },
229
+ async execute({ name, arguments: args, signal, callId }) {
230
+ // MCP-08: routed through the DSH tool runtime with a SERVER-ISSUED call
231
+ // identity and the CALLER's signal, never by resolving a definition and
232
+ // calling it directly — that path would bypass the grant, the receipt and
233
+ // the result policy the runtime applies.
234
+ const result = await ctx.tools.execute({
235
+ signal,
236
+ callId: ToolCallId(callId),
237
+ name,
238
+ arguments: args,
239
+ });
240
+ return result.isError
241
+ ? { isError: true, message: result.error.message }
242
+ : { isError: false, value: result.value };
243
+ },
244
+ },
245
+ onWithheld: tool => ctx.logger.info(`model-tools: ${tool.toolName} is not exported over MCP: ${tool.notExportableReason ?? 'no reason given'}`),
246
+ });
247
+ /**
248
+ * The publication decision for one catalog entry (EXT-24, EXT-32).
249
+ *
250
+ * ONE function decides what may be published, used by the installer and by the
251
+ * discovery surface. Two call sites computing this separately is how a tool ends
252
+ * up registered while discovery reports it withheld, or the reverse.
253
+ */
254
+ const surfaceFor = (entry) => {
255
+ const binding = entry.contract === null ? undefined : bindings.get(entry.contract);
256
+ if (binding === undefined)
257
+ return undefined;
258
+ return buildSurface({
259
+ generation: host.activationGeneration,
260
+ tools: [{
261
+ name: binding.contract.toolName,
262
+ contractId: binding.contract.contract,
263
+ selectedModelId: binding.implementation.id,
264
+ implementations: [
265
+ {
266
+ extensionId: binding.implementation.provider?.extension
267
+ ?? binding.implementation.npmPackage
268
+ ?? 'unknown',
269
+ modelId: binding.implementation.id,
270
+ // There is no preparation lifecycle yet, so a model the capability
271
+ // resolver proved runnable is treated as ready. When preparation exists
272
+ // this reads the deployment's real readiness instead, and the surface
273
+ // already withholds the tool when it is not ready.
274
+ readiness: 'ready',
275
+ runnable: true,
276
+ },
277
+ ...binding.alternatives.map(alternativeId => {
278
+ // The alternative's own extension, looked up rather than assumed: an
279
+ // alternative served by a different package is exactly the case a
280
+ // second implementation exists for (EXT-22).
281
+ const alternative = resolved.find(item => item.entry.id === alternativeId);
282
+ return {
283
+ extensionId: alternative?.entry.provider?.extension
284
+ ?? alternative?.entry.npmPackage
285
+ ?? 'unknown',
286
+ modelId: alternativeId,
287
+ readiness: 'ready',
288
+ runnable: true,
289
+ };
290
+ }),
291
+ ],
292
+ }],
293
+ skills: [],
294
+ });
295
+ };
110
296
  /**
111
297
  * Register one model's tool and skill.
112
298
  *
113
- * Idempotent: registering an already-installed model is a no-op, so the
114
- * control tool can call it without first checking.
299
+ * A model bound to a standard contract contributes to that CONTRACT's single
300
+ * tool; a model with no contract keeps its own per-model tool. Idempotent in
301
+ * both cases, so the control tool can call it without first checking.
115
302
  * @param item - the resolved model to install.
116
303
  * @returns whether the model is installed after the call.
117
304
  */
@@ -121,14 +308,62 @@ export function apply(ctx, config = {}) {
121
308
  return true;
122
309
  if (!verdict.runnable && !exposeUnavailable)
123
310
  return false;
124
- const adapter = createAdapter(entry, { route: verdict.route, ...transportOptions });
125
- if (adapter === undefined) {
126
- // Runnable but no adapter yet: report rather than register a dead tool.
127
- ctx.logger.info(`model-tools: ${entry.name} can run here (${verdict.route}) but no adapter ships yet; skipping ${entry.tool.name}`);
128
- return false;
311
+ let toolName = entry.tool.name;
312
+ let dispose;
313
+ if (entry.contract !== null && contractById.has(entry.contract)) {
314
+ const binding = bindings.get(entry.contract);
315
+ if (binding === undefined) {
316
+ // The model implements a contract but lost provider selection, or cannot
317
+ // run. Report rather than register a second tool for the same contract.
318
+ ctx.logger.info(`model-tools: ${entry.name} implements ${entry.contract} but is not the selected provider; `
319
+ + 'no separate tool is registered (BASE-02)');
320
+ return false;
321
+ }
322
+ /**
323
+ * EXT-24: decide what may be published BEFORE registering anything.
324
+ *
325
+ * The surface is built whole and consulted first, so a tool is registered
326
+ * only when the surface actually published it. Registering first and
327
+ * reconciling afterwards is the state EXT-24 forbids: a caller would observe
328
+ * an available tool whose binding was not yet in place.
329
+ *
330
+ * One helper serves the installer and the discovery surface, because two call
331
+ * sites computing this separately is how a tool gets registered while
332
+ * discovery reports it withheld, or the reverse.
333
+ */
334
+ const surface = surfaceFor(entry);
335
+ const decision = surface?.tools[0];
336
+ if (decision === undefined) {
337
+ const reason = surface?.withheld[0];
338
+ ctx.logger.info(`model-tools: ${entry.name} is active but its tool is withheld (${reason?.code ?? 'unknown'}): `
339
+ + `${reason?.message ?? 'no reason reported'}`);
340
+ return false;
341
+ }
342
+ const existing = registeredContracts.get(entry.contract);
343
+ if (existing !== undefined) {
344
+ // Already served by another implementation: this model joins that tool.
345
+ toolName = existing.toolName;
346
+ dispose = () => { };
347
+ }
348
+ else {
349
+ const registered = registerContractTool(ctx, binding, host);
350
+ registeredContracts.set(entry.contract, registered);
351
+ toolName = registered.toolName;
352
+ dispose = registered.dispose;
353
+ }
129
354
  }
130
- const registration = registerModelTool(ctx, entry, adapter);
131
- const record = { ...registration, entry };
355
+ else {
356
+ const adapter = createAdapter(entry, { route: verdict.route, ...transportOptions }, undefined, bridge);
357
+ if (adapter === undefined) {
358
+ // Runnable but no adapter yet: report rather than register a dead tool.
359
+ ctx.logger.info(`model-tools: ${entry.name} can run here (${verdict.route}) but no adapter ships yet; skipping ${entry.tool.name}`);
360
+ return false;
361
+ }
362
+ const registration = registerModelTool(ctx, entry, adapter);
363
+ toolName = registration.toolName;
364
+ dispose = registration.dispose;
365
+ }
366
+ const record = { entry, toolName, dispose };
132
367
  if (registerSkills) {
133
368
  const skills = ctx.get('skills');
134
369
  if (skills !== undefined) {
@@ -142,8 +377,19 @@ export function apply(ctx, config = {}) {
142
377
  });
143
378
  }
144
379
  }
380
+ // BASE-03: the deprecated alias is opt-in, so the default catalog shows one
381
+ // transcription tool. It keeps its own name and adapter, never the new
382
+ // contract's schema, so an old caller's arguments are not reinterpreted.
383
+ if (config.legacyAliases === true && entry.contract !== null && entry.tool.name !== toolName) {
384
+ const aliasAdapter = createAdapter(entry, { route: verdict.route, ...transportOptions }, undefined, bridge);
385
+ if (aliasAdapter !== undefined) {
386
+ const alias = registerModelTool(ctx, entry, aliasAdapter);
387
+ record.disposeAlias = alias.dispose;
388
+ ctx.logger.info(`model-tools: registered deprecated alias ${alias.toolName} (BASE-03)`);
389
+ }
390
+ }
145
391
  installed.set(entry.id, record);
146
- ctx.logger.info(`model-tools: registered ${entry.tool.name} for ${entry.name} (${verdict.route})`);
392
+ ctx.logger.info(`model-tools: registered ${toolName} for ${entry.name} (${verdict.route})`);
147
393
  return true;
148
394
  };
149
395
  /**
@@ -156,7 +402,16 @@ export function apply(ctx, config = {}) {
156
402
  if (record === undefined)
157
403
  return false;
158
404
  record.disposeSkill?.();
405
+ record.disposeAlias?.();
159
406
  record.dispose();
407
+ // A contract-served model's registration is owned by the contract; dropping
408
+ // the last model that used it must also release the contract's tool, or a
409
+ // removed implementation would keep answering calls.
410
+ if (record.entry.contract !== null) {
411
+ const stillInstalled = [...installed.values()].some(other => other.entry.id !== modelId && other.entry.contract === record.entry.contract);
412
+ if (!stillInstalled)
413
+ registeredContracts.delete(record.entry.contract);
414
+ }
160
415
  installed.delete(modelId);
161
416
  ctx.logger.info(`model-tools: unregistered ${record.toolName}`);
162
417
  return true;
@@ -206,7 +461,7 @@ export function apply(ctx, config = {}) {
206
461
  action: {
207
462
  type: 'string',
208
463
  required: true,
209
- enum: ['list', 'installed', 'describe', 'enable', 'disable', 'reset'],
464
+ enum: ['list', 'installed', 'describe', 'enable', 'disable', 'reset', 'receipts', 'mcp'],
210
465
  description: 'list: every catalogued model and whether it runs here. installed: only the models whose tools are '
211
466
  + 'registered. describe: full detail for one model. enable: install a model\'s tool and remember it. '
212
467
  + 'disable: remove a model\'s tool and remember it. reset: forget the remembered choice for one model, '
@@ -251,11 +506,88 @@ export function apply(ctx, config = {}) {
251
506
  },
252
507
  license: { type: 'string', required: true },
253
508
  docs: { type: 'string', required: true },
509
+ /**
510
+ * Public extension/implementation provenance (EXT-32).
511
+ *
512
+ * Declared rather than left open: a discovery surface that could
513
+ * return anything would let the shape drift unnoticed, and the
514
+ * output schema refusing undeclared fields is what caught this
515
+ * addition in the first place.
516
+ */
517
+ provenance: {
518
+ type: 'object',
519
+ additionalProperties: false,
520
+ properties: {
521
+ contract: {
522
+ required: true,
523
+ oneOf: [{ type: 'string' }, { type: 'null' }],
524
+ },
525
+ extension: {
526
+ required: true,
527
+ oneOf: [{ type: 'string' }, { type: 'null' }],
528
+ },
529
+ modelId: {
530
+ required: true,
531
+ oneOf: [{ type: 'string' }, { type: 'null' }],
532
+ },
533
+ publishedTool: {
534
+ required: true,
535
+ oneOf: [{ type: 'string' }, { type: 'null' }],
536
+ },
537
+ alternatives: { type: 'array', required: true, items: { type: 'string' } },
538
+ },
539
+ },
540
+ /** The deployment's readiness, so an absent tool is explicable. */
541
+ readiness: { type: 'string', required: true },
542
+ /** Whether the tool is genuinely callable right now. */
543
+ callable: { type: 'boolean', required: true },
544
+ /** Why the tool is absent, when it is. */
545
+ withheld: {
546
+ type: 'object',
547
+ additionalProperties: false,
548
+ properties: {
549
+ code: { type: 'string', required: true },
550
+ message: { type: 'string', required: true },
551
+ },
552
+ },
254
553
  },
255
554
  },
256
555
  },
257
556
  installed: { type: 'integer', required: true },
258
557
  runnable: { type: 'integer', required: true },
558
+ /** Set by the mcp action: what this host would export over MCP (MCP-09). */
559
+ mcp: {
560
+ type: 'array',
561
+ items: {
562
+ type: 'object',
563
+ additionalProperties: false,
564
+ properties: {
565
+ name: { type: 'string', required: true },
566
+ hasOutputSchema: { type: 'boolean', required: true },
567
+ title: {
568
+ required: true,
569
+ oneOf: [{ type: 'string' }, { type: 'null' }],
570
+ },
571
+ },
572
+ },
573
+ },
574
+ /** Set by the receipts action: recent invocation provenance (EXT-35). */
575
+ receipts: {
576
+ type: 'array',
577
+ items: {
578
+ type: 'object',
579
+ additionalProperties: false,
580
+ properties: {
581
+ receiptId: { type: 'string', required: true },
582
+ contract: { type: 'string', required: true },
583
+ implementationId: { type: 'string', required: true },
584
+ extension: { type: 'string', required: true },
585
+ modelIdentity: { type: 'string', required: true },
586
+ activationGeneration: { type: 'integer', required: true },
587
+ executionLocation: { type: 'string', required: true },
588
+ },
589
+ },
590
+ },
259
591
  /** Set by enable/disable/reset: whether the requested change took effect. */
260
592
  changed: { type: 'boolean' },
261
593
  /** Set by enable/disable/reset: what the call did to the tool surface. */
@@ -288,17 +620,92 @@ export function apply(ctx, config = {}) {
288
620
  const modelId = args.model;
289
621
  /** The user's remembered choice for one model. */
290
622
  const choiceOf = (id) => persisted.enabled.includes(id) ? 'enabled' : persisted.disabled.includes(id) ? 'disabled' : 'none';
291
- /** The current snapshot, with per-model installed and remembered state. */
292
- const snapshot = (items) => items.map(item => ({
293
- ...describeModels([item])[0],
294
- installed: installed.has(item.entry.id),
295
- choice: choiceOf(item.entry.id),
623
+ /**
624
+ * The provenance and readiness a discovery surface must expose (EXT-32).
625
+ *
626
+ * ALWAYS returns the full shape, including for a model with no contract
627
+ * binding: the fields are declared required in the output schema, so a
628
+ * partial object would be refused — and a caller reading `readiness` should
629
+ * get an answer for every model rather than having to handle its absence.
630
+ */
631
+ const provenanceOf = (entry) => {
632
+ const surface = surfaceFor(entry);
633
+ const published = surface?.tools[0];
634
+ const notice = surface?.readiness.find(item => item.modelId === entry.id);
635
+ const withheld = surface === undefined
636
+ ? undefined
637
+ : surface.withheld.find(item => item.subject === published?.name) ?? surface.withheld[0];
638
+ return {
639
+ provenance: {
640
+ contract: entry.contract,
641
+ // Which PACKAGE implements this model, and which model inside it. Read
642
+ // from the catalog binding, so it is reported even for a model that is
643
+ // not the selected provider and therefore has no published tool.
644
+ extension: entry.provider?.extension ?? null,
645
+ modelId: entry.provider?.modelId ?? null,
646
+ publishedTool: published?.name ?? null,
647
+ // Copied into a mutable array: the surface's own list is readonly, and
648
+ // a discovery result is a plain JSON value.
649
+ alternatives: [...(published?.alternatives ?? [])],
650
+ },
651
+ readiness: notice?.readiness ?? 'unknown',
652
+ callable: notice?.callable ?? false,
653
+ ...withheld === undefined ? {} : {
654
+ withheld: { code: withheld.code, message: withheld.message },
655
+ },
656
+ };
657
+ };
658
+ /**
659
+ * The current snapshot, with per-model installed and remembered state.
660
+ *
661
+ * `tool` reports the name actually REGISTERED, not the catalog's per-model
662
+ * name: under a contract two implementations share one tool, and reporting
663
+ * the catalog name would tell a caller to call something that does not exist.
664
+ */
665
+ const snapshot = (items) => items.map(item => {
666
+ const record = installed.get(item.entry.id);
667
+ return {
668
+ ...describeModels([item])[0],
669
+ ...record === undefined ? {} : { tool: record.toolName },
670
+ ...provenanceOf(item.entry),
671
+ installed: record !== undefined,
672
+ choice: choiceOf(item.entry.id),
673
+ };
674
+ });
675
+ /**
676
+ * The most recent invocation receipts (EXT-35).
677
+ *
678
+ * Exposed through discovery so the provenance is observable rather than
679
+ * merely recorded: a receipt nobody can read is indistinguishable from no
680
+ * receipt at all.
681
+ */
682
+ const receipts = () => invocationReceipts.map(receipt => ({
683
+ receiptId: receipt.receiptId,
684
+ contract: receipt.contract,
685
+ implementationId: receipt.implementationId,
686
+ extension: receipt.extension,
687
+ modelIdentity: receipt.modelIdentity,
688
+ activationGeneration: receipt.activationGeneration,
689
+ executionLocation: receipt.executionLocation,
296
690
  }));
297
691
  const counts = () => ({
298
692
  installed: installed.size,
299
693
  runnable: resolved.filter(item => item.verdict.runnable).length,
300
694
  ...persist ? { stateFile: config.stateFile ?? defaultStateFile() } : {},
301
695
  });
696
+ if (action === 'mcp') {
697
+ return { models: [], ...counts(), mcp: mcpHost.listTools().map(tool => ({
698
+ name: tool.name,
699
+ // Presence of the OUTPUT schema is the MCP-09 fact worth exposing: DSH
700
+ // model-facing projection omits it, so this is the evidence that the
701
+ // export is built from the contract instead.
702
+ hasOutputSchema: tool.outputSchema !== undefined,
703
+ title: tool.title ?? null,
704
+ })) };
705
+ }
706
+ if (action === 'receipts') {
707
+ return { models: [], ...counts(), receipts: receipts() };
708
+ }
302
709
  if (action === 'reset') {
303
710
  // Forgetting a choice is what makes the config authoritative again.
304
711
  if (modelId !== undefined) {
@@ -373,6 +780,7 @@ export function apply(ctx, config = {}) {
373
780
  ctx.effect(() => () => {
374
781
  for (const record of installed.values()) {
375
782
  record.disposeSkill?.();
783
+ record.disposeAlias?.();
376
784
  record.dispose();
377
785
  }
378
786
  installed.clear();