@bevel-software/platform-core-backend 0.7.5 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (230) hide show
  1. package/LICENSE +202 -202
  2. package/THIRD-PARTY-NOTICES.md +428 -454
  3. package/dist/core/core-ports.d.ts +1 -1
  4. package/dist/core/create-core-server.d.ts.map +1 -1
  5. package/dist/core/create-core-server.js +51 -7
  6. package/dist/core/create-core-server.js.map +1 -1
  7. package/dist/core/create-core-services.d.ts +5 -3
  8. package/dist/core/create-core-services.d.ts.map +1 -1
  9. package/dist/core/create-core-services.js +30 -18
  10. package/dist/core/create-core-services.js.map +1 -1
  11. package/dist/modules/access/access-control.interface.d.ts +8 -7
  12. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  13. package/dist/modules/access/access-control.service.d.ts +1 -1
  14. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  15. package/dist/modules/access/access-control.service.js +6 -6
  16. package/dist/modules/access/access-control.service.js.map +1 -1
  17. package/dist/modules/access/access-declarations.d.ts +5 -5
  18. package/dist/modules/access/access-declarations.js +3 -3
  19. package/dist/modules/access/access-mutation.service.d.ts +3 -3
  20. package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
  21. package/dist/modules/access/access-mutation.service.js +3 -3
  22. package/dist/modules/access/access-mutation.service.js.map +1 -1
  23. package/dist/modules/access/access-splice.js +4 -4
  24. package/dist/modules/access/access-splice.js.map +1 -1
  25. package/dist/modules/access/access.routes.js +19 -19
  26. package/dist/modules/access/access.routes.js.map +1 -1
  27. package/dist/modules/access/creator-access.d.ts +2 -2
  28. package/dist/modules/access/creator-access.js +5 -5
  29. package/dist/modules/access/creator-access.js.map +1 -1
  30. package/dist/modules/access/roles-admin.service.d.ts +18 -4
  31. package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
  32. package/dist/modules/access/roles-admin.service.js +14 -4
  33. package/dist/modules/access/roles-admin.service.js.map +1 -1
  34. package/dist/modules/code-mode/code-mode-names.d.ts +5 -13
  35. package/dist/modules/code-mode/code-mode-names.d.ts.map +1 -1
  36. package/dist/modules/code-mode/code-mode-names.js +5 -27
  37. package/dist/modules/code-mode/code-mode-names.js.map +1 -1
  38. package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
  39. package/dist/modules/code-mode/code-mode.tool.js +29 -7
  40. package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
  41. package/dist/modules/mcp/mcp.service.d.ts +11 -52
  42. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  43. package/dist/modules/mcp/mcp.service.js +33 -395
  44. package/dist/modules/mcp/mcp.service.js.map +1 -1
  45. package/dist/modules/plugins/index.d.ts +7 -0
  46. package/dist/modules/plugins/index.d.ts.map +1 -0
  47. package/dist/modules/plugins/index.js +6 -0
  48. package/dist/modules/plugins/index.js.map +1 -0
  49. package/dist/modules/plugins/join-proposals.d.ts +53 -0
  50. package/dist/modules/plugins/join-proposals.d.ts.map +1 -0
  51. package/dist/modules/plugins/join-proposals.js +67 -0
  52. package/dist/modules/plugins/join-proposals.js.map +1 -0
  53. package/dist/modules/plugins/join-requests.service.d.ts +81 -0
  54. package/dist/modules/plugins/join-requests.service.d.ts.map +1 -0
  55. package/dist/modules/plugins/join-requests.service.js +135 -0
  56. package/dist/modules/plugins/join-requests.service.js.map +1 -0
  57. package/dist/modules/plugins/plugin-provision.service.d.ts +134 -0
  58. package/dist/modules/plugins/plugin-provision.service.d.ts.map +1 -0
  59. package/dist/modules/plugins/plugin-provision.service.js +344 -0
  60. package/dist/modules/plugins/plugin-provision.service.js.map +1 -0
  61. package/dist/modules/plugins/plugins.contract.d.ts +106 -0
  62. package/dist/modules/plugins/plugins.contract.d.ts.map +1 -0
  63. package/dist/modules/plugins/plugins.contract.js +36 -0
  64. package/dist/modules/plugins/plugins.contract.js.map +1 -0
  65. package/dist/modules/plugins/plugins.routes.d.ts +42 -0
  66. package/dist/modules/plugins/plugins.routes.d.ts.map +1 -0
  67. package/dist/modules/plugins/plugins.routes.js +379 -0
  68. package/dist/modules/plugins/plugins.routes.js.map +1 -0
  69. package/dist/modules/plugins/plugins.service.d.ts +60 -0
  70. package/dist/modules/plugins/plugins.service.d.ts.map +1 -0
  71. package/dist/modules/plugins/plugins.service.js +172 -0
  72. package/dist/modules/plugins/plugins.service.js.map +1 -0
  73. package/dist/modules/skills/pending-skills.service.d.ts +2 -2
  74. package/dist/modules/skills/pending-skills.service.js +7 -7
  75. package/dist/modules/skills/pending-skills.service.js.map +1 -1
  76. package/dist/modules/skills/skills.contract.d.ts +4 -4
  77. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  78. package/dist/modules/skills/skills.contract.js +1 -1
  79. package/dist/modules/skills/skills.service.js +5 -5
  80. package/dist/modules/skills/skills.service.js.map +1 -1
  81. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts +65 -0
  82. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -0
  83. package/dist/modules/tool-manuals/mcp-json-discovery.js +276 -0
  84. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -0
  85. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts +92 -0
  86. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -0
  87. package/dist/modules/tool-manuals/mcp-server-edit.service.js +328 -0
  88. package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -0
  89. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +38 -12
  90. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  91. package/dist/modules/tool-manuals/tool-manuals.contract.js +1 -1
  92. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +13 -2
  93. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
  94. package/dist/modules/tool-manuals/tool-manuals.routes.js +233 -2
  95. package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
  96. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  97. package/dist/modules/tool-manuals/tool-manuals.service.js +74 -37
  98. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  99. package/dist/modules/tool-manuals/tool-manuals.tools.js +6 -3
  100. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  101. package/dist/modules/workflow/git/git.service.js +2 -2
  102. package/dist/modules/workflow/git/git.service.js.map +1 -1
  103. package/dist/modules/workspace/kb-seed.service.d.ts +2 -2
  104. package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -1
  105. package/dist/modules/workspace/kb-seed.service.js +43 -10
  106. package/dist/modules/workspace/kb-seed.service.js.map +1 -1
  107. package/dist/modules/workspace/plugins-migration.d.ts +50 -0
  108. package/dist/modules/workspace/plugins-migration.d.ts.map +1 -0
  109. package/dist/modules/workspace/plugins-migration.js +379 -0
  110. package/dist/modules/workspace/plugins-migration.js.map +1 -0
  111. package/dist/modules/workspace/workspace.routes.js +3 -3
  112. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  113. package/dist/modules/workspace/workspace.service.d.ts +26 -0
  114. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  115. package/dist/modules/workspace/workspace.service.js +83 -12
  116. package/dist/modules/workspace/workspace.service.js.map +1 -1
  117. package/dist/shared/kb-layout.test.js +3 -3
  118. package/dist/shared/kb-layout.test.js.map +1 -1
  119. package/dist/shared/utcp-namespace.d.ts +6 -27
  120. package/dist/shared/utcp-namespace.d.ts.map +1 -1
  121. package/dist/shared/utcp-namespace.js +6 -63
  122. package/dist/shared/utcp-namespace.js.map +1 -1
  123. package/dist/shared/variable-refs.d.ts +42 -0
  124. package/dist/shared/variable-refs.d.ts.map +1 -0
  125. package/dist/shared/variable-refs.js +60 -0
  126. package/dist/shared/variable-refs.js.map +1 -0
  127. package/kb-template/.bevelignore +1 -1
  128. package/kb-template/AGENTS.md +88 -35
  129. package/kb-template/KnowledgeBase/How to get started.md +10 -10
  130. package/kb-template/access.md +36 -36
  131. package/migrations/meta/0000_snapshot.json +1479 -1479
  132. package/package.json +5 -4
  133. package/src/assets.ts +25 -25
  134. package/src/core/core-ports.ts +106 -106
  135. package/src/core/create-core-server.ts +55 -9
  136. package/src/core/create-core-services.ts +40 -20
  137. package/src/index.ts +69 -69
  138. package/src/modules/access/__tests__/access-control.atref-batch.test.ts +98 -98
  139. package/src/modules/access/__tests__/access-declarations.test.ts +28 -28
  140. package/src/modules/access/__tests__/access-md-format.test.ts +18 -18
  141. package/src/modules/access/__tests__/access-mutation.service.test.ts +5 -5
  142. package/src/modules/access/__tests__/access-splice.test.ts +2 -2
  143. package/src/modules/access/__tests__/access.routes.overrides.test.ts +16 -16
  144. package/src/modules/access/__tests__/grant-sources.test.ts +12 -12
  145. package/src/modules/access/__tests__/roles-admin.service.test.ts +13 -1
  146. package/src/modules/access/access-control.interface.ts +8 -7
  147. package/src/modules/access/access-control.service.ts +7 -7
  148. package/src/modules/access/access-declarations.ts +5 -5
  149. package/src/modules/access/access-mutation.service.ts +3 -3
  150. package/src/modules/access/access-splice.ts +4 -4
  151. package/src/modules/access/access.routes.ts +20 -20
  152. package/src/modules/access/creator-access.ts +5 -5
  153. package/src/modules/access/roles-admin.service.ts +13 -2
  154. package/src/modules/admin/admin-access.routes.ts +29 -29
  155. package/src/modules/auth/__tests__/auth.routes.test.ts +91 -91
  156. package/src/modules/auth/__tests__/rate-limit.test.ts +36 -36
  157. package/src/modules/auth/rate-limit.ts +45 -45
  158. package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +67 -0
  159. package/src/modules/code-mode/code-mode-names.ts +10 -36
  160. package/src/modules/code-mode/code-mode.tool.ts +27 -7
  161. package/src/modules/database/connection.ts +15 -15
  162. package/src/modules/database/schema.ts +11 -11
  163. package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +150 -150
  164. package/src/modules/mcp/mcp.service.ts +57 -435
  165. package/src/modules/{groups → plugins}/__tests__/join-proposals.test.ts +1 -1
  166. package/src/modules/{groups → plugins}/__tests__/join-requests.service.test.ts +7 -7
  167. package/src/modules/{groups/__tests__/group-index.service.test.ts → plugins/__tests__/plugin-index.service.test.ts} +41 -41
  168. package/src/modules/plugins/__tests__/plugin-provision.service.test.ts +312 -0
  169. package/src/modules/{groups/__tests__/groups.routes.test.ts → plugins/__tests__/plugins.routes.test.ts} +100 -100
  170. package/src/modules/plugins/index.ts +17 -0
  171. package/src/modules/{groups → plugins}/join-proposals.ts +2 -2
  172. package/src/modules/{groups → plugins}/join-requests.service.ts +8 -8
  173. package/src/modules/{groups/group-provision.service.ts → plugins/plugin-provision.service.ts} +139 -69
  174. package/src/modules/{groups/groups.contract.ts → plugins/plugins.contract.ts} +26 -26
  175. package/src/modules/{groups/groups.routes.ts → plugins/plugins.routes.ts} +102 -102
  176. package/src/modules/{groups/groups.service.ts → plugins/plugins.service.ts} +43 -43
  177. package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +143 -143
  178. package/src/modules/skills/__tests__/pending-skills.service.test.ts +14 -14
  179. package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
  180. package/src/modules/skills/pending-skills.service.ts +7 -7
  181. package/src/modules/skills/skills.contract.ts +4 -4
  182. package/src/modules/skills/skills.service.ts +5 -5
  183. package/src/modules/tool-auth/llm-usage-meter.ts +19 -19
  184. package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +198 -0
  185. package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +346 -0
  186. package/src/modules/tool-manuals/__tests__/tool-manuals.archive.route.test.ts +101 -0
  187. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +3 -3
  188. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +2 -2
  189. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +27 -27
  190. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +3 -3
  191. package/src/modules/tool-manuals/mcp-json-discovery.ts +328 -0
  192. package/src/modules/tool-manuals/mcp-server-edit.service.ts +434 -0
  193. package/src/modules/tool-manuals/tool-manuals.contract.ts +35 -12
  194. package/src/modules/tool-manuals/tool-manuals.routes.ts +222 -1
  195. package/src/modules/tool-manuals/tool-manuals.service.ts +82 -42
  196. package/src/modules/tool-manuals/tool-manuals.tools.ts +6 -3
  197. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +1 -1
  198. package/src/modules/workflow/git/__tests__/branch-name.test.ts +3 -3
  199. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +3 -3
  200. package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +13 -8
  201. package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +1 -1
  202. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +1 -1
  203. package/src/modules/workflow/git/git.service.ts +2 -2
  204. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +1 -1
  205. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +1 -1
  206. package/src/modules/workflow/workflow-hooks.ts +101 -101
  207. package/src/modules/workspace/__tests__/kb-seed.service.test.ts +81 -13
  208. package/src/modules/workspace/__tests__/plugins-migration.test.ts +427 -0
  209. package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +237 -237
  210. package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +236 -236
  211. package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +179 -179
  212. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +320 -320
  213. package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +337 -337
  214. package/src/modules/workspace/__tests__/workspace.service.test.ts +116 -0
  215. package/src/modules/workspace/bevel-ignore.ts +66 -66
  216. package/src/modules/workspace/kb-seed.service.ts +38 -9
  217. package/src/modules/workspace/plugins-migration.ts +479 -0
  218. package/src/modules/workspace/session-sink.ts +25 -25
  219. package/src/modules/workspace/workspace.routes.ts +3 -3
  220. package/src/modules/workspace/workspace.service.ts +85 -14
  221. package/src/modules/workspace/workspace.tools.ts +922 -922
  222. package/src/shared/__tests__/join-request.test.ts +13 -13
  223. package/src/shared/__tests__/kb-layout.plugin.test.ts +45 -0
  224. package/src/shared/kb-layout.test.ts +3 -3
  225. package/src/shared/utcp-namespace.ts +10 -68
  226. package/src/shared/variable-refs.ts +64 -0
  227. package/src/modules/groups/__tests__/group-provision.service.test.ts +0 -247
  228. package/src/modules/groups/index.ts +0 -17
  229. package/src/shared/__tests__/kb-layout.group.test.ts +0 -45
  230. /package/kb-template/{Groups → Plugins}/.gitkeep +0 -0
@@ -0,0 +1,434 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import {
4
+ DEFAULT_BRANCH,
5
+ HEXIS_EXTENSION_NS,
6
+ PLUGINS_DIR,
7
+ PLUGIN_MANIFEST_FILE,
8
+ PLUGIN_MCP_FILE,
9
+ type AuthUser,
10
+ } from '@bevel-software/platform-shared';
11
+ import { workspaceIdForBranch, type WorkspaceService } from '../workspace/workspace.service.js';
12
+ import { validatedVariables } from './mcp-json-discovery.js';
13
+ import { assertSafeFetchUrl } from '../../shared/ssrf.js';
14
+ import { containsVariableReference, findReservedVariableRef } from '../../shared/variable-refs.js';
15
+ import type { IAccessControl } from '../access/access-control.interface.js';
16
+ import type { IToolManualService, ToolVariable } from './tool-manuals.contract.js';
17
+
18
+ /**
19
+ * Server-scoped editing of one MCP server — the form the tool page uses
20
+ * instead of dropping a writer into raw `mcp.json`.
21
+ *
22
+ * One server's truth spans TWO files (the spec's split, not ours): the
23
+ * portable half in `mcp.json` (transport, url, literal headers) and our half
24
+ * in `plugin.json`'s `extensions["software.bevel.hexis"].mcpServers[<name>]`
25
+ * (auth headers carrying `${VAR}` vault references, variable declarations,
26
+ * description, `local`). This service is the ONE writer that keeps the two in
27
+ * step: a save rewrites both entries and commits them together, because a
28
+ * server whose auth landed without its url — or the reverse — is a broken
29
+ * tool with no author to blame.
30
+ *
31
+ * RENAME IS DESTRUCTIVE BY DESIGN and the route says so: the `mcpServers` key
32
+ * is the namespace vault secrets bind to (`<name>_<VAR>`), so renaming a
33
+ * server orphans every configured secret and completed sign-in under the old
34
+ * key. The frontend counts what disconnects (it already holds per-variable
35
+ * status) and confirms; this service just refuses a rename onto a taken key.
36
+ */
37
+
38
+ export type McpTransport = 'streamable-http' | 'sse' | 'stdio';
39
+
40
+ export interface McpServerView {
41
+ name: string;
42
+ transport: McpTransport;
43
+ url?: string;
44
+ command?: string;
45
+ args?: string[];
46
+ cwd?: string;
47
+ env?: Record<string, string>;
48
+ /** Portable headers, stored in mcp.json. Never carry a `${VAR}`. */
49
+ literalHeaders: Record<string, string>;
50
+ /** Auth headers with vault references, stored in the extensions block. */
51
+ authHeaders: Record<string, string>;
52
+ variables: ToolVariable[];
53
+ description?: string;
54
+ local: boolean;
55
+ canWrite: boolean;
56
+ }
57
+
58
+ /**
59
+ * PATCH semantics, field by field: `undefined` means "the client did not
60
+ * surface this field" and the stored value survives; a present-but-empty
61
+ * value (`{}`, `[]`, `''`) is an explicit clear. A full-replace contract
62
+ * would make every client responsible for echoing back fields it does not
63
+ * edit — and the one that forgot would silently destroy the literal headers
64
+ * in mcp.json or the variable declarations behind `${VAR}` references.
65
+ */
66
+ export interface McpServerWrite {
67
+ newName?: string;
68
+ transport: McpTransport;
69
+ url?: string;
70
+ command?: string;
71
+ args?: string[];
72
+ cwd?: string;
73
+ env?: Record<string, string>;
74
+ literalHeaders?: Record<string, string>;
75
+ authHeaders?: Record<string, string>;
76
+ variables?: ToolVariable[];
77
+ description?: string;
78
+ local?: boolean;
79
+ }
80
+
81
+ export class McpServerEditError extends Error {
82
+ constructor(
83
+ message: string,
84
+ readonly status: number,
85
+ ) {
86
+ super(message);
87
+ this.name = 'McpServerEditError';
88
+ }
89
+ }
90
+
91
+ /** Same shape the discovery accepts: the key is a namespace, not prose. */
92
+ const SERVER_NAME_RE = /^[a-z0-9][a-z0-9_-]*$/;
93
+
94
+ // The substitutor's own grammar (shared/variable-refs.ts) decides what is a
95
+ // reference: `${A-B}` is not (stays a literal header), `$5` is (digit-leading
96
+ // names are legal). The migration's header split applies the same predicate.
97
+ const hasVarRef = containsVariableReference;
98
+
99
+ function isRecord(v: unknown): v is Record<string, unknown> {
100
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
101
+ }
102
+
103
+ /**
104
+ * The same reserved-reference policy discovery enforces, applied at SAVE: a
105
+ * `${…API_URL}`/`${…CONNECTION_KEY}` persisted here would make discovery drop
106
+ * the server on its next scan — an editor that can save self-invalidating
107
+ * config is worse than a 422 naming the reference. The same shared grammar
108
+ * both sides scan with is what makes "saveable" and "discoverable" agree.
109
+ */
110
+ const findReservedRef = findReservedVariableRef;
111
+
112
+ interface CommitDriver {
113
+ runPendingCommit(
114
+ workspaceId: string,
115
+ branch: string,
116
+ targetPath: string,
117
+ user: AuthUser,
118
+ opts?: { systemAuthorized?: boolean },
119
+ ): Promise<void>;
120
+ }
121
+
122
+ export class McpServerEditService {
123
+ constructor(
124
+ private readonly workspaceService: WorkspaceService,
125
+ private readonly commits: CommitDriver,
126
+ private readonly accessControl: IAccessControl,
127
+ private readonly toolManuals: IToolManualService,
128
+ private readonly kbDirName: string,
129
+ ) {}
130
+
131
+ /** The merged view of one server, or null when unknown/unreadable (indistinguishable, fail closed). */
132
+ async getServer(userEmail: string, slug: string): Promise<McpServerView | null> {
133
+ const located = await this.locate(userEmail, slug);
134
+ if (!located) return null;
135
+ const { folder, name, wsId, mcpJsonPath } = located;
136
+ const { mcp, manifest } = await this.readFiles(folder);
137
+ const entry = (mcp?.mcpServers as Record<string, unknown> | undefined)?.[name];
138
+ if (!entry || typeof entry !== 'object') return null;
139
+ const raw = entry as Record<string, unknown>;
140
+ const ext = this.extensionEntry(manifest, name);
141
+ return {
142
+ name,
143
+ transport: (raw.type as McpTransport) ?? 'streamable-http',
144
+ ...(typeof raw.url === 'string' ? { url: raw.url } : {}),
145
+ ...(typeof raw.command === 'string' ? { command: raw.command } : {}),
146
+ ...(Array.isArray(raw.args) ? { args: raw.args.map(String) } : {}),
147
+ ...(typeof raw.cwd === 'string' ? { cwd: raw.cwd } : {}),
148
+ ...(raw.env && typeof raw.env === 'object' ? { env: raw.env as Record<string, string> } : {}),
149
+ // Shape-checked, not merely null-coalesced: both files are hand-editable
150
+ // knowledge-base content, and a string where an object/array belongs
151
+ // would flow into the form's Object.entries/`.map` as nonsense.
152
+ literalHeaders: isRecord(raw.headers) ? (raw.headers as Record<string, string>) : {},
153
+ authHeaders: isRecord(ext.headers) ? (ext.headers as Record<string, string>) : {},
154
+ variables: Array.isArray(ext.variables) ? (ext.variables as ToolVariable[]) : [],
155
+ ...(typeof ext.description === 'string' ? { description: ext.description } : {}),
156
+ local: ext.local === true || raw.type === 'stdio',
157
+ canWrite: await this.accessControl.canWrite(wsId, userEmail, mcpJsonPath),
158
+ };
159
+ }
160
+
161
+ /** Rewrite one server across both files, in one commit. Returns the (possibly new) name. */
162
+ async putServer(user: AuthUser, slug: string, write: McpServerWrite): Promise<{ name: string }> {
163
+ const located = await this.locate(user.email, slug);
164
+ if (!located) throw new McpServerEditError('No such tool.', 404);
165
+ const { folder, name, wsId, mcpJsonPath } = located;
166
+ if (!(await this.accessControl.canWrite(wsId, user.email, mcpJsonPath))) {
167
+ throw new McpServerEditError("You don't have permission to edit this plugin's servers.", 403);
168
+ }
169
+ const { mcp, manifest, mcpAbs, manifestAbs } = await this.readFiles(folder);
170
+ if (!mcp?.mcpServers || typeof mcp.mcpServers !== 'object') {
171
+ throw new McpServerEditError(`${PLUGIN_MCP_FILE} is missing or unparsable — fix the file first.`, 422);
172
+ }
173
+ const servers = mcp.mcpServers as Record<string, unknown>;
174
+ if (!(name in servers)) throw new McpServerEditError('No such server.', 404);
175
+
176
+ if (write.transport !== 'streamable-http' && write.transport !== 'sse' && write.transport !== 'stdio') {
177
+ // Persisting an unknown transport would save an entry discovery then
178
+ // refuses — an unusable server with no error at the moment it was made.
179
+ throw new McpServerEditError(`Unknown transport "${String(write.transport)}".`, 422);
180
+ }
181
+ // The EFFECTIVE value of every field, PATCH-over-stored (see
182
+ // McpServerWrite): `undefined` keeps what the files already say, a
183
+ // present value — empty included — replaces it.
184
+ const prior = isRecord(servers[name]) ? (servers[name] as Record<string, unknown>) : {};
185
+ const priorExt = this.extensionEntry(manifest, name);
186
+ const url = write.url ?? (typeof prior.url === 'string' ? prior.url : undefined);
187
+ const command = write.command ?? (typeof prior.command === 'string' ? prior.command : undefined);
188
+ const args = write.args ?? (Array.isArray(prior.args) ? prior.args.map(String) : undefined);
189
+ const env = write.env ?? (isRecord(prior.env) ? (prior.env as Record<string, string>) : undefined);
190
+ const cwd = write.cwd ?? (typeof prior.cwd === 'string' ? prior.cwd : undefined);
191
+ const literalIn =
192
+ write.literalHeaders ?? (isRecord(prior.headers) ? (prior.headers as Record<string, string>) : {});
193
+ const authIn =
194
+ write.authHeaders ?? (isRecord(priorExt.headers) ? (priorExt.headers as Record<string, string>) : {});
195
+ // RAW on the stored side, no shape fallback: coercing malformed stored
196
+ // declarations to [] here would let a save that never touched variables
197
+ // silently CLEAR them — the validator below owes that case a 422, same
198
+ // as any other malformed input.
199
+ const variablesIn = write.variables ?? priorExt.variables;
200
+ // Discovery's OWN validator, at save time (like the reserved-ref check
201
+ // below): a malformed declaration — bad name, duplicate, re-declared
202
+ // platform name, oauth on a shared scope or with a broken provider —
203
+ // persisted here would make discovery drop the whole server on its next
204
+ // scan. What comes back is the normalized form discovery would read, and
205
+ // that is what gets stored.
206
+ const variables = validatedVariables(variablesIn);
207
+ if (variables === null) {
208
+ throw new McpServerEditError(
209
+ 'The `variables` declaration is malformed — each entry needs a unique, non-reserved ' +
210
+ 'alphanumeric/underscore name, a scope of `admin` or `user`, and any `oauth` block must sit ' +
211
+ 'on a `user`-scoped variable with valid https provider URLs and a client id.',
212
+ 422,
213
+ );
214
+ }
215
+ const description =
216
+ write.description ?? (typeof priorExt.description === 'string' ? priorExt.description : undefined);
217
+ const local = write.local ?? (priorExt.local === true);
218
+
219
+ // The WHOLE writable surface, mirroring what discovery scans — effective
220
+ // values, not just the incoming patch: a reserved ref smuggled through a
221
+ // stdio arg (`--url=${X_API_URL}`), env value, cwd or description would
222
+ // save with a 200 and then vanish from the catalog on the next scan —
223
+ // the exact self-invalidating state this 422 prevents.
224
+ const reservedRef = findReservedRef({
225
+ url,
226
+ command,
227
+ args,
228
+ env,
229
+ cwd,
230
+ description,
231
+ literalHeaders: literalIn,
232
+ authHeaders: authIn,
233
+ variables,
234
+ });
235
+ if (reservedRef !== null) {
236
+ throw new McpServerEditError(
237
+ `"${reservedRef}" references a platform-seeded variable (API_URL / CONNECTION_KEY) — ` +
238
+ 'discovery refuses servers that name them, so this cannot be saved.',
239
+ 422,
240
+ );
241
+ }
242
+ const target = write.newName?.trim() || name;
243
+ if (!SERVER_NAME_RE.test(target)) {
244
+ throw new McpServerEditError(
245
+ 'A server name is its secret namespace: lowercase alphanumeric with `_`/`-`.',
246
+ 422,
247
+ );
248
+ }
249
+ if (target !== name && target in servers) {
250
+ throw new McpServerEditError(`A server named "${target}" already exists here.`, 409);
251
+ }
252
+
253
+ // Defense in depth on the portable file: a `${VAR}` in mcp.json would be
254
+ // transmitted literally by a conformant client AND violate the spec's
255
+ // no-credentials rule — reroute it to the extensions block instead of
256
+ // trusting the frontend's split.
257
+ const literal: Record<string, string> = {};
258
+ const auth: Record<string, string> = { ...authIn };
259
+ for (const [k, v] of Object.entries(literalIn)) {
260
+ if (hasVarRef(v)) auth[k] = v;
261
+ else literal[k] = v;
262
+ }
263
+
264
+ let entry: Record<string, unknown>;
265
+ if (write.transport === 'stdio') {
266
+ const cmd = command?.trim();
267
+ if (!cmd) throw new McpServerEditError('A stdio server needs a command.', 422);
268
+ entry = {
269
+ type: 'stdio',
270
+ command: cmd,
271
+ ...(args && args.length > 0 ? { args } : {}),
272
+ ...(env && Object.keys(env).length > 0 ? { env } : {}),
273
+ ...(cwd ? { cwd } : {}),
274
+ };
275
+ } else {
276
+ let parsed: URL;
277
+ try {
278
+ parsed = new URL(url ?? '');
279
+ } catch {
280
+ throw new McpServerEditError('The server needs a valid http(s) URL.', 422);
281
+ }
282
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
283
+ throw new McpServerEditError('The server needs a valid http(s) URL.', 422);
284
+ }
285
+ // The same SSRF gate discovery applies on read: without it here, a save
286
+ // succeeds and the server then silently disappears from the catalog —
287
+ // an edit flow that eats its own output. Local-only servers are exempt;
288
+ // loopback is what local means.
289
+ if (local !== true) {
290
+ try {
291
+ assertSafeFetchUrl(url ?? '', { label: 'Server URL' });
292
+ } catch (err) {
293
+ throw new McpServerEditError(
294
+ `${err instanceof Error ? err.message : 'The URL is not reachable from the workspace'} — ` +
295
+ 'mark the server local-only if it is deliberately private.',
296
+ 422,
297
+ );
298
+ }
299
+ }
300
+ entry = {
301
+ type: write.transport,
302
+ url,
303
+ ...(Object.keys(literal).length > 0 ? { headers: literal } : {}),
304
+ };
305
+ }
306
+
307
+ if (target !== name) delete servers[name];
308
+ servers[target] = entry;
309
+
310
+ // The extensions half: rewrite our entry, drop the old key on rename.
311
+ // A missing/unparsable manifest refuses the save when there is auth to
312
+ // store — silently dropping a credential mapping is worse than an error.
313
+ const extEntry = {
314
+ ...(Object.keys(auth).length > 0 ? { headers: auth } : {}),
315
+ ...(variables.length > 0 ? { variables } : {}),
316
+ ...(description ? { description } : {}),
317
+ ...(local === true && write.transport !== 'stdio' ? { local: true } : {}),
318
+ };
319
+ if (manifest === null && Object.keys(extEntry).length > 0) {
320
+ throw new McpServerEditError(`${PLUGIN_MANIFEST_FILE} is missing or unparsable — fix the file first.`, 422);
321
+ }
322
+ if (manifest !== null) {
323
+ // A wrong-TYPED extensions chain (a string, an array) would throw a
324
+ // TypeError below and surface as a 500. It is also not ours to silently
325
+ // replace: an array `extensions` may be another tool's data, malformed
326
+ // or not, and a save aimed at one server should not discard it.
327
+ const badShape = (v: unknown): boolean => v !== undefined && (typeof v !== 'object' || v === null || Array.isArray(v));
328
+ const ext = (manifest.extensions ??= {}) as Record<string, unknown>;
329
+ if (badShape(manifest.extensions)) {
330
+ throw new McpServerEditError(`${PLUGIN_MANIFEST_FILE} has a malformed \`extensions\` block — fix the file first.`, 422);
331
+ }
332
+ if (badShape(ext[HEXIS_EXTENSION_NS]) || badShape((ext[HEXIS_EXTENSION_NS] as Record<string, unknown> | undefined)?.mcpServers)) {
333
+ throw new McpServerEditError(`${PLUGIN_MANIFEST_FILE} has a malformed \`extensions\` block — fix the file first.`, 422);
334
+ }
335
+ const ns = (ext[HEXIS_EXTENSION_NS] ??= {}) as Record<string, unknown>;
336
+ const extServers = (ns.mcpServers ??= {}) as Record<string, unknown>;
337
+ delete extServers[name];
338
+ if (Object.keys(extEntry).length > 0) extServers[target] = extEntry;
339
+ }
340
+
341
+ // All-or-nothing for real: snapshot both files first, and on ANY failure
342
+ // past the first write put the originals back — the API reporting failure
343
+ // while the workspace keeps half the edit is the state this exists to
344
+ // prevent.
345
+ // Rollback scope is the WRITES only. A failed write restores both files
346
+ // (a half-written pair is the state this exists to prevent); a failed
347
+ // COMMIT must not — the pending-commit pipeline may already have created
348
+ // a local commit before the push refused, and rewriting the working tree
349
+ // to pre-edit bytes would leave it dirty AGAINST that commit, a state the
350
+ // workflow layer's own pull-rebase recovery then misreads. Commit-stage
351
+ // failures propagate as-is and the pipeline's recovery owns the cleanup.
352
+ const [mcpBefore, manifestBefore] = await Promise.all([
353
+ fs.readFile(mcpAbs, 'utf8').catch(() => null),
354
+ fs.readFile(manifestAbs, 'utf8').catch(() => null),
355
+ ]);
356
+ try {
357
+ await fs.writeFile(mcpAbs, `${JSON.stringify(mcp, null, 2)}\n`, 'utf8');
358
+ if (manifest !== null) {
359
+ await fs.writeFile(manifestAbs, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
360
+ }
361
+ } catch (err) {
362
+ if (mcpBefore !== null) await fs.writeFile(mcpAbs, mcpBefore, 'utf8').catch(() => {});
363
+ if (manifestBefore !== null) await fs.writeFile(manifestAbs, manifestBefore, 'utf8').catch(() => {});
364
+ throw err;
365
+ }
366
+ // One folder-scoped commit, ungated beyond the caller's own write access —
367
+ // both files or neither. The catalog cache is stale the moment it lands.
368
+ await this.commits.runPendingCommit(
369
+ workspaceIdForBranch(DEFAULT_BRANCH),
370
+ DEFAULT_BRANCH,
371
+ `${this.kbDirName}/${PLUGINS_DIR}/${folder}`,
372
+ user,
373
+ );
374
+ this.toolManuals.invalidate();
375
+ return { name: target };
376
+ }
377
+
378
+ /** Resolve a slug to an mcp.json-backed server the caller can READ; null otherwise. */
379
+ private async locate(
380
+ userEmail: string,
381
+ slug: string,
382
+ ): Promise<{ folder: string; name: string; wsId: string; mcpJsonPath: string } | null> {
383
+ const summaries = await this.toolManuals.listAccessible(userEmail);
384
+ const found = summaries.find((s) => s.slug === slug);
385
+ if (!found || found.type !== 'mcp' || !found.path.endsWith(`/${PLUGIN_MCP_FILE}`)) return null;
386
+ const segments = found.path.split('/');
387
+ const folder = segments[1];
388
+ if (!folder) return null;
389
+ return {
390
+ folder,
391
+ name: found.name,
392
+ wsId: workspaceIdForBranch(DEFAULT_BRANCH),
393
+ mcpJsonPath: found.path,
394
+ };
395
+ }
396
+
397
+ private async readFiles(folder: string): Promise<{
398
+ mcp: Record<string, unknown> | null;
399
+ manifest: Record<string, unknown> | null;
400
+ mcpAbs: string;
401
+ manifestAbs: string;
402
+ }> {
403
+ const wsId = workspaceIdForBranch(DEFAULT_BRANCH);
404
+ await this.workspaceService.getOrCreateForBranch(DEFAULT_BRANCH);
405
+ const wsDir = await this.workspaceService.getWorkspacePath(wsId);
406
+ const pluginDir = path.join(wsDir, this.kbDirName, PLUGINS_DIR, folder);
407
+ const mcpAbs = path.join(pluginDir, PLUGIN_MCP_FILE);
408
+ const manifestAbs = path.join(pluginDir, PLUGIN_MANIFEST_FILE);
409
+ return {
410
+ mcp: await readJson(mcpAbs),
411
+ manifest: await readJson(manifestAbs),
412
+ mcpAbs,
413
+ manifestAbs,
414
+ };
415
+ }
416
+
417
+ private extensionEntry(manifest: Record<string, unknown> | null, name: string): Record<string, unknown> {
418
+ const ns = (manifest?.extensions as Record<string, unknown> | undefined)?.[HEXIS_EXTENSION_NS];
419
+ const servers = (ns as Record<string, unknown> | undefined)?.mcpServers;
420
+ const entry = (servers as Record<string, unknown> | undefined)?.[name];
421
+ return entry && typeof entry === 'object' ? (entry as Record<string, unknown>) : {};
422
+ }
423
+ }
424
+
425
+ async function readJson(p: string): Promise<Record<string, unknown> | null> {
426
+ try {
427
+ const parsed: unknown = JSON.parse(await fs.readFile(p, 'utf-8'));
428
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
429
+ ? (parsed as Record<string, unknown>)
430
+ : null;
431
+ } catch {
432
+ return null;
433
+ }
434
+ }
@@ -1,7 +1,7 @@
1
1
  import type { CallTemplate } from '@utcp/sdk';
2
2
 
3
3
  /**
4
- * Tool manuals — user-authored `*.tool` files under `Groups/` in the DEFAULT
4
+ * Tool manuals — user-authored `*.tool` files under `Plugins/` in the DEFAULT
5
5
  * branch KB. Each is a UTCP *manual* (a pointer to where tools come from), not a
6
6
  * flattened tool list. Access-controlled exactly like Skills (default-deny ACL
7
7
  * on the `.tool` file path). The MCP/UTCP endpoint serves every manual a user
@@ -85,8 +85,8 @@ export interface ToolManualSetup {
85
85
  reason?: string;
86
86
  }
87
87
 
88
- /** A parsed `.tool` file (normalized). */
89
- export interface ToolManualDescriptor {
88
+ /** Fields common to every parsed manual — see {@link ToolManualDescriptor}. */
89
+ export interface ToolManualDescriptorBase {
90
90
  /** URL-safe id derived from the file name (route `:slug`), unique in the catalog. */
91
91
  slug: string;
92
92
  /**
@@ -95,7 +95,7 @@ export interface ToolManualDescriptor {
95
95
  * catalog; namespacing doubles underscores in vault keys (`utcpNamespacedKey`).
96
96
  */
97
97
  name: string;
98
- /** Repo-root-relative path of the `.tool` file (e.g. `Groups/Everyone/weather.tool`). */
98
+ /** Repo-root-relative path of the `.tool` file (e.g. `Plugins/Everyone/weather.tool`). */
99
99
  path: string;
100
100
  type: ToolManualType;
101
101
  /**
@@ -113,17 +113,40 @@ export interface ToolManualDescriptor {
113
113
  tools?: unknown[];
114
114
  /** Declared `${VAR}` scopes (see {@link ToolVariable}); empty when none declared. */
115
115
  variables?: ToolVariable[];
116
- /**
117
- * Whether this tool can run for a REMOTE consumer (Bevel's hosted MCP proxy).
118
- * Absent/`true` ⇒ available remotely; `false` ⇒ LOCAL-ONLY — the remote endpoint
119
- * skips it (it can't reach e.g. a `localhost` MCP server), and instead surfaces
120
- * its path via the `list_local_tools` tool so a local agent can self-configure it.
121
- */
122
- remote?: boolean;
123
116
  /** For `type: mcp`: the admin-facing setup requirement from auto-discovery. */
124
117
  setup?: ToolManualSetup;
125
118
  }
126
119
 
120
+ /** The spawn spec of a stdio-declared MCP server (a plugin `mcp.json` entry). */
121
+ export interface ToolManualStdioSpec {
122
+ command: string;
123
+ args: string[];
124
+ env?: Record<string, string>;
125
+ cwd?: string;
126
+ }
127
+
128
+ /**
129
+ * A parsed manual (a `.tool` file or a plugin `mcp.json` entry, normalized).
130
+ *
131
+ * `remote` — whether the tool can run for a REMOTE consumer (Bevel's hosted
132
+ * MCP proxy). Absent/`true` ⇒ available remotely; `false` ⇒ LOCAL-ONLY — the
133
+ * remote endpoint skips it (it can't reach e.g. a `localhost` MCP server),
134
+ * and instead surfaces its path via the `list_local_tools` tool so a local
135
+ * agent can self-configure it.
136
+ *
137
+ * `stdio` — for a `type: mcp` server declared with a stdio transport in a
138
+ * plugin's `mcp.json`: the spawn spec. A UNION, not two optional fields,
139
+ * because a stdio spec REQUIRES `remote: false` — the hosted proxy can never
140
+ * spawn a subprocess out of knowledge-base content, so stdio servers are
141
+ * served only to local consumers, which run them per the Agent Plugins
142
+ * runtime contract (PLUGIN_ROOT/PLUGIN_DATA, `./` containment) — and the
143
+ * type refusing `remote: true` beside a spawn spec is what keeps every
144
+ * producer honest about that.
145
+ */
146
+ export type ToolManualDescriptor =
147
+ | (ToolManualDescriptorBase & { remote?: boolean; stdio?: undefined })
148
+ | (ToolManualDescriptorBase & { type: 'mcp'; remote: false; stdio: ToolManualStdioSpec });
149
+
127
150
  /** A validated UTCP manual dict (`{ utcp_version, manual_version, tools }`). */
128
151
  export type UtcpManualDict = Record<string, unknown>;
129
152
 
@@ -188,7 +211,7 @@ export interface IToolManualService {
188
211
  /**
189
212
  * Every manual in the catalog, UNFILTERED by access — the mirror of
190
213
  * `skillService.listSkills(undefined)`. For caller-INDEPENDENT counting only
191
- * (the group index's "N tools", which a non-member is allowed to see as a
214
+ * (the plugin index's "N tools", which a non-member is allowed to see as a
192
215
  * number). Never surface a name, path or description from this to someone
193
216
  * who cannot read the file; `listAccessible` is the surface for that.
194
217
  */