@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
@@ -1,17 +1,19 @@
1
1
  import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
2
- import { NodeFs } from '../../kb-fs/node-fs.js';
3
- import { KbPluginSource } from '../../plugins/discovery/kb-plugin-source.js';
2
+ // The real disk, and the service wired over it, come from the composition-root
3
+ // fixtures, as production wires them: this suite constructs no implementation
4
+ // from its file, its own module's included.
5
+ import { realDisk } from '../../../__tests__/real-disk.js';
6
+ import { realToolManualService, type McpAuthDiscoveryPort } from '../../../__tests__/tool-manuals.js';
4
7
 
5
8
  /** The one disk, as production wires it: the service and its discovery read the same one. */
6
- const disk = new NodeFs();
9
+ const disk = realDisk();
7
10
  import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises';
8
11
  import { tmpdir } from 'node:os';
9
12
  import { join } from 'node:path';
10
13
  import { DEFAULT_BRANCH } from '@bevel-software/platform-shared';
11
- import { ToolManualService, type McpAuthDiscoveryPort } from '../tool-manuals.service.js';
12
14
  import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
13
- import type { WorkspaceService } from '../../workspace/workspace.service.js';
14
- import type { IAccessControl } from '../../access/access-control.interface.js';
15
+ import type { WorkspaceService } from '../../workspace/index.js';
16
+ import type { IAccessControl } from '../../access/index.js';
15
17
  import { testKbContext } from '../../../__tests__/kb-context.js';
16
18
 
17
19
  const KB_DIR = 'knowledge-base';
@@ -63,9 +65,10 @@ describe('ToolManualService — MCP OAuth auto-discovery decoration', () => {
63
65
  });
64
66
 
65
67
  function svcWith(discovery: McpAuthDiscoveryPort) {
66
- const svc = new ToolManualService(workspaceService, allowAll, testKbContext({ kbDirName: KB_DIR }), disk, new KbPluginSource(disk, testKbContext({ kbDirName: KB_DIR })));
67
- svc.setMcpAuthDiscovery(discovery);
68
- return svc;
68
+ return realToolManualService(
69
+ { workspaceService, accessControl: allowAll, kb: testKbContext({ kbDirName: KB_DIR }), disk },
70
+ discovery,
71
+ );
69
72
  }
70
73
 
71
74
  test('a bare mcp tool whose server demands OAuth gains the synthetic sign-in var + header', async () => {
@@ -94,8 +97,10 @@ describe('ToolManualService — MCP OAuth auto-discovery decoration', () => {
94
97
 
95
98
  // The served call template carries the header the variable loader will fill.
96
99
  const templates = await svc.toManualCallTemplates('user@example.com');
97
- const notionTemplate = templates.find((t) => t.name === 'notion') as any;
98
- expect(notionTemplate.config.mcpServers.notion.headers).toEqual({
100
+ const notionTemplate = templates.find((t) => t.name === 'notion') as
101
+ | { config: { mcpServers: Record<string, { headers: Record<string, string> }> } }
102
+ | undefined;
103
+ expect(notionTemplate?.config.mcpServers.notion?.headers).toEqual({
99
104
  Authorization: 'Bearer ${MCP_OAUTH}',
100
105
  });
101
106
  });
@@ -298,4 +303,185 @@ describe('ToolManualService — MCP OAuth auto-discovery decoration', () => {
298
303
  // The declaration is carried verbatim, opt-out included.
299
304
  expect(hubspot.variables?.[0].oauth).toMatchObject({ clientId: 'owner-app', pkce: false });
300
305
  });
306
+
307
+ describe('liveDeclaration — what a sign-in start reads', () => {
308
+ const HUBSPOT_ENDPOINTS = {
309
+ authorizationUrl: 'https://auth.hubspot.example/authorize',
310
+ tokenUrl: 'https://auth.hubspot.example/token',
311
+ };
312
+
313
+ test('reads the file as it is now, not the catalog the last scan cached', async () => {
314
+ await writeDeclaredServer({ clientId: 'client-A', ...HUBSPOT_ENDPOINTS });
315
+ const svc = svcWith({ statusFor: vi.fn(async () => ({ status: 'open' as const })) });
316
+ // The catalog is scanned and cached under client A…
317
+ const cached = (await svc.listAccessible('user@example.com')).find((m) => m.name === 'hubspot')!;
318
+ expect(cached.variables?.[0].oauth?.clientId).toBe('client-A');
319
+
320
+ // …the file is edited, and nothing invalidates the catalog.
321
+ await writeDeclaredServer({ clientId: 'client-B', ...HUBSPOT_ENDPOINTS, scopes: ['crm.read'] });
322
+ const stale = (await svc.listAccessible('user@example.com')).find((m) => m.name === 'hubspot')!;
323
+ expect(stale.variables?.[0].oauth?.clientId).toBe('client-A');
324
+
325
+ const live = await svc.liveDeclaration('user@example.com', 'hubspot', 'HUBSPOT_TOKEN');
326
+ expect(live?.manual.name).toBe('hubspot');
327
+ expect(live?.variable?.oauth).toEqual({ clientId: 'client-B', ...HUBSPOT_ENDPOINTS, scopes: ['crm.read'] });
328
+ });
329
+
330
+ test('a .tool file is read afresh the same way', async () => {
331
+ const toolsDir = join(root, wsId, KB_DIR, 'Plugins');
332
+ const write = (clientId: string) =>
333
+ writeFile(
334
+ join(toolsDir, 'crm.tool'),
335
+ JSON.stringify({
336
+ name: 'crm',
337
+ type: 'http',
338
+ url: 'https://api.crm.example/openapi.json',
339
+ variables: [{ name: 'SIGNIN', scope: 'user', oauth: { clientId, ...HUBSPOT_ENDPOINTS } }],
340
+ }),
341
+ );
342
+ await write('client-A');
343
+ const svc = svcWith({ statusFor: vi.fn(async () => ({ status: 'open' as const })) });
344
+ await svc.listAccessible('user@example.com');
345
+ await write('client-B');
346
+ const live = await svc.liveDeclaration('user@example.com', 'crm', 'SIGNIN');
347
+ expect(live?.variable?.oauth?.clientId).toBe('client-B');
348
+ });
349
+
350
+ test('completes a client-id-only declaration from the server metadata, or carries the discovery reason', async () => {
351
+ await writeDeclaredServer({ clientId: 'owner-app' });
352
+ const statusFor = vi.fn(async () => ({ status: 'open' as const }));
353
+ const providerForDeclaredClient = vi.fn(async () => ({
354
+ status: 'oauth' as const,
355
+ provider: { ...HUBSPOT_ENDPOINTS, clientId: 'owner-app', pkce: true, resource: 'https://mcp.hubspot.example/mcp' },
356
+ }));
357
+ const live = await svcWith({ statusFor, providerForDeclaredClient }).liveDeclaration(
358
+ 'user@example.com',
359
+ 'hubspot',
360
+ 'HUBSPOT_TOKEN',
361
+ );
362
+ expect(live?.variable?.oauth).toEqual({
363
+ clientId: 'owner-app',
364
+ ...HUBSPOT_ENDPOINTS,
365
+ resource: 'https://mcp.hubspot.example/mcp',
366
+ });
367
+ // Never a zero-config probe or registration for a declared sign-in.
368
+ expect(statusFor).not.toHaveBeenCalled();
369
+
370
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
371
+ const stuck = await svcWith({
372
+ statusFor,
373
+ providerForDeclaredClient: async () => ({ status: 'unsupported' as const, reason: 'no authorization-server metadata' }),
374
+ }).liveDeclaration('user@example.com', 'hubspot', 'HUBSPOT_TOKEN');
375
+ expect(stuck?.variable?.oauth).toEqual({ clientId: 'owner-app' });
376
+ expect(stuck?.manual.setup).toEqual({ kind: 'oauth-manual', reason: 'no authorization-server metadata' });
377
+ warn.mockRestore();
378
+ });
379
+
380
+ test('a .tool whose sign-in endpoint fails validation answers the parser’s reason, even while the catalog holds the valid version', async () => {
381
+ const toolsDir = join(root, wsId, KB_DIR, 'Plugins');
382
+ const write = (tokenUrl: string) =>
383
+ writeFile(
384
+ join(toolsDir, 'crm.tool'),
385
+ JSON.stringify({
386
+ name: 'crm',
387
+ type: 'http',
388
+ url: 'https://api.crm.example/openapi.json',
389
+ variables: [{ name: 'SIGNIN', scope: 'user', oauth: { clientId: 'c', authorizationUrl: HUBSPOT_ENDPOINTS.authorizationUrl, tokenUrl } }],
390
+ }),
391
+ );
392
+ await write(HUBSPOT_ENDPOINTS.tokenUrl);
393
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
394
+ const svc = svcWith({ statusFor: vi.fn(async () => ({ status: 'open' as const })) });
395
+ expect((await svc.listAccessible('user@example.com')).some((m) => m.name === 'crm')).toBe(true);
396
+
397
+ await write('http://127.0.0.1/token');
398
+ await expect(svc.liveDeclaration('user@example.com', 'crm', 'SIGNIN')).resolves.toEqual({
399
+ refused: '`variables[0].oauth.tokenUrl` must use https',
400
+ });
401
+ await write('https://10.0.0.5/token');
402
+ await expect(svc.liveDeclaration('user@example.com', 'crm', 'SIGNIN')).resolves.toEqual({
403
+ refused: '`variables[0].oauth.tokenUrl` host is not allowed',
404
+ });
405
+ warn.mockRestore();
406
+ });
407
+
408
+ test('a plugin.json sign-in whose endpoint fails validation answers the reason too, not only a log line', async () => {
409
+ await writeDeclaredServer({ clientId: 'owner-app', ...HUBSPOT_ENDPOINTS, tokenUrl: 'http://127.0.0.1/token' });
410
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
411
+ const svc = svcWith({ statusFor: vi.fn(async () => ({ status: 'open' as const })) });
412
+ const live = await svc.liveDeclaration('user@example.com', 'hubspot', 'HUBSPOT_TOKEN');
413
+ expect(live?.refused).toContain('`variables[0].oauth.tokenUrl` must use https');
414
+ // Located by position, never by the name the entry declared (the .tool parser's rule).
415
+ expect(live?.refused).not.toContain('HUBSPOT_TOKEN');
416
+ // The catalog's public listing is unchanged by this: the server is still left out.
417
+ const catalog = await svc.listAccessibleCatalog('user@example.com');
418
+ expect(catalog.tools.some((m) => m.name === 'hubspot')).toBe(false);
419
+ warn.mockRestore();
420
+ });
421
+
422
+ test('a refusal is read-gated like the manual it would have been', async () => {
423
+ await writeDeclaredServer({ clientId: 'owner-app', ...HUBSPOT_ENDPOINTS, tokenUrl: 'http://127.0.0.1/token' });
424
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
425
+ const denyAll = {
426
+ canRead: async () => false,
427
+ canReadBatch: async (_w: string, _e: string, paths: string[]) => new Map(paths.map((p) => [p, false])),
428
+ } as unknown as IAccessControl;
429
+ const locked = realToolManualService({
430
+ workspaceService,
431
+ accessControl: denyAll,
432
+ kb: testKbContext({ kbDirName: KB_DIR }),
433
+ disk,
434
+ });
435
+ await expect(locked.liveDeclaration('user@example.com', 'hubspot', 'HUBSPOT_TOKEN')).resolves.toBeNull();
436
+ warn.mockRestore();
437
+ });
438
+
439
+ test('a workspace that cannot be opened is an error for a sign-in start, where the catalog answers empty', async () => {
440
+ await writeDeclaredServer({ clientId: 'owner-app', ...HUBSPOT_ENDPOINTS });
441
+ const down = {
442
+ getOrCreateForBranch: async () => {
443
+ throw new Error('workspace unavailable');
444
+ },
445
+ } as unknown as WorkspaceService;
446
+ const allowAll = {
447
+ canRead: async () => true,
448
+ canReadBatch: async (_w: string, _e: string, paths: string[]) => new Map(paths.map((p) => [p, true])),
449
+ } as unknown as IAccessControl;
450
+ const svc = realToolManualService({
451
+ workspaceService: down,
452
+ accessControl: allowAll,
453
+ kb: testKbContext({ kbDirName: KB_DIR }),
454
+ disk,
455
+ });
456
+ // The catalog degrades to "nothing released" and tries again next scan…
457
+ await expect(svc.listAccessible('user@example.com')).resolves.toEqual([]);
458
+ // …but a sign-in start must not read that as "no sign-in declared".
459
+ await expect(svc.liveDeclaration('user@example.com', 'hubspot', 'HUBSPOT_TOKEN')).rejects.toThrow(
460
+ 'workspace unavailable',
461
+ );
462
+ });
463
+
464
+ test('answers no variable when the file declares none of that name, and null for an unreadable manual', async () => {
465
+ await writeDeclaredServer({ clientId: 'owner-app', ...HUBSPOT_ENDPOINTS });
466
+ const statusFor = vi.fn(async () => ({ status: 'oauth' as const, provider: OAUTH_PROVIDER }));
467
+ const svc = svcWith({ statusFor });
468
+ // The zero-config variable is synthesised by the catalog, not declared in any file.
469
+ const bare = await svc.liveDeclaration('user@example.com', 'notion', 'MCP_OAUTH');
470
+ expect(bare?.manual.name).toBe('notion');
471
+ expect(bare?.variable).toBeUndefined();
472
+ expect(statusFor).not.toHaveBeenCalled();
473
+
474
+ const denyAll = {
475
+ canRead: async () => false,
476
+ canReadBatch: async (_w: string, _e: string, paths: string[]) => new Map(paths.map((p) => [p, false])),
477
+ } as unknown as IAccessControl;
478
+ const locked = realToolManualService({
479
+ workspaceService,
480
+ accessControl: denyAll,
481
+ kb: testKbContext({ kbDirName: KB_DIR }),
482
+ disk,
483
+ });
484
+ await expect(locked.liveDeclaration('user@example.com', 'hubspot', 'HUBSPOT_TOKEN')).resolves.toBeNull();
485
+ });
486
+ });
301
487
  });
@@ -1,11 +1,13 @@
1
1
  import type { Server as HttpServer } from 'node:http';
2
2
  import express from 'express';
3
3
  import { afterEach, describe, expect, it, vi } from 'vitest';
4
- import { ToolRegistry } from '../../tool-registry/tool-registry.js';
5
- import { InternalTokenService } from '../../tool-auth/internal-token.service.js';
6
- import { createToolAuthMiddleware } from '../../tool-auth/tool-auth.middleware.js';
7
- import { createToolContextResolver } from '../../tool-helpers/tool-context.js';
8
- import { createToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
4
+ import { ToolRegistry } from '../../tool-registry/index.js';
5
+ import {
6
+ InternalTokenService,
7
+ createToolAuthMiddleware,
8
+ createToolContextResolver,
9
+ createToolHandlerFactory,
10
+ } from '../../../__tests__/tool-wiring.js';
9
11
  import { registerToolManualsTools } from '../tool-manuals.tools.js';
10
12
  import type { IToolManualService } from '../tool-manuals.contract.js';
11
13
  import { testKbContext } from '../../../__tests__/kb-context.js';
@@ -337,3 +339,92 @@ describe('list_tool_setup — a declaration that lives on a draft says so', () =
337
339
  expect(toolManualService.listDeclaredOnlyOnBranch).not.toHaveBeenCalled();
338
340
  });
339
341
  });
342
+
343
+ describe('list_tool_setup — a tool that needs the caller’s own value says so', () => {
344
+ const TICKETS = {
345
+ slug: 'ticketdesk',
346
+ name: 'ticketdesk',
347
+ path: 'Plugins/Support/ticketdesk.tool',
348
+ type: 'http' as const,
349
+ variables: [{ name: 'TICKETDESK_TOKEN', scope: 'user' as const, label: null }],
350
+ };
351
+
352
+ it('tells a caller without their own value where to set it, and to reconnect', async () => {
353
+ const base = await start();
354
+ vi.mocked(toolManualService.listAccessibleCatalog).mockResolvedValueOnce({ tools: [TICKETS], invalid: [] } as never);
355
+ // Bob has configured nothing (see `statusFor`).
356
+ const body = (await (await callSetup(base, 'bevel_bob')).json()) as { note?: string };
357
+ expect(body.note).toContain('`ticketdesk` needs your own value or sign-in');
358
+ expect(body.note).toContain('Connect page, never in the chat');
359
+ expect(body.note).toContain('reconnect your assistant');
360
+ });
361
+
362
+ it('says nothing to a caller who has set theirs', async () => {
363
+ const base = await start();
364
+ vi.mocked(toolManualService.listAccessibleCatalog).mockResolvedValueOnce({ tools: [TICKETS], invalid: [] } as never);
365
+ const body = (await (await callSetup(base, 'bevel_alice')).json()) as { note?: string };
366
+ expect(body.note).toBeUndefined();
367
+ });
368
+
369
+ const SIGN_IN = {
370
+ ...TICKETS,
371
+ variables: [{ name: 'TICKETDESK_OAUTH', scope: 'user' as const, label: null, oauth: { scopes: ['tickets'] } }],
372
+ };
373
+
374
+ /** The owner's side of a sign-in: a provider row of the OAuth kind. */
375
+ const ownerSignedUp = { adminConfigured: true, adminKind: 'oauth' as const };
376
+
377
+ it('tells a caller to sign in once the owner has set the sign-in up', async () => {
378
+ const base = await start();
379
+ vi.mocked(toolManualService.listAccessibleCatalog).mockResolvedValueOnce({ tools: [SIGN_IN], invalid: [] } as never);
380
+ statusFor.mockImplementationOnce(async (_userId: string, keys: string[]) =>
381
+ keys.map((key) => ({ key, ...ownerSignedUp, userConfigured: false })),
382
+ );
383
+ const body = (await (await callSetup(base, 'bevel_bob')).json()) as { note?: string };
384
+ expect(body.note).toContain('`ticketdesk` needs your own value or sign-in');
385
+ });
386
+
387
+ it('names a tool the proxy hides for a stale credential: the wrong kind, or a sign-in short of a scope', async () => {
388
+ const base = await start();
389
+ // A row left from when the variable was a plain key.
390
+ vi.mocked(toolManualService.listAccessibleCatalog).mockResolvedValueOnce({ tools: [SIGN_IN], invalid: [] } as never);
391
+ statusFor.mockImplementationOnce(async (_userId: string, keys: string[]) =>
392
+ keys.map((key) => ({ key, ...ownerSignedUp, userConfigured: true, userKind: 'static' as const })),
393
+ );
394
+ expect(((await (await callSetup(base, 'bevel_bob')).json()) as { note?: string }).note).toContain('`ticketdesk` needs your own value or sign-in');
395
+ // Signed in, but before the tool asked for `tickets`.
396
+ vi.mocked(toolManualService.listAccessibleCatalog).mockResolvedValueOnce({ tools: [SIGN_IN], invalid: [] } as never);
397
+ statusFor.mockImplementationOnce(async (_userId: string, keys: string[]) =>
398
+ keys.map((key) => ({ key, ...ownerSignedUp, userConfigured: true, userKind: 'oauth' as const, userAuthorized: true, grantedScopes: 'profile' })),
399
+ );
400
+ expect(((await (await callSetup(base, 'bevel_bob')).json()) as { note?: string }).note).toContain('`ticketdesk` needs your own value or sign-in');
401
+ // Signed in with the scope: nothing to say.
402
+ vi.mocked(toolManualService.listAccessibleCatalog).mockResolvedValueOnce({ tools: [SIGN_IN], invalid: [] } as never);
403
+ statusFor.mockImplementationOnce(async (_userId: string, keys: string[]) =>
404
+ keys.map((key) => ({ key, ...ownerSignedUp, userConfigured: true, userKind: 'oauth' as const, userAuthorized: true, grantedScopes: 'tickets' })),
405
+ );
406
+ expect(((await (await callSetup(base, 'bevel_bob')).json()) as { note?: string }).note).toBeUndefined();
407
+ });
408
+
409
+ it('does not send a caller to sign in before the owner has set the sign-in up', async () => {
410
+ const base = await start();
411
+ vi.mocked(toolManualService.listAccessibleCatalog).mockResolvedValueOnce({ tools: [SIGN_IN], invalid: [] } as never);
412
+ statusFor.mockImplementationOnce(async (_userId: string, keys: string[]) =>
413
+ keys.map((key) => ({ key, adminConfigured: false, userConfigured: false })),
414
+ );
415
+ const body = (await (await callSetup(base, 'bevel_bob')).json()) as { note?: string };
416
+ expect(body.note).toBeUndefined();
417
+ });
418
+
419
+ it('does not send a caller to sign in on a shared key left from when the variable was static', async () => {
420
+ const base = await start();
421
+ // The owner turned a plain key into a sign-in; the old shared row is still
422
+ // there, so `adminConfigured` is true, but it is no provider to sign in to.
423
+ vi.mocked(toolManualService.listAccessibleCatalog).mockResolvedValueOnce({ tools: [SIGN_IN], invalid: [] } as never);
424
+ statusFor.mockImplementationOnce(async (_userId: string, keys: string[]) =>
425
+ keys.map((key) => ({ key, adminConfigured: true, adminKind: 'static' as const, userConfigured: false })),
426
+ );
427
+ const body = (await (await callSetup(base, 'bevel_bob')).json()) as { note?: string };
428
+ expect(body.note).toBeUndefined();
429
+ });
430
+ });
@@ -90,39 +90,62 @@ const SERVER_NAME_RE = /^[a-z0-9][a-z0-9_-]*$/;
90
90
  * silently vanishes from the catalog the moment its edit lands.
91
91
  */
92
92
  export function validatedVariables(raw: unknown): ToolVariable[] | null {
93
- if (raw === undefined) return [];
94
- if (!Array.isArray(raw)) return null;
93
+ const verdict = judgeVariables(raw);
94
+ return verdict.ok ? verdict.variables : null;
95
+ }
96
+
97
+ /**
98
+ * {@link validatedVariables} with the reason a declaration was refused. The
99
+ * reason locates the entry by its position (`` `variables[2] ``), never by
100
+ * the name it declared or any other value the author wrote — the `.tool`
101
+ * parser's discipline (`describeManualFault`): passing `[A-Za-z0-9_]+` makes
102
+ * a string a legal identifier, not one this process may repeat. The reason
103
+ * reaches logs and, for a sign-in start on the server, the person signing in.
104
+ */
105
+ function judgeVariables(
106
+ raw: unknown,
107
+ ): { ok: true; variables: ToolVariable[] } | { ok: false; reason: string } {
108
+ const no = (reason: string) => ({ ok: false as const, reason });
109
+ if (raw === undefined) return { ok: true, variables: [] };
110
+ if (!Array.isArray(raw)) return no('`variables` must be a list');
95
111
  const out: ToolVariable[] = [];
96
112
  const declared = new Set<string>();
97
- for (const entry of raw) {
98
- if (!isRecord(entry)) return null;
99
- if (typeof entry.name !== 'string' || !/^[A-Za-z0-9_]+$/.test(entry.name)) return null;
113
+ for (const [i, entry] of raw.entries()) {
114
+ const at = `\`variables[${i}]`;
115
+ if (!isRecord(entry)) return no(`${at}\` must be an object`);
116
+ if (typeof entry.name !== 'string' || !/^[A-Za-z0-9_]+$/.test(entry.name)) {
117
+ return no(`${at}.name\` must match [A-Za-z0-9_]+`);
118
+ }
100
119
  // The `.tool` parser's declaration rules, at this file's grain: a
101
120
  // platform-seeded name may not be re-declared (it would shadow the
102
121
  // seeding), and a duplicate would make later scope resolution depend on
103
122
  // declaration order.
104
- if (RESERVED_VARIABLE_NAMES.includes(entry.name) || declared.has(entry.name)) return null;
123
+ if (RESERVED_VARIABLE_NAMES.includes(entry.name)) return no(`${at}.name\` is reserved for platform seeding`);
124
+ if (declared.has(entry.name)) return no(`${at}.name\` duplicates an earlier entry's`);
105
125
  declared.add(entry.name);
106
126
  const scope = entry.scope ?? 'admin';
107
- if (scope !== 'admin' && scope !== 'user') return null;
127
+ if (scope !== 'admin' && scope !== 'user') return no(`${at}.scope\` must be \`admin\` or \`user\``);
108
128
  let oauth: ToolVariable['oauth'];
109
129
  if (entry.oauth !== undefined) {
110
130
  // OAuth is inherently per-caller (same rule the `.tool` parser
111
131
  // enforces): an admin-shared OAuth token would leak one user's token
112
132
  // to all callers.
113
- if (!isRecord(entry.oauth) || scope !== 'user') return null;
133
+ if (!isRecord(entry.oauth)) return no(`${at}.oauth\` must be an object`);
134
+ if (scope !== 'user') return no(`${at}\` has \`oauth\`, so its \`scope\` must be \`user\``);
114
135
  const o = entry.oauth;
115
136
  // Confidential material never loads from a file, on either declaration
116
137
  // surface: the `.tool` parser throws on these keys, and silently
117
138
  // dropping one here would leave a plugin.json carrying a secret that
118
139
  // "worked" — the file is portable package data, readable by every
119
140
  // client that loads the plugin.
120
- if (o.clientSecret !== undefined || o.client_secret !== undefined || o.secret !== undefined) return null;
141
+ if (o.clientSecret !== undefined || o.client_secret !== undefined || o.secret !== undefined) {
142
+ return no(`${at}.oauth\` may not carry a client secret — it is set through the protected client-secret route`);
143
+ }
121
144
  // `clientId` is trimmed and must be non-empty, exactly as the `.tool`
122
145
  // parser requires: a whitespace-only value would pass discovery and
123
146
  // then fail the owner's client-secret setup with "clientId is
124
147
  // required" — an error at the wrong surface, long after the save.
125
- if (typeof o.clientId !== 'string' || !o.clientId.trim()) return null;
148
+ if (typeof o.clientId !== 'string' || !o.clientId.trim()) return no(`${at}.oauth.clientId\` is required`);
126
149
  // The endpoints are OPTIONAL here, unlike in a `.tool`: an MCP server
127
150
  // publishes its authorization-server metadata, so a declaration that
128
151
  // names only the client id is completed at scan time. Both or neither
@@ -137,33 +160,38 @@ export function validatedVariables(raw: unknown): ToolVariable[] | null {
137
160
  const authorizationUrl = optionalUrl(o.authorizationUrl);
138
161
  const tokenUrl = optionalUrl(o.tokenUrl);
139
162
  const resource = optionalUrl(o.resource);
140
- if (authorizationUrl === null || tokenUrl === null || resource === null) return null;
141
- if ((authorizationUrl === undefined) !== (tokenUrl === undefined)) return null;
163
+ if (authorizationUrl === null || tokenUrl === null || resource === null) {
164
+ return no(`${at}.oauth\` URLs must be strings`);
165
+ }
166
+ if ((authorizationUrl === undefined) !== (tokenUrl === undefined)) {
167
+ return no(`${at}.oauth\` must declare both \`authorizationUrl\` and \`tokenUrl\`, or neither`);
168
+ }
142
169
  // The same https + SSRF gate the `.tool` parser runs on these URLs: a
143
170
  // sign-in or token exchange aimed at an internal host is a declaration
144
171
  // this surface must refuse exactly like the other one does. `resource`
145
172
  // is never fetched, but it names the remote server — same bar.
146
173
  try {
147
174
  if (authorizationUrl !== undefined) {
148
- assertSafeFetchUrl(authorizationUrl, { requireHttps: true, label: `${entry.name} oauth.authorizationUrl` });
149
- assertSafeFetchUrl(tokenUrl!, { requireHttps: true, label: `${entry.name} oauth.tokenUrl` });
175
+ assertSafeFetchUrl(authorizationUrl, { requireHttps: true, label: `${at}.oauth.authorizationUrl\`` });
176
+ assertSafeFetchUrl(tokenUrl!, { requireHttps: true, label: `${at}.oauth.tokenUrl\`` });
150
177
  }
151
178
  if (resource !== undefined) {
152
- assertSafeFetchUrl(resource, { requireHttps: true, label: `${entry.name} oauth.resource` });
179
+ assertSafeFetchUrl(resource, { requireHttps: true, label: `${at}.oauth.resource\`` });
153
180
  }
154
- } catch {
155
- return null;
181
+ } catch (err) {
182
+ // `assertSafeFetchUrl` names the field and the rule, never the URL.
183
+ return no(err instanceof Error ? err.message : `${at}.oauth\` has an invalid URL`);
156
184
  }
157
185
  // Forwarded verbatim as query params later — a non-string value here is
158
186
  // a malformed declaration, not something to coerce.
159
187
  if (o.authParams !== undefined) {
160
188
  if (!isRecord(o.authParams) || !Object.values(o.authParams).every((v) => typeof v === 'string')) {
161
- return null;
189
+ return no(`${at}.oauth.authParams\` must be an object of string values`);
162
190
  }
163
191
  }
164
192
  // PKCE is on unless the declaration says `false`; anything else there is
165
193
  // a malformed flag, not a preference.
166
- if (o.pkce !== undefined && typeof o.pkce !== 'boolean') return null;
194
+ if (o.pkce !== undefined && typeof o.pkce !== 'boolean') return no(`${at}.oauth.pkce\` must be a boolean`);
167
195
  oauth = {
168
196
  ...(authorizationUrl !== undefined ? { authorizationUrl, tokenUrl: tokenUrl! } : {}),
169
197
  clientId: o.clientId.trim(),
@@ -182,7 +210,7 @@ export function validatedVariables(raw: unknown): ToolVariable[] | null {
182
210
  ...(oauth ? { oauth } : {}),
183
211
  });
184
212
  }
185
- return out;
213
+ return { ok: true, variables: out };
186
214
  }
187
215
 
188
216
  /**
@@ -318,6 +346,12 @@ export function descriptorsFromMcpJson(
318
346
  mcpJsonText: string,
319
347
  pluginJsonText: string | null,
320
348
  layout: KbLayout,
349
+ /**
350
+ * Told of every server this file declares and the parse refuses, with the
351
+ * reason the log line gives. The scan keeps them so a sign-in started on a
352
+ * refused server answers why, instead of finding nothing by that name.
353
+ */
354
+ onRefused?: (server: string, reason: string, path: string) => void,
321
355
  ): ToolManualDescriptor[] {
322
356
  const { pluginsDir } = layout;
323
357
  let mcp: unknown;
@@ -347,10 +381,14 @@ export function descriptorsFromMcpJson(
347
381
  const mcpJsonPath = `${pluginsDir}/${pluginFolder}/${PLUGIN_MCP_FILE}`;
348
382
 
349
383
  const out: ToolManualDescriptor[] = [];
384
+ const skip = (name: string, reason: string) => {
385
+ log.warn(`skipping mcp server "${name}" in ${mcpJsonPath}: ${reason}`);
386
+ onRefused?.(name, reason, mcpJsonPath);
387
+ };
350
388
  for (const [name, rawEntry] of Object.entries(mcp.mcpServers)) {
351
389
  const verdict = judgeMcpServerEntry(name, rawEntry);
352
390
  if (!verdict.ok) {
353
- log.warn(`skipping mcp server "${name}" in ${mcpJsonPath}: ${verdict.reason}.`);
391
+ skip(name, `${verdict.reason}.`);
354
392
  continue;
355
393
  }
356
394
  // Narrowed by the verdict: an entry that passed is a record.
@@ -365,22 +403,23 @@ export function descriptorsFromMcpJson(
365
403
  // into outbound requests exactly the same way.
366
404
  const reserved = referencesReservedVariable({ raw, ext });
367
405
  if (reserved !== null) {
368
- log.warn(
369
- `skipping mcp server "${name}" in ${mcpJsonPath}: its declaration (mcp.json ` +
370
- `entry or plugin.json extension) references the reserved variable "${reserved}" — API_URL and ` +
371
- 'CONNECTION_KEY are platform-seeded and may not appear in server declarations.',
406
+ skip(
407
+ name,
408
+ `its declaration (mcp.json entry or plugin.json extension) references the reserved variable "${reserved}" — ` +
409
+ 'API_URL and CONNECTION_KEY are platform-seeded and may not appear in server declarations.',
372
410
  );
373
411
  continue;
374
412
  }
375
- const variables = validatedVariables(ext.variables);
376
- if (variables === null) {
377
- log.warn(
378
- `skipping mcp server "${name}" in ${mcpJsonPath}: its plugin.json \`variables\` ` +
379
- 'declaration is malformed — a dropped declaration would silently re-scope a credential, so ' +
380
- 'the server stays offline until the manifest is fixed.',
413
+ const judged = judgeVariables(ext.variables);
414
+ if (!judged.ok) {
415
+ skip(
416
+ name,
417
+ `its plugin.json \`variables\` declaration is malformed (${judged.reason}) — a dropped declaration ` +
418
+ 'would silently re-scope a credential, so the server stays offline until the manifest is fixed.',
381
419
  );
382
420
  continue;
383
421
  }
422
+ const variables = judged.variables;
384
423
  const shared = {
385
424
  slug: name,
386
425
  name,
@@ -408,9 +447,9 @@ export function descriptorsFromMcpJson(
408
447
  try {
409
448
  assertSafeFetchUrl(url, { label: `mcp server "${name}" url` });
410
449
  } catch (err) {
411
- log.warn(
412
- `skipping mcp server "${name}" in ${mcpJsonPath}: ` +
413
- `${err instanceof Error ? err.message : String(err)} (declare it \`local: true\` if it is deliberately private).`,
450
+ skip(
451
+ name,
452
+ `${err instanceof Error ? err.message : String(err)} (declare it \`local: true\` if it is deliberately private).`,
414
453
  );
415
454
  continue;
416
455
  }
@@ -366,6 +366,11 @@ export interface ToolManualDetail extends Omit<ToolManualSummary, 'description'>
366
366
  */
367
367
  export type ConnectorScope = (definitionPath: string) => boolean;
368
368
 
369
+ /** A manual as its file reads now — see {@link IToolManualService.liveDeclaration}. */
370
+ export type LiveDeclaration =
371
+ | { manual: ToolManualSummary; variable?: ToolVariable; refused?: undefined }
372
+ | { refused: string; manual?: undefined; variable?: undefined };
373
+
369
374
  export interface IToolManualService {
370
375
  /** The `.tool` manuals the user can read, as summaries. */
371
376
  listAccessible(userEmail: string): Promise<ToolManualSummary[]>;
@@ -395,6 +400,23 @@ export interface IToolManualService {
395
400
  * confirm that a tool the caller can't see exists).
396
401
  */
397
402
  getDetail(userEmail: string, slug: string): Promise<ToolManualDetail | null>;
403
+ /**
404
+ * One manual's declaration as its file reads on the default branch NOW, for a
405
+ * sign-in about to start: the files are parsed afresh, never served from the
406
+ * catalog's cached scan, so an edit made seconds ago is already what this
407
+ * answers. A sign-in variable that names only its `clientId` is completed from
408
+ * the server's OAuth metadata by the same walk the catalog completes it with;
409
+ * when that fails, the endpoints stay absent and `manual.setup.reason` says why.
410
+ *
411
+ * `refused` when the file declaring this slug is one the parse refuses as it
412
+ * reads now (an endpoint failing the https/SSRF check, say): the parser's
413
+ * reason, so the sign-in can say why instead of finding nothing. `null` when
414
+ * no readable manual has this slug (fail-closed, like {@link getDetail});
415
+ * `variable` absent when the file declares no variable of that name. Nothing
416
+ * the platform synthesises from discovery (the zero-config sign-in variable)
417
+ * is in the answer: it is not in any file.
418
+ */
419
+ liveDeclaration(userEmail: string, slug: string, varName: string): Promise<LiveDeclaration | null>;
398
420
 
399
421
  /**
400
422
  * Wire where a hidden tool's schema finding comes from (the MCP proxy, which