@bevel-software/platform-core-backend 0.34.0 → 0.35.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.
Files changed (179) hide show
  1. package/agent-guide/access-control.md +146 -300
  2. package/agent-guide/conventions.md +23 -25
  3. package/agent-guide/directory-structure.md +72 -120
  4. package/agent-guide/drafts-and-change-requests.md +92 -0
  5. package/agent-guide/for-git-users.md +47 -0
  6. package/agent-guide/glossary.md +36 -0
  7. package/agent-guide/how-agents-find-things.md +49 -0
  8. package/agent-guide/html-views.md +16 -11
  9. package/agent-guide/new-knowledge-base.md +9 -8
  10. package/agent-guide/skills.md +67 -42
  11. package/agent-guide/start-here.md +65 -0
  12. package/agent-guide/tool-manuals.md +132 -154
  13. package/agent-guide/tools.md +67 -0
  14. package/agent-guide/where-a-new-file-goes.md +45 -28
  15. package/dist/core/create-core-server.js +1 -1
  16. package/dist/core/create-core-server.js.map +1 -1
  17. package/dist/core/create-core-services.d.ts +6 -0
  18. package/dist/core/create-core-services.d.ts.map +1 -1
  19. package/dist/core/create-core-services.js +7 -4
  20. package/dist/core/create-core-services.js.map +1 -1
  21. package/dist/modules/access/access-control.interface.d.ts +22 -0
  22. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  23. package/dist/modules/access/access-control.service.d.ts +3 -1
  24. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  25. package/dist/modules/access/access-control.service.js +75 -0
  26. package/dist/modules/access/access-control.service.js.map +1 -1
  27. package/dist/modules/access/access.routes.d.ts.map +1 -1
  28. package/dist/modules/access/access.routes.js +23 -4
  29. package/dist/modules/access/access.routes.js.map +1 -1
  30. package/dist/modules/access/index.d.ts +1 -1
  31. package/dist/modules/access/index.d.ts.map +1 -1
  32. package/dist/modules/agent-guide/agent-guide.d.ts +47 -7
  33. package/dist/modules/agent-guide/agent-guide.d.ts.map +1 -1
  34. package/dist/modules/agent-guide/agent-guide.js +103 -13
  35. package/dist/modules/agent-guide/agent-guide.js.map +1 -1
  36. package/dist/modules/agent-guide/agent-guide.tools.d.ts +6 -2
  37. package/dist/modules/agent-guide/agent-guide.tools.d.ts.map +1 -1
  38. package/dist/modules/agent-guide/agent-guide.tools.js +45 -31
  39. package/dist/modules/agent-guide/agent-guide.tools.js.map +1 -1
  40. package/dist/modules/agent-guide/guide-budget.d.ts +48 -0
  41. package/dist/modules/agent-guide/guide-budget.d.ts.map +1 -0
  42. package/dist/modules/agent-guide/guide-budget.js +67 -0
  43. package/dist/modules/agent-guide/guide-budget.js.map +1 -0
  44. package/dist/modules/agent-guide/index.d.ts +2 -1
  45. package/dist/modules/agent-guide/index.d.ts.map +1 -1
  46. package/dist/modules/agent-guide/index.js +2 -1
  47. package/dist/modules/agent-guide/index.js.map +1 -1
  48. package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
  49. package/dist/modules/agent-instructions/compose.js +4 -0
  50. package/dist/modules/agent-instructions/compose.js.map +1 -1
  51. package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -1
  52. package/dist/modules/agent-instructions/shared-file-rules.js +10 -8
  53. package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -1
  54. package/dist/modules/code-mode/code-mode.tool.js +2 -2
  55. package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
  56. package/dist/modules/connection-probe/connection-probe.service.d.ts +0 -12
  57. package/dist/modules/connection-probe/connection-probe.service.d.ts.map +1 -1
  58. package/dist/modules/connection-probe/connection-probe.service.js +1 -38
  59. package/dist/modules/connection-probe/connection-probe.service.js.map +1 -1
  60. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  61. package/dist/modules/mcp/mcp.service.js +6 -35
  62. package/dist/modules/mcp/mcp.service.js.map +1 -1
  63. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts +11 -1
  64. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -1
  65. package/dist/modules/secrets-vault/db-secrets-vault.service.js +159 -24
  66. package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
  67. package/dist/modules/secrets-vault/index.d.ts +1 -1
  68. package/dist/modules/secrets-vault/index.d.ts.map +1 -1
  69. package/dist/modules/secrets-vault/index.js +1 -1
  70. package/dist/modules/secrets-vault/index.js.map +1 -1
  71. package/dist/modules/secrets-vault/secrets-vault.contract.d.ts +53 -11
  72. package/dist/modules/secrets-vault/secrets-vault.contract.d.ts.map +1 -1
  73. package/dist/modules/secrets-vault/secrets-vault.contract.js +27 -0
  74. package/dist/modules/secrets-vault/secrets-vault.contract.js.map +1 -1
  75. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  76. package/dist/modules/secrets-vault/secrets-vault.routes.js +85 -39
  77. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  78. package/dist/modules/settings/oidc-check.d.ts.map +1 -1
  79. package/dist/modules/settings/oidc-check.js +5 -17
  80. package/dist/modules/settings/oidc-check.js.map +1 -1
  81. package/dist/modules/skills/skills.tools.js +1 -1
  82. package/dist/modules/skills/skills.tools.js.map +1 -1
  83. package/dist/modules/tool-helpers/tool-def.js +1 -1
  84. package/dist/modules/tool-helpers/tool-def.js.map +1 -1
  85. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts +7 -1
  86. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
  87. package/dist/modules/tool-manuals/mcp-json-discovery.js +69 -38
  88. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
  89. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +27 -0
  90. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  91. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +12 -8
  92. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  93. package/dist/modules/tool-manuals/tool-manuals.service.js +115 -44
  94. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  95. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts +8 -2
  96. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  97. package/dist/modules/tool-manuals/tool-manuals.tools.js +31 -10
  98. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  99. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  100. package/dist/modules/workflow/agent-tools/workflow.tools.js +22 -18
  101. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  102. package/dist/modules/workspace/agent-access.gate.d.ts +1 -1
  103. package/dist/modules/workspace/agent-access.gate.d.ts.map +1 -1
  104. package/dist/modules/workspace/agent-access.gate.js +1 -1
  105. package/dist/modules/workspace/agent-access.gate.js.map +1 -1
  106. package/dist/modules/workspace/workspace.tools.d.ts +3 -2
  107. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  108. package/dist/modules/workspace/workspace.tools.js +71 -21
  109. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  110. package/dist/shared/oauth-token-error.d.ts +23 -0
  111. package/dist/shared/oauth-token-error.d.ts.map +1 -0
  112. package/dist/shared/oauth-token-error.js +47 -0
  113. package/dist/shared/oauth-token-error.js.map +1 -0
  114. package/dist/shared/read-capped.d.ts +19 -0
  115. package/dist/shared/read-capped.d.ts.map +1 -0
  116. package/dist/shared/read-capped.js +45 -0
  117. package/dist/shared/read-capped.js.map +1 -0
  118. package/package.json +4 -3
  119. package/src/__tests__/hexis-catalog.ts +144 -0
  120. package/src/__tests__/real-disk.ts +21 -0
  121. package/src/__tests__/tokens.ts +19 -0
  122. package/src/__tests__/tool-manuals.ts +32 -0
  123. package/src/__tests__/tool-wiring.ts +25 -0
  124. package/src/core/__tests__/oauth-sign-in-real-file.test.ts +179 -0
  125. package/src/core/create-core-server.ts +1 -1
  126. package/src/core/create-core-services.ts +13 -3
  127. package/src/modules/access/__tests__/access.routes.prospective.test.ts +52 -12
  128. package/src/modules/access/__tests__/holder-changes.test.ts +127 -0
  129. package/src/modules/access/access-control.interface.ts +26 -0
  130. package/src/modules/access/access-control.service.ts +92 -0
  131. package/src/modules/access/access.routes.ts +23 -4
  132. package/src/modules/access/index.ts +1 -1
  133. package/src/modules/agent-guide/__tests__/agent-guide.test.ts +79 -25
  134. package/src/modules/agent-guide/__tests__/agent-guide.tools.test.ts +38 -11
  135. package/src/modules/agent-guide/__tests__/guide-budget.test.ts +95 -0
  136. package/src/modules/agent-guide/__tests__/guide-eval.test.ts +182 -0
  137. package/src/modules/agent-guide/__tests__/guide-matches-code.test.ts +263 -0
  138. package/src/modules/agent-guide/__tests__/tool-surface.json +514 -0
  139. package/src/modules/agent-guide/__tests__/tool-surface.test.ts +75 -0
  140. package/src/modules/agent-guide/agent-guide.tools.ts +49 -31
  141. package/src/modules/agent-guide/agent-guide.ts +120 -13
  142. package/src/modules/agent-guide/guide-budget.ts +73 -0
  143. package/src/modules/agent-guide/index.ts +12 -0
  144. package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +4 -4
  145. package/src/modules/agent-instructions/compose.ts +4 -0
  146. package/src/modules/agent-instructions/shared-file-rules.ts +10 -8
  147. package/src/modules/code-mode/code-mode.tool.ts +2 -2
  148. package/src/modules/connection-probe/__tests__/connection-probe.service.test.ts +5 -24
  149. package/src/modules/connection-probe/connection-probe.service.ts +1 -34
  150. package/src/modules/mcp/mcp.service.ts +5 -35
  151. package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +430 -3
  152. package/src/modules/secrets-vault/__tests__/oauth-live-declaration.route.test.ts +218 -0
  153. package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +39 -6
  154. package/src/modules/secrets-vault/db-secrets-vault.service.ts +170 -27
  155. package/src/modules/secrets-vault/index.ts +1 -1
  156. package/src/modules/secrets-vault/secrets-vault.contract.ts +61 -11
  157. package/src/modules/secrets-vault/secrets-vault.routes.ts +91 -39
  158. package/src/modules/settings/oidc-check.ts +5 -18
  159. package/src/modules/skills/skills.tools.ts +1 -1
  160. package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +19 -11
  161. package/src/modules/tool-helpers/__tests__/route-tool-schemas.catalog.test.ts +38 -0
  162. package/src/modules/tool-helpers/tool-def.ts +1 -1
  163. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +197 -11
  164. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +96 -5
  165. package/src/modules/tool-manuals/mcp-json-discovery.ts +73 -34
  166. package/src/modules/tool-manuals/tool-manuals.contract.ts +22 -0
  167. package/src/modules/tool-manuals/tool-manuals.service.ts +138 -46
  168. package/src/modules/tool-manuals/tool-manuals.tools.ts +53 -14
  169. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +6 -113
  170. package/src/modules/workflow/agent-tools/workflow.tools.ts +22 -18
  171. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +8 -10
  172. package/src/modules/workspace/__tests__/workspace.tools.test.ts +156 -49
  173. package/src/modules/workspace/agent-access.gate.ts +1 -1
  174. package/src/modules/workspace/workspace.tools.ts +74 -21
  175. package/src/shared/__tests__/read-capped.test.ts +68 -0
  176. package/src/shared/oauth-token-error.ts +46 -0
  177. package/src/shared/read-capped.ts +40 -0
  178. package/agent-guide/finding-things.md +0 -7
  179. package/agent-guide/introduction.md +0 -27
@@ -59,6 +59,7 @@ import {
59
59
  type ToolProbeTarget,
60
60
  type ToolVariableScope,
61
61
  type ToolVariableOAuth,
62
+ type LiveDeclaration,
62
63
  } from './tool-manuals.contract.js';
63
64
 
64
65
  const CACHE_TTL_MS = 60_000;
@@ -182,6 +183,19 @@ export function describeManualFault(err: unknown): string {
182
183
  interface ScanResult {
183
184
  manuals: ToolManualDescriptor[];
184
185
  invalid: InvalidToolManual[];
186
+ /**
187
+ * Every manual the parse refused whose file still says which manual it is,
188
+ * by slug, with the reason. Not a listing surface (`invalid` is that, by
189
+ * path): it lets a sign-in started on a refused declaration answer why,
190
+ * where the slug alone would otherwise find nothing.
191
+ */
192
+ refused: RefusedManual[];
193
+ }
194
+
195
+ interface RefusedManual {
196
+ slug: string;
197
+ path: string;
198
+ reason: string;
185
199
  }
186
200
 
187
201
  /**
@@ -191,7 +205,7 @@ interface ScanResult {
191
205
  * convention instance is one push away from every empty scan in the process
192
206
  * inheriting another's contents.
193
207
  */
194
- const emptyScan = (): ScanResult => ({ manuals: [], invalid: [] });
208
+ const emptyScan = (): ScanResult => ({ manuals: [], invalid: [], refused: [] });
195
209
 
196
210
  const manualSerializer = new UtcpManualSerializer();
197
211
  const callTemplateSerializer = new CallTemplateSerializer();
@@ -209,8 +223,8 @@ export interface McpAuthDiscoveryPort {
209
223
  /**
210
224
  * The sign-in endpoints for an OWNER-REGISTERED client: the same metadata
211
225
  * walk as `statusFor`, stopping short of dynamic registration — the manual
212
- * already names its `clientId`. Nothing is persisted; the owner's
213
- * client-secret save pins the completed provider. Optional so a port that
226
+ * already names its `clientId`. Nothing is persisted; every sign-in start
227
+ * completes the declaration afresh (`liveDeclaration`). Optional so a port that
214
228
  * only knows the zero-config path still satisfies the interface.
215
229
  */
216
230
  providerForDeclaredClient?(manualName: string, mcpUrl: string, clientId: string): Promise<McpAuthDiscoveryResult>;
@@ -342,6 +356,38 @@ export class ToolManualService implements IToolManualService {
342
356
  };
343
357
  }
344
358
 
359
+ async liveDeclaration(
360
+ userEmail: string,
361
+ slug: string,
362
+ varName: string,
363
+ ): Promise<LiveDeclaration | null> {
364
+ // The disk walk the catalog's scan is built from, run afresh and never
365
+ // cached: a sign-in must follow the file as it is, and the catalog's scan
366
+ // can be a refresh behind it. The whole walk rather than the one file, so
367
+ // the descriptor comes out of exactly the parse, plugin discovery and
368
+ // namespace dedup the catalog applies — a sign-in start is rare and
369
+ // interactive, and a second, single-file reading of the same files is a
370
+ // second place for those rules to drift. A workspace that cannot be opened
371
+ // is an error here, never an empty answer: "no sign-in declared" would be
372
+ // the wrong diagnosis for a server that could not read its files.
373
+ const { manuals, refused } = await this.readableBy(await this.scanDisk(undefined, 'throw'), userEmail);
374
+ const m = manuals.find((x) => x.slug === slug);
375
+ if (!m) {
376
+ // The file is there and says it is this manual, but the parse refused
377
+ // it — e.g. its sign-in names an http:// or private token endpoint. That
378
+ // reason is the answer; "not found" would hide the one thing to fix.
379
+ const r = refused.find((x) => x.slug === slug);
380
+ return r ? { refused: r.reason } : null;
381
+ }
382
+ const variable = (m.variables ?? []).find((v) => v.name === varName);
383
+ // Completed by the catalog's own decoration, on this one fresh descriptor.
384
+ // Only when THIS variable is a sign-in: then the manual has a declared one,
385
+ // so the pass completes its endpoints from the server's metadata and never
386
+ // probes for (or registers) a zero-config client.
387
+ if (variable?.oauth) await this.decorateMcpOAuth([m]);
388
+ return { manual: toSummary(m), ...(variable ? { variable } : {}) };
389
+ }
390
+
345
391
  async listLocalOnly(userEmail: string, within?: ConnectorScope): Promise<{ slug: string; name: string; path: string }[]> {
346
392
  const manuals = await this.accessibleManuals(userEmail, within);
347
393
  return manuals.filter((m) => m.remote === false).map((m) => ({ slug: m.slug, name: m.name, path: m.path }));
@@ -616,11 +662,16 @@ export class ToolManualService implements IToolManualService {
616
662
  * in. Absent, the answer is the caller's whole readable catalog.
617
663
  */
618
664
  private async accessibleScan(userEmail: string, within?: ConnectorScope): Promise<ScanResult> {
619
- const { manuals, invalid } = await this.scan();
620
- if (manuals.length === 0 && invalid.length === 0) return emptyScan();
665
+ return this.readableBy(await this.scan(), userEmail, within);
666
+ }
667
+
668
+ /** A scan cut to what this caller may read — see {@link accessibleScan}. */
669
+ private async readableBy(scan: ScanResult, userEmail: string, within?: ConnectorScope): Promise<ScanResult> {
670
+ const { manuals, invalid, refused } = scan;
671
+ if (manuals.length === 0 && invalid.length === 0 && refused.length === 0) return emptyScan();
621
672
  const wsId = this.kb.defaultWorkspaceId();
622
673
  const allowed = await this.accessControl.canReadBatch(wsId, userEmail, [
623
- ...new Set([...manuals.map((m) => m.path), ...invalid.map((i) => i.path)]),
674
+ ...new Set([...manuals.map((m) => m.path), ...invalid.map((i) => i.path), ...refused.map((r) => r.path)]),
624
675
  ]);
625
676
  // Fail closed: keep an entry only on an explicit `true` verdict (a missing
626
677
  // entry is treated as denied, matching the KB's default-deny read model).
@@ -631,6 +682,7 @@ export class ToolManualService implements IToolManualService {
631
682
  return {
632
683
  manuals: manuals.filter((m) => inScope(m.path)),
633
684
  invalid: invalid.filter((i) => inScope(i.path)),
685
+ refused: refused.filter((r) => inScope(r.path)),
634
686
  };
635
687
  }
636
688
 
@@ -655,14 +707,14 @@ export class ToolManualService implements IToolManualService {
655
707
  // See `TtlCache.begin`: taken before the read so an `invalidate()` that
656
708
  // lands mid-scan discards this result instead of being overwritten by it.
657
709
  const token = this.cache.begin();
658
- const { manuals, invalid } = await this.scanDisk();
710
+ const scanned = await this.scanDisk();
711
+ const { manuals } = scanned;
659
712
  await this.decorateMcpOAuth(manuals);
660
713
  // AFTER the oauth decoration, so an injected `${MCP_OAUTH}` header ref is
661
714
  // already declared and isn't re-surfaced as a bare admin key.
662
715
  for (const m of manuals) this.surfaceReferencedVariables(m);
663
- const result = { manuals, invalid };
664
- this.cache.set(result, token);
665
- return result;
716
+ this.cache.set(scanned, token);
717
+ return scanned;
666
718
  }
667
719
 
668
720
  /**
@@ -833,11 +885,12 @@ export class ToolManualService implements IToolManualService {
833
885
  * (the owner registered an app with a provider that offers no dynamic
834
886
  * registration — HubSpot, Google) gets its endpoints, PKCE and resource
835
887
  * indicator from the server's own OAuth metadata, exactly as the zero-config
836
- * path would. The descriptor is completed IN MEMORY: the client-secret route
837
- * reads the completed declaration and pins it with the secret, so nothing
838
- * here persists. When the metadata can't be had, the declaration stays
839
- * incomplete and `setup.reason` says so — the secret route then refuses
840
- * with the same reason instead of pinning a provider with no endpoints.
888
+ * path would. The descriptor is completed IN MEMORY, for the catalog's scan
889
+ * and for every sign-in start (`liveDeclaration`) alike, so nothing here
890
+ * persists. When the metadata can't be had, the declaration stays
891
+ * incomplete and `setup.reason` says so — the client-secret save and the
892
+ * sign-in start then refuse with the same reason instead of running a
893
+ * provider with no endpoints.
841
894
  */
842
895
  private async completeDeclaredOAuth(
843
896
  discovery: McpAuthDiscoveryPort,
@@ -896,11 +949,22 @@ export class ToolManualService implements IToolManualService {
896
949
  * calls: a refusal lives only in the scan it came from, so a fixed file is a
897
950
  * valid manual on the very next scan.
898
951
  */
899
- private async scanDisk(branch: string = this.kb.defaultBranch): Promise<ScanResult> {
952
+ private async scanDisk(
953
+ branch: string = this.kb.defaultBranch,
954
+ /**
955
+ * What a workspace that cannot be opened means. For the catalog, an empty
956
+ * scan: a listing surface answers "nothing released" and the next scan
957
+ * tries again. For a sign-in start (`liveDeclaration`), the error itself:
958
+ * an empty answer there would read as "this tool has no sign-in", a
959
+ * misconfiguration, when the server could not read its files at all.
960
+ */
961
+ workspaceFailure: 'empty' | 'throw' = 'empty',
962
+ ): Promise<ScanResult> {
900
963
  let wsId: string;
901
964
  try {
902
965
  wsId = (await this.workspaceService.getOrCreateForBranch(branch)).id;
903
- } catch {
966
+ } catch (err) {
967
+ if (workspaceFailure === 'throw') throw err;
904
968
  return emptyScan();
905
969
  }
906
970
  const kbRoot = path.join(await this.workspaceService.getWorkspacePath(wsId), this.kbDirName);
@@ -921,6 +985,7 @@ export class ToolManualService implements IToolManualService {
921
985
  // The plugins (and their mcp.json text) come from the configured source, so
922
986
  // a dialect that expands servers from a registry lands here unchanged.
923
987
  const parsed: ToolManualDescriptor[] = [];
988
+ const refused: RefusedManual[] = [];
924
989
  const discovered = await this.source.discover(kbRoot);
925
990
  for (const plugin of discovered.plugins) {
926
991
  if (plugin.mcpJsonText === null) continue; // no servers is the common case, not an error
@@ -929,7 +994,9 @@ export class ToolManualService implements IToolManualService {
929
994
  // (a URL, a header, an added or dropped server, a `local: true` flip)
930
995
  // moves every server's digest — which is correct, if slightly generous.
931
996
  const from = sourceDigest(plugin.mcpJsonText + '\u0000' + (plugin.manifestText ?? ''));
932
- for (const d of descriptorsFromMcpJson(plugin.relFolder, plugin.mcpJsonText, plugin.manifestText, layout)) {
997
+ const onRefused = (slug: string, reason: string, path: string) =>
998
+ refused.push({ slug, path, reason: describeManualFault(reason) });
999
+ for (const d of descriptorsFromMcpJson(plugin.relFolder, plugin.mcpJsonText, plugin.manifestText, layout, onRefused)) {
933
1000
  d.sourceRevision = from;
934
1001
  parsed.push(d);
935
1002
  }
@@ -965,6 +1032,8 @@ export class ToolManualService implements IToolManualService {
965
1032
  // outage from where the author stands: the tool is gone from every
966
1033
  // listing, with nothing anywhere saying which file or why.
967
1034
  invalid.push({ path: f.rel, reason: describeManualFault(err) });
1035
+ const slug = refusedToolManualName(baseName(f.rel), content);
1036
+ if (slug !== null) refused.push({ slug, path: f.rel, reason: describeManualFault(err) });
968
1037
  continue;
969
1038
  }
970
1039
  // The route slug IS the id (unique after dedup below, snake_case → URL-safe),
@@ -1013,7 +1082,7 @@ export class ToolManualService implements IToolManualService {
1013
1082
  // draft workspace, and a draft's half-written `.tool` is not news about the
1014
1083
  // served catalog — it would also flap the log every time an agent saves.
1015
1084
  if (branch === this.kb.defaultBranch) this.logInvalidOnChange(invalid);
1016
- return { manuals, invalid };
1085
+ return { manuals, invalid, refused };
1017
1086
  }
1018
1087
  }
1019
1088
 
@@ -1125,6 +1194,54 @@ function manualName(raw: string): string {
1125
1194
  return name;
1126
1195
  }
1127
1196
 
1197
+ /**
1198
+ * The object a `.tool` file holds: its `---` block when fenced, the whole file
1199
+ * when not. Throws when that is not an object.
1200
+ */
1201
+ function toolFileObject(content: string): Record<string, unknown> {
1202
+ const fm = extractFrontmatter(content);
1203
+ const source = fm ? fm.frontmatter : content;
1204
+ const parsed = parseYaml(source) as unknown;
1205
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
1206
+ throw new Error('`.tool` file must be a YAML/JSON object (in the `---` block when fenced)');
1207
+ }
1208
+ return parsed as Record<string, unknown>;
1209
+ }
1210
+
1211
+ /**
1212
+ * A `.tool`'s manual name — its UTCP namespace, and the slug the scan serves it
1213
+ * under. An explicit `id` is used VERBATIM, so it must be lowercase snake_case
1214
+ * (UTCP's namespace grammar). With no explicit id, fall back to `name` then the
1215
+ * filename and sanitize (legacy behaviour — keeps existing tools' namespaces).
1216
+ */
1217
+ function toolManualName(obj: Record<string, unknown>, provisionalSlug: string): string {
1218
+ const explicitId = typeof obj.id === 'string' ? obj.id.trim() : '';
1219
+ if (!explicitId) return manualName(resolveDeclaredId(obj, provisionalSlug));
1220
+ if (!isValidId(explicitId)) {
1221
+ // The rejected id is NOT quoted back — see `describeManualFault`: this
1222
+ // message becomes an `invalid[].reason` on an agent transcript, a browser
1223
+ // and a log, and `id` is a field an author can paste anything into,
1224
+ // including the token they meant for a header. The field name plus the
1225
+ // rule is what the author needs; the value is in front of them.
1226
+ throw new Error('tool `id` must be lowercase snake_case (letters, digits, underscores)');
1227
+ }
1228
+ return explicitId;
1229
+ }
1230
+
1231
+ /**
1232
+ * The manual name of a `.tool` the parser REFUSED, when the file still says
1233
+ * which manual it is — by the same rule {@link normalizeToolManual} names a
1234
+ * manual it accepts. `null` when even that cannot be read (unparseable YAML,
1235
+ * a malformed `id`): then nothing can claim the refusal by name.
1236
+ */
1237
+ function refusedToolManualName(provisionalSlug: string, content: string): string | null {
1238
+ try {
1239
+ return toolManualName(toolFileObject(content), provisionalSlug);
1240
+ } catch {
1241
+ return null;
1242
+ }
1243
+ }
1244
+
1128
1245
  /**
1129
1246
  * Parse + normalize a `.tool` file into a descriptor. THE TOOL IS THE
1130
1247
  * FRONTMATTER: a fenced file's single `---` YAML block carries everything —
@@ -1148,34 +1265,9 @@ export function normalizeToolManual(
1148
1265
  // finds its verbs inside the fence, the id index finds `id`/`name`, and the
1149
1266
  // config keys live in the same object. A fence-less file is the legacy form
1150
1267
  // (the whole file is the object) — JSON `.tool`s can't carry fences.
1151
- const fm = extractFrontmatter(content);
1152
- const source = fm ? fm.frontmatter : content;
1153
- const parsed = parseYaml(source) as unknown;
1154
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
1155
- throw new Error('`.tool` file must be a YAML/JSON object (in the `---` block when fenced)');
1156
- }
1157
- const obj = parsed as Record<string, unknown>;
1268
+ const obj = toolFileObject(content);
1158
1269
  const type = normalizeType(obj.type);
1159
-
1160
- // Identity: an explicit `id` is used VERBATIM as the UTCP manual namespace, so
1161
- // it must be lowercase snake_case (UTCP's namespace grammar). With no explicit
1162
- // id, fall back to `name` then the filename and sanitize (legacy behaviour —
1163
- // keeps existing tools' namespaces).
1164
- const explicitId = typeof obj.id === 'string' ? obj.id.trim() : '';
1165
- let name: string;
1166
- if (explicitId) {
1167
- if (!isValidId(explicitId)) {
1168
- // The rejected id is NOT quoted back — see `describeManualFault`: this
1169
- // message becomes an `invalid[].reason` on an agent transcript, a browser
1170
- // and a log, and `id` is a field an author can paste anything into,
1171
- // including the token they meant for a header. The field name plus the
1172
- // rule is what the author needs; the value is in front of them.
1173
- throw new Error('tool `id` must be lowercase snake_case (letters, digits, underscores)');
1174
- }
1175
- name = explicitId;
1176
- } else {
1177
- name = manualName(resolveDeclaredId(obj, provisionalSlug));
1178
- }
1270
+ const name = toolManualName(obj, provisionalSlug);
1179
1271
 
1180
1272
  // A `.tool` may not reproduce a built-in manual's UTCP namespace — that would
1181
1273
  // let it read the loopback creds seeded under that namespace on the agent's
@@ -7,18 +7,32 @@ import type { IAccessControl } from '../access/index.js';
7
7
  import { utcpNamespacedKey } from '../../shared/utcp-namespace.js';
8
8
  import type { ConnectorScope, IToolManualService } from './tool-manuals.contract.js';
9
9
  import type { HiddenToolSource } from '../../shared/hidden-tools.js';
10
+ import { userSecretGap } from '../secrets-vault/index.js';
10
11
 
11
12
  /**
12
13
  * What `list_tool_setup` needs from the secrets vault (structurally satisfied
13
14
  * by `ISecretsVaultService.statusFor`). A local port keeps this module free of
14
- * a secrets-vault import — the same decoupling discipline as
15
- * `McpAuthDiscoveryPort` in the service.
15
+ * the vault service — the same decoupling discipline as
16
+ * `McpAuthDiscoveryPort` in the service. Only the vault's pure readiness rule
17
+ * (`userSecretGap`, its exported contract) is imported, the one the MCP proxy
18
+ * hides tools by.
16
19
  */
17
20
  export interface VariableStatusPort {
18
21
  statusFor(
19
22
  userId: string,
20
23
  keys: string[],
21
- ): Promise<{ key: string; adminConfigured: boolean; userConfigured: boolean; userAuthorized?: boolean }[]>;
24
+ ): Promise<
25
+ {
26
+ key: string;
27
+ adminConfigured: boolean;
28
+ /** What the owner's row is: a sign-in needs an `oauth` one, not a key left from when the variable was static. */
29
+ adminKind?: 'static' | 'oauth' | null;
30
+ userConfigured: boolean;
31
+ userAuthorized?: boolean;
32
+ userKind?: 'static' | 'oauth' | null;
33
+ grantedScopes?: string;
34
+ }[]
35
+ >;
22
36
  }
23
37
 
24
38
  /**
@@ -240,7 +254,9 @@ export function registerToolManualsTools(
240
254
  },
241
255
  note: {
242
256
  type: 'string',
243
- description: 'Present when `onBranchOnly` is non-empty: what it takes for those tools to go live.',
257
+ description:
258
+ 'Present when something is left to do: what it takes for the tools in `onBranchOnly` to go live, and for ' +
259
+ 'tools that need your own value or sign-in to work.',
244
260
  },
245
261
  },
246
262
  required: ['tools', 'invalid', 'onBranchOnly'],
@@ -302,17 +318,40 @@ export function registerToolManualsTools(
302
318
  }),
303
319
  );
304
320
  const onBranchOnly = pending.map((p) => ({ ...p, branch: branch! }));
305
- if (onBranchOnly.length === 0) return { tools, invalid, onBranchOnly };
306
- return {
307
- tools,
308
- invalid,
309
- onBranchOnly,
310
- note:
321
+ const notes: string[] = [];
322
+ if (onBranchOnly.length > 0) {
323
+ notes.push(
311
324
  `${onBranchOnly.map((p) => `\`${p.name}\``).join(', ')} ${onBranchOnly.length === 1 ? 'is' : 'are'} declared on ` +
312
- `\`${branch}\` only. Tools are served from \`${deps.kb.defaultBranch}\`, so ${onBranchOnly.length === 1 ? 'it stays' : 'they stay'} ` +
313
- 'unlisted, uncallable and without a sign-in until that branch is merged: open a change request with ' +
314
- `\`open_change_request\` (target \`${deps.kb.defaultBranch}\`), then ask the user to review and merge it in the app.`,
315
- };
325
+ `\`${branch}\` only. Tools are served from \`${deps.kb.defaultBranch}\`, so ${onBranchOnly.length === 1 ? 'it stays' : 'they stay'} ` +
326
+ 'unlisted, uncallable and without a sign-in until that branch is merged: open a change request with ' +
327
+ `\`open_change_request\` (target \`${deps.kb.defaultBranch}\`), then ask the user to review and merge it in the app.`,
328
+ );
329
+ }
330
+ // The person's own part, said where the agent is when a tool "does not work": the
331
+ // reconnect step is the one agents leave out (the agent-guide benchmark, 2026-10-26).
332
+ // By the rule the proxy hides a tool by (`userSecretGap`), so the note
333
+ // names every tool that is hidden for want of the caller's own value. A
334
+ // sign-in waits on the owner's provider setup (an OAuth admin row, not a
335
+ // key left from when the variable was static — the Connect page counts
336
+ // that setup incomplete and the sign-in start refuses it): until then
337
+ // there is nothing to sign in to.
338
+ const needYou = manuals.filter((m) =>
339
+ (m.variables ?? []).some((v) => {
340
+ if (v.scope !== 'user') return false;
341
+ const st = statusByKey.get(varKey(m.name, v.name));
342
+ const gap = userSecretGap({ oauth: v.oauth != null, oauthScopes: v.oauth?.scopes }, st);
343
+ return gap !== null && (v.oauth == null || (st?.adminConfigured === true && st.adminKind === 'oauth'));
344
+ }),
345
+ );
346
+ if (needYou.length > 0) {
347
+ notes.push(
348
+ `${needYou.map((t) => `\`${t.name}\``).join(', ')} ${needYou.length === 1 ? 'needs' : 'need'} your own value or sign-in. ` +
349
+ 'Set it yourself on the Connect page, never in the chat; then reconnect your assistant, or start a new chat, ' +
350
+ 'so the tool is listed.',
351
+ );
352
+ }
353
+ if (notes.length === 0) return { tools, invalid, onBranchOnly };
354
+ return { tools, invalid, onBranchOnly, note: notes.join(' ') };
316
355
  }),
317
356
  );
318
357
  }
@@ -1,4 +1,3 @@
1
- import express from 'express';
2
1
  import { describe, expect, it } from 'vitest';
3
2
  import {
4
3
  CALL_TOOL_CHAIN_NAME,
@@ -9,21 +8,9 @@ import {
9
8
  splitCallLine,
10
9
  } from '@bevel-software/platform-mcp-core';
11
10
  import { testKbContext } from '../../../__tests__/kb-context.js';
12
- import { EXTERNAL_KB_MANUAL_NAME } from '../../tool-manuals/tool-manuals.contract.js';
13
- import { ToolRegistry } from '../tool-registry.js';
11
+ import { EXTERNAL_KB_MANUAL_NAME } from '../../tool-manuals/index.js';
14
12
  import type { UtcpTool } from '../tool.contract.js';
15
- import { createToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
16
- import type { ToolAuth } from '../../tool-auth/tool-auth.middleware.js';
17
- import { registerWorkspaceTools } from '../../workspace/workspace.tools.js';
18
- import { RipgrepPatternMatcher } from '../../workspace/index.js';
19
- import { registerWorkflowTools } from '../../workflow/agent-tools/workflow.tools.js';
20
- import { registerPluginsTools } from '../../plugins/plugins.tools.js';
21
- import { registerSkillsTools } from '../../skills/skills.tools.js';
22
- import { registerToolManualsTools } from '../../tool-manuals/tool-manuals.tools.js';
23
- import { ToolDescriptionNotes } from '../../workspace/agent-access.gate.js';
24
- import { WorkflowHooks } from '../../workflow/workflow-hooks.js';
25
- import { UuidSessionSink } from '../../workspace/session-sink.js';
26
- import { RoutineWritePolicyService } from '../../workspace/routine-write-policy.js';
13
+ import type { registerSkillsTools } from '../../skills/index.js';
27
14
  import { CLIENT_SHORT_CUT, TOOL_DESCRIPTION_CAP, clientVisibleLength, firstSentenceEnd } from '../description-length.js';
28
15
  import { CAN_WRITE_CLAUSE } from '../../tool-helpers/index.js';
29
16
  import {
@@ -31,9 +18,8 @@ import {
31
18
  sharedFileRules,
32
19
  } from '../../agent-instructions/index.js';
33
20
  import { isPlatformFile, platformFilesByDepth } from '@bevel-software/platform-shared';
34
- import { GET_AGENT_GUIDE_TOOL, GUIDE_FIRST_SENTENCE, guideFirstDescription } from '../guide-first.js';
35
- import { registerAgentGuideTool } from '../../agent-guide/agent-guide.tools.js';
36
- import { agentGuideSections } from '../../agent-guide/agent-guide.js';
21
+ import { GET_AGENT_GUIDE_TOOL, GUIDE_FIRST_SENTENCE } from '../guide-first.js';
22
+ import { emptySkills, hexisTools, servedMetaTools, unused } from '../../../__tests__/hexis-catalog.js';
37
23
 
38
24
  /**
39
25
  * The cap exists because clients cut a long tool description, and they cut it
@@ -43,104 +29,11 @@ import { agentGuideSections } from '../../agent-guide/agent-guide.js';
43
29
  * NAMING the tool: the next paragraph someone appends to a description has to
44
30
  * answer to this test rather than to an agent's truncated catalog.
45
31
  *
46
- * Every registrar Hexis owns is mounted here, on stand-ins, because the
47
- * descriptions are the only thing under test: a stand-in that is never called
48
- * is honest about that. Tools PROXIED from connected MCP servers are not
32
+ * The catalog is every registrar Hexis owns, mounted on stand-ins (see
33
+ * `hexis-catalog.ts`). Tools PROXIED from connected MCP servers are not
49
34
  * measured — their text is the other server's.
50
35
  */
51
36
 
52
- /** Dependencies the registrars take but never touch while they are only building defs. */
53
- const unused = <T,>(): T => ({}) as T;
54
-
55
- /** A `.tool` catalog with nothing in it: the shortest honest answer for a listing. */
56
- const emptyManuals = {
57
- listLocalOnly: async () => [],
58
- listAll: async () => [],
59
- list: async () => [],
60
- listInvalid: async () => [],
61
- } as unknown as Parameters<typeof registerToolManualsTools>[4];
62
-
63
- /** A skill catalog with nothing in it — the per-user skill line is then one fixed sentence. */
64
- const emptySkills = { listSkills: async () => [] } as unknown as Parameters<typeof registerSkillsTools>[4];
65
-
66
- /**
67
- * The meta-tools as the hosted endpoint builds them (`mcp.service.ts`):
68
- * examples written against the namespace it registers the knowledge-base tools
69
- * under and against the catalog it serves, and the chain without the rules it
70
- * states only in the shared places. So the chain description measured here
71
- * carries the worked call a client is really sent, which is its longest form.
72
- */
73
- function servedMetaTools(external: readonly UtcpTool[]) {
74
- return codeModeMetaTools(
75
- EXTERNAL_KB_MANUAL_NAME,
76
- external.map((t) => ({ utcpName: `${EXTERNAL_KB_MANUAL_NAME}.${t.name}`, inputSchema: t.inputs })),
77
- { sharedRulesPointer: '' },
78
- // As the hosted endpoint serves them: opening with the guide-first
79
- // sentence, through the one helper the endpoint itself uses.
80
- ).map((t) => ({ ...t, description: guideFirstDescription(t.description) }));
81
- }
82
-
83
- /** Every tool Hexis itself registers, on both surfaces, deduplicated by name. */
84
- async function hexisTools(skills: Parameters<typeof registerSkillsTools>[4] = emptySkills): Promise<UtcpTool[]> {
85
- const registry = new ToolRegistry();
86
- const router = express.Router();
87
- const toolAuth = ((_req, _res, next) => next()) as unknown as ToolAuth;
88
- const toolHandler = createToolHandlerFactory(unused());
89
- const kb = testKbContext();
90
- const gate = { recoveryBotEmail: 'bot@x', hooks: new WorkflowHooks(), notes: new ToolDescriptionNotes() };
91
-
92
- registerWorkspaceTools(
93
- registry,
94
- router,
95
- toolAuth,
96
- toolHandler,
97
- unused(),
98
- unused(),
99
- unused(),
100
- kb,
101
- gate,
102
- new RoutineWritePolicyService(),
103
- new UuidSessionSink(),
104
- undefined,
105
- undefined,
106
- // The two upload tools are mounted only when a store is supplied, and
107
- // every real composition supplies one: without it they would be the two
108
- // descriptions this suite never measured.
109
- unused(),
110
- // The guide reader, not needed to build defs; then the download store,
111
- // mounted on the same terms as the upload one.
112
- undefined,
113
- unused(),
114
- // Starter packs and the tree walk, not needed; then the grep matcher,
115
- // mounted on the same terms (it spawns no worker until a search runs).
116
- undefined,
117
- undefined,
118
- new RipgrepPatternMatcher(),
119
- );
120
- registerWorkflowTools(registry, router, toolAuth, toolHandler, kb, async () => true, { canRead: async () => true, canReadAtRef: async () => true, canReadBatch: async () => new Map(), canReadBatchAtRef: async () => new Map(), canWrite: async () => true } as never, { recoveryBotEmail: 'recovery@x', hooks: { runAgentRead: async () => undefined }, notes: { onChange: () => undefined } } as never);
121
- registerPluginsTools(registry, kb);
122
- registerSkillsTools(registry, router, toolAuth, toolHandler, skills);
123
- registerToolManualsTools(registry, router, toolAuth, toolHandler, emptyManuals, {
124
- accessControl: unused(),
125
- variableStatus: unused(),
126
- kb,
127
- });
128
- // The guide's own tool, with the platform's sections: the one description
129
- // that lists the sections is measured with the list it really carries.
130
- registerAgentGuideTool(registry, router, toolAuth, toolHandler, () => agentGuideSections(kb.layout));
131
-
132
- const byName = new Map<string, UtcpTool>();
133
- // The meta-tools as a client is served them (`mcp.service.ts` opens each
134
- // with the guide-first sentence at the mount), so the catalog measured here
135
- // is the catalog that goes out rather than the bare constant.
136
- const external = (await registry.listExternal()) as UtcpTool[];
137
- const metaTools = servedMetaTools(external);
138
- for (const tool of [...(await registry.listInternal()), ...external, ...metaTools]) {
139
- byName.set(tool.name, tool as UtcpTool);
140
- }
141
- return [...byName.values()];
142
- }
143
-
144
37
  describe('no Hexis tool description is long enough to be cut', () => {
145
38
  it(`keeps every description a client is handed within ${TOOL_DESCRIPTION_CAP} characters`, async () => {
146
39
  const over = (await hexisTools())