@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.
- package/agent-guide/access-control.md +146 -300
- package/agent-guide/conventions.md +23 -25
- package/agent-guide/directory-structure.md +72 -120
- package/agent-guide/drafts-and-change-requests.md +92 -0
- package/agent-guide/for-git-users.md +47 -0
- package/agent-guide/glossary.md +36 -0
- package/agent-guide/how-agents-find-things.md +49 -0
- package/agent-guide/html-views.md +16 -11
- package/agent-guide/new-knowledge-base.md +9 -8
- package/agent-guide/skills.md +67 -42
- package/agent-guide/start-here.md +65 -0
- package/agent-guide/tool-manuals.md +132 -154
- package/agent-guide/tools.md +67 -0
- package/agent-guide/where-a-new-file-goes.md +45 -28
- package/dist/core/create-core-server.js +1 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +6 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +7 -4
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +22 -0
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +3 -1
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +75 -0
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +23 -4
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/index.d.ts +1 -1
- package/dist/modules/access/index.d.ts.map +1 -1
- package/dist/modules/agent-guide/agent-guide.d.ts +47 -7
- package/dist/modules/agent-guide/agent-guide.d.ts.map +1 -1
- package/dist/modules/agent-guide/agent-guide.js +103 -13
- package/dist/modules/agent-guide/agent-guide.js.map +1 -1
- package/dist/modules/agent-guide/agent-guide.tools.d.ts +6 -2
- package/dist/modules/agent-guide/agent-guide.tools.d.ts.map +1 -1
- package/dist/modules/agent-guide/agent-guide.tools.js +45 -31
- package/dist/modules/agent-guide/agent-guide.tools.js.map +1 -1
- package/dist/modules/agent-guide/guide-budget.d.ts +48 -0
- package/dist/modules/agent-guide/guide-budget.d.ts.map +1 -0
- package/dist/modules/agent-guide/guide-budget.js +67 -0
- package/dist/modules/agent-guide/guide-budget.js.map +1 -0
- package/dist/modules/agent-guide/index.d.ts +2 -1
- package/dist/modules/agent-guide/index.d.ts.map +1 -1
- package/dist/modules/agent-guide/index.js +2 -1
- package/dist/modules/agent-guide/index.js.map +1 -1
- package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
- package/dist/modules/agent-instructions/compose.js +4 -0
- package/dist/modules/agent-instructions/compose.js.map +1 -1
- package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -1
- package/dist/modules/agent-instructions/shared-file-rules.js +10 -8
- package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -1
- package/dist/modules/code-mode/code-mode.tool.js +2 -2
- package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
- package/dist/modules/connection-probe/connection-probe.service.d.ts +0 -12
- package/dist/modules/connection-probe/connection-probe.service.d.ts.map +1 -1
- package/dist/modules/connection-probe/connection-probe.service.js +1 -38
- package/dist/modules/connection-probe/connection-probe.service.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +6 -35
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts +11 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.js +159 -24
- package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
- package/dist/modules/secrets-vault/index.d.ts +1 -1
- package/dist/modules/secrets-vault/index.d.ts.map +1 -1
- package/dist/modules/secrets-vault/index.js +1 -1
- package/dist/modules/secrets-vault/index.js.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.contract.d.ts +53 -11
- package/dist/modules/secrets-vault/secrets-vault.contract.d.ts.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.contract.js +27 -0
- package/dist/modules/secrets-vault/secrets-vault.contract.js.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.js +85 -39
- package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
- package/dist/modules/settings/oidc-check.d.ts.map +1 -1
- package/dist/modules/settings/oidc-check.js +5 -17
- package/dist/modules/settings/oidc-check.js.map +1 -1
- package/dist/modules/skills/skills.tools.js +1 -1
- package/dist/modules/skills/skills.tools.js.map +1 -1
- package/dist/modules/tool-helpers/tool-def.js +1 -1
- package/dist/modules/tool-helpers/tool-def.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts +7 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.js +69 -38
- package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +27 -0
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts +12 -8
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +115 -44
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts +8 -2
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +31 -10
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +22 -18
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workspace/agent-access.gate.d.ts +1 -1
- package/dist/modules/workspace/agent-access.gate.d.ts.map +1 -1
- package/dist/modules/workspace/agent-access.gate.js +1 -1
- package/dist/modules/workspace/agent-access.gate.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts +3 -2
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +71 -21
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/oauth-token-error.d.ts +23 -0
- package/dist/shared/oauth-token-error.d.ts.map +1 -0
- package/dist/shared/oauth-token-error.js +47 -0
- package/dist/shared/oauth-token-error.js.map +1 -0
- package/dist/shared/read-capped.d.ts +19 -0
- package/dist/shared/read-capped.d.ts.map +1 -0
- package/dist/shared/read-capped.js +45 -0
- package/dist/shared/read-capped.js.map +1 -0
- package/package.json +4 -3
- package/src/__tests__/hexis-catalog.ts +144 -0
- package/src/__tests__/real-disk.ts +21 -0
- package/src/__tests__/tokens.ts +19 -0
- package/src/__tests__/tool-manuals.ts +32 -0
- package/src/__tests__/tool-wiring.ts +25 -0
- package/src/core/__tests__/oauth-sign-in-real-file.test.ts +179 -0
- package/src/core/create-core-server.ts +1 -1
- package/src/core/create-core-services.ts +13 -3
- package/src/modules/access/__tests__/access.routes.prospective.test.ts +52 -12
- package/src/modules/access/__tests__/holder-changes.test.ts +127 -0
- package/src/modules/access/access-control.interface.ts +26 -0
- package/src/modules/access/access-control.service.ts +92 -0
- package/src/modules/access/access.routes.ts +23 -4
- package/src/modules/access/index.ts +1 -1
- package/src/modules/agent-guide/__tests__/agent-guide.test.ts +79 -25
- package/src/modules/agent-guide/__tests__/agent-guide.tools.test.ts +38 -11
- package/src/modules/agent-guide/__tests__/guide-budget.test.ts +95 -0
- package/src/modules/agent-guide/__tests__/guide-eval.test.ts +182 -0
- package/src/modules/agent-guide/__tests__/guide-matches-code.test.ts +263 -0
- package/src/modules/agent-guide/__tests__/tool-surface.json +514 -0
- package/src/modules/agent-guide/__tests__/tool-surface.test.ts +75 -0
- package/src/modules/agent-guide/agent-guide.tools.ts +49 -31
- package/src/modules/agent-guide/agent-guide.ts +120 -13
- package/src/modules/agent-guide/guide-budget.ts +73 -0
- package/src/modules/agent-guide/index.ts +12 -0
- package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +4 -4
- package/src/modules/agent-instructions/compose.ts +4 -0
- package/src/modules/agent-instructions/shared-file-rules.ts +10 -8
- package/src/modules/code-mode/code-mode.tool.ts +2 -2
- package/src/modules/connection-probe/__tests__/connection-probe.service.test.ts +5 -24
- package/src/modules/connection-probe/connection-probe.service.ts +1 -34
- package/src/modules/mcp/mcp.service.ts +5 -35
- package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +430 -3
- package/src/modules/secrets-vault/__tests__/oauth-live-declaration.route.test.ts +218 -0
- package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +39 -6
- package/src/modules/secrets-vault/db-secrets-vault.service.ts +170 -27
- package/src/modules/secrets-vault/index.ts +1 -1
- package/src/modules/secrets-vault/secrets-vault.contract.ts +61 -11
- package/src/modules/secrets-vault/secrets-vault.routes.ts +91 -39
- package/src/modules/settings/oidc-check.ts +5 -18
- package/src/modules/skills/skills.tools.ts +1 -1
- package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +19 -11
- package/src/modules/tool-helpers/__tests__/route-tool-schemas.catalog.test.ts +38 -0
- package/src/modules/tool-helpers/tool-def.ts +1 -1
- package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +197 -11
- package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +96 -5
- package/src/modules/tool-manuals/mcp-json-discovery.ts +73 -34
- package/src/modules/tool-manuals/tool-manuals.contract.ts +22 -0
- package/src/modules/tool-manuals/tool-manuals.service.ts +138 -46
- package/src/modules/tool-manuals/tool-manuals.tools.ts +53 -14
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +6 -113
- package/src/modules/workflow/agent-tools/workflow.tools.ts +22 -18
- package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +8 -10
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +156 -49
- package/src/modules/workspace/agent-access.gate.ts +1 -1
- package/src/modules/workspace/workspace.tools.ts +74 -21
- package/src/shared/__tests__/read-capped.test.ts +68 -0
- package/src/shared/oauth-token-error.ts +46 -0
- package/src/shared/read-capped.ts +40 -0
- package/agent-guide/finding-things.md +0 -7
- package/agent-guide/introduction.md +0 -27
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
-
|
|
3
|
-
|
|
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 =
|
|
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/
|
|
14
|
-
import type { IAccessControl } from '../../access/
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
98
|
-
|
|
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/
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
99
|
-
if (
|
|
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)
|
|
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
|
|
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)
|
|
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)
|
|
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
|
|
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)
|
|
141
|
-
|
|
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: `${
|
|
149
|
-
assertSafeFetchUrl(tokenUrl!, { requireHttps: true, label: `${
|
|
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: `${
|
|
179
|
+
assertSafeFetchUrl(resource, { requireHttps: true, label: `${at}.oauth.resource\`` });
|
|
153
180
|
}
|
|
154
|
-
} catch {
|
|
155
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
|
376
|
-
if (
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|