@bevel-software/platform-core-backend 0.12.1 → 0.13.3
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/THIRD-PARTY-NOTICES.md +5 -3
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +8 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +58 -2
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +174 -33
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/admin-locked-commit.d.ts.map +1 -1
- package/dist/modules/access/admin-locked-commit.js +1 -0
- package/dist/modules/access/admin-locked-commit.js.map +1 -1
- package/dist/modules/access/synced-groups-committer.js +1 -1
- package/dist/modules/access/synced-groups-committer.js.map +1 -1
- package/dist/modules/access-model/access-errors.d.ts +11 -0
- package/dist/modules/access-model/access-errors.d.ts.map +1 -1
- package/dist/modules/access-model/access-errors.js +14 -0
- package/dist/modules/access-model/access-errors.js.map +1 -1
- package/dist/modules/access-model/access-grammar.d.ts +24 -8
- package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
- package/dist/modules/access-model/access-grammar.js +123 -7
- package/dist/modules/access-model/access-grammar.js.map +1 -1
- package/dist/modules/declared-variables/declared-variables.routes.d.ts +42 -0
- package/dist/modules/declared-variables/declared-variables.routes.d.ts.map +1 -0
- package/dist/modules/declared-variables/declared-variables.routes.js +135 -0
- package/dist/modules/declared-variables/declared-variables.routes.js.map +1 -0
- package/dist/modules/declared-variables/index.d.ts +2 -0
- package/dist/modules/declared-variables/index.d.ts.map +1 -0
- package/dist/modules/declared-variables/index.js +2 -0
- package/dist/modules/declared-variables/index.js.map +1 -0
- package/dist/modules/diff/diff.routes.d.ts +1 -1
- package/dist/modules/diff/diff.routes.d.ts.map +1 -1
- package/dist/modules/diff/diff.routes.js +3 -3
- package/dist/modules/diff/diff.routes.js.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.d.ts +16 -0
- package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.js +20 -0
- package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
- package/dist/modules/kb-fs/repo-path.d.ts +32 -0
- package/dist/modules/kb-fs/repo-path.d.ts.map +1 -0
- package/dist/modules/kb-fs/repo-path.js +54 -0
- package/dist/modules/kb-fs/repo-path.js.map +1 -0
- package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.js +60 -18
- package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts +20 -0
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts.map +1 -1
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js +121 -47
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.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 +20 -2
- package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
- package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-context.js +1 -0
- package/dist/modules/tool-helpers/tool-context.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.js +45 -12
- package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.js +2 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +40 -8
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts +38 -14
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +164 -55
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +10 -5
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
- package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts +55 -0
- package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts.map +1 -0
- package/dist/modules/tool-manuals/utcp-cli-parse-only.js +76 -0
- package/dist/modules/tool-manuals/utcp-cli-parse-only.js.map +1 -0
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +3 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +19 -2
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +32 -1
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +70 -5
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.d.ts +3 -3
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.js +20 -2
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
- package/dist/modules/workflow/pending-commits.worker.d.ts +8 -0
- package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.worker.js +74 -16
- package/dist/modules/workflow/pending-commits.worker.js.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.js +7 -0
- package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +12 -0
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +1 -0
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +4 -0
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +31 -15
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/kb-template/AGENTS.md +52 -8
- package/package.json +4 -3
- package/src/core/create-core-server.ts +13 -1
- package/src/core/create-core-services.ts +1 -0
- package/src/modules/access/__tests__/access-control.atref-cache.test.ts +260 -0
- package/src/modules/access/__tests__/access-groups.test.ts +28 -0
- package/src/modules/access/__tests__/access-own-read-grant.test.ts +116 -0
- package/src/modules/access/access-control.service.ts +198 -37
- package/src/modules/access/admin-locked-commit.ts +1 -0
- package/src/modules/access/synced-groups-committer.ts +1 -1
- package/src/modules/access-model/__tests__/access-grammar.test.ts +199 -1
- package/src/modules/access-model/access-errors.ts +19 -0
- package/src/modules/access-model/access-grammar.ts +123 -6
- package/src/modules/declared-variables/__tests__/declared-variables.route.test.ts +166 -0
- package/src/modules/declared-variables/declared-variables.routes.ts +151 -0
- package/src/modules/declared-variables/index.ts +1 -0
- package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +4 -4
- package/src/modules/diff/diff.routes.ts +3 -2
- package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +306 -131
- package/src/modules/kb-fs/__tests__/repo-path.test.ts +106 -0
- package/src/modules/kb-fs/locking-filesystem.ts +30 -0
- package/src/modules/kb-fs/repo-path.ts +56 -0
- package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +104 -0
- package/src/modules/secrets-vault/__tests__/mcp-oauth-discovery.service.test.ts +52 -0
- package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +48 -1
- package/src/modules/secrets-vault/db-secrets-vault.service.ts +73 -22
- package/src/modules/secrets-vault/mcp-oauth-discovery.service.ts +141 -50
- package/src/modules/secrets-vault/secrets-vault.routes.ts +600 -582
- package/src/modules/tool-helpers/tool-context.ts +1 -0
- package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +38 -0
- package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +2 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.cli.test.ts +243 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +95 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +17 -1
- package/src/modules/tool-manuals/mcp-json-discovery.ts +39 -15
- package/src/modules/tool-manuals/mcp-server-edit.service.ts +2 -1
- package/src/modules/tool-manuals/tool-manuals.contract.ts +40 -9
- package/src/modules/tool-manuals/tool-manuals.service.ts +156 -28
- package/src/modules/tool-manuals/tool-manuals.tools.ts +10 -5
- package/src/modules/tool-manuals/utcp-cli-parse-only.ts +76 -0
- package/src/modules/workflow/__tests__/pending-commits.worker.test.ts +46 -0
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +60 -1
- package/src/modules/workflow/agent-tools/workflow.tools.ts +18 -1
- package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +56 -0
- package/src/modules/workflow/git/__tests__/git.service.commitFile.strayPath.test.ts +162 -0
- package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +114 -0
- package/src/modules/workflow/git/git.service.ts +73 -6
- package/src/modules/workflow/git/pull-request.service.ts +22 -4
- package/src/modules/workflow/pending-commits.worker.ts +80 -18
- package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +60 -0
- package/src/modules/workflow/review-workflow/review-workflow.service.ts +5 -0
- package/src/modules/workflow/workflow.routes.ts +12 -0
- package/src/modules/workflow/workflow.service.ts +5 -1
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +45 -0
- package/src/modules/workspace/workspace.tools.ts +35 -15
|
@@ -69,6 +69,7 @@ export function createToolContextResolver(deps: ToolContextDeps): ResolveToolCon
|
|
|
69
69
|
workspaceId,
|
|
70
70
|
branch: branchForWorkspaceId(workspaceId),
|
|
71
71
|
user,
|
|
72
|
+
kbDirName: deps.kbDirName,
|
|
72
73
|
validateWrite: validateRolesWrite,
|
|
73
74
|
creatorAccess: deps.creatorAccess,
|
|
74
75
|
},
|
|
@@ -188,6 +188,44 @@ describe('descriptorsFromMcpJson', () => {
|
|
|
188
188
|
expect(out[0]?.variables?.[0]?.oauth?.clientId).toBe('c-1');
|
|
189
189
|
});
|
|
190
190
|
|
|
191
|
+
it('accepts a client-id-only sign-in (endpoints discovered later), refuses half a pair, carries pkce/resource', () => {
|
|
192
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
193
|
+
const declare = (oauth: unknown) =>
|
|
194
|
+
descriptorsFromMcpJson(
|
|
195
|
+
'GTM',
|
|
196
|
+
JSON.stringify({ mcpServers: { vendor: { type: 'streamable-http', url: 'https://v.example/mcp' } } }),
|
|
197
|
+
JSON.stringify({
|
|
198
|
+
name: 'gtm',
|
|
199
|
+
extensions: {
|
|
200
|
+
'software.bevel.hexis': { mcpServers: { vendor: { variables: [{ name: 'T', scope: 'user', oauth }] } } },
|
|
201
|
+
},
|
|
202
|
+
}),
|
|
203
|
+
)[0]?.variables?.[0]?.oauth;
|
|
204
|
+
// An MCP server publishes its endpoints — the client id alone is a complete declaration here.
|
|
205
|
+
expect(declare({ clientId: 'c' })).toEqual({ clientId: 'c' });
|
|
206
|
+
// …but half a pair is a broken declaration, not a discoverable one.
|
|
207
|
+
expect(declare({ clientId: 'c', authorizationUrl: 'https://v.example/auth' })).toBeUndefined();
|
|
208
|
+
expect(declare({ clientId: 'c', tokenUrl: 'https://v.example/token' })).toBeUndefined();
|
|
209
|
+
// An emptied editor field reads as absent, not as a malformed URL.
|
|
210
|
+
expect(declare({ clientId: 'c', authorizationUrl: '', tokenUrl: ' ' })).toEqual({ clientId: 'c' });
|
|
211
|
+
// PKCE is on by default: only the opt-out is stored, and it must be a boolean.
|
|
212
|
+
expect(declare({ clientId: 'c', pkce: true })).toEqual({ clientId: 'c' });
|
|
213
|
+
expect(declare({ clientId: 'c', pkce: false })).toEqual({ clientId: 'c', pkce: false });
|
|
214
|
+
expect(declare({ clientId: 'c', pkce: 'no' })).toBeUndefined();
|
|
215
|
+
// The resource indicator names the remote server — same https/SSRF bar as the endpoints.
|
|
216
|
+
expect(declare({ clientId: 'c', resource: 'https://v.example/mcp' })).toEqual({
|
|
217
|
+
clientId: 'c',
|
|
218
|
+
resource: 'https://v.example/mcp',
|
|
219
|
+
});
|
|
220
|
+
expect(declare({ clientId: 'c', resource: 'http://v.example/mcp' })).toBeUndefined();
|
|
221
|
+
expect(declare({ clientId: 'c', resource: 'https://169.254.169.254/mcp' })).toBeUndefined();
|
|
222
|
+
// A secret in a portable file never loads — same keys the `.tool` parser
|
|
223
|
+
// refuses, and the whole server is dropped rather than the key ignored.
|
|
224
|
+
expect(declare({ clientId: 'c', clientSecret: 'shh' })).toBeUndefined();
|
|
225
|
+
expect(declare({ clientId: 'c', client_secret: 'shh' })).toBeUndefined();
|
|
226
|
+
expect(declare({ clientId: 'c', secret: 'shh' })).toBeUndefined();
|
|
227
|
+
});
|
|
228
|
+
|
|
191
229
|
it('yields nothing for an unparsable file, quietly for an absent extensions block', () => {
|
|
192
230
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
193
231
|
expect(descriptorsFromMcpJson('GTM', '{ not json', null)).toEqual([]);
|
|
@@ -290,6 +290,8 @@ describe('putServer', () => {
|
|
|
290
290
|
oauth: { authorizationUrl: 'https://v.example/auth', tokenUrl: 'https://v.example/token', clientId: ' ' },
|
|
291
291
|
},
|
|
292
292
|
],
|
|
293
|
+
[{ name: 'K', scope: 'user', oauth: { clientId: 'c', clientSecret: 'shh' } }], // a secret never lands in a file
|
|
294
|
+
[{ name: 'K', scope: 'user', oauth: { clientId: 'c', authorizationUrl: 'https://v.example/auth' } }], // half a pair
|
|
293
295
|
];
|
|
294
296
|
const before = await readJson('Plugins/GTM/plugin.json');
|
|
295
297
|
for (const variables of bad) {
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { describe, test, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { CallTemplateSerializer, CommunicationProtocol, UtcpClient } from '@utcp/sdk';
|
|
6
|
+
import { DEFAULT_BRANCH } from '@bevel-software/platform-shared';
|
|
7
|
+
import { ToolManualService, normalizeToolManual } from '../tool-manuals.service.js';
|
|
8
|
+
import { canExecuteCliTemplates, containsCliCallTemplate } from '../utcp-cli-parse-only.js';
|
|
9
|
+
import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
|
|
10
|
+
import { utcpNamespacePrefix } from '../../../shared/utcp-namespace.js';
|
|
11
|
+
import type { WorkspaceService } from '../../workspace/workspace.service.js';
|
|
12
|
+
import type { IAccessControl } from '../../access/access-control.interface.js';
|
|
13
|
+
|
|
14
|
+
const KB_DIR = 'knowledge-base';
|
|
15
|
+
const wsId = workspaceIdForBranch(DEFAULT_BRANCH);
|
|
16
|
+
|
|
17
|
+
/** A shell `.tool` in the shape the KB's `git.tool` uses: fenced YAML, cli templates. */
|
|
18
|
+
const gitTool = (extra = ''): string => `---
|
|
19
|
+
name: git
|
|
20
|
+
type: inline
|
|
21
|
+
description: Git over the local checkout.
|
|
22
|
+
${extra}tools:
|
|
23
|
+
- name: status
|
|
24
|
+
description: Porcelain status of a checkout.
|
|
25
|
+
inputs:
|
|
26
|
+
type: object
|
|
27
|
+
properties:
|
|
28
|
+
cwd: { type: string }
|
|
29
|
+
required: [cwd]
|
|
30
|
+
outputs:
|
|
31
|
+
type: object
|
|
32
|
+
properties: {}
|
|
33
|
+
tool_call_template:
|
|
34
|
+
call_template_type: cli
|
|
35
|
+
commands:
|
|
36
|
+
- command: git -C "UTCP_ARG_cwd_UTCP_END" status --porcelain
|
|
37
|
+
append_to_final_output: true
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
Notes after the fence are ignored by the parser.
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
describe('cli `.tool` manuals — parsed and listed, never executed', () => {
|
|
44
|
+
test('the hosted process cannot DISPATCH a cli template', async () => {
|
|
45
|
+
// The claim worth pinning is not "the map lacks a key" — it is that a cli
|
|
46
|
+
// template actually fails to dispatch. A registry assertion alone would
|
|
47
|
+
// still pass if some other module re-registered the protocol at runtime, or
|
|
48
|
+
// if dispatch consulted a different map; this exercises the path a
|
|
49
|
+
// dispatching caller takes.
|
|
50
|
+
expect(canExecuteCliTemplates()).toBe(false);
|
|
51
|
+
|
|
52
|
+
const template = new CallTemplateSerializer().validateDict({
|
|
53
|
+
name: 'git',
|
|
54
|
+
call_template_type: 'cli',
|
|
55
|
+
commands: [{ command: 'git status', append_to_final_output: true }],
|
|
56
|
+
});
|
|
57
|
+
const protocol = CommunicationProtocol.communicationProtocols[template.call_template_type];
|
|
58
|
+
expect(protocol).toBeUndefined();
|
|
59
|
+
|
|
60
|
+
// And a client built in this process refuses it rather than shelling out.
|
|
61
|
+
const client = await UtcpClient.create(process.cwd(), { variables: {} } as never);
|
|
62
|
+
try {
|
|
63
|
+
// The reason, not any reason: a shape or URL error would also reject,
|
|
64
|
+
// and would leave the guarantee this test exists for unverified.
|
|
65
|
+
await expect(client.registerManual(template)).rejects.toThrow(
|
|
66
|
+
/No communication protocol registered for type: 'cli'/,
|
|
67
|
+
);
|
|
68
|
+
} finally {
|
|
69
|
+
// Closing releases the client's communication protocols and their
|
|
70
|
+
// transports; left open they outlive the test for the whole run.
|
|
71
|
+
await client.close().catch(() => {});
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('the serializer IS registered, or these files would not parse at all', () => {
|
|
76
|
+
// The other half of the split: the platform must understand a cli template
|
|
77
|
+
// well enough to validate and serve it, which is why the package is
|
|
78
|
+
// imported at all.
|
|
79
|
+
expect(() =>
|
|
80
|
+
new CallTemplateSerializer().validateDict({
|
|
81
|
+
name: 'git',
|
|
82
|
+
call_template_type: 'cli',
|
|
83
|
+
commands: [{ command: 'git status' }],
|
|
84
|
+
}),
|
|
85
|
+
).not.toThrow();
|
|
86
|
+
expect(CommunicationProtocol.communicationProtocols.http).toBeDefined();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('a cyclic document is examined, not blown up on', () => {
|
|
90
|
+
// A YAML self-alias really does produce a cyclic object. The backstop must
|
|
91
|
+
// still find the cli template beside the cycle rather than overflow.
|
|
92
|
+
const cyclic: Record<string, unknown> = { tools: [{ tool_call_template: { call_template_type: 'cli' } }] };
|
|
93
|
+
cyclic.self = cyclic;
|
|
94
|
+
expect(containsCliCallTemplate(cyclic)).toBe(true);
|
|
95
|
+
const benign: Record<string, unknown> = { a: { call_template_type: 'http' } };
|
|
96
|
+
benign.self = benign;
|
|
97
|
+
expect(containsCliCallTemplate(benign)).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('a cli call template is found wherever it is nested', () => {
|
|
101
|
+
expect(containsCliCallTemplate(JSON.parse(JSON.stringify({ a: [{ b: { call_template_type: 'cli' } }] })))).toBe(true);
|
|
102
|
+
expect(containsCliCallTemplate({ call_template_type: 'http' })).toBe(false);
|
|
103
|
+
expect(containsCliCallTemplate({ call_template_type: ' CLI ' })).toBe(true);
|
|
104
|
+
expect(containsCliCallTemplate(null)).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('a shell `.tool` is forced local even without declaring it', () => {
|
|
108
|
+
const d = normalizeToolManual('git', 'Plugins/git.tool', gitTool());
|
|
109
|
+
expect(d.remote).toBe(false);
|
|
110
|
+
expect(d.type).toBe('inline');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('`remote: false` beside a cli template is accepted as written', () => {
|
|
114
|
+
expect(normalizeToolManual('git', 'Plugins/git.tool', gitTool('remote: false\n')).remote).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('`remote: true` beside a cli template is refused, not corrected', () => {
|
|
118
|
+
expect(() => normalizeToolManual('git', 'Plugins/git.tool', gitTool('remote: true\n'))).toThrow(/cli.*call template|call template/i);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('a shell `.tool` in a workspace', () => {
|
|
123
|
+
let root: string;
|
|
124
|
+
|
|
125
|
+
const workspaceService = {
|
|
126
|
+
getOrCreateForBranch: async () => ({ id: wsId }),
|
|
127
|
+
getWorkspacePath: async (id: string) => join(root, id),
|
|
128
|
+
} as unknown as WorkspaceService;
|
|
129
|
+
|
|
130
|
+
const allowAll = {
|
|
131
|
+
canRead: async () => true,
|
|
132
|
+
canReadBatch: async (_w: string, _e: string, paths: string[]) => new Map(paths.map((p) => [p, true])),
|
|
133
|
+
} as unknown as IAccessControl;
|
|
134
|
+
|
|
135
|
+
beforeEach(async () => {
|
|
136
|
+
root = await mkdtemp(join(tmpdir(), 'clitools-'));
|
|
137
|
+
const dir = join(root, wsId, KB_DIR, 'Plugins');
|
|
138
|
+
await mkdir(dir, { recursive: true });
|
|
139
|
+
await writeFile(join(dir, 'git.tool'), gitTool());
|
|
140
|
+
});
|
|
141
|
+
afterEach(() => rm(root, { recursive: true, force: true }));
|
|
142
|
+
|
|
143
|
+
test('is listed to local consumers and withheld from remote ones', async () => {
|
|
144
|
+
const svc = new ToolManualService(workspaceService, allowAll, KB_DIR);
|
|
145
|
+
// `list_local_tools` names it, so the local server knows to materialize it.
|
|
146
|
+
expect(await svc.listLocalOnly('user@x.eu')).toEqual([{ slug: 'git', name: 'git', path: 'Plugins/git.tool' }]);
|
|
147
|
+
// The hosted proxy's manual set excludes it entirely.
|
|
148
|
+
expect((await svc.toManualCallTemplates('user@x.eu', { remoteOnly: true })).map((t) => t.name)).toEqual([]);
|
|
149
|
+
expect((await svc.toManualCallTemplates('user@x.eu')).map((t) => t.name)).toEqual(['git']);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('its embedded cli tools survive manual serialization', async () => {
|
|
153
|
+
// The whole point of registering the serializer: without it the manual body
|
|
154
|
+
// fails validation and the local server gets nothing to run.
|
|
155
|
+
const svc = new ToolManualService(workspaceService, allowAll, KB_DIR);
|
|
156
|
+
const manual = await svc.resolveInlineManual('user@x.eu', 'git');
|
|
157
|
+
expect(manual).not.toBeNull();
|
|
158
|
+
const tools = (manual as { tools: { name: string; tool_call_template: { call_template_type: string } }[] }).tools;
|
|
159
|
+
expect(tools.map((t) => t.name)).toEqual(['status']);
|
|
160
|
+
expect(tools[0].tool_call_template.call_template_type).toBe('cli');
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('manual namespaces are unique, not merely manual names', () => {
|
|
165
|
+
let root: string;
|
|
166
|
+
|
|
167
|
+
const workspaceService = {
|
|
168
|
+
getOrCreateForBranch: async () => ({ id: wsId }),
|
|
169
|
+
getWorkspacePath: async (id: string) => join(root, id),
|
|
170
|
+
} as unknown as WorkspaceService;
|
|
171
|
+
|
|
172
|
+
const allowAll = {
|
|
173
|
+
canRead: async () => true,
|
|
174
|
+
canReadBatch: async (_w: string, _e: string, paths: string[]) => new Map(paths.map((p) => [p, true])),
|
|
175
|
+
} as unknown as IAccessControl;
|
|
176
|
+
|
|
177
|
+
const pluginsDir = () => join(root, wsId, KB_DIR, 'Plugins');
|
|
178
|
+
|
|
179
|
+
beforeEach(async () => {
|
|
180
|
+
root = await mkdtemp(join(tmpdir(), 'ns-'));
|
|
181
|
+
await mkdir(pluginsDir(), { recursive: true });
|
|
182
|
+
});
|
|
183
|
+
afterEach(() => rm(root, { recursive: true, force: true }));
|
|
184
|
+
|
|
185
|
+
/** A `.tool` file. Its `id` grammar is `[a-z0-9_]` — it cannot carry a hyphen. */
|
|
186
|
+
const writeTool = (file: string, id: string) =>
|
|
187
|
+
writeFile(join(pluginsDir(), file), JSON.stringify({ id, type: 'inline', tools: [] }));
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* An mcp.json server. THIS is the only path a hyphen can enter a manual name:
|
|
191
|
+
* its grammar allows `-`, so it is the only way to build a real collision.
|
|
192
|
+
*/
|
|
193
|
+
async function writeMcpServer(folder: string, serverName: string): Promise<void> {
|
|
194
|
+
await mkdir(join(pluginsDir(), folder), { recursive: true });
|
|
195
|
+
await writeFile(
|
|
196
|
+
join(pluginsDir(), folder, 'mcp.json'),
|
|
197
|
+
JSON.stringify({ mcpServers: { [serverName]: { type: 'streamable-http', url: 'https://v.example/mcp' } } }),
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const names = async (): Promise<string[]> =>
|
|
202
|
+
(await new ToolManualService(workspaceService, allowAll, KB_DIR).listAllSummaries()).map((m) => m.name).sort();
|
|
203
|
+
|
|
204
|
+
it('keeps two manuals whose namespaces genuinely differ', async () => {
|
|
205
|
+
// Underscore-doubling is injective, so `a_b` (a__b_) and `a__b` (a____b_)
|
|
206
|
+
// are distinct namespaces and both must survive.
|
|
207
|
+
await writeTool('a.tool', 'a_b');
|
|
208
|
+
await writeTool('b.tool', 'a__b');
|
|
209
|
+
expect(await names()).toEqual(['a__b', 'a_b']);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('drops the second of two manuals sharing one namespace', async () => {
|
|
213
|
+
// `a-b` and `a_b` are different NAMES with the same NAMESPACE `a__b_`, and
|
|
214
|
+
// therefore one set of vault keys — either able to resolve the other's
|
|
215
|
+
// secrets. Deduping by raw name let both through; this is the case that
|
|
216
|
+
// proves the check compares namespaces.
|
|
217
|
+
await writeMcpServer('Vendor', 'a-b');
|
|
218
|
+
await writeTool('a.tool', 'a_b');
|
|
219
|
+
const kept = await names();
|
|
220
|
+
expect(kept).toHaveLength(1);
|
|
221
|
+
// mcp.json descriptors are listed before `.tool` files, and the shared
|
|
222
|
+
// dedupe keeps the first occurrence, so the server is the survivor.
|
|
223
|
+
expect(kept).toEqual(['a-b']);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('leaves both when the hyphenated name does NOT collide', async () => {
|
|
227
|
+
// Guards the fix from being over-broad: hyphens stay usable, which matters
|
|
228
|
+
// because MCP servers in the wild are routinely named with them.
|
|
229
|
+
await writeMcpServer('Vendor', 'sequential-thinking');
|
|
230
|
+
await writeTool('a.tool', 'a_b');
|
|
231
|
+
expect(await names()).toEqual(['a_b', 'sequential-thinking']);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('keeps every surviving namespace distinct', async () => {
|
|
235
|
+
// The invariant secrets actually depend on, asserted directly.
|
|
236
|
+
await writeMcpServer('Vendor', 'a-b');
|
|
237
|
+
await writeTool('a.tool', 'a_b');
|
|
238
|
+
await writeTool('c.tool', 'other');
|
|
239
|
+
const kept = await new ToolManualService(workspaceService, allowAll, KB_DIR).listAllSummaries();
|
|
240
|
+
const namespaces = kept.map((m) => utcpNamespacePrefix(m.name));
|
|
241
|
+
expect(new Set(namespaces).size).toBe(namespaces.length);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
@@ -197,4 +197,99 @@ describe('ToolManualService — MCP OAuth auto-discovery decoration', () => {
|
|
|
197
197
|
expect(survived.map((m) => m.name).sort()).toEqual(['jira', 'notion']);
|
|
198
198
|
warn.mockRestore();
|
|
199
199
|
});
|
|
200
|
+
|
|
201
|
+
/** An mcp.json server whose plugin.json declares a sign-in by client id alone. */
|
|
202
|
+
async function writeDeclaredServer(oauth: Record<string, unknown>) {
|
|
203
|
+
const pluginDir = join(root, wsId, KB_DIR, 'Plugins', 'GTM');
|
|
204
|
+
await mkdir(pluginDir, { recursive: true });
|
|
205
|
+
await writeFile(
|
|
206
|
+
join(pluginDir, 'mcp.json'),
|
|
207
|
+
JSON.stringify({ mcpServers: { hubspot: { type: 'streamable-http', url: 'https://mcp.hubspot.example/mcp' } } }),
|
|
208
|
+
);
|
|
209
|
+
await writeFile(
|
|
210
|
+
join(pluginDir, 'plugin.json'),
|
|
211
|
+
JSON.stringify({
|
|
212
|
+
name: 'gtm',
|
|
213
|
+
extensions: {
|
|
214
|
+
'software.bevel.hexis': {
|
|
215
|
+
mcpServers: {
|
|
216
|
+
hubspot: {
|
|
217
|
+
headers: { Authorization: 'Bearer ${HUBSPOT_TOKEN}' },
|
|
218
|
+
variables: [{ name: 'HUBSPOT_TOKEN', scope: 'user', oauth }],
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
}),
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
test('a declared client id without endpoints is completed from the server metadata; a miss lands in setup.reason', async () => {
|
|
228
|
+
await writeDeclaredServer({ clientId: 'owner-app' });
|
|
229
|
+
const statusFor = vi.fn(async () => ({ status: 'open' as const }));
|
|
230
|
+
const providerForDeclaredClient = vi.fn(async () => ({
|
|
231
|
+
status: 'oauth' as const,
|
|
232
|
+
provider: {
|
|
233
|
+
authorizationUrl: 'https://auth.hubspot.example/authorize',
|
|
234
|
+
tokenUrl: 'https://auth.hubspot.example/token',
|
|
235
|
+
clientId: 'owner-app',
|
|
236
|
+
pkce: true,
|
|
237
|
+
resource: 'https://mcp.hubspot.example/mcp',
|
|
238
|
+
},
|
|
239
|
+
}));
|
|
240
|
+
const svc = svcWith({ statusFor, providerForDeclaredClient });
|
|
241
|
+
const hubspot = (await svc.listAccessible('user@example.com')).find((m) => m.name === 'hubspot')!;
|
|
242
|
+
// Explicit wins: a declared server is never probed for registration…
|
|
243
|
+
expect(statusFor).not.toHaveBeenCalledWith('hubspot', expect.anything());
|
|
244
|
+
expect(providerForDeclaredClient).toHaveBeenCalledWith('hubspot', 'https://mcp.hubspot.example/mcp', 'owner-app');
|
|
245
|
+
// …its declaration is completed in place — an owner-registered sign-in
|
|
246
|
+
// with nothing left to explain, so no `reason`.
|
|
247
|
+
expect(hubspot.setup).toEqual({ kind: 'oauth-manual' });
|
|
248
|
+
expect(hubspot.variables?.[0].oauth).toEqual({
|
|
249
|
+
clientId: 'owner-app',
|
|
250
|
+
authorizationUrl: 'https://auth.hubspot.example/authorize',
|
|
251
|
+
tokenUrl: 'https://auth.hubspot.example/token',
|
|
252
|
+
resource: 'https://mcp.hubspot.example/mcp',
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// When the metadata can't be had, the declaration stays incomplete and the
|
|
256
|
+
// reason travels with the tool — to the UI banner and to `list_tool_setup`.
|
|
257
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
258
|
+
const missing = svcWith({
|
|
259
|
+
statusFor,
|
|
260
|
+
providerForDeclaredClient: async () => ({
|
|
261
|
+
status: 'unsupported' as const,
|
|
262
|
+
reason: 'no authorization-server metadata at https://mcp.hubspot.example',
|
|
263
|
+
}),
|
|
264
|
+
});
|
|
265
|
+
const stuck = (await missing.listAccessible('user@example.com')).find((m) => m.name === 'hubspot')!;
|
|
266
|
+
expect(stuck.setup).toEqual({
|
|
267
|
+
kind: 'oauth-manual',
|
|
268
|
+
reason: 'no authorization-server metadata at https://mcp.hubspot.example',
|
|
269
|
+
});
|
|
270
|
+
expect(stuck.variables?.[0].oauth).toEqual({ clientId: 'owner-app' });
|
|
271
|
+
// A port without the declared-client path says so rather than pretending.
|
|
272
|
+
const legacy = svcWith({ statusFor });
|
|
273
|
+
const unsupported = (await legacy.listAccessible('user@example.com')).find((m) => m.name === 'hubspot')!;
|
|
274
|
+
expect(unsupported.setup?.reason).toContain('discovery is unavailable');
|
|
275
|
+
warn.mockRestore();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('a fully declared sign-in is oauth-manual with no reason, and is never probed', async () => {
|
|
279
|
+
await writeDeclaredServer({
|
|
280
|
+
clientId: 'owner-app',
|
|
281
|
+
authorizationUrl: 'https://auth.hubspot.example/authorize',
|
|
282
|
+
tokenUrl: 'https://auth.hubspot.example/token',
|
|
283
|
+
pkce: false,
|
|
284
|
+
});
|
|
285
|
+
const statusFor = vi.fn(async () => ({ status: 'open' as const }));
|
|
286
|
+
const providerForDeclaredClient = vi.fn();
|
|
287
|
+
const svc = svcWith({ statusFor, providerForDeclaredClient });
|
|
288
|
+
const hubspot = (await svc.listAccessible('user@example.com')).find((m) => m.name === 'hubspot')!;
|
|
289
|
+
expect(hubspot.setup).toEqual({ kind: 'oauth-manual' });
|
|
290
|
+
expect(providerForDeclaredClient).not.toHaveBeenCalled();
|
|
291
|
+
expect(statusFor).not.toHaveBeenCalledWith('hubspot', expect.anything());
|
|
292
|
+
// The declaration is carried verbatim, opt-out included.
|
|
293
|
+
expect(hubspot.variables?.[0].oauth).toMatchObject({ clientId: 'owner-app', pkce: false });
|
|
294
|
+
});
|
|
200
295
|
});
|
|
@@ -266,6 +266,22 @@ describe('ToolManualService', () => {
|
|
|
266
266
|
expect(await svc().listAccessible('user@x.eu')).toHaveLength(0);
|
|
267
267
|
});
|
|
268
268
|
|
|
269
|
+
test('carries oauth.pkce:false and a gated oauth.resource; a `.tool` still needs both endpoints', async () => {
|
|
270
|
+
await writeOAuthTool(oauthVar({ pkce: false, resource: 'https://api.example.com/mcp' }));
|
|
271
|
+
const [manual] = await svc().listAccessible('user@x.eu');
|
|
272
|
+
expect(manual.variables?.[0].oauth).toMatchObject({ pkce: false, resource: 'https://api.example.com/mcp' });
|
|
273
|
+
// PKCE is the default and is not echoed; only the opt-out is stored.
|
|
274
|
+
await writeOAuthTool(oauthVar({ pkce: true }));
|
|
275
|
+
expect((await svc().listAccessible('user@x.eu'))[0].variables?.[0].oauth).not.toHaveProperty('pkce');
|
|
276
|
+
await writeOAuthTool(oauthVar({ pkce: 'yes' }));
|
|
277
|
+
expect(await svc().listAccessible('user@x.eu')).toHaveLength(0);
|
|
278
|
+
await writeOAuthTool(oauthVar({ resource: 'http://localhost/mcp' }));
|
|
279
|
+
expect(await svc().listAccessible('user@x.eu')).toHaveLength(0);
|
|
280
|
+
// A `.tool` has no server whose metadata could fill the endpoints in.
|
|
281
|
+
await writeOAuthTool(oauthVar({ authorizationUrl: undefined }));
|
|
282
|
+
expect(await svc().listAccessible('user@x.eu')).toHaveLength(0);
|
|
283
|
+
});
|
|
284
|
+
|
|
269
285
|
test('parses `remote`: default true, explicit false, rejects non-boolean', async () => {
|
|
270
286
|
root = await mkdtemp(join(tmpdir(), 'toolsrem-'));
|
|
271
287
|
const tools = join(root, wsId, KB_DIR, 'Plugins');
|
|
@@ -288,7 +304,7 @@ describe('ToolManualService', () => {
|
|
|
288
304
|
const s = svc();
|
|
289
305
|
expect((await s.toManualCallTemplates('user@x.eu', { remoteOnly: true })).map((t) => t.name).sort()).toEqual(['pub']);
|
|
290
306
|
expect((await s.toManualCallTemplates('user@x.eu')).map((t) => t.name).sort()).toEqual(['loc', 'pub']);
|
|
291
|
-
expect(await s.listLocalOnly('user@x.eu')).toEqual([{ name: 'loc', path: 'Plugins/loc.tool' }]);
|
|
307
|
+
expect(await s.listLocalOnly('user@x.eu')).toEqual([{ slug: 'loc', name: 'loc', path: 'Plugins/loc.tool' }]);
|
|
292
308
|
// The local-only manual is still browsable/editable regardless of the remote flag.
|
|
293
309
|
expect((await s.listAccessible('user@x.eu')).map((m) => m.name).sort()).toEqual(['loc', 'pub']);
|
|
294
310
|
});
|
|
@@ -106,28 +106,48 @@ export function validatedVariables(raw: unknown): ToolVariable[] | null {
|
|
|
106
106
|
if (entry.oauth !== undefined) {
|
|
107
107
|
// OAuth is inherently per-caller (same rule the `.tool` parser
|
|
108
108
|
// enforces): an admin-shared OAuth token would leak one user's token
|
|
109
|
-
// to all callers.
|
|
110
|
-
// — a sign-in wired to a missing URL is a declaration, not a feature.
|
|
109
|
+
// to all callers.
|
|
111
110
|
if (!isRecord(entry.oauth) || scope !== 'user') return null;
|
|
112
111
|
const o = entry.oauth;
|
|
112
|
+
// Confidential material never loads from a file, on either declaration
|
|
113
|
+
// surface: the `.tool` parser throws on these keys, and silently
|
|
114
|
+
// dropping one here would leave a plugin.json carrying a secret that
|
|
115
|
+
// "worked" — the file is portable package data, readable by every
|
|
116
|
+
// client that loads the plugin.
|
|
117
|
+
if (o.clientSecret !== undefined || o.client_secret !== undefined || o.secret !== undefined) return null;
|
|
113
118
|
// `clientId` is trimmed and must be non-empty, exactly as the `.tool`
|
|
114
119
|
// parser requires: a whitespace-only value would pass discovery and
|
|
115
120
|
// then fail the owner's client-secret setup with "clientId is
|
|
116
121
|
// required" — an error at the wrong surface, long after the save.
|
|
117
|
-
if (
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
)
|
|
123
|
-
|
|
124
|
-
|
|
122
|
+
if (typeof o.clientId !== 'string' || !o.clientId.trim()) return null;
|
|
123
|
+
// The endpoints are OPTIONAL here, unlike in a `.tool`: an MCP server
|
|
124
|
+
// publishes its authorization-server metadata, so a declaration that
|
|
125
|
+
// names only the client id is completed at scan time. Both or neither
|
|
126
|
+
// — one URL without the other is a half-declaration, not a feature.
|
|
127
|
+
// An empty string counts as absent (the editor's cleared field).
|
|
128
|
+
const optionalUrl = (v: unknown): string | undefined | null => {
|
|
129
|
+
if (v === undefined || v === null) return undefined;
|
|
130
|
+
if (typeof v !== 'string') return null;
|
|
131
|
+
const trimmed = v.trim();
|
|
132
|
+
return trimmed ? trimmed : undefined;
|
|
133
|
+
};
|
|
134
|
+
const authorizationUrl = optionalUrl(o.authorizationUrl);
|
|
135
|
+
const tokenUrl = optionalUrl(o.tokenUrl);
|
|
136
|
+
const resource = optionalUrl(o.resource);
|
|
137
|
+
if (authorizationUrl === null || tokenUrl === null || resource === null) return null;
|
|
138
|
+
if ((authorizationUrl === undefined) !== (tokenUrl === undefined)) return null;
|
|
125
139
|
// The same https + SSRF gate the `.tool` parser runs on these URLs: a
|
|
126
140
|
// sign-in or token exchange aimed at an internal host is a declaration
|
|
127
|
-
// this surface must refuse exactly like the other one does.
|
|
141
|
+
// this surface must refuse exactly like the other one does. `resource`
|
|
142
|
+
// is never fetched, but it names the remote server — same bar.
|
|
128
143
|
try {
|
|
129
|
-
|
|
130
|
-
|
|
144
|
+
if (authorizationUrl !== undefined) {
|
|
145
|
+
assertSafeFetchUrl(authorizationUrl, { requireHttps: true, label: `${entry.name} oauth.authorizationUrl` });
|
|
146
|
+
assertSafeFetchUrl(tokenUrl!, { requireHttps: true, label: `${entry.name} oauth.tokenUrl` });
|
|
147
|
+
}
|
|
148
|
+
if (resource !== undefined) {
|
|
149
|
+
assertSafeFetchUrl(resource, { requireHttps: true, label: `${entry.name} oauth.resource` });
|
|
150
|
+
}
|
|
131
151
|
} catch {
|
|
132
152
|
return null;
|
|
133
153
|
}
|
|
@@ -138,14 +158,18 @@ export function validatedVariables(raw: unknown): ToolVariable[] | null {
|
|
|
138
158
|
return null;
|
|
139
159
|
}
|
|
140
160
|
}
|
|
161
|
+
// PKCE is on unless the declaration says `false`; anything else there is
|
|
162
|
+
// a malformed flag, not a preference.
|
|
163
|
+
if (o.pkce !== undefined && typeof o.pkce !== 'boolean') return null;
|
|
141
164
|
oauth = {
|
|
142
|
-
authorizationUrl
|
|
143
|
-
tokenUrl: o.tokenUrl,
|
|
165
|
+
...(authorizationUrl !== undefined ? { authorizationUrl, tokenUrl: tokenUrl! } : {}),
|
|
144
166
|
clientId: o.clientId.trim(),
|
|
145
167
|
...(Array.isArray(o.scopes) && o.scopes.every((s) => typeof s === 'string')
|
|
146
168
|
? { scopes: o.scopes as string[] }
|
|
147
169
|
: {}),
|
|
148
170
|
...(o.authParams !== undefined ? { authParams: o.authParams as Record<string, string> } : {}),
|
|
171
|
+
...(o.pkce === false ? { pkce: false } : {}),
|
|
172
|
+
...(resource !== undefined ? { resource } : {}),
|
|
149
173
|
};
|
|
150
174
|
}
|
|
151
175
|
out.push({
|
|
@@ -209,7 +209,8 @@ export class McpServerEditService {
|
|
|
209
209
|
throw new McpServerEditError(
|
|
210
210
|
'The `variables` declaration is malformed — each entry needs a unique, non-reserved ' +
|
|
211
211
|
'alphanumeric/underscore name, a scope of `admin` or `user`, and any `oauth` block must sit ' +
|
|
212
|
-
'on a `user`-scoped variable with
|
|
212
|
+
'on a `user`-scoped variable with a client id and never a client secret; its provider URLs are ' +
|
|
213
|
+
'optional (discovered from the server) but must be valid https URLs, both or neither, when given.',
|
|
213
214
|
422,
|
|
214
215
|
);
|
|
215
216
|
}
|
|
@@ -30,10 +30,17 @@ export type ToolVariableScope = 'admin' | 'user';
|
|
|
30
30
|
* present, the variable's value is obtained by signing in with this provider
|
|
31
31
|
* (the per-user secret row is `kind:'oauth'`), rather than being typed. The
|
|
32
32
|
* confidential client secret is provisioned separately by a tool writer.
|
|
33
|
+
*
|
|
34
|
+
* The endpoints are OPTIONAL for an `mcp.json` server: an MCP-spec server
|
|
35
|
+
* publishes its authorization-server metadata, so a declaration carrying only
|
|
36
|
+
* the `clientId` of an owner-registered app is completed at scan time
|
|
37
|
+
* (`authorizationUrl`/`tokenUrl`/`resource` filled in from that metadata). A
|
|
38
|
+
* `.tool` manual has no server to ask, so there both URLs are required.
|
|
33
39
|
*/
|
|
34
40
|
export interface ToolVariableOAuth {
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
/** Absent on an mcp.json server ⇒ discovered from the server's OAuth metadata. */
|
|
42
|
+
authorizationUrl?: string;
|
|
43
|
+
tokenUrl?: string;
|
|
37
44
|
clientId: string;
|
|
38
45
|
scopes?: string[];
|
|
39
46
|
/**
|
|
@@ -45,6 +52,20 @@ export interface ToolVariableOAuth {
|
|
|
45
52
|
* secret is still provisioned only through the protected route.
|
|
46
53
|
*/
|
|
47
54
|
authParams?: Record<string, string>;
|
|
55
|
+
/**
|
|
56
|
+
* PKCE (S256) on the authorization-code flow. ON unless explicitly `false`:
|
|
57
|
+
* the MCP authorization spec requires it, and a provider that doesn't
|
|
58
|
+
* implement it ignores the extra parameters (RFC 6749 §3.1). Only `false` is
|
|
59
|
+
* ever stored — absent means the default.
|
|
60
|
+
*/
|
|
61
|
+
pkce?: boolean;
|
|
62
|
+
/**
|
|
63
|
+
* RFC 8707 resource indicator — the MCP server's canonical URL, so the token
|
|
64
|
+
* is audience-bound. Filled in from the server's metadata for an mcp.json
|
|
65
|
+
* server; declare it by hand only when the provider demands it and the
|
|
66
|
+
* endpoints are declared by hand too.
|
|
67
|
+
*/
|
|
68
|
+
resource?: string;
|
|
48
69
|
}
|
|
49
70
|
|
|
50
71
|
/**
|
|
@@ -74,14 +95,18 @@ export interface ToolVariable {
|
|
|
74
95
|
* - `open` — the server needs no auth; nothing to configure.
|
|
75
96
|
* - `oauth-auto` — OAuth was auto-configured; the sign-in appears as a
|
|
76
97
|
* `user`-scoped variable, so users just authorize.
|
|
77
|
-
* - `oauth-manual` — the server needs OAuth
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
98
|
+
* - `oauth-manual` — the server needs OAuth with an OWNER-REGISTERED client:
|
|
99
|
+
* auto-discovery couldn't register one (typically no dynamic
|
|
100
|
+
* client registration — Google, HubSpot), or the declaration
|
|
101
|
+
* already names a client id. A tool writer declares the
|
|
102
|
+
* sign-in on a `user`-scoped variable (the server editor for
|
|
103
|
+
* an `mcp.json` server; the `variables` block of a `.tool`)
|
|
104
|
+
* and sets its client secret. `reason` is present only while
|
|
105
|
+
* something is still missing, and says exactly what.
|
|
81
106
|
*/
|
|
82
107
|
export interface ToolManualSetup {
|
|
83
108
|
kind: 'open' | 'oauth-auto' | 'oauth-manual';
|
|
84
|
-
/**
|
|
109
|
+
/** What still blocks the sign-in — present for an unfinished `oauth-manual`. */
|
|
85
110
|
reason?: string;
|
|
86
111
|
}
|
|
87
112
|
|
|
@@ -224,8 +249,14 @@ export interface IToolManualService {
|
|
|
224
249
|
* (the remote MCP proxy), LOCAL-ONLY manuals (`remote: false`) are excluded.
|
|
225
250
|
*/
|
|
226
251
|
toManualCallTemplates(userEmail: string, opts?: { remoteOnly?: boolean }): Promise<CallTemplate[]>;
|
|
227
|
-
/**
|
|
228
|
-
|
|
252
|
+
/**
|
|
253
|
+
* The caller's accessible LOCAL-ONLY (`remote: false`) manuals.
|
|
254
|
+
*
|
|
255
|
+
* `slug` rides along with `name` and `path` because a local runtime needs
|
|
256
|
+
* both: `name` is the UTCP namespace its `${VAR}` refs are keyed by, and
|
|
257
|
+
* `slug` is what addresses the manual on the variable-resolution route.
|
|
258
|
+
*/
|
|
259
|
+
listLocalOnly(userEmail: string): Promise<{ slug: string; name: string; path: string }[]>;
|
|
229
260
|
/** The embedded UTCP manual for an inline `.tool` (served at `/api/tools/:slug/manual`). */
|
|
230
261
|
resolveInlineManual(userEmail: string, slug: string): Promise<UtcpManualDict | null>;
|
|
231
262
|
/** Validate a draft `.tool` file's content for the renderer preview. */
|