@bevel-software/platform-core-backend 0.12.0 → 0.13.1

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 (177) hide show
  1. package/THIRD-PARTY-NOTICES.md +9 -7
  2. package/dist/core/create-core-server.d.ts.map +1 -1
  3. package/dist/core/create-core-server.js +8 -1
  4. package/dist/core/create-core-server.js.map +1 -1
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js.map +1 -1
  7. package/dist/modules/access/access-control.service.d.ts +58 -2
  8. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  9. package/dist/modules/access/access-control.service.js +174 -33
  10. package/dist/modules/access/access-control.service.js.map +1 -1
  11. package/dist/modules/access/admin-locked-commit.d.ts.map +1 -1
  12. package/dist/modules/access/admin-locked-commit.js +1 -0
  13. package/dist/modules/access/admin-locked-commit.js.map +1 -1
  14. package/dist/modules/access/synced-groups-committer.js +1 -1
  15. package/dist/modules/access/synced-groups-committer.js.map +1 -1
  16. package/dist/modules/access-model/access-errors.d.ts +11 -0
  17. package/dist/modules/access-model/access-errors.d.ts.map +1 -1
  18. package/dist/modules/access-model/access-errors.js +14 -0
  19. package/dist/modules/access-model/access-errors.js.map +1 -1
  20. package/dist/modules/access-model/access-grammar.d.ts +24 -8
  21. package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
  22. package/dist/modules/access-model/access-grammar.js +64 -3
  23. package/dist/modules/access-model/access-grammar.js.map +1 -1
  24. package/dist/modules/declared-variables/declared-variables.routes.d.ts +42 -0
  25. package/dist/modules/declared-variables/declared-variables.routes.d.ts.map +1 -0
  26. package/dist/modules/declared-variables/declared-variables.routes.js +135 -0
  27. package/dist/modules/declared-variables/declared-variables.routes.js.map +1 -0
  28. package/dist/modules/declared-variables/index.d.ts +2 -0
  29. package/dist/modules/declared-variables/index.d.ts.map +1 -0
  30. package/dist/modules/declared-variables/index.js +2 -0
  31. package/dist/modules/declared-variables/index.js.map +1 -0
  32. package/dist/modules/diff/diff.routes.d.ts +1 -1
  33. package/dist/modules/diff/diff.routes.d.ts.map +1 -1
  34. package/dist/modules/diff/diff.routes.js +3 -3
  35. package/dist/modules/diff/diff.routes.js.map +1 -1
  36. package/dist/modules/kb-fs/clone-config.d.ts +40 -2
  37. package/dist/modules/kb-fs/clone-config.d.ts.map +1 -1
  38. package/dist/modules/kb-fs/clone-config.js +94 -2
  39. package/dist/modules/kb-fs/clone-config.js.map +1 -1
  40. package/dist/modules/kb-fs/locking-filesystem.d.ts +16 -0
  41. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  42. package/dist/modules/kb-fs/locking-filesystem.js +20 -0
  43. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  44. package/dist/modules/kb-fs/repo-path.d.ts +32 -0
  45. package/dist/modules/kb-fs/repo-path.d.ts.map +1 -0
  46. package/dist/modules/kb-fs/repo-path.js +54 -0
  47. package/dist/modules/kb-fs/repo-path.js.map +1 -0
  48. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -1
  49. package/dist/modules/secrets-vault/db-secrets-vault.service.js +60 -18
  50. package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
  51. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts +20 -0
  52. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts.map +1 -1
  53. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js +121 -47
  54. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js.map +1 -1
  55. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  56. package/dist/modules/secrets-vault/secrets-vault.routes.js +20 -2
  57. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  58. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  59. package/dist/modules/tool-helpers/tool-context.js +1 -0
  60. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  61. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
  62. package/dist/modules/tool-manuals/mcp-json-discovery.js +45 -12
  63. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
  64. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
  65. package/dist/modules/tool-manuals/mcp-server-edit.service.js +2 -1
  66. package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
  67. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +40 -8
  68. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  69. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +38 -14
  70. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  71. package/dist/modules/tool-manuals/tool-manuals.service.js +164 -55
  72. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  73. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  74. package/dist/modules/tool-manuals/tool-manuals.tools.js +10 -5
  75. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  76. package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts +55 -0
  77. package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts.map +1 -0
  78. package/dist/modules/tool-manuals/utcp-cli-parse-only.js +76 -0
  79. package/dist/modules/tool-manuals/utcp-cli-parse-only.js.map +1 -0
  80. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +3 -1
  81. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  82. package/dist/modules/workflow/agent-tools/workflow.tools.js +19 -2
  83. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  84. package/dist/modules/workflow/git/git.service.d.ts +32 -1
  85. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  86. package/dist/modules/workflow/git/git.service.js +70 -5
  87. package/dist/modules/workflow/git/git.service.js.map +1 -1
  88. package/dist/modules/workflow/git/pull-request.service.d.ts +3 -3
  89. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  90. package/dist/modules/workflow/git/pull-request.service.js +20 -2
  91. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  92. package/dist/modules/workflow/pending-commits.worker.d.ts +8 -0
  93. package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -1
  94. package/dist/modules/workflow/pending-commits.worker.js +74 -16
  95. package/dist/modules/workflow/pending-commits.worker.js.map +1 -1
  96. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  97. package/dist/modules/workflow/review-workflow/review-workflow.service.js +7 -0
  98. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  99. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  100. package/dist/modules/workflow/workflow.routes.js +12 -0
  101. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  102. package/dist/modules/workflow/workflow.service.d.ts +39 -0
  103. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  104. package/dist/modules/workflow/workflow.service.js +116 -6
  105. package/dist/modules/workflow/workflow.service.js.map +1 -1
  106. package/dist/modules/workspace/startup/kb-git.d.ts.map +1 -1
  107. package/dist/modules/workspace/startup/kb-git.js +21 -4
  108. package/dist/modules/workspace/startup/kb-git.js.map +1 -1
  109. package/dist/modules/workspace/workspace.service.d.ts +52 -8
  110. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  111. package/dist/modules/workspace/workspace.service.js +121 -23
  112. package/dist/modules/workspace/workspace.service.js.map +1 -1
  113. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  114. package/dist/modules/workspace/workspace.tools.js +31 -15
  115. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  116. package/kb-template/AGENTS.md +52 -8
  117. package/package.json +6 -5
  118. package/src/core/create-core-server.ts +13 -1
  119. package/src/core/create-core-services.ts +1 -0
  120. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +260 -0
  121. package/src/modules/access/__tests__/access-groups.test.ts +28 -0
  122. package/src/modules/access/access-control.service.ts +198 -37
  123. package/src/modules/access/admin-locked-commit.ts +1 -0
  124. package/src/modules/access/synced-groups-committer.ts +1 -1
  125. package/src/modules/access-model/__tests__/access-grammar.test.ts +101 -1
  126. package/src/modules/access-model/access-errors.ts +19 -0
  127. package/src/modules/access-model/access-grammar.ts +67 -3
  128. package/src/modules/declared-variables/__tests__/declared-variables.route.test.ts +166 -0
  129. package/src/modules/declared-variables/declared-variables.routes.ts +151 -0
  130. package/src/modules/declared-variables/index.ts +1 -0
  131. package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +4 -4
  132. package/src/modules/diff/diff.routes.ts +3 -2
  133. package/src/modules/kb-fs/__tests__/clone-config.test.ts +63 -2
  134. package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +306 -131
  135. package/src/modules/kb-fs/__tests__/repo-path.test.ts +106 -0
  136. package/src/modules/kb-fs/clone-config.ts +97 -2
  137. package/src/modules/kb-fs/locking-filesystem.ts +30 -0
  138. package/src/modules/kb-fs/repo-path.ts +56 -0
  139. package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +104 -0
  140. package/src/modules/secrets-vault/__tests__/mcp-oauth-discovery.service.test.ts +52 -0
  141. package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +48 -1
  142. package/src/modules/secrets-vault/db-secrets-vault.service.ts +73 -22
  143. package/src/modules/secrets-vault/mcp-oauth-discovery.service.ts +141 -50
  144. package/src/modules/secrets-vault/secrets-vault.routes.ts +20 -2
  145. package/src/modules/tool-helpers/tool-context.ts +1 -0
  146. package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +38 -0
  147. package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +2 -0
  148. package/src/modules/tool-manuals/__tests__/tool-manuals.cli.test.ts +243 -0
  149. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +95 -0
  150. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +17 -1
  151. package/src/modules/tool-manuals/mcp-json-discovery.ts +39 -15
  152. package/src/modules/tool-manuals/mcp-server-edit.service.ts +2 -1
  153. package/src/modules/tool-manuals/tool-manuals.contract.ts +40 -9
  154. package/src/modules/tool-manuals/tool-manuals.service.ts +156 -28
  155. package/src/modules/tool-manuals/tool-manuals.tools.ts +10 -5
  156. package/src/modules/tool-manuals/utcp-cli-parse-only.ts +76 -0
  157. package/src/modules/workflow/__tests__/pending-commits.worker.test.ts +46 -0
  158. package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +11 -5
  159. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +172 -7
  160. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +60 -1
  161. package/src/modules/workflow/agent-tools/workflow.tools.ts +18 -1
  162. package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +56 -0
  163. package/src/modules/workflow/git/__tests__/git.service.commitFile.strayPath.test.ts +162 -0
  164. package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +114 -0
  165. package/src/modules/workflow/git/git.service.ts +73 -6
  166. package/src/modules/workflow/git/pull-request.service.ts +22 -4
  167. package/src/modules/workflow/pending-commits.worker.ts +80 -18
  168. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +60 -0
  169. package/src/modules/workflow/review-workflow/review-workflow.service.ts +5 -0
  170. package/src/modules/workflow/workflow.routes.ts +12 -0
  171. package/src/modules/workflow/workflow.service.ts +123 -7
  172. package/src/modules/workspace/__tests__/workspace.service.test.ts +1 -1
  173. package/src/modules/workspace/__tests__/workspace.tools.test.ts +45 -0
  174. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +141 -0
  175. package/src/modules/workspace/startup/kb-git.ts +20 -7
  176. package/src/modules/workspace/workspace.service.ts +132 -25
  177. package/src/modules/workspace/workspace.tools.ts +35 -15
@@ -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. The provider config's required halves must be there
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
- typeof o.authorizationUrl !== 'string' ||
119
- typeof o.tokenUrl !== 'string' ||
120
- typeof o.clientId !== 'string' ||
121
- !o.clientId.trim()
122
- ) {
123
- return null;
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
- assertSafeFetchUrl(o.authorizationUrl, { requireHttps: true, label: `${entry.name} oauth.authorizationUrl` });
130
- assertSafeFetchUrl(o.tokenUrl, { requireHttps: true, label: `${entry.name} oauth.tokenUrl` });
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: o.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 valid https provider URLs and a client id.',
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
- authorizationUrl: string;
36
- tokenUrl: string;
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 but auto-discovery couldn't set it
78
- * up (typically no dynamic client registration, e.g. Google);
79
- * a tool writer must declare the provider in the `.tool` file
80
- * and set its client secret. `reason` says exactly why.
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
- /** Why auto-setup didn't complete — present for `oauth-manual`. */
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
- /** The `{ name, path }` of the caller's accessible LOCAL-ONLY (`remote: false`) manuals. */
228
- listLocalOnly(userEmail: string): Promise<{ name: string; path: string }[]>;
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. */