@bevel-software/platform-core-backend 0.25.0 → 0.26.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 (254) hide show
  1. package/agent-guide/access-control.md +234 -0
  2. package/agent-guide/conventions.md +27 -0
  3. package/agent-guide/directory-structure.md +145 -0
  4. package/agent-guide/finding-things.md +7 -0
  5. package/agent-guide/introduction.md +27 -0
  6. package/agent-guide/skills.md +47 -0
  7. package/agent-guide/tool-manuals.md +217 -0
  8. package/agent-guide/where-a-new-file-goes.md +36 -0
  9. package/dist/assets.d.ts +7 -0
  10. package/dist/assets.d.ts.map +1 -1
  11. package/dist/assets.js +9 -0
  12. package/dist/assets.js.map +1 -1
  13. package/dist/core/core-ports.d.ts +11 -0
  14. package/dist/core/core-ports.d.ts.map +1 -1
  15. package/dist/core/core-ports.js.map +1 -1
  16. package/dist/core/create-core-server.d.ts.map +1 -1
  17. package/dist/core/create-core-server.js +13 -2
  18. package/dist/core/create-core-server.js.map +1 -1
  19. package/dist/core/create-core-services.d.ts +9 -0
  20. package/dist/core/create-core-services.d.ts.map +1 -1
  21. package/dist/core/create-core-services.js +14 -4
  22. package/dist/core/create-core-services.js.map +1 -1
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +2 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/modules/access/access-control.interface.d.ts +9 -0
  28. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  29. package/dist/modules/access/access-control.service.d.ts +1 -0
  30. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  31. package/dist/modules/access/access-control.service.js +16 -0
  32. package/dist/modules/access/access-control.service.js.map +1 -1
  33. package/dist/modules/agent-guide/agent-guide.d.ts +139 -0
  34. package/dist/modules/agent-guide/agent-guide.d.ts.map +1 -0
  35. package/dist/modules/agent-guide/agent-guide.js +191 -0
  36. package/dist/modules/agent-guide/agent-guide.js.map +1 -0
  37. package/dist/modules/agent-guide/agent-guide.tools.d.ts +24 -0
  38. package/dist/modules/agent-guide/agent-guide.tools.d.ts.map +1 -0
  39. package/dist/modules/agent-guide/agent-guide.tools.js +100 -0
  40. package/dist/modules/agent-guide/agent-guide.tools.js.map +1 -0
  41. package/dist/modules/agent-guide/index.d.ts +4 -0
  42. package/dist/modules/agent-guide/index.d.ts.map +1 -0
  43. package/dist/modules/agent-guide/index.js +4 -0
  44. package/dist/modules/agent-guide/index.js.map +1 -0
  45. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts +3 -2
  46. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts.map +1 -1
  47. package/dist/modules/agent-instructions/agent-instructions.routes.js +3 -2
  48. package/dist/modules/agent-instructions/agent-instructions.routes.js.map +1 -1
  49. package/dist/modules/agent-instructions/compose.d.ts +9 -6
  50. package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
  51. package/dist/modules/agent-instructions/compose.js +9 -6
  52. package/dist/modules/agent-instructions/compose.js.map +1 -1
  53. package/dist/modules/agent-instructions/index.d.ts +1 -1
  54. package/dist/modules/agent-instructions/index.d.ts.map +1 -1
  55. package/dist/modules/agent-instructions/index.js +1 -1
  56. package/dist/modules/agent-instructions/index.js.map +1 -1
  57. package/dist/modules/agent-instructions/shared-file-rules.d.ts +10 -50
  58. package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -1
  59. package/dist/modules/agent-instructions/shared-file-rules.js +32 -85
  60. package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -1
  61. package/dist/modules/mcp/mcp.service.d.ts +29 -2
  62. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  63. package/dist/modules/mcp/mcp.service.js +113 -16
  64. package/dist/modules/mcp/mcp.service.js.map +1 -1
  65. package/dist/modules/mcp/tool-schema-guard.d.ts +105 -0
  66. package/dist/modules/mcp/tool-schema-guard.d.ts.map +1 -0
  67. package/dist/modules/mcp/tool-schema-guard.js +171 -0
  68. package/dist/modules/mcp/tool-schema-guard.js.map +1 -0
  69. package/dist/modules/plugins/plugins.tools.d.ts +36 -2
  70. package/dist/modules/plugins/plugins.tools.d.ts.map +1 -1
  71. package/dist/modules/plugins/plugins.tools.js +71 -14
  72. package/dist/modules/plugins/plugins.tools.js.map +1 -1
  73. package/dist/modules/settings/deployment-settings.service.d.ts +0 -7
  74. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  75. package/dist/modules/settings/deployment-settings.service.js +14 -53
  76. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  77. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  78. package/dist/modules/settings/setup.routes.js +3 -6
  79. package/dist/modules/settings/setup.routes.js.map +1 -1
  80. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  81. package/dist/modules/skills/skills.tools.js +58 -16
  82. package/dist/modules/skills/skills.tools.js.map +1 -1
  83. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +23 -4
  84. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  85. package/dist/modules/tool-manuals/tool-manuals.contract.js.map +1 -1
  86. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +4 -0
  87. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  88. package/dist/modules/tool-manuals/tool-manuals.service.js +14 -0
  89. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  90. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts +7 -0
  91. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  92. package/dist/modules/tool-manuals/tool-manuals.tools.js +66 -36
  93. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  94. package/dist/modules/tool-registry/description-length.d.ts +14 -14
  95. package/dist/modules/tool-registry/description-length.d.ts.map +1 -1
  96. package/dist/modules/tool-registry/description-length.js +24 -26
  97. package/dist/modules/tool-registry/description-length.js.map +1 -1
  98. package/dist/modules/tool-registry/guide-first.d.ts +23 -0
  99. package/dist/modules/tool-registry/guide-first.d.ts.map +1 -0
  100. package/dist/modules/tool-registry/guide-first.js +32 -0
  101. package/dist/modules/tool-registry/guide-first.js.map +1 -0
  102. package/dist/modules/tool-registry/tool-registry.d.ts +6 -0
  103. package/dist/modules/tool-registry/tool-registry.d.ts.map +1 -1
  104. package/dist/modules/tool-registry/tool-registry.js +9 -2
  105. package/dist/modules/tool-registry/tool-registry.js.map +1 -1
  106. package/dist/modules/workflow/agent-tools/change-request-read-shape.d.ts +449 -0
  107. package/dist/modules/workflow/agent-tools/change-request-read-shape.d.ts.map +1 -0
  108. package/dist/modules/workflow/agent-tools/change-request-read-shape.js +481 -0
  109. package/dist/modules/workflow/agent-tools/change-request-read-shape.js.map +1 -0
  110. package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts +73 -0
  111. package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -0
  112. package/dist/modules/workflow/agent-tools/change-request-read.tools.js +582 -0
  113. package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -0
  114. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +12 -1
  115. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -1
  116. package/dist/modules/workflow/agent-tools/change-request-summary.js +5 -1
  117. package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -1
  118. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  119. package/dist/modules/workflow/agent-tools/workflow.tools.js +9 -0
  120. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  121. package/dist/modules/workflow/git/git.service.d.ts +210 -13
  122. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  123. package/dist/modules/workflow/git/git.service.js +456 -91
  124. package/dist/modules/workflow/git/git.service.js.map +1 -1
  125. package/dist/modules/workflow/git/merge-commit.d.ts +73 -0
  126. package/dist/modules/workflow/git/merge-commit.d.ts.map +1 -0
  127. package/dist/modules/workflow/git/merge-commit.js +89 -0
  128. package/dist/modules/workflow/git/merge-commit.js.map +1 -0
  129. package/dist/modules/workflow/git/pull-request.service.d.ts +94 -1
  130. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  131. package/dist/modules/workflow/git/pull-request.service.js +332 -37
  132. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  133. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts +35 -0
  134. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  135. package/dist/modules/workflow/review-workflow/review-workflow.service.js +178 -12
  136. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  137. package/dist/modules/workflow/workflow.routes.d.ts +6 -2
  138. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  139. package/dist/modules/workflow/workflow.routes.js +7 -2
  140. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  141. package/dist/modules/workflow/workflow.service.d.ts +4 -0
  142. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  143. package/dist/modules/workflow/workflow.service.js +3 -0
  144. package/dist/modules/workflow/workflow.service.js.map +1 -1
  145. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +70 -0
  146. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  147. package/dist/modules/workspace/startup/kb-startup-runner.js +213 -20
  148. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  149. package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
  150. package/dist/modules/workspace/startup/steps/seed-tree.js +22 -27
  151. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
  152. package/dist/modules/workspace/startup/steps/template-files.step.d.ts +58 -52
  153. package/dist/modules/workspace/startup/steps/template-files.step.d.ts.map +1 -1
  154. package/dist/modules/workspace/startup/steps/template-files.step.js +209 -223
  155. package/dist/modules/workspace/startup/steps/template-files.step.js.map +1 -1
  156. package/dist/modules/workspace/startup/steps/template-source.d.ts +5 -3
  157. package/dist/modules/workspace/startup/steps/template-source.d.ts.map +1 -1
  158. package/dist/modules/workspace/startup/steps/template-source.js +5 -3
  159. package/dist/modules/workspace/startup/steps/template-source.js.map +1 -1
  160. package/dist/modules/workspace/workspace.tools.d.ts +10 -1
  161. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  162. package/dist/modules/workspace/workspace.tools.js +211 -18
  163. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  164. package/dist/shared/domain-errors.d.ts +11 -0
  165. package/dist/shared/domain-errors.d.ts.map +1 -1
  166. package/dist/shared/domain-errors.js +14 -0
  167. package/dist/shared/domain-errors.js.map +1 -1
  168. package/dist/shared/hidden-tools.d.ts +44 -0
  169. package/dist/shared/hidden-tools.d.ts.map +1 -0
  170. package/dist/shared/hidden-tools.js +13 -0
  171. package/dist/shared/hidden-tools.js.map +1 -0
  172. package/kb-template/.bevelignore +0 -5
  173. package/package.json +4 -3
  174. package/src/__tests__/kb-layout-config.test.ts +10 -100
  175. package/src/__tests__/packaged-assets-ship.test.ts +54 -0
  176. package/src/assets.ts +10 -0
  177. package/src/core/core-ports.ts +11 -0
  178. package/src/core/create-core-server.ts +13 -2
  179. package/src/core/create-core-services.ts +28 -4
  180. package/src/index.ts +2 -2
  181. package/src/modules/access/__tests__/access-control.atref-batch.test.ts +58 -0
  182. package/src/modules/access/__tests__/access-control.platform-restore.test.ts +8 -7
  183. package/src/modules/access/__tests__/access-personal-plugin.test.ts +1 -18
  184. package/src/modules/access/access-control.interface.ts +15 -0
  185. package/src/modules/access/access-control.service.ts +21 -0
  186. package/src/modules/agent-guide/__tests__/agent-guide.test.ts +328 -0
  187. package/src/modules/agent-guide/__tests__/agent-guide.tools.test.ts +189 -0
  188. package/src/modules/agent-guide/agent-guide.tools.ts +122 -0
  189. package/src/modules/agent-guide/agent-guide.ts +291 -0
  190. package/src/modules/agent-guide/index.ts +21 -0
  191. package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +28 -121
  192. package/src/modules/agent-instructions/agent-instructions.routes.ts +3 -2
  193. package/src/modules/agent-instructions/compose.ts +9 -6
  194. package/src/modules/agent-instructions/index.ts +0 -3
  195. package/src/modules/agent-instructions/shared-file-rules.ts +31 -93
  196. package/src/modules/mcp/__tests__/fake-downstream-mcp-server.ts +14 -3
  197. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +250 -0
  198. package/src/modules/mcp/__tests__/mcp.service.test.ts +31 -23
  199. package/src/modules/mcp/__tests__/tool-schema-guard.test.ts +266 -0
  200. package/src/modules/mcp/mcp.service.ts +137 -19
  201. package/src/modules/mcp/tool-schema-guard.ts +196 -0
  202. package/src/modules/plugins/__tests__/plugins.tools.test.ts +154 -4
  203. package/src/modules/plugins/plugins.tools.ts +75 -15
  204. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +26 -55
  205. package/src/modules/settings/deployment-settings.service.ts +13 -54
  206. package/src/modules/settings/setup.routes.ts +3 -6
  207. package/src/modules/skills/__tests__/skills.tools.description.test.ts +91 -0
  208. package/src/modules/skills/skills.tools.ts +62 -16
  209. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +57 -0
  210. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +73 -4
  211. package/src/modules/tool-manuals/tool-manuals.contract.ts +24 -4
  212. package/src/modules/tool-manuals/tool-manuals.service.ts +17 -0
  213. package/src/modules/tool-manuals/tool-manuals.tools.ts +74 -36
  214. package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +160 -0
  215. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +61 -59
  216. package/src/modules/tool-registry/description-length.ts +24 -26
  217. package/src/modules/tool-registry/guide-first.ts +34 -0
  218. package/src/modules/tool-registry/tool-registry.ts +9 -2
  219. package/src/modules/workflow/__tests__/apply-failure.test.ts +6 -1
  220. package/src/modules/workflow/agent-tools/__tests__/change-request-read-shape.test.ts +705 -0
  221. package/src/modules/workflow/agent-tools/__tests__/change-request-read.tools.test.ts +1518 -0
  222. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +23 -2
  223. package/src/modules/workflow/agent-tools/change-request-read-shape.ts +712 -0
  224. package/src/modules/workflow/agent-tools/change-request-read.tools.ts +724 -0
  225. package/src/modules/workflow/agent-tools/change-request-summary.ts +5 -1
  226. package/src/modules/workflow/agent-tools/workflow.tools.ts +8 -0
  227. package/src/modules/workflow/git/__tests__/git.service.appliedChange.test.ts +285 -0
  228. package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +124 -0
  229. package/src/modules/workflow/git/__tests__/git.service.mergeChangeRequest.test.ts +334 -0
  230. package/src/modules/workflow/git/__tests__/pull-request.service.list-fetch.test.ts +72 -2
  231. package/src/modules/workflow/git/__tests__/pull-request.service.placeholder.test.ts +24 -2
  232. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +620 -1
  233. package/src/modules/workflow/git/git.service.ts +537 -94
  234. package/src/modules/workflow/git/merge-commit.ts +88 -0
  235. package/src/modules/workflow/git/pull-request.service.ts +380 -54
  236. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +7 -1
  237. package/src/modules/workflow/review-workflow/__tests__/merge-records-own-commit.test.ts +407 -0
  238. package/src/modules/workflow/review-workflow/review-workflow.service.ts +189 -11
  239. package/src/modules/workflow/workflow.routes.ts +7 -2
  240. package/src/modules/workflow/workflow.service.ts +7 -0
  241. package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +4 -3
  242. package/src/modules/workspace/__tests__/workspace.routes.move-platform-files.test.ts +21 -10
  243. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +33 -55
  244. package/src/modules/workspace/__tests__/workspace.tools.test.ts +255 -22
  245. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +231 -1
  246. package/src/modules/workspace/startup/kb-startup-runner.ts +216 -19
  247. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +191 -489
  248. package/src/modules/workspace/startup/steps/seed-tree.ts +21 -27
  249. package/src/modules/workspace/startup/steps/template-files.step.ts +217 -249
  250. package/src/modules/workspace/startup/steps/template-source.ts +5 -3
  251. package/src/modules/workspace/workspace.tools.ts +226 -16
  252. package/src/shared/domain-errors.ts +15 -0
  253. package/src/shared/hidden-tools.ts +45 -0
  254. package/kb-template/AGENTS.md +0 -730
@@ -0,0 +1,266 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { inputSchemaDefect } from '@bevel-software/platform-mcp-core';
3
+ import { MAX_REMEMBERED_CALLERS, ToolSchemaGuard, type ScreenedTool } from '../tool-schema-guard.js';
4
+
5
+ const tool = (name: string, inputSchema: unknown): ScreenedTool => ({
6
+ utcpName: `notion.srv.${name}`,
7
+ mcpName: `notion_srv_${name}`,
8
+ inputSchema,
9
+ });
10
+
11
+ const VALID = { type: 'object', properties: { text: { type: 'string' } } };
12
+ /** `required` holding a number, in an `anyOf` branch — the Notion refusal. */
13
+ const INVALID = { type: 'object', properties: { value: { anyOf: [{ type: 'object', required: [7] }] } } };
14
+
15
+ /** One caller's load: the manuals on their surface, each with the tools it advertised. */
16
+ const load = (groups: Record<string, readonly ScreenedTool[]>) => new Map(Object.entries(groups));
17
+
18
+ /**
19
+ * One load, with its own ticket. Every call takes a fresh one, so the order the
20
+ * tests screen in is the order the guard sees — which is what the two
21
+ * out-of-order tests below then break on purpose.
22
+ */
23
+ const screen = (guard: ToolSchemaGuard, userId: string, groups: ReadonlyMap<string, readonly ScreenedTool[]>) =>
24
+ guard.screen(userId, guard.beginLoad(), groups);
25
+
26
+ describe('ToolSchemaGuard', () => {
27
+ it('names the tool, the place and the reason, and leaves its siblings alone', () => {
28
+ const guard = new ToolSchemaGuard();
29
+ const hidden = screen(guard, 'u1', load({ notion: [tool('a', VALID), tool('bad', INVALID), tool('b', VALID)] }));
30
+
31
+ expect([...hidden.keys()]).toEqual(['notion.srv.bad']);
32
+ // The UTCP name rides on what the LOAD gets back: the proxy takes the tool
33
+ // out of its repository by that name and records it in the audit trail by
34
+ // it, so a multi-segment `<manual>.<server>.<tool>` stays intact.
35
+ expect(hidden.get('notion.srv.bad')?.utcpName).toBe('notion.srv.bad');
36
+ // What the owner reads is about the tool by the name an agent calls it.
37
+ expect(guard.hiddenFor('notion')).toEqual([
38
+ {
39
+ manual: 'notion',
40
+ name: 'notion_srv_bad',
41
+ path: '/properties/value/anyOf/0/required/0',
42
+ reason: 'must be a string',
43
+ marker:
44
+ 'Hidden from agents: its schema is invalid at /properties/value/anyOf/0/required/0 (must be a string).',
45
+ },
46
+ ]);
47
+ });
48
+
49
+ it('holds nothing for a server whose tools are all valid, and nothing for one never loaded', () => {
50
+ const guard = new ToolSchemaGuard();
51
+ screen(guard, 'u1', load({ notion: [tool('a', VALID)] }));
52
+ expect(guard.hiddenFor('notion')).toEqual([]);
53
+ expect(guard.hiddenFor('hubspot')).toEqual([]);
54
+ });
55
+
56
+ it('forgets the finding when the server sends a corrected schema', () => {
57
+ const guard = new ToolSchemaGuard();
58
+ screen(guard, 'u1', load({ notion: [tool('bad', INVALID)] }));
59
+ expect(guard.hiddenFor('notion')).toHaveLength(1);
60
+
61
+ screen(guard, 'u1', load({ notion: [tool('bad', VALID)] }));
62
+ expect(guard.hiddenFor('notion')).toEqual([]);
63
+ });
64
+
65
+ /**
66
+ * A load with NO tools for a manual is the load that clears it, and there are
67
+ * two ways to get one: the server removed the offending tool, or the manual
68
+ * could not be attached at all and its tools were never loaded. Either way
69
+ * nothing should still be claiming a tool is hidden for a schema this process
70
+ * can no longer see.
71
+ */
72
+ it('forgets the finding when the server advertises nothing at all', () => {
73
+ const guard = new ToolSchemaGuard();
74
+ screen(guard, 'u1', load({ notion: [tool('bad', INVALID)] }));
75
+ screen(guard, 'u1', load({ notion: [] }));
76
+ expect(guard.hiddenFor('notion')).toEqual([]);
77
+ });
78
+
79
+ it('forgets the finding when the manual leaves the surface entirely', () => {
80
+ const guard = new ToolSchemaGuard();
81
+ screen(guard, 'u1', load({ notion: [tool('bad', INVALID)], hubspot: [tool('fine', VALID)] }));
82
+ expect(guard.hiddenFor('notion')).toHaveLength(1);
83
+
84
+ screen(guard, 'u1', load({ hubspot: [tool('fine', VALID)] }));
85
+ expect(guard.hiddenFor('notion')).toEqual([]);
86
+ });
87
+
88
+ it('keeps each server to itself', () => {
89
+ const guard = new ToolSchemaGuard();
90
+ screen(guard, 'u1', load({ notion: [tool('bad', INVALID)], hubspot: [tool('fine', VALID)] }));
91
+ expect(guard.hiddenFor('notion')).toHaveLength(1);
92
+ expect(guard.hiddenFor('hubspot')).toEqual([]);
93
+ });
94
+
95
+ /**
96
+ * Discovery runs on the REQUESTING user's own connection, so two callers can
97
+ * be shown different tools by one server. A finding is about the server, and
98
+ * the person who can fix it is not necessarily the person whose connection
99
+ * saw it — so one caller's load must never erase another's finding, and the
100
+ * owner-facing surfaces read the union.
101
+ */
102
+ describe('with more than one caller', () => {
103
+ it('does not let one caller\'s load erase another\'s finding', () => {
104
+ const guard = new ToolSchemaGuard();
105
+ screen(guard, 'u1', load({ notion: [tool('bad', INVALID)] }));
106
+ // u2's connection is shown a different, healthy subset of the same server.
107
+ screen(guard, 'u2', load({ notion: [tool('a', VALID)] }));
108
+ expect(guard.hiddenFor('notion').map((t) => t.name)).toEqual(['notion_srv_bad']);
109
+ });
110
+
111
+ it('reports a finding once, however many callers were shown it', () => {
112
+ const guard = new ToolSchemaGuard();
113
+ screen(guard, 'u1', load({ notion: [tool('bad', INVALID)] }));
114
+ screen(guard, 'u2', load({ notion: [tool('bad', INVALID)] }));
115
+ expect(guard.hiddenFor('notion')).toHaveLength(1);
116
+ });
117
+
118
+ it('reports two different defects of one server together', () => {
119
+ const guard = new ToolSchemaGuard();
120
+ screen(guard, 'u1', load({ notion: [tool('bad', INVALID)] }));
121
+ screen(guard, 'u2', load({ notion: [tool('other', { type: 'object', properties: { x: { anyOf: {} } } })] }));
122
+ expect(guard.hiddenFor('notion').map((t) => t.name).sort()).toEqual([
123
+ 'notion_srv_bad',
124
+ 'notion_srv_other',
125
+ ]);
126
+ });
127
+ });
128
+
129
+ /**
130
+ * Requests overlap — a caller's surface is rebuilt on every one of them, and
131
+ * two can be dialling the same server at once. So the order loads LAND in is
132
+ * not the order they READ in, and only the second one is evidence about the
133
+ * server's current schemas.
134
+ */
135
+ describe('with loads that land out of order', () => {
136
+ it('does not let a load that read the server earlier overwrite a newer one', () => {
137
+ const guard = new ToolSchemaGuard();
138
+ const stale = guard.beginLoad(); // request A starts, the schema still broken
139
+ const fresh = guard.beginLoad(); // request B starts, after the vendor's fix
140
+ guard.screen('u1', fresh, load({ notion: [tool('bad', VALID)] }));
141
+
142
+ // A lands late, with what it saw. Its OWN request still keeps the tool off
143
+ // its surface — it has judged that schema invalid and must not offer it …
144
+ const found = guard.screen('u1', stale, load({ notion: [tool('bad', INVALID)] }));
145
+ expect([...found.keys()]).toEqual(['notion.srv.bad']);
146
+ // … but the marker everyone else reads is not put back on a tool that is
147
+ // now fine, to sit there until some later request happened to clear it.
148
+ expect(guard.hiddenFor('notion')).toEqual([]);
149
+ });
150
+
151
+ it('applies the newer load when the older one landed first', () => {
152
+ const guard = new ToolSchemaGuard();
153
+ const first = guard.beginLoad();
154
+ const second = guard.beginLoad();
155
+ guard.screen('u1', first, load({ notion: [tool('bad', INVALID)] }));
156
+ expect(guard.hiddenFor('notion')).toHaveLength(1);
157
+
158
+ guard.screen('u1', second, load({ notion: [tool('bad', VALID)] }));
159
+ expect(guard.hiddenFor('notion')).toEqual([]);
160
+ });
161
+
162
+ it('orders each caller against itself, not against the others', () => {
163
+ const guard = new ToolSchemaGuard();
164
+ const early = guard.beginLoad();
165
+ const late = guard.beginLoad();
166
+ // u2's NEWER load lands first, and u1's older one after it. Ordering kept
167
+ // globally rather than per caller would call u1's load stale on the
168
+ // strength of a ticket belonging to someone else, and drop a finding
169
+ // nothing else in this process has seen. The order matters: with u1's
170
+ // load first, both readings pass and the test proves nothing.
171
+ guard.screen('u2', late, load({ notion: [tool('a', VALID)] }));
172
+ guard.screen('u1', early, load({ notion: [tool('bad', INVALID)] }));
173
+ expect(guard.hiddenFor('notion').map((t) => t.name)).toEqual(['notion_srv_bad']);
174
+ });
175
+ });
176
+
177
+ /**
178
+ * The point of remembering. The MCP surface is rebuilt per request, so these
179
+ * same schemas come past on every `tools/list` AND every `tools/call`; the
180
+ * CHECK happens when a server's tools are loaded and when a refresh brings a
181
+ * schema this process has not seen, and never again.
182
+ */
183
+ it('checks a schema once, however many times it is screened', () => {
184
+ const check = vi.fn(inputSchemaDefect);
185
+ const guard = new ToolSchemaGuard(check);
186
+ const tools = [tool('a', VALID), tool('bad', INVALID)];
187
+
188
+ for (let i = 0; i < 10; i += 1) screen(guard, 'u1', load({ notion: tools }));
189
+ expect(check).toHaveBeenCalledTimes(2);
190
+
191
+ // A CHANGED schema is a schema this process has not checked, so it is.
192
+ screen(guard, 'u1', load({ notion: [tool('a', { type: 'object', properties: { other: { type: 'number' } } })] }));
193
+ expect(check).toHaveBeenCalledTimes(3);
194
+ });
195
+
196
+ it('checks two tools that share one schema once', () => {
197
+ const check = vi.fn(inputSchemaDefect);
198
+ const guard = new ToolSchemaGuard(check);
199
+ screen(guard, 'u1', load({ notion: [tool('a', VALID), tool('b', { ...VALID })] }));
200
+ expect(check).toHaveBeenCalledTimes(1);
201
+ });
202
+
203
+ it('checks a schema once across callers, too — the verdict is the schema\'s', () => {
204
+ const check = vi.fn(inputSchemaDefect);
205
+ const guard = new ToolSchemaGuard(check);
206
+ screen(guard, 'u1', load({ notion: [tool('bad', INVALID)] }));
207
+ screen(guard, 'u2', load({ notion: [tool('bad', INVALID)] }));
208
+ expect(check).toHaveBeenCalledTimes(1);
209
+ });
210
+
211
+ describe('with more callers than it remembers', () => {
212
+ const MANY = MAX_REMEMBERED_CALLERS;
213
+
214
+ it('evicts the caller longest unseen, and keeps every other caller\'s finding on the owner\'s page', () => {
215
+ const guard = new ToolSchemaGuard();
216
+ // The first caller's finding, then a crowd of callers with nothing to
217
+ // report, then one more than fits.
218
+ screen(guard, 'first', load({ notion: [tool('bad', INVALID)] }));
219
+ for (let i = 1; i < MANY; i += 1) screen(guard, `u${i}`, load({ notion: [tool('a', VALID)] }));
220
+ expect(guard.hiddenFor('notion')).toHaveLength(1);
221
+ // `first` was seen again since — so it is not the longest unseen, and
222
+ // its finding stays when the crowd overflows.
223
+ screen(guard, 'first', load({ notion: [tool('bad', INVALID)] }));
224
+ screen(guard, 'overflow', load({ notion: [tool('a', VALID)] }));
225
+ expect(guard.hiddenFor('notion')).toHaveLength(1);
226
+ // Overflow once more: now `u1` goes, then `u2` — never the whole table.
227
+ screen(guard, 'overflow-2', load({ notion: [tool('a', VALID)] }));
228
+ expect(guard.hiddenFor('notion')).toHaveLength(1);
229
+ });
230
+
231
+ it("keeps the order per caller: an evicted caller's next load is their newest, whichever began first", () => {
232
+ const guard = new ToolSchemaGuard();
233
+ for (let i = 0; i < MANY; i += 1) screen(guard, `u${i}`, load({ notion: [tool('a', VALID)] }));
234
+ // `u0` is the longest unseen. Two loads of theirs begin, old then new —
235
+ // and before either lands, the table overflows and `u0` is evicted,
236
+ // watermark and all.
237
+ const older = guard.beginLoad();
238
+ const newer = guard.beginLoad();
239
+ screen(guard, 'newcomer', load({ notion: [tool('a', VALID)] }));
240
+ // Nothing of `u0` is held, so whichever lands first is the newest
241
+ // picture this process has of them: the old load, with its finding.
242
+ guard.screen('u0', older, load({ notion: [tool('bad', INVALID)] }));
243
+ expect(guard.hiddenFor('notion')).toHaveLength(1);
244
+ // The newer load then lands with the corrected schema and wins —
245
+ // and the watermark it restores rejects anything older after it.
246
+ guard.screen('u0', newer, load({ notion: [tool('a', VALID)] }));
247
+ expect(guard.hiddenFor('notion')).toEqual([]);
248
+ const own = guard.screen('u0', older, load({ notion: [tool('bad', INVALID)] }));
249
+ expect([...own.keys()]).toEqual(['notion.srv.bad']);
250
+ expect(guard.hiddenFor('notion')).toEqual([]);
251
+ });
252
+
253
+ it("does not rank one caller's load against another caller's eviction", () => {
254
+ const guard = new ToolSchemaGuard();
255
+ // `x` begins a load; the table then fills and overflows, evicting
256
+ // callers whose tickets are newer than `x`'s.
257
+ const x = guard.beginLoad();
258
+ for (let i = 0; i < MANY; i += 1) screen(guard, `u${i}`, load({ notion: [tool('a', VALID)] }));
259
+ screen(guard, 'overflow', load({ notion: [tool('a', VALID)] }));
260
+ // `x`'s load is `x`'s newest, evictions elsewhere notwithstanding: its
261
+ // finding reaches the owner's page.
262
+ guard.screen('x', x, load({ notion: [tool('bad', INVALID)] }));
263
+ expect(guard.hiddenFor('notion')).toHaveLength(1);
264
+ });
265
+ });
266
+ });
@@ -53,6 +53,7 @@ import {
53
53
  import { EXTERNAL_KB_MANUAL_NAME } from '../tool-manuals/tool-manuals.contract.js';
54
54
  import type { IToolManualService } from '../tool-manuals/tool-manuals.contract.js';
55
55
  import type { SpillStore } from '../workspace/spill-store.js';
56
+ import type { HiddenToolSource } from '../../shared/hidden-tools.js';
56
57
  import { seedBevelHostedManualVars } from '../../shared/utcp-namespace.js';
57
58
  import type { InternalTokenService } from '../tool-auth/internal-token.service.js';
58
59
  import type { IAgentEventRecorder } from '../audit/audit.contract.js';
@@ -60,16 +61,17 @@ import { RequestAudit } from '../audit/request-audit.js';
60
61
  import { ManualFailureMemo } from './manual-failure-memo.js';
61
62
  import { DownstreamPool, POOL_KEY_SEPARATOR, type DownstreamPoolOptions, type Lease } from './downstream-pool.js';
62
63
  import { SurfaceLogThrottle } from './surface-log-throttle.js';
64
+ import { ToolSchemaGuard, type ScreenedHiddenTool } from './tool-schema-guard.js';
63
65
  import { DownstreamRefreshGuard, isDownstreamTokenRejection } from './downstream-token-refresh.js';
64
66
  import { printable } from '../../shared/printable.js';
65
67
  import {
66
68
  composeAgentInstructions,
67
69
  prefixToolDescription,
68
70
  PREFIXED_TOOLS,
69
- sharedRulesPointer,
70
71
  type AgentPreambleReader,
71
72
  type ComposedAgentInstructions,
72
73
  } from '../agent-instructions/index.js';
74
+ import { guideFirstDescription } from '../tool-registry/guide-first.js';
73
75
 
74
76
  /**
75
77
  * Configuration for the loopback proxy. `loopbackBaseUrl` is the backend's own
@@ -95,8 +97,8 @@ export interface McpProxyOptions {
95
97
  readAgentPreamble?: AgentPreambleReader;
96
98
  /**
97
99
  * The layout in effect, read per request: the shared file rules name the
98
- * managed guide, and a deployment may rename it after boot (the setup save
99
- * applies a name without a restart). A GETTER, so nothing snapshots the
100
+ * guide by the name a deployment saved for it, and the setup save applies
101
+ * a layout without a restart. A GETTER, so nothing snapshots the
100
102
  * pre-setup default. Absent, the rules name `AGENTS.md`.
101
103
  */
102
104
  kbLayout?: () => KbLayout;
@@ -166,6 +168,14 @@ interface RequestSurface {
166
168
  * They differ only when the name holds a non-word character (`my-server`).
167
169
  */
168
170
  catalogNames: ReadonlyMap<string, string>;
171
+ /**
172
+ * The tools this request's load took OFF the surface for an invalid schema,
173
+ * by the name an agent would have called them by. Carried on the surface
174
+ * rather than read back out of the guard: it is this caller's own load, so a
175
+ * call answered from it cannot answer about a schema some other caller's
176
+ * connection was shown.
177
+ */
178
+ hidden: ReadonlyMap<string, ScreenedHiddenTool>;
169
179
  }
170
180
 
171
181
  /** A manual off the surface for want of a sign-in — see {@link RequestSurface.unavailable}. */
@@ -273,6 +283,15 @@ export class McpService {
273
283
  */
274
284
  private readonly knownDownstreamTools = new Map<string, { definition: string; tools: UtcpTool[] }>();
275
285
 
286
+ /**
287
+ * The JSON Schema check on connected tools, and the memory of what it found.
288
+ * Process-wide rather than per request, because a schema's validity is a
289
+ * property of the server and not of the caller — and because the point of
290
+ * remembering is that the check runs when a server's tools are loaded, not
291
+ * once per request and never on a tool call. See {@link ToolSchemaGuard}.
292
+ */
293
+ private readonly toolSchemas = new ToolSchemaGuard();
294
+
276
295
  // The downstream connection pool for `mcp` manuals — see the class doc.
277
296
  private readonly downstream: DownstreamPool<PooledDownstream>;
278
297
 
@@ -486,14 +505,17 @@ export class McpService {
486
505
  // different name, and one fixed example is necessarily wrong on one of the
487
506
  // two surfaces.
488
507
  //
489
- // The chain's description ends with the same pointer every file tool ends
490
- // with: what a chain does with a failure, a large result or an image is
491
- // stated once, in the shared rules, and the clients that drop
492
- // `instructions` have the description and the guide to go on. Composed
493
- // here because the guide's name is this deployment's setting.
508
+ // What a chain does with a failure, a large result or an image is
509
+ // stated once, in the shared rules — in the guide and in the handshake
510
+ // instructions — and not on the chain itself (an EMPTY pointer is how
511
+ // `mcp-core` is told the rules are served elsewhere). Like every tool of
512
+ // the platform's own, each meta-tool opens with the one sentence saying
513
+ // what to do before any of them, which is where those rules are. The
514
+ // registry puts the sentence on the tools it lists; the meta-tools are
515
+ // built here, so here it is.
494
516
  const metaTools = codeModeMetaTools(EXTERNAL_KB_MANUAL_NAME, examplePool, {
495
- sharedRulesPointer: sharedRulesPointer(this.opts.kbLayout?.()),
496
- });
517
+ sharedRulesPointer: '',
518
+ }).map((tool) => ({ ...tool, description: guideFirstDescription(tool.description) }));
497
519
  // Log only when a tool was dropped (name/schema/duplicate) — that's the
498
520
  // anomaly worth surfacing, since a downstream client would otherwise hide
499
521
  // it by rejecting the whole response.
@@ -510,7 +532,7 @@ export class McpService {
510
532
  });
511
533
 
512
534
  server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
513
- const { client, tools, unavailable, catalogNames } = await requestSurface();
535
+ const { client, tools, unavailable, catalogNames, hidden: hiddenTools } = await requestSurface();
514
536
  const toolName = request.params.name;
515
537
  const args = request.params.arguments ?? {};
516
538
  if (META_TOOL_NAMES.has(toolName)) {
@@ -551,6 +573,24 @@ export class McpService {
551
573
  };
552
574
  const proxied = tools.find((t) => t.mcpName === toolName);
553
575
  if (!proxied) {
576
+ // A tool this process hid for an invalid schema: say that, rather than
577
+ // "Unknown tool" about a tool the agent has every reason to think
578
+ // exists. The place and the reason are deliberately NOT here — they are
579
+ // for the people who manage the server, who are the only ones who can
580
+ // act on them.
581
+ const hidden = hiddenTools.get(toolName);
582
+ if (hidden) {
583
+ // The UTCP name, as every other audit path records a tool: the
584
+ // flattened agent name loses the server segment of a multi-segment
585
+ // `<manual>.<server>.<tool>`, and an audit trail that names a
586
+ // different tool than the rest of the trail is worse than none.
587
+ if (audit) await audit.denied(hidden.utcpName, args);
588
+ return toolError(
589
+ `The "${toolName}" tool is hidden from agents because its schema is invalid, so it cannot be ` +
590
+ `called. The people who manage its server can see the place in the schema and the reason, on ` +
591
+ `the tool's page in Hexis and through \`list_tool_setup\`.`,
592
+ );
593
+ }
554
594
  // Not on the surface — but if the name belongs to a manual that is off
555
595
  // it only because this caller's sign-in is gone (and nothing in this
556
596
  // process has seen its tools yet), the honest answer is the sign-in
@@ -626,7 +666,7 @@ export class McpService {
626
666
  const manuals = await this.fetchManualTemplates(loopbackBearer);
627
667
  const catalogMs = performance.now() - started;
628
668
  const client = await this.buildClient(loopbackBearer, userId, manuals);
629
- const { tools, unavailable, catalogNames } = await this.discoverTools(client, manuals, userId);
669
+ const { tools, unavailable, catalogNames, hidden } = await this.discoverTools(client, manuals, userId);
630
670
  const totalMs = performance.now() - started;
631
671
  // Per user: on a shape change or once per interval, never per request —
632
672
  // see SurfaceLogThrottle for why both halves matter.
@@ -641,7 +681,7 @@ export class McpService {
641
681
  (decision.suppressed > 0 ? ` [+${decision.suppressed} identical rebuild(s) since last line]` : ''),
642
682
  );
643
683
  }
644
- return { client, tools, unavailable, catalogNames };
684
+ return { client, tools, unavailable, catalogNames, hidden };
645
685
  }
646
686
 
647
687
  /**
@@ -798,13 +838,37 @@ export class McpService {
798
838
  client: CodeModeUtcpClient,
799
839
  manuals: CallTemplate[],
800
840
  userId: string,
801
- ): Promise<{ tools: ProxiedTool[]; unavailable: UnavailableManual[]; catalogNames: Map<string, string> }> {
841
+ ): Promise<{
842
+ tools: ProxiedTool[];
843
+ unavailable: UnavailableManual[];
844
+ catalogNames: Map<string, string>;
845
+ hidden: Map<string, ScreenedHiddenTool>;
846
+ }> {
802
847
  const routes = new Map<string, DownstreamRoute>();
803
848
  const unavailable: UnavailableManual[] = [];
849
+ // Taken BEFORE a single manual is registered, so it ranks this load by the
850
+ // freshness of what it is about to read — see ToolSchemaGuard.beginLoad.
851
+ const loadId = this.toolSchemas.beginLoad();
804
852
  const catalogNames = new Map<string, string>();
805
853
  for (const m of manuals) {
806
854
  if (m.call_template_type === 'mcp') catalogNames.set(utcpManualName(m), String(m.name));
807
855
  }
856
+ // Every manual's catalog name, which is how the owner-facing surfaces know
857
+ // it. `catalogNames` above is the credential catalog's map and covers `mcp`
858
+ // manuals only; the schema check is about the tools of every connected
859
+ // server, so it needs the whole list. Taken HERE, before registration:
860
+ // registering a manual renames the template in place, so `m.name` read
861
+ // afterwards is the rewritten identifier and `my-server` would be recorded
862
+ // as `my_server` — a name no tool page ever looks up.
863
+ // Keyed by the rewritten name, which two manuals can share (the collision
864
+ // handled below): the KB manual's entry is the one that stays, since it is
865
+ // the one that is registered when a `.tool` collides with it.
866
+ const manualCatalogNames = new Map<string, string>();
867
+ for (const m of manuals) {
868
+ const rewritten = utcpManualName(m);
869
+ if (manualCatalogNames.get(rewritten) === EXTERNAL_KB_MANUAL_NAME) continue;
870
+ manualCatalogNames.set(rewritten, String(m.name));
871
+ }
808
872
  // The shared layer rewrites every manual name (`[^\w]` → `_`) and tools
809
873
  // route by the rewritten prefix, so two manuals whose names rewrite to one
810
874
  // identifier would silently share it. Sequential registration used to
@@ -884,11 +948,65 @@ export class McpService {
884
948
  if (kbFailure && !kbFailure.ok) throw new Error(`Bevel tool discovery failed: ${kbFailure.error}`);
885
949
  if (routes.size > 0) routeToDownstream(client, routes);
886
950
  const utcpTools = await client.getTools();
887
- return {
888
- tools: utcpTools.map((tool: UtcpTool) => flattenManualTool(tool, EXTERNAL_KB_MANUAL_NAME)),
889
- unavailable,
890
- catalogNames,
891
- };
951
+ const flattened = utcpTools.map((tool: UtcpTool) => flattenManualTool(tool, EXTERNAL_KB_MANUAL_NAME));
952
+ const { tools, hidden } = await this.hideInvalidSchemas(client, flattened, manualCatalogNames, userId, loadId);
953
+ return { tools, unavailable, catalogNames, hidden };
954
+ }
955
+
956
+ /**
957
+ * Check the input schema of every tool the servers just advertised, and take
958
+ * the invalid ones OFF the client's tool repository.
959
+ *
960
+ * The repository is the single place `tools/list`, `list_tools`/`tools_info`
961
+ * and the TypeScript interfaces `call_tool_chain` generates are all built
962
+ * from, so removing a tool here removes it from all three — which is what
963
+ * "not offered to agents" has to mean. Its siblings are untouched: the check
964
+ * is per tool, so one bad schema costs that tool and nothing else of the
965
+ * server.
966
+ *
967
+ * A client would otherwise drop such a tool itself, silently, and the agent
968
+ * would be left without it and without a reason. Hidden here, the reason
969
+ * reaches the people who manage the server (see {@link ToolSchemaGuard}).
970
+ */
971
+ private async hideInvalidSchemas(
972
+ client: CodeModeUtcpClient,
973
+ tools: ProxiedTool[],
974
+ catalogNames: ReadonlyMap<string, string>,
975
+ userId: string,
976
+ loadId: number,
977
+ ): Promise<{ tools: ProxiedTool[]; hidden: Map<string, ScreenedHiddenTool> }> {
978
+ const byManual = new Map<string, ProxiedTool[]>();
979
+ // EVERY manual of this surface is screened, including the ones that
980
+ // advertised nothing. A server that removed its last invalid tool, and one
981
+ // whose tools never loaded at all (a sign-in that has gone), both come
982
+ // through here with an empty group — and an empty group is what clears the
983
+ // marker. Screening only the groups that have tools would leave the tool
984
+ // page, `list_tool_setup` and the hidden-tool answer on a call all
985
+ // reporting a defect this process can no longer see.
986
+ for (const manual of catalogNames.values()) byManual.set(manual, []);
987
+ for (const tool of tools) {
988
+ const manual = catalogNames.get(tool.manualName) ?? tool.manualName;
989
+ byManual.set(manual, [...(byManual.get(manual) ?? []), tool]);
990
+ }
991
+ const found = this.toolSchemas.screen(userId, loadId, byManual);
992
+ const hidden = new Map<string, ScreenedHiddenTool>();
993
+ for (const tool of found.values()) hidden.set(tool.name, tool);
994
+ if (found.size === 0) return { tools, hidden };
995
+ for (const utcpName of found.keys()) {
996
+ await client.config.tool_repository.removeTool(utcpName);
997
+ }
998
+ // Logged every time rather than throttled: a hidden tool is the one thing
999
+ // about this surface that a person has to act on, and it is rare.
1000
+ log.warn(
1001
+ `hiding ${found.size} connected tool(s) whose input schema is not valid JSON Schema: ` +
1002
+ `${[...found.values()].map((t) => `${t.name} (${t.path}: ${t.reason})`).join(', ')}`,
1003
+ );
1004
+ return { tools: tools.filter((tool) => !found.has(tool.utcpName)), hidden };
1005
+ }
1006
+
1007
+ /** The schema findings the owner-facing surfaces report — see {@link ToolSchemaGuard}. */
1008
+ get hiddenTools(): HiddenToolSource {
1009
+ return this.toolSchemas;
892
1010
  }
893
1011
 
894
1012
  /**