@bevel-software/platform-core-backend 0.13.5 → 0.14.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 (120) hide show
  1. package/THIRD-PARTY-NOTICES.md +639 -433
  2. package/dist/core/create-core-server.d.ts.map +1 -1
  3. package/dist/core/create-core-server.js +6 -3
  4. package/dist/core/create-core-server.js.map +1 -1
  5. package/dist/core/create-core-services.d.ts +2 -0
  6. package/dist/core/create-core-services.d.ts.map +1 -1
  7. package/dist/core/create-core-services.js +9 -0
  8. package/dist/core/create-core-services.js.map +1 -1
  9. package/dist/core-config.d.ts.map +1 -1
  10. package/dist/core-config.js +12 -5
  11. package/dist/core-config.js.map +1 -1
  12. package/dist/modules/access-model/kb-read-filter.d.ts +12 -0
  13. package/dist/modules/access-model/kb-read-filter.d.ts.map +1 -1
  14. package/dist/modules/access-model/kb-read-filter.js +15 -0
  15. package/dist/modules/access-model/kb-read-filter.js.map +1 -1
  16. package/dist/modules/connection-probe/connection-probe.contract.d.ts +43 -0
  17. package/dist/modules/connection-probe/connection-probe.contract.d.ts.map +1 -0
  18. package/dist/modules/connection-probe/connection-probe.contract.js +2 -0
  19. package/dist/modules/connection-probe/connection-probe.contract.js.map +1 -0
  20. package/dist/modules/connection-probe/connection-probe.service.d.ts +94 -0
  21. package/dist/modules/connection-probe/connection-probe.service.d.ts.map +1 -0
  22. package/dist/modules/connection-probe/connection-probe.service.js +684 -0
  23. package/dist/modules/connection-probe/connection-probe.service.js.map +1 -0
  24. package/dist/modules/connection-probe/index.d.ts +3 -0
  25. package/dist/modules/connection-probe/index.d.ts.map +1 -0
  26. package/dist/modules/connection-probe/index.js +3 -0
  27. package/dist/modules/connection-probe/index.js.map +1 -0
  28. package/dist/modules/diff/diff.routes.d.ts.map +1 -1
  29. package/dist/modules/diff/diff.routes.js +3 -5
  30. package/dist/modules/diff/diff.routes.js.map +1 -1
  31. package/dist/modules/kb-fs/mutex.d.ts +37 -0
  32. package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
  33. package/dist/modules/kb-fs/mutex.js +48 -5
  34. package/dist/modules/kb-fs/mutex.js.map +1 -1
  35. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  36. package/dist/modules/mcp/mcp.routes.js +95 -13
  37. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  38. package/dist/modules/secrets-vault/db-secrets-vault.service.js +1 -1
  39. package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
  40. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +6 -0
  41. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  42. package/dist/modules/secrets-vault/secrets-vault.routes.js +31 -1
  43. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  44. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
  45. package/dist/modules/tool-manuals/mcp-json-discovery.js +10 -1
  46. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
  47. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
  48. package/dist/modules/tool-manuals/mcp-server-edit.service.js +10 -4
  49. package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
  50. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +83 -0
  51. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  52. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +9 -1
  53. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  54. package/dist/modules/tool-manuals/tool-manuals.service.js +181 -13
  55. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  56. package/dist/modules/workflow/git/git.service.d.ts +18 -1
  57. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  58. package/dist/modules/workflow/git/git.service.js +98 -6
  59. package/dist/modules/workflow/git/git.service.js.map +1 -1
  60. package/dist/modules/workflow/workflow.routes.d.ts +2 -1
  61. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  62. package/dist/modules/workflow/workflow.routes.js +98 -9
  63. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  64. package/dist/modules/workflow/workflow.service.d.ts +81 -8
  65. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  66. package/dist/modules/workflow/workflow.service.js +220 -43
  67. package/dist/modules/workflow/workflow.service.js.map +1 -1
  68. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  69. package/dist/modules/workspace/workspace.routes.js +2 -5
  70. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  71. package/dist/modules/workspace/workspace.service.d.ts +5 -1
  72. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  73. package/dist/modules/workspace/workspace.service.js +21 -4
  74. package/dist/modules/workspace/workspace.service.js.map +1 -1
  75. package/dist/shared/token-crypto.d.ts +17 -2
  76. package/dist/shared/token-crypto.d.ts.map +1 -1
  77. package/dist/shared/token-crypto.js +25 -8
  78. package/dist/shared/token-crypto.js.map +1 -1
  79. package/package.json +3 -3
  80. package/src/__tests__/core-config.admin.test.ts +21 -0
  81. package/src/core/create-core-server.ts +7 -2
  82. package/src/core/create-core-services.ts +11 -0
  83. package/src/core-config.ts +14 -7
  84. package/src/modules/access-model/kb-read-filter.ts +22 -0
  85. package/src/modules/connection-probe/__tests__/connection-probe.service.test.ts +685 -0
  86. package/src/modules/connection-probe/connection-probe.contract.ts +44 -0
  87. package/src/modules/connection-probe/connection-probe.service.ts +734 -0
  88. package/src/modules/connection-probe/index.ts +2 -0
  89. package/src/modules/diff/diff.routes.ts +9 -5
  90. package/src/modules/kb-fs/__tests__/mutex.test.ts +107 -0
  91. package/src/modules/kb-fs/mutex.ts +50 -5
  92. package/src/modules/mcp/__tests__/mcp-routes-harness.ts +89 -0
  93. package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +6 -34
  94. package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +8 -40
  95. package/src/modules/mcp/__tests__/mcp.routes.session.test.ts +226 -0
  96. package/src/modules/mcp/mcp.routes.ts +482 -398
  97. package/src/modules/secrets-vault/__tests__/connect-pending.route.test.ts +12 -0
  98. package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +10 -3
  99. package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +26 -0
  100. package/src/modules/secrets-vault/db-secrets-vault.service.ts +1 -1
  101. package/src/modules/secrets-vault/secrets-vault.routes.ts +35 -1
  102. package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +11 -0
  103. package/src/modules/tool-manuals/__tests__/tool-manuals.health-check.test.ts +104 -0
  104. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +56 -0
  105. package/src/modules/tool-manuals/mcp-json-discovery.ts +13 -1
  106. package/src/modules/tool-manuals/mcp-server-edit.service.ts +13 -4
  107. package/src/modules/tool-manuals/tool-manuals.contract.ts +89 -0
  108. package/src/modules/tool-manuals/tool-manuals.service.ts +196 -14
  109. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +139 -0
  110. package/src/modules/workflow/__tests__/workflow.service.branch-in-use.test.ts +337 -0
  111. package/src/modules/workflow/__tests__/workflow.service.deleted-branch-sweep.test.ts +7 -1
  112. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +95 -8
  113. package/src/modules/workflow/git/__tests__/git.service.history-guards.test.ts +86 -0
  114. package/src/modules/workflow/git/git.service.ts +115 -7
  115. package/src/modules/workflow/workflow.routes.ts +104 -4
  116. package/src/modules/workflow/workflow.service.ts +245 -55
  117. package/src/modules/workspace/workspace.routes.ts +8 -4
  118. package/src/modules/workspace/workspace.service.ts +21 -3
  119. package/src/shared/__tests__/token-crypto.test.ts +34 -0
  120. package/src/shared/token-crypto.ts +28 -10
@@ -76,6 +76,10 @@ async function baseUrlWith(auth: { userId?: string; email?: string }): Promise<s
76
76
  secretsVault,
77
77
  toolManualService,
78
78
  accessControl,
79
+ // These tests are about the credential write, not the probe.
80
+ connectionProbe: {
81
+ probe: async () => ({ status: 'unverifiable' as const, detail: null, checkedAt: new Date() }),
82
+ },
79
83
  stateSecret: 'test-secret',
80
84
  publicBackendUrl: 'http://localhost:3000',
81
85
  publicFrontendUrl: 'http://localhost:5173',
@@ -174,6 +178,10 @@ describe('GET /api/connect/pending — OAuth scope coverage', () => {
174
178
  secretsVault: vaultWithGranted(grantedScopes),
175
179
  toolManualService: oauthTool,
176
180
  accessControl,
181
+ // These tests are about the credential write, not the probe.
182
+ connectionProbe: {
183
+ probe: async () => ({ status: 'unverifiable' as const, detail: null, checkedAt: new Date() }),
184
+ },
177
185
  stateSecret: 'test-secret',
178
186
  publicBackendUrl: 'http://localhost:3000',
179
187
  publicFrontendUrl: 'http://localhost:5173',
@@ -257,6 +265,10 @@ describe('GET /api/connect/pending — tool sign-ins are not double-listed as st
257
265
  secretsVault: vault,
258
266
  toolManualService: oauthTool,
259
267
  accessControl,
268
+ // These tests are about the credential write, not the probe.
269
+ connectionProbe: {
270
+ probe: async () => ({ status: 'unverifiable' as const, detail: null, checkedAt: new Date() }),
271
+ },
260
272
  stateSecret: 'test-secret',
261
273
  publicBackendUrl: 'http://localhost:3000',
262
274
  publicFrontendUrl: 'http://localhost:5173',
@@ -31,8 +31,7 @@ const TOOL_PATH = 'Tools/weather.tool';
31
31
  const USER = 'user@x.com';
32
32
  const RETURN_TO = '/skills-and-tools/tools/weather';
33
33
 
34
- const toolManualService = {
35
- listAccessible: async () => [
34
+ const MANUALS = [
36
35
  {
37
36
  slug: 'weather',
38
37
  name: 'weather',
@@ -51,7 +50,10 @@ const toolManualService = {
51
50
  },
52
51
  ],
53
52
  },
54
- ],
53
+ ];
54
+
55
+ const toolManualService = {
56
+ listAccessible: async () => MANUALS,
55
57
  } as unknown as Parameters<typeof createSecretsVaultRoutes>[0]['toolManualService'];
56
58
 
57
59
  const accessControl = {
@@ -65,10 +67,15 @@ const secretsVault = {
65
67
  completeOAuth,
66
68
  } as unknown as Parameters<typeof createSecretsVaultRoutes>[0]['secretsVault'];
67
69
 
70
+ const connectionProbe = {
71
+ probe: async () => ({ status: 'unverifiable' as const, detail: null, checkedAt: new Date() }),
72
+ } as unknown as Parameters<typeof createSecretsVaultRoutes>[0]['connectionProbe'];
73
+
68
74
  const deps = {
69
75
  secretsVault,
70
76
  toolManualService,
71
77
  accessControl,
78
+ connectionProbe,
72
79
  stateSecret: STATE_SECRET,
73
80
  publicBackendUrl: 'http://localhost:3000',
74
81
  publicFrontendUrl: FRONTEND,
@@ -28,6 +28,9 @@ const toolManualService = {
28
28
  setup: { kind: 'oauth-manual' as const, reason: 'no authorization-server metadata at https://weather.example' },
29
29
  variables: [
30
30
  { name: 'SHARED_KEY', scope: 'admin' as const, label: 'Org key' },
31
+ // A plain per-user key, so the user-scoped write path (and the
32
+ // per-user invalidation it owes) is exercised alongside the shared one.
33
+ { name: 'MY_KEY', scope: 'user' as const, label: 'Your key' },
31
34
  {
32
35
  name: 'SIGNIN',
33
36
  scope: 'user' as const,
@@ -56,9 +59,11 @@ const toolManualService = {
56
59
  ],
57
60
  } as unknown as Parameters<typeof createSecretsVaultRoutes>[0]['toolManualService'];
58
61
 
62
+ const putStatic = vi.fn(async () => ({ id: 'u1' }));
59
63
  const putSharedStatic = vi.fn(async () => ({ id: 's1' }));
60
64
  const putSharedOAuthClientSecret = vi.fn(async () => {});
61
65
  const secretsVault = {
66
+ putStatic,
62
67
  putSharedStatic,
63
68
  putSharedOAuthClientSecret,
64
69
  } as unknown as Parameters<typeof createSecretsVaultRoutes>[0]['secretsVault'];
@@ -69,6 +74,10 @@ const accessControl = {
69
74
  canWrite: async (_ws: string, email: string, path: string) => email === WRITER && path === TOOL_PATH,
70
75
  } as unknown as Parameters<typeof createSecretsVaultRoutes>[0]['accessControl'];
71
76
 
77
+ const connectionProbe = {
78
+ probe: async () => ({ status: 'unverifiable' as const, detail: null, checkedAt: new Date() }),
79
+ } as unknown as Parameters<typeof createSecretsVaultRoutes>[0]['connectionProbe'];
80
+
72
81
  let httpServer: HttpServer | undefined;
73
82
 
74
83
  async function baseUrlAs(email: string): Promise<string> {
@@ -85,6 +94,7 @@ async function baseUrlAs(email: string): Promise<string> {
85
94
  secretsVault,
86
95
  toolManualService,
87
96
  accessControl,
97
+ connectionProbe,
88
98
  stateSecret: 'test-secret',
89
99
  publicBackendUrl: 'http://localhost:3000',
90
100
  publicFrontendUrl: 'http://localhost:5173',
@@ -102,6 +112,7 @@ afterEach(async () => {
102
112
  httpServer = undefined;
103
113
  putSharedStatic.mockClear();
104
114
  putSharedOAuthClientSecret.mockClear();
115
+ putStatic.mockClear();
105
116
  });
106
117
 
107
118
  describe('tool owner gate — shared config requires WRITE on the `.tool` file', () => {
@@ -116,6 +127,21 @@ describe('tool owner gate — shared config requires WRITE on the `.tool` file',
116
127
  expect(putSharedStatic).toHaveBeenCalledWith(expect.objectContaining({ key: 'weather_SHARED_KEY' }));
117
128
  });
118
129
 
130
+ it('a mere READER may still set their OWN value for the same tool', async () => {
131
+ // The gate is on the SHARED value only. One person's own key says nothing
132
+ // about anyone else's, so needing write access to the `.tool` file to type
133
+ // your own credential would lock every reader out of the tools they can see.
134
+ const base = await baseUrlAs(READER);
135
+ const res = await fetch(`${base}/api/secrets/tools/weather/vars/MY_KEY/user`, {
136
+ method: 'PUT',
137
+ headers: { 'Content-Type': 'application/json' },
138
+ body: JSON.stringify({ value: 'mine-123' }),
139
+ });
140
+ expect(res.status).toBe(201);
141
+ expect(putStatic).toHaveBeenCalledWith(expect.objectContaining({ key: 'weather_MY_KEY' }));
142
+ expect(putSharedStatic).not.toHaveBeenCalled();
143
+ });
144
+
119
145
  it('a non-writer is refused (403), even though they can READ the tool', async () => {
120
146
  const base = await baseUrlAs(READER);
121
147
  const res = await fetch(`${base}/api/secrets/tools/weather/vars/SHARED_KEY/admin`, {
@@ -97,7 +97,7 @@ export class DbSecretsVaultService implements ISecretsVaultService {
97
97
  if (!this.cryptoInstance) {
98
98
  if (!this.encKey) {
99
99
  throw new InvalidSecretError(
100
- 'Secrets require an encryption key — set CONNECTOR_CONFIG_ENC_KEY (or SHAREPOINT_TOKEN_ENC_KEY).',
100
+ 'Secrets require an encryption key — set SECRETS_ENC_KEY.',
101
101
  );
102
102
  }
103
103
  this.cryptoInstance = new TokenCrypto(this.encKey);
@@ -10,6 +10,7 @@ import {
10
10
  type ISecretsVaultService,
11
11
  } from './secrets-vault.contract.js';
12
12
  import type { IToolManualService, ToolManualSummary, ToolVariable } from '../tool-manuals/tool-manuals.contract.js';
13
+ import type { IConnectionProbeService } from '../connection-probe/connection-probe.contract.js';
13
14
  import { utcpNamespacedKey } from '../../shared/utcp-namespace.js';
14
15
  import type { IAccessControl } from '../access/access-control.interface.js';
15
16
  import { workspaceIdForBranch } from '../../shared/workspace-id.js';
@@ -21,6 +22,11 @@ export interface SecretsVaultRoutesDeps {
21
22
  toolManualService: IToolManualService;
22
23
  /** Gates who may set a tool's ADMIN (shared) secrets — writers of the `.tool` file. */
23
24
  accessControl: IAccessControl;
25
+ /**
26
+ * Verdicts of the last credential PROBE per tool — what makes the difference
27
+ * between "a key is stored" and "the key works" visible to the UI.
28
+ */
29
+ connectionProbe: IConnectionProbeService;
24
30
  /** HMAC secret for signing the OAuth `state` (reuse the connector state secret). */
25
31
  stateSecret: string;
26
32
  /** Public base URL of THIS backend — builds the OAuth redirect URI. */
@@ -69,7 +75,7 @@ export function isSafeReturnPath(returnTo: unknown): returnTo is string {
69
75
  * `req.userId` — secrets are private per user. Mounted behind the JWT middleware.
70
76
  */
71
77
  export function createSecretsVaultRoutes(deps: SecretsVaultRoutesDeps): express.Router {
72
- const { secretsVault, toolManualService, accessControl } = deps;
78
+ const { secretsVault, toolManualService, accessControl, connectionProbe } = deps;
73
79
  const router = express.Router();
74
80
  // A FUNCTION, not a constant. Routers are constructed at boot, and on a
75
81
  // deployment configured through the setup screen the branch model does not
@@ -361,6 +367,10 @@ export function createSecretsVaultRoutes(deps: SecretsVaultRoutesDeps): express.
361
367
  value: body.value,
362
368
  label: body.label,
363
369
  });
370
+ // No probe here, and nothing to invalidate: a verdict is never stored, so
371
+ // saving a key cannot leave a stale one behind. The caller follows this
372
+ // with an explicit check, which keeps the save fast and gives the UI a
373
+ // "Testing…" state to show instead of a frozen dialog.
364
374
  res.status(201).json({ secret });
365
375
  } catch (err) {
366
376
  mapError(err, res, 'set user var');
@@ -504,6 +514,30 @@ export function createSecretsVaultRoutes(deps: SecretsVaultRoutesDeps): express.
504
514
  }
505
515
  });
506
516
 
517
+ /**
518
+ * Probe one tool's credential NOW and return the verdict.
519
+ *
520
+ * A POST because it has an effect on the world — it makes a real
521
+ * authenticated call to the provider — and because browsers and proxies are
522
+ * free to cache a GET, which for a freshness check would defeat the point.
523
+ *
524
+ * A probe outcome is never an HTTP error: a rejected credential is a
525
+ * successful check that found a problem, so it comes back 200 with
526
+ * `status: 'failed'`. Only a tool that can't be found or read 404s.
527
+ */
528
+ router.post('/secrets/tools/:slug/check', async (req, res) => {
529
+ const userId = req.userId;
530
+ const email = req.userEmail;
531
+ if (!userId || !email) return void res.status(401).json({ error: 'Not authenticated' });
532
+ try {
533
+ const verdict = await connectionProbe.probe(userId, email, req.params.slug);
534
+ if (!verdict) return void res.status(404).json({ error: 'Tool not found' });
535
+ res.json({ verdict });
536
+ } catch (err) {
537
+ mapError(err, res, 'check tool connection');
538
+ }
539
+ });
540
+
507
541
  return router;
508
542
  }
509
543
 
@@ -37,6 +37,17 @@ describe('descriptorsFromMcpJson', () => {
37
37
  ]);
38
38
  });
39
39
 
40
+ it('refuses an sse server rather than rebuilding it as a transport it is not', () => {
41
+ // The pinned MCP client has no sse transport; emitting `http` for an sse
42
+ // server configures a handshake the server does not speak.
43
+ const out = descriptorsFromMcpJson(
44
+ 'GTM',
45
+ JSON.stringify({ mcpServers: { legacy: { type: 'sse', url: 'https://mcp.legacy.example/sse' } } }),
46
+ null,
47
+ );
48
+ expect(out).toEqual([]);
49
+ });
50
+
40
51
  it('merges extension auth over mcp.json literals and carries variables + description', () => {
41
52
  const out = descriptorsFromMcpJson(
42
53
  'GTM',
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { normalizeToolManual } from '../tool-manuals.service.js';
3
+
4
+ /**
5
+ * The `healthCheck:` block is a URL this SERVER will fetch with the caller's
6
+ * credential attached, unattended, on every save and re-check. That makes it a
7
+ * second fetch target on the same footing as the manual's own `url`, so it is
8
+ * policed by the same SSRF rule — and held to one extra rule of its own: it may
9
+ * not mutate.
10
+ */
11
+
12
+ const toolFile = (body: string) => `---
13
+ name: acme
14
+ type: http
15
+ url: https://api.acme.test/utcp
16
+ headers:
17
+ Authorization: Bearer \${API_KEY}
18
+ ${body}---
19
+ notes
20
+ `;
21
+
22
+ const parse = (body: string) => normalizeToolManual('acme', 'Plugins/acme.tool', toolFile(body));
23
+
24
+ describe('`.tool` healthCheck', () => {
25
+ it('is absent when nothing is declared — the tool is simply unverifiable', () => {
26
+ expect(parse('').healthCheck).toBeUndefined();
27
+ });
28
+
29
+ it('inherits the manual\'s headers, so a one-line declaration still authenticates', () => {
30
+ // This is what makes the common case cheap: the credential already lives in
31
+ // `headers`, and re-typing it on the probe would be a chance to get it wrong.
32
+ expect(parse('healthCheck:\n url: https://api.acme.test/me\n').healthCheck).toEqual({
33
+ url: 'https://api.acme.test/me',
34
+ headers: { Authorization: 'Bearer ${API_KEY}' },
35
+ });
36
+ });
37
+
38
+ it('keeps its own headers when it declares them', () => {
39
+ const hc = parse('healthCheck:\n url: https://api.acme.test/me\n headers:\n X-Key: ${API_KEY}\n').healthCheck;
40
+ expect(hc?.headers).toEqual({ 'X-Key': '${API_KEY}' });
41
+ });
42
+
43
+ it("an INLINE manual's probe inherits NO top-level headers — execution never sends them", () => {
44
+ // An inline manual's real calls go through each embedded tool's own call
45
+ // template; nothing ever sends the manual's top-level `headers:`. A probe
46
+ // that inherited them proved a request no call makes — `Connected` about
47
+ // the wrong request. An inline probe declares its headers on the
48
+ // healthCheck itself, or sends none.
49
+ const inline = normalizeToolManual(
50
+ 'acme',
51
+ 'Plugins/acme.tool',
52
+ `---
53
+ name: acme
54
+ type: inline
55
+ headers:
56
+ Authorization: Bearer \${API_KEY}
57
+ healthCheck:
58
+ url: https://api.acme.test/me
59
+ tools: []
60
+ ---
61
+ `,
62
+ );
63
+ expect(inline.healthCheck?.headers).toBeUndefined();
64
+ });
65
+
66
+ it('refuses a method that could mutate', () => {
67
+ // Silently downgrading POST to GET would leave the author believing they had
68
+ // declared something we are not doing.
69
+ expect(() => parse('healthCheck:\n url: https://api.acme.test/me\n method: POST\n')).toThrow(
70
+ /may not mutate/,
71
+ );
72
+ });
73
+
74
+ it('refuses a probe pointed at an internal host', () => {
75
+ expect(() => parse('healthCheck:\n url: http://169.254.169.254/latest/meta-data\n')).toThrow(
76
+ /not allowed/,
77
+ );
78
+ });
79
+
80
+ it('refuses a declaration with no url', () => {
81
+ expect(() => parse('healthCheck:\n method: GET\n')).toThrow(/must have a `url`/);
82
+ });
83
+
84
+ /**
85
+ * A local-only `.tool` is never fetched by this server, so its probe is exempt
86
+ * from the guard for exactly the same reason its `url` is.
87
+ */
88
+ it('exempts a local-only tool, as it does for the manual url', () => {
89
+ const local = normalizeToolManual(
90
+ 'acme',
91
+ 'Plugins/acme.tool',
92
+ `---
93
+ name: acme
94
+ type: http
95
+ remote: false
96
+ url: http://localhost:9000/utcp
97
+ healthCheck:
98
+ url: http://localhost:9000/me
99
+ ---
100
+ `,
101
+ );
102
+ expect(local.healthCheck?.url).toBe('http://localhost:9000/me');
103
+ });
104
+ });
@@ -82,6 +82,62 @@ describe('ToolManualService', () => {
82
82
  expect(list.find((m) => m.name === 'billing')!.type).toBe('http');
83
83
  });
84
84
 
85
+ test('surfaces a variable referenced ONLY by a health check, and keeps the probe off the summary', async () => {
86
+ // Two halves of the same contract. The probe's `${VAR}` has to reach the
87
+ // secrets UI or nobody can ever fill it in and the tool reports
88
+ // `unverifiable` forever — but the probe's HEADERS must not ride the
89
+ // browser-facing summary, since a `.tool` may write a literal token there.
90
+ const tools = join(root, wsId, KB_DIR, 'Plugins');
91
+ await writeFile(
92
+ join(tools, 'probe.tool'),
93
+ JSON.stringify({
94
+ name: 'probe',
95
+ type: 'http',
96
+ url: 'https://api.example.com/utcp',
97
+ healthCheck: { url: 'https://api.example.com/me', headers: { 'X-Key': '${PROBE_ONLY_KEY}' } },
98
+ }),
99
+ );
100
+
101
+ const summary = (await svc().listAccessible('user@x.eu')).find((m) => m.name === 'probe')!;
102
+ expect(summary.variables?.map((v) => v.name)).toContain('PROBE_ONLY_KEY');
103
+ expect(summary).not.toHaveProperty('healthCheck');
104
+
105
+ // The server still reaches it, through the accessor that never serializes.
106
+ const target = await svc().probeTargetFor('user@x.eu', 'probe');
107
+ expect(target?.healthCheck?.headers).toEqual({ 'X-Key': '${PROBE_ONLY_KEY}' });
108
+ });
109
+
110
+ test('builds a call template only for the probe that actually dials one', async () => {
111
+ // The template is dialled by exactly one probe: an `mcp` manual, reachable
112
+ // from this process, that declared no health check. Building it for the
113
+ // others costs a validation pass per probe and warn-logs about a value
114
+ // nothing was going to use.
115
+ const tools = join(root, wsId, KB_DIR, 'Plugins');
116
+ await writeFile(
117
+ join(tools, 'chat.tool'),
118
+ JSON.stringify({ name: 'chat', type: 'mcp', url: 'https://mcp.example.com' }),
119
+ );
120
+ await writeFile(
121
+ join(tools, 'checked.tool'),
122
+ JSON.stringify({
123
+ name: 'checked',
124
+ type: 'mcp',
125
+ url: 'https://mcp.example.com',
126
+ healthCheck: { url: 'https://api.example.com/me' },
127
+ }),
128
+ );
129
+
130
+ const service = svc();
131
+ // Asserted through the template's own type, not `not.toBeNull()`: the
132
+ // optional chain yields `undefined` when the lookup itself fails, and
133
+ // `undefined` is not null — so the weaker form passes on no target at all.
134
+ expect((await service.probeTargetFor('user@x.eu', 'chat'))?.callTemplate?.call_template_type).toBe('mcp');
135
+ // A declared health check wins for every type, so this one never dials it.
136
+ expect((await service.probeTargetFor('user@x.eu', 'checked'))?.callTemplate).toBeNull();
137
+ // An http manual is probed only by what it declares, never by a handshake.
138
+ expect((await service.probeTargetFor('user@x.eu', 'billing'))?.callTemplate).toBeNull();
139
+ });
140
+
85
141
  test('ACL filters out manuals the user cannot read', async () => {
86
142
  const list = await svc(denyBilling).listAccessible('user@x.eu');
87
143
  expect(list.map((m) => m.name)).toEqual(['weather']);
@@ -292,7 +292,19 @@ export function descriptorsFromMcpJson(
292
292
  continue;
293
293
  }
294
294
 
295
- if (raw.type === 'streamable-http' || raw.type === 'sse') {
295
+ if (raw.type === 'sse') {
296
+ // The pinned `@utcp/mcp` speaks `stdio` and streamable `http` — there
297
+ // is no sse transport in its schema, so a template claiming one either
298
+ // fails validation or, worse, dials a handshake the server does not
299
+ // speak. Refusing here names the fix; silently rebuilding as http used
300
+ // to configure exactly that wrong handshake.
301
+ console.warn(
302
+ `[tool-manuals] skipping mcp server "${name}" in ${mcpJsonPath}: the MCP client has no \`sse\` transport — declare the server as \`streamable-http\` if it supports it.`,
303
+ );
304
+ continue;
305
+ }
306
+
307
+ if (raw.type === 'streamable-http') {
296
308
  if (typeof raw.url !== 'string' || raw.url.length === 0) {
297
309
  console.warn(`[tool-manuals] skipping mcp server "${name}" in ${mcpJsonPath}: no url.`);
298
310
  continue;
@@ -174,10 +174,19 @@ export class McpServerEditService {
174
174
  const servers = mcp.mcpServers as Record<string, unknown>;
175
175
  if (!(name in servers)) throw new McpServerEditError('No such server.', 404);
176
176
 
177
- if (write.transport !== 'streamable-http' && write.transport !== 'sse' && write.transport !== 'stdio') {
178
- // Persisting an unknown transport would save an entry discovery then
179
- // refuses — an unusable server with no error at the moment it was made.
180
- throw new McpServerEditError(`Unknown transport "${String(write.transport)}".`, 422);
177
+ if (write.transport !== 'streamable-http' && write.transport !== 'stdio') {
178
+ // Persisting a transport discovery refuses would save an entry that
179
+ // vanishes from the catalog the moment it is written — an unusable
180
+ // server with no error at the moment it was made. `sse` is refused BY
181
+ // NAME with the fix spelled out, because the pinned MCP client has no
182
+ // sse transport; an EXISTING sse entry stays readable here, so it can
183
+ // be edited to `streamable-http` rather than being stranded.
184
+ throw new McpServerEditError(
185
+ write.transport === 'sse'
186
+ ? 'The MCP client has no `sse` transport — declare the server as `streamable-http` if it supports it.'
187
+ : `Unknown transport "${String(write.transport)}".`,
188
+ 422,
189
+ );
181
190
  }
182
191
  // The EFFECTIVE value of every field, PATCH-over-stored (see
183
192
  // McpServerWrite): `undefined` keeps what the files already say, a
@@ -88,6 +88,57 @@ export interface ToolVariable {
88
88
  oauth?: ToolVariableOAuth;
89
89
  }
90
90
 
91
+ /**
92
+ * An optional cheap, read-only call that proves a credential actually WORKS.
93
+ *
94
+ * A `type: mcp` manual needs none — its handshake is authenticated, so merely
95
+ * connecting already tests the token. `http` and `inline` manuals have no such
96
+ * moment: registering an `http` manual fetches its DESCRIPTION, which providers
97
+ * usually serve publicly, so a wrong key sails through; an `inline` manual's
98
+ * discovery template points at Bevel's own API and never touches the provider
99
+ * at all. Without a declaration there is genuinely nothing to call, and the
100
+ * platform reports the connection as unverified rather than guessing.
101
+ *
102
+ * NOT inferred from the manual's own tools. Picking one automatically would
103
+ * mean calling an arbitrary tool with invented arguments — a probe that could
104
+ * send a message or create a record is worse than no probe. The author names a
105
+ * safe endpoint or the tool stays unverified.
106
+ *
107
+ * `${VAR}` refs resolve from the caller's vault exactly as they do in `url` and
108
+ * `headers`, which is the whole point: the probe must carry the same credential
109
+ * the real calls carry. Any 2xx is a pass; 401/403 is a rejection; anything
110
+ * else is treated as the provider being unwell rather than the key being wrong.
111
+ */
112
+ export interface ToolHealthCheck {
113
+ /** Absolute URL to call. May contain `${VAR}` refs; SSRF-checked like `url`. */
114
+ url: string;
115
+ /** Default `GET`. A health check may not mutate, so nothing else is allowed. */
116
+ method?: 'GET';
117
+ /** Defaults to the manual's own `headers` — usually where the credential is. */
118
+ headers?: Record<string, string>;
119
+ }
120
+
121
+ /**
122
+ * One manual, reduced to exactly what testing its credential requires.
123
+ *
124
+ * Server-internal, like {@link ToolHealthCheck} and for the same reason: both
125
+ * `healthCheck.headers` and `callTemplate` can carry a literal token.
126
+ */
127
+ export interface ToolProbeTarget {
128
+ name: string;
129
+ type: ToolManualType;
130
+ /** `false` for a local-only tool this server is not the one that can reach it. */
131
+ remote?: boolean;
132
+ /** The probe the `.tool` declares, if any. */
133
+ healthCheck?: ToolHealthCheck;
134
+ /**
135
+ * The validated UTCP call template, or `null` when the manual doesn't produce
136
+ * a valid one. Only the `mcp` probe uses it — its handshake IS the test — but
137
+ * it is built here so the probe never has to walk the catalog again.
138
+ */
139
+ callTemplate: CallTemplate | null;
140
+ }
141
+
91
142
  /**
92
143
  * For a `type: mcp` tool, what an admin must do to make the remote server
93
144
  * reachable — derived from OAuth auto-discovery, which a non-mcp tool has no
@@ -140,6 +191,15 @@ export interface ToolManualDescriptorBase {
140
191
  variables?: ToolVariable[];
141
192
  /** For `type: mcp`: the admin-facing setup requirement from auto-discovery. */
142
193
  setup?: ToolManualSetup;
194
+ /**
195
+ * Optional credential probe — see {@link ToolHealthCheck}. Declared on
196
+ * `http`/`inline` manuals, and honoured on `type: mcp` too, where it
197
+ * OVERRIDES the default handshake probe (a server whose health endpoint is
198
+ * cheaper or more truthful than a full MCP handshake says so here).
199
+ * INTERNAL: lives on the descriptor, never on {@link ToolManualSummary},
200
+ * because it carries `headers`.
201
+ */
202
+ healthCheck?: ToolHealthCheck;
143
203
  }
144
204
 
145
205
  /** The spawn spec of a stdio-declared MCP server (a plugin `mcp.json` entry). */
@@ -197,6 +257,10 @@ export interface ToolManualSummary {
197
257
  remote?: boolean;
198
258
  /** For `type: mcp`: the admin-facing setup requirement from auto-discovery. */
199
259
  setup?: ToolManualSetup;
260
+ // NOTE: no `healthCheck` here, deliberately. This type is serialized straight
261
+ // to the browser by the tool endpoints, and a probe carries `headers` — which
262
+ // a `.tool` author may write as a literal token rather than a `${VAR}` ref.
263
+ // The server reads probe config through `IToolManualService.probeTargetFor`.
200
264
  }
201
265
 
202
266
  /** One thing an `inline` manual's embedded tool list says the assistant can do. */
@@ -269,6 +333,31 @@ export interface IToolManualService {
269
333
  * a `resolve` reads the shared row or the caller's row.
270
334
  */
271
335
  scopeOfVariable(effectiveKey: string): Promise<ToolVariableScope>;
336
+ /**
337
+ * Everything the credential probe needs about one readable manual, resolved
338
+ * in a SINGLE catalog + ACL pass.
339
+ *
340
+ * One accessor rather than three because the probe needs three facts that all
341
+ * come from the same file — is it local-only, does it declare a health check,
342
+ * and what call template would reach it — and asking for them separately made
343
+ * one probe walk the catalog four times, building call templates for every
344
+ * manual in the workspace to use exactly one of them.
345
+ *
346
+ * Deliberately NOT reachable through {@link ToolManualSummary}: that type is
347
+ * serialized straight to the browser by the tool endpoints, and a probe
348
+ * carries `headers` — which a `.tool` author may write as a literal token
349
+ * rather than a `${VAR}` ref. Probe config is internal to the server, so it
350
+ * travels by its own accessor and never rides a public DTO.
351
+ *
352
+ * Addressed by SLUG, which is what the route has: resolving the slug here
353
+ * rather than making the caller map it to a name first is what reduces a
354
+ * probe to one pass. `ToolProbeTarget.name` carries the UTCP namespace back
355
+ * out, since that is what the probe's `${VAR}` lookups are keyed by.
356
+ *
357
+ * `null` when no such manual exists OR the caller can't read it — the two are
358
+ * indistinguishable on purpose, as everywhere else in this contract.
359
+ */
360
+ probeTargetFor(userEmail: string, slug: string): Promise<ToolProbeTarget | null>;
272
361
  /**
273
362
  * The per-user (`user`-scoped) variables a manual declares, each with the vault
274
363
  * key (`<manual>_<VAR>`) the caller's value is stored under, its bare name, and