@bevel-software/platform-core-backend 0.7.5 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -202
- package/THIRD-PARTY-NOTICES.md +428 -454
- package/dist/core/core-ports.d.ts +8 -1
- package/dist/core/core-ports.d.ts.map +1 -1
- package/dist/core/core-ports.js.map +1 -1
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +75 -18
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +12 -5
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +69 -36
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +8 -7
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +1 -1
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +6 -6
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access-declarations.d.ts +5 -5
- package/dist/modules/access/access-declarations.js +3 -3
- package/dist/modules/access/access-mutation.service.d.ts +3 -3
- package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
- package/dist/modules/access/access-mutation.service.js +3 -3
- package/dist/modules/access/access-mutation.service.js.map +1 -1
- package/dist/modules/access/access-splice.js +4 -4
- package/dist/modules/access/access-splice.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts +3 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +22 -20
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/creator-access.d.ts +2 -2
- package/dist/modules/access/creator-access.js +5 -5
- package/dist/modules/access/creator-access.js.map +1 -1
- package/dist/modules/access/render-roles-yaml.d.ts +22 -0
- package/dist/modules/access/render-roles-yaml.d.ts.map +1 -0
- package/dist/modules/access/render-roles-yaml.js +56 -0
- package/dist/modules/access/render-roles-yaml.js.map +1 -0
- package/dist/modules/access/roles-admin.service.d.ts +33 -5
- package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
- package/dist/modules/access/roles-admin.service.js +44 -19
- package/dist/modules/access/roles-admin.service.js.map +1 -1
- package/dist/modules/code-mode/code-mode-names.d.ts +5 -13
- package/dist/modules/code-mode/code-mode-names.d.ts.map +1 -1
- package/dist/modules/code-mode/code-mode-names.js +5 -27
- package/dist/modules/code-mode/code-mode-names.js.map +1 -1
- package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
- package/dist/modules/code-mode/code-mode.tool.js +29 -7
- package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +11 -52
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +33 -395
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/plugins/index.d.ts +7 -0
- package/dist/modules/plugins/index.d.ts.map +1 -0
- package/dist/modules/plugins/index.js +6 -0
- package/dist/modules/plugins/index.js.map +1 -0
- package/dist/modules/plugins/join-proposals.d.ts +53 -0
- package/dist/modules/plugins/join-proposals.d.ts.map +1 -0
- package/dist/modules/plugins/join-proposals.js +67 -0
- package/dist/modules/plugins/join-proposals.js.map +1 -0
- package/dist/modules/plugins/join-requests.service.d.ts +81 -0
- package/dist/modules/plugins/join-requests.service.d.ts.map +1 -0
- package/dist/modules/plugins/join-requests.service.js +135 -0
- package/dist/modules/plugins/join-requests.service.js.map +1 -0
- package/dist/modules/plugins/plugin-provision.service.d.ts +134 -0
- package/dist/modules/plugins/plugin-provision.service.d.ts.map +1 -0
- package/dist/modules/plugins/plugin-provision.service.js +344 -0
- package/dist/modules/plugins/plugin-provision.service.js.map +1 -0
- package/dist/modules/plugins/plugins.contract.d.ts +106 -0
- package/dist/modules/plugins/plugins.contract.d.ts.map +1 -0
- package/dist/modules/plugins/plugins.contract.js +36 -0
- package/dist/modules/plugins/plugins.contract.js.map +1 -0
- package/dist/modules/plugins/plugins.routes.d.ts +42 -0
- package/dist/modules/plugins/plugins.routes.d.ts.map +1 -0
- package/dist/modules/plugins/plugins.routes.js +379 -0
- package/dist/modules/plugins/plugins.routes.js.map +1 -0
- package/dist/modules/plugins/plugins.service.d.ts +60 -0
- package/dist/modules/plugins/plugins.service.d.ts.map +1 -0
- package/dist/modules/plugins/plugins.service.js +172 -0
- package/dist/modules/plugins/plugins.service.js.map +1 -0
- package/dist/modules/settings/setup.routes.d.ts +10 -1
- package/dist/modules/settings/setup.routes.d.ts.map +1 -1
- package/dist/modules/settings/setup.routes.js +111 -6
- package/dist/modules/settings/setup.routes.js.map +1 -1
- package/dist/modules/skills/pending-skills.service.d.ts +2 -2
- package/dist/modules/skills/pending-skills.service.js +7 -7
- package/dist/modules/skills/pending-skills.service.js.map +1 -1
- package/dist/modules/skills/skills.contract.d.ts +4 -4
- package/dist/modules/skills/skills.contract.d.ts.map +1 -1
- package/dist/modules/skills/skills.contract.js +1 -1
- package/dist/modules/skills/skills.service.js +5 -5
- package/dist/modules/skills/skills.service.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts +65 -0
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -0
- package/dist/modules/tool-manuals/mcp-json-discovery.js +276 -0
- package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -0
- package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts +92 -0
- package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -0
- package/dist/modules/tool-manuals/mcp-server-edit.service.js +328 -0
- package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -0
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +38 -12
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.js +1 -1
- package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +13 -2
- package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.routes.js +233 -2
- package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +74 -37
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +6 -3
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
- package/dist/modules/workflow/git/git.service.js +2 -2
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workspace/kb-seed.service.d.ts +2 -2
- package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -1
- package/dist/modules/workspace/kb-seed.service.js +43 -10
- package/dist/modules/workspace/kb-seed.service.js.map +1 -1
- package/dist/modules/workspace/plugins-migration.d.ts +50 -0
- package/dist/modules/workspace/plugins-migration.d.ts.map +1 -0
- package/dist/modules/workspace/plugins-migration.js +379 -0
- package/dist/modules/workspace/plugins-migration.js.map +1 -0
- package/dist/modules/workspace/startup/kb-git.d.ts +23 -0
- package/dist/modules/workspace/startup/kb-git.d.ts.map +1 -0
- package/dist/modules/workspace/startup/kb-git.js +86 -0
- package/dist/modules/workspace/startup/kb-git.js.map +1 -0
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts +74 -0
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -0
- package/dist/modules/workspace/startup/kb-startup-runner.js +528 -0
- package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -0
- package/dist/modules/workspace/startup/on-server-start.d.ts +105 -0
- package/dist/modules/workspace/startup/on-server-start.d.ts.map +1 -0
- package/dist/modules/workspace/startup/on-server-start.js +21 -0
- package/dist/modules/workspace/startup/on-server-start.js.map +1 -0
- package/dist/modules/workspace/startup/steps/groups-to-plugins.step.d.ts +46 -0
- package/dist/modules/workspace/startup/steps/groups-to-plugins.step.d.ts.map +1 -0
- package/dist/modules/workspace/startup/steps/groups-to-plugins.step.js +492 -0
- package/dist/modules/workspace/startup/steps/groups-to-plugins.step.js.map +1 -0
- package/dist/modules/workspace/startup/steps/roles-yaml.step.d.ts +23 -0
- package/dist/modules/workspace/startup/steps/roles-yaml.step.d.ts.map +1 -0
- package/dist/modules/workspace/startup/steps/roles-yaml.step.js +69 -0
- package/dist/modules/workspace/startup/steps/roles-yaml.step.js.map +1 -0
- package/dist/modules/workspace/startup/steps/seed-tree.d.ts +17 -0
- package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -0
- package/dist/modules/workspace/startup/steps/seed-tree.js +109 -0
- package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -0
- package/dist/modules/workspace/startup/steps/template-files.step.d.ts +103 -0
- package/dist/modules/workspace/startup/steps/template-files.step.d.ts.map +1 -0
- package/dist/modules/workspace/startup/steps/template-files.step.js +337 -0
- package/dist/modules/workspace/startup/steps/template-files.step.js.map +1 -0
- package/dist/modules/workspace/workspace.routes.js +3 -3
- package/dist/modules/workspace/workspace.routes.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +0 -9
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +2 -30
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/shared/kb-layout.test.js +3 -3
- package/dist/shared/kb-layout.test.js.map +1 -1
- package/dist/shared/utcp-namespace.d.ts +6 -27
- package/dist/shared/utcp-namespace.d.ts.map +1 -1
- package/dist/shared/utcp-namespace.js +6 -63
- package/dist/shared/utcp-namespace.js.map +1 -1
- package/dist/shared/variable-refs.d.ts +42 -0
- package/dist/shared/variable-refs.d.ts.map +1 -0
- package/dist/shared/variable-refs.js +60 -0
- package/dist/shared/variable-refs.js.map +1 -0
- package/kb-template/.bevelignore +1 -1
- package/kb-template/AGENTS.md +88 -35
- package/kb-template/KnowledgeBase/How to get started.md +10 -10
- package/kb-template/access.md +36 -36
- package/kb-template/gitignore.template +16 -0
- package/migrations/meta/0000_snapshot.json +1479 -1479
- package/package.json +5 -4
- package/src/assets.ts +25 -25
- package/src/core/core-ports.ts +113 -106
- package/src/core/create-core-server.ts +85 -20
- package/src/core/create-core-services.ts +83 -42
- package/src/index.ts +69 -69
- package/src/modules/access/__tests__/access-control.atref-batch.test.ts +98 -98
- package/src/modules/access/__tests__/access-declarations.test.ts +28 -28
- package/src/modules/access/__tests__/access-md-format.test.ts +18 -18
- package/src/modules/access/__tests__/access-mutation.service.test.ts +5 -5
- package/src/modules/access/__tests__/access-splice.test.ts +2 -2
- package/src/modules/access/__tests__/access.routes.overrides.test.ts +16 -16
- package/src/modules/access/__tests__/grant-sources.test.ts +12 -12
- package/src/modules/access/__tests__/roles-admin.service.test.ts +36 -2
- package/src/modules/access/access-control.interface.ts +8 -7
- package/src/modules/access/access-control.service.ts +7 -7
- package/src/modules/access/access-declarations.ts +5 -5
- package/src/modules/access/access-mutation.service.ts +3 -3
- package/src/modules/access/access-splice.ts +4 -4
- package/src/modules/access/access.routes.ts +23 -20
- package/src/modules/access/creator-access.ts +5 -5
- package/src/modules/access/render-roles-yaml.ts +65 -0
- package/src/modules/access/roles-admin.service.ts +45 -16
- package/src/modules/admin/admin-access.routes.ts +29 -29
- package/src/modules/auth/__tests__/auth.routes.test.ts +91 -91
- package/src/modules/auth/__tests__/rate-limit.test.ts +36 -36
- package/src/modules/auth/rate-limit.ts +45 -45
- package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +67 -0
- package/src/modules/code-mode/code-mode-names.ts +10 -36
- package/src/modules/code-mode/code-mode.tool.ts +27 -7
- package/src/modules/database/connection.ts +15 -15
- package/src/modules/database/schema.ts +11 -11
- package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +150 -150
- package/src/modules/mcp/mcp.service.ts +57 -435
- package/src/modules/{groups → plugins}/__tests__/join-proposals.test.ts +1 -1
- package/src/modules/{groups → plugins}/__tests__/join-requests.service.test.ts +7 -7
- package/src/modules/{groups/__tests__/group-index.service.test.ts → plugins/__tests__/plugin-index.service.test.ts} +41 -41
- package/src/modules/plugins/__tests__/plugin-provision.service.test.ts +312 -0
- package/src/modules/{groups/__tests__/groups.routes.test.ts → plugins/__tests__/plugins.routes.test.ts} +100 -100
- package/src/modules/plugins/index.ts +17 -0
- package/src/modules/{groups → plugins}/join-proposals.ts +2 -2
- package/src/modules/{groups → plugins}/join-requests.service.ts +8 -8
- package/src/modules/{groups/group-provision.service.ts → plugins/plugin-provision.service.ts} +139 -69
- package/src/modules/{groups/groups.contract.ts → plugins/plugins.contract.ts} +26 -26
- package/src/modules/{groups/groups.routes.ts → plugins/plugins.routes.ts} +102 -102
- package/src/modules/{groups/groups.service.ts → plugins/plugins.service.ts} +43 -43
- package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +143 -143
- package/src/modules/settings/__tests__/setup.routes.test.ts +124 -3
- package/src/modules/settings/setup.routes.ts +112 -5
- package/src/modules/skills/__tests__/pending-skills.service.test.ts +14 -14
- package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
- package/src/modules/skills/pending-skills.service.ts +7 -7
- package/src/modules/skills/skills.contract.ts +4 -4
- package/src/modules/skills/skills.service.ts +5 -5
- package/src/modules/tool-auth/llm-usage-meter.ts +19 -19
- package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +198 -0
- package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +346 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.archive.route.test.ts +101 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +3 -3
- package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +2 -2
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +27 -27
- package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +3 -3
- package/src/modules/tool-manuals/mcp-json-discovery.ts +328 -0
- package/src/modules/tool-manuals/mcp-server-edit.service.ts +434 -0
- package/src/modules/tool-manuals/tool-manuals.contract.ts +35 -12
- package/src/modules/tool-manuals/tool-manuals.routes.ts +222 -1
- package/src/modules/tool-manuals/tool-manuals.service.ts +82 -42
- package/src/modules/tool-manuals/tool-manuals.tools.ts +6 -3
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +1 -1
- package/src/modules/workflow/git/__tests__/branch-name.test.ts +3 -3
- package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +3 -3
- package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +13 -8
- package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +1 -1
- package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +1 -1
- package/src/modules/workflow/git/git.service.ts +2 -2
- package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +1 -1
- package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +1 -1
- package/src/modules/workflow/workflow-hooks.ts +101 -101
- package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +237 -237
- package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +236 -236
- package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +179 -179
- package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +320 -320
- package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +337 -337
- package/src/modules/workspace/__tests__/workspace.service.test.ts +27 -0
- package/src/modules/workspace/bevel-ignore.ts +66 -66
- package/src/modules/workspace/session-sink.ts +25 -25
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +495 -0
- package/src/modules/workspace/startup/kb-git.ts +94 -0
- package/src/modules/workspace/startup/kb-startup-runner.ts +597 -0
- package/src/modules/workspace/startup/on-server-start.ts +97 -0
- package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +636 -0
- package/src/modules/workspace/startup/steps/groups-to-plugins.step.ts +561 -0
- package/src/modules/workspace/startup/steps/roles-yaml.step.ts +71 -0
- package/src/modules/workspace/startup/steps/seed-tree.ts +115 -0
- package/src/modules/workspace/startup/steps/template-files.step.ts +360 -0
- package/src/modules/workspace/workspace.routes.ts +3 -3
- package/src/modules/workspace/workspace.service.ts +2 -35
- package/src/modules/workspace/workspace.tools.ts +922 -922
- package/src/shared/__tests__/join-request.test.ts +13 -13
- package/src/shared/__tests__/kb-layout.plugin.test.ts +45 -0
- package/src/shared/kb-layout.test.ts +3 -3
- package/src/shared/utcp-namespace.ts +10 -68
- package/src/shared/variable-refs.ts +64 -0
- package/src/modules/groups/__tests__/group-provision.service.test.ts +0 -247
- package/src/modules/groups/index.ts +0 -17
- package/src/modules/workspace/__tests__/kb-seed.service.test.ts +0 -444
- package/src/modules/workspace/kb-seed.interface.ts +0 -36
- package/src/modules/workspace/kb-seed.service.ts +0 -555
- package/src/shared/__tests__/kb-layout.group.test.ts +0 -45
- /package/kb-template/{Groups → Plugins}/.gitkeep +0 -0
|
@@ -46,7 +46,7 @@ describe('ToolManualService — MCP OAuth auto-discovery decoration', () => {
|
|
|
46
46
|
|
|
47
47
|
beforeEach(async () => {
|
|
48
48
|
root = await mkdtemp(join(tmpdir(), 'tools-oauth-'));
|
|
49
|
-
const toolsDir = join(root, wsId, KB_DIR, '
|
|
49
|
+
const toolsDir = join(root, wsId, KB_DIR, 'Plugins');
|
|
50
50
|
await mkdir(toolsDir, { recursive: true });
|
|
51
51
|
await writeFile(join(toolsDir, 'notion.tool'), BARE_MCP_TOOL);
|
|
52
52
|
await writeFile(join(toolsDir, 'jira.tool'), KEYED_MCP_TOOL);
|
|
@@ -109,7 +109,7 @@ describe('ToolManualService — MCP OAuth auto-discovery decoration', () => {
|
|
|
109
109
|
});
|
|
110
110
|
|
|
111
111
|
test('referenced-but-undeclared ${VAR}s are auto-surfaced as admin keys (any tool type)', async () => {
|
|
112
|
-
const toolsDir = join(root, wsId, KB_DIR, '
|
|
112
|
+
const toolsDir = join(root, wsId, KB_DIR, 'Plugins');
|
|
113
113
|
await writeFile(
|
|
114
114
|
join(toolsDir, 'billing.tool'),
|
|
115
115
|
JSON.stringify({
|
|
@@ -68,7 +68,7 @@ describe('ToolManualService', () => {
|
|
|
68
68
|
|
|
69
69
|
beforeEach(async () => {
|
|
70
70
|
root = await mkdtemp(join(tmpdir(), 'tools-'));
|
|
71
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
71
|
+
const tools = join(root, wsId, KB_DIR, 'Plugins');
|
|
72
72
|
await mkdir(tools, { recursive: true });
|
|
73
73
|
await writeFile(join(tools, 'weather.tool'), INLINE_TOOL);
|
|
74
74
|
await writeFile(join(tools, 'billing.tool'), HTTP_TOOL);
|
|
@@ -88,13 +88,13 @@ describe('ToolManualService', () => {
|
|
|
88
88
|
});
|
|
89
89
|
|
|
90
90
|
test('listAllSummaries returns every manual regardless of caller access', async () => {
|
|
91
|
-
// The
|
|
91
|
+
// The plugin index counts a plugin's tools for people who cannot read them,
|
|
92
92
|
// so this surface must ignore the ACL that `listAccessible` applies.
|
|
93
93
|
const service = svc(denyBilling);
|
|
94
94
|
expect((await service.listAccessible('user@x.eu')).map((m) => m.name)).toEqual(['weather']);
|
|
95
95
|
const all = await service.listAllSummaries();
|
|
96
96
|
expect(all.map((m) => m.name).sort()).toEqual(['billing', 'weather']);
|
|
97
|
-
expect(all.map((m) => m.path).sort()).toEqual(['
|
|
97
|
+
expect(all.map((m) => m.path).sort()).toEqual(['Plugins/billing.tool', 'Plugins/weather.tool']);
|
|
98
98
|
});
|
|
99
99
|
|
|
100
100
|
test('builds an inline manual as an http sub-manual call-template', async () => {
|
|
@@ -123,7 +123,7 @@ describe('ToolManualService', () => {
|
|
|
123
123
|
|
|
124
124
|
test('manual names are alphanumeric (no underscores) for variable namespacing', async () => {
|
|
125
125
|
root = await mkdtemp(join(tmpdir(), 'tools2-'));
|
|
126
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
126
|
+
const tools = join(root, wsId, KB_DIR, 'Plugins');
|
|
127
127
|
await mkdir(tools, { recursive: true });
|
|
128
128
|
await writeFile(join(tools, 'my_cool-tool.tool'), JSON.stringify({ type: 'http', url: 'https://x.example.com/m' }));
|
|
129
129
|
const list = await svc().listAccessible('user@x.eu');
|
|
@@ -133,7 +133,7 @@ describe('ToolManualService', () => {
|
|
|
133
133
|
|
|
134
134
|
test('refuses a `.tool` whose manual name collides (no silent suffix)', async () => {
|
|
135
135
|
root = await mkdtemp(join(tmpdir(), 'tools3-'));
|
|
136
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
136
|
+
const tools = join(root, wsId, KB_DIR, 'Plugins');
|
|
137
137
|
await mkdir(tools, { recursive: true });
|
|
138
138
|
// Both names normalize to `stripe`. The scanner keeps the first (sorted path
|
|
139
139
|
// order) and REFUSES the duplicate rather than renaming it `stripe2` — a
|
|
@@ -144,12 +144,12 @@ describe('ToolManualService', () => {
|
|
|
144
144
|
await writeFile(join(tools, 'b.tool'), JSON.stringify({ name: 'stripe!', type: 'http', url: 'https://b.example.com/m' }));
|
|
145
145
|
const list = await svc().listAccessible('user@x.eu');
|
|
146
146
|
expect(list.map((m) => m.name)).toEqual(['stripe']);
|
|
147
|
-
expect(list[0].path).toBe('
|
|
147
|
+
expect(list[0].path).toBe('Plugins/a.tool');
|
|
148
148
|
});
|
|
149
149
|
|
|
150
150
|
test('parses `variables` scopes (default admin, explicit user/admin)', async () => {
|
|
151
151
|
root = await mkdtemp(join(tmpdir(), 'toolsv-'));
|
|
152
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
152
|
+
const tools = join(root, wsId, KB_DIR, 'Plugins');
|
|
153
153
|
await mkdir(tools, { recursive: true });
|
|
154
154
|
await writeFile(
|
|
155
155
|
join(tools, 'weather.tool'),
|
|
@@ -174,7 +174,7 @@ describe('ToolManualService', () => {
|
|
|
174
174
|
|
|
175
175
|
test('skips a `.tool` with a malformed `variables` entry (never silently mis-scoped)', async () => {
|
|
176
176
|
root = await mkdtemp(join(tmpdir(), 'toolsvbad-'));
|
|
177
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
177
|
+
const tools = join(root, wsId, KB_DIR, 'Plugins');
|
|
178
178
|
await mkdir(tools, { recursive: true });
|
|
179
179
|
await writeFile(
|
|
180
180
|
join(tools, 'weather.tool'),
|
|
@@ -185,7 +185,7 @@ describe('ToolManualService', () => {
|
|
|
185
185
|
|
|
186
186
|
test('scopeOfVariable: declared scope wins; undeclared/unknown default to admin', async () => {
|
|
187
187
|
root = await mkdtemp(join(tmpdir(), 'toolsscope-'));
|
|
188
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
188
|
+
const tools = join(root, wsId, KB_DIR, 'Plugins');
|
|
189
189
|
await mkdir(tools, { recursive: true });
|
|
190
190
|
await writeFile(
|
|
191
191
|
join(tools, 'weather.tool'),
|
|
@@ -221,7 +221,7 @@ describe('ToolManualService', () => {
|
|
|
221
221
|
});
|
|
222
222
|
const writeOAuthTool = async (variable: unknown) => {
|
|
223
223
|
root = await mkdtemp(join(tmpdir(), 'toolsoauth-'));
|
|
224
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
224
|
+
const tools = join(root, wsId, KB_DIR, 'Plugins');
|
|
225
225
|
await mkdir(tools, { recursive: true });
|
|
226
226
|
await writeFile(
|
|
227
227
|
join(tools, 'g.tool'),
|
|
@@ -268,7 +268,7 @@ describe('ToolManualService', () => {
|
|
|
268
268
|
|
|
269
269
|
test('parses `remote`: default true, explicit false, rejects non-boolean', async () => {
|
|
270
270
|
root = await mkdtemp(join(tmpdir(), 'toolsrem-'));
|
|
271
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
271
|
+
const tools = join(root, wsId, KB_DIR, 'Plugins');
|
|
272
272
|
await mkdir(tools, { recursive: true });
|
|
273
273
|
await writeFile(join(tools, 'pub.tool'), JSON.stringify({ name: 'pub', type: 'http', url: 'https://x/m' }));
|
|
274
274
|
await writeFile(join(tools, 'loc.tool'), JSON.stringify({ name: 'loc', type: 'mcp', url: 'https://x/m', remote: false }));
|
|
@@ -281,21 +281,21 @@ describe('ToolManualService', () => {
|
|
|
281
281
|
|
|
282
282
|
test('remoteOnly excludes local-only manuals; listLocalOnly returns them', async () => {
|
|
283
283
|
root = await mkdtemp(join(tmpdir(), 'toolsrem2-'));
|
|
284
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
284
|
+
const tools = join(root, wsId, KB_DIR, 'Plugins');
|
|
285
285
|
await mkdir(tools, { recursive: true });
|
|
286
286
|
await writeFile(join(tools, 'pub.tool'), JSON.stringify({ name: 'pub', type: 'http', url: 'https://x/m' }));
|
|
287
287
|
await writeFile(join(tools, 'loc.tool'), JSON.stringify({ name: 'loc', type: 'http', url: 'https://x/m', remote: false }));
|
|
288
288
|
const s = svc();
|
|
289
289
|
expect((await s.toManualCallTemplates('user@x.eu', { remoteOnly: true })).map((t) => t.name).sort()).toEqual(['pub']);
|
|
290
290
|
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: '
|
|
291
|
+
expect(await s.listLocalOnly('user@x.eu')).toEqual([{ name: 'loc', path: 'Plugins/loc.tool' }]);
|
|
292
292
|
// The local-only manual is still browsable/editable regardless of the remote flag.
|
|
293
293
|
expect((await s.listAccessible('user@x.eu')).map((m) => m.name).sort()).toEqual(['loc', 'pub']);
|
|
294
294
|
});
|
|
295
295
|
|
|
296
296
|
test('refuses a `.tool` that reproduces a reserved built-in namespace', async () => {
|
|
297
297
|
root = await mkdtemp(join(tmpdir(), 'toolsresv-'));
|
|
298
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
298
|
+
const tools = join(root, wsId, KB_DIR, 'Plugins');
|
|
299
299
|
await mkdir(tools, { recursive: true });
|
|
300
300
|
// A `.tool` named exactly like the internal manual, or with the KB namespace as
|
|
301
301
|
// an explicit id, would resolve the loopback creds seeded under that namespace.
|
|
@@ -308,7 +308,7 @@ describe('ToolManualService', () => {
|
|
|
308
308
|
|
|
309
309
|
test('refuses a remote `.tool` whose url is a private/loopback/metadata host (SSRF)', async () => {
|
|
310
310
|
root = await mkdtemp(join(tmpdir(), 'toolsssrf-'));
|
|
311
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
311
|
+
const tools = join(root, wsId, KB_DIR, 'Plugins');
|
|
312
312
|
await mkdir(tools, { recursive: true });
|
|
313
313
|
await writeFile(join(tools, 'imds.tool'), JSON.stringify({ name: 'imds', type: 'http', url: 'http://169.254.169.254/latest/meta-data' }));
|
|
314
314
|
await writeFile(join(tools, 'loop.tool'), JSON.stringify({ name: 'loop', type: 'mcp', url: 'http://127.0.0.1:9000/mcp' }));
|
|
@@ -322,7 +322,7 @@ describe('ToolManualService', () => {
|
|
|
322
322
|
|
|
323
323
|
test('SSRF guard still applies when a private host is combined with a template token in the query', async () => {
|
|
324
324
|
root = await mkdtemp(join(tmpdir(), 'toolsssrf3-'));
|
|
325
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
325
|
+
const tools = join(root, wsId, KB_DIR, 'Plugins');
|
|
326
326
|
await mkdir(tools, { recursive: true });
|
|
327
327
|
// A `${VAR}` in the QUERY doesn't make the authority templated — the host is
|
|
328
328
|
// still the literal metadata endpoint, so the guard must reject it. Only a
|
|
@@ -335,7 +335,7 @@ describe('ToolManualService', () => {
|
|
|
335
335
|
|
|
336
336
|
test('refuses any `.tool` that references a platform-seeded variable (API_URL / CONNECTION_KEY)', async () => {
|
|
337
337
|
root = await mkdtemp(join(tmpdir(), 'toolsresvvar-'));
|
|
338
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
338
|
+
const tools = join(root, wsId, KB_DIR, 'Plugins');
|
|
339
339
|
await mkdir(tools, { recursive: true });
|
|
340
340
|
// Both reference forms (`${VAR}` and `$VAR`), in any `.tool` type and any
|
|
341
341
|
// field — url query, header, an inline tool's call template. All refused at
|
|
@@ -358,7 +358,7 @@ describe('ToolManualService', () => {
|
|
|
358
358
|
|
|
359
359
|
test('refuses a `.tool` that references a NAMESPACED reserved variable (`<ns>_API_URL` / `<ns>_CONNECTION_KEY`)', async () => {
|
|
360
360
|
root = await mkdtemp(join(tmpdir(), 'toolsresvns-'));
|
|
361
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
361
|
+
const tools = join(root, wsId, KB_DIR, 'Plugins');
|
|
362
362
|
await mkdir(tools, { recursive: true });
|
|
363
363
|
// The substitutor resolves a manual's variables under its UTCP namespace, so
|
|
364
364
|
// `${BEVEL_CONNECTION_KEY}` reaches the same seeded platform bearer the bare
|
|
@@ -386,7 +386,7 @@ describe('ToolManualService', () => {
|
|
|
386
386
|
|
|
387
387
|
test('refuses a `.tool` that declares a variable named API_URL or CONNECTION_KEY', async () => {
|
|
388
388
|
root = await mkdtemp(join(tmpdir(), 'toolsresvdecl-'));
|
|
389
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
389
|
+
const tools = join(root, wsId, KB_DIR, 'Plugins');
|
|
390
390
|
await mkdir(tools, { recursive: true });
|
|
391
391
|
await writeFile(
|
|
392
392
|
join(tools, 'decl.tool'),
|
|
@@ -402,7 +402,7 @@ describe('ToolManualService', () => {
|
|
|
402
402
|
|
|
403
403
|
test('SSRF guard validates the literal host even when userinfo or port are templated', async () => {
|
|
404
404
|
root = await mkdtemp(join(tmpdir(), 'toolsssrf4-'));
|
|
405
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
405
|
+
const tools = join(root, wsId, KB_DIR, 'Plugins');
|
|
406
406
|
await mkdir(tools, { recursive: true });
|
|
407
407
|
// A `${VAR}` in the USERINFO or PORT doesn't make the hostname dynamic — the
|
|
408
408
|
// fetch still targets the literal metadata IP, so the guard must reject it.
|
|
@@ -427,7 +427,7 @@ describe('ToolManualService', () => {
|
|
|
427
427
|
|
|
428
428
|
test('SSRF guard exempts a local-only (remote:false) `.tool` and a templated url', async () => {
|
|
429
429
|
root = await mkdtemp(join(tmpdir(), 'toolsssrf2-'));
|
|
430
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
430
|
+
const tools = join(root, wsId, KB_DIR, 'Plugins');
|
|
431
431
|
await mkdir(tools, { recursive: true });
|
|
432
432
|
// remote:false is never fetched server-side; a `${VAR}` url resolves at call time.
|
|
433
433
|
await writeFile(join(tools, 'local.tool'), JSON.stringify({ name: 'localmcp', type: 'mcp', url: 'http://localhost:3333/mcp', remote: false }));
|
|
@@ -447,7 +447,7 @@ describe('ToolManualService', () => {
|
|
|
447
447
|
|
|
448
448
|
test('parses `.tool` frontmatter: id is the manual name/namespace', async () => {
|
|
449
449
|
root = await mkdtemp(join(tmpdir(), 'toolsfm-'));
|
|
450
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
450
|
+
const tools = join(root, wsId, KB_DIR, 'Plugins');
|
|
451
451
|
await mkdir(tools, { recursive: true });
|
|
452
452
|
// The tool IS the frontmatter: id + config in the one `---` block.
|
|
453
453
|
await writeFile(
|
|
@@ -461,7 +461,7 @@ describe('ToolManualService', () => {
|
|
|
461
461
|
|
|
462
462
|
test('snake_case id resolves scope via the UTCP doubled key', async () => {
|
|
463
463
|
root = await mkdtemp(join(tmpdir(), 'toolssnake-'));
|
|
464
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
464
|
+
const tools = join(root, wsId, KB_DIR, 'Plugins');
|
|
465
465
|
await mkdir(tools, { recursive: true });
|
|
466
466
|
await writeFile(
|
|
467
467
|
join(tools, 't.tool'),
|
|
@@ -475,7 +475,7 @@ describe('ToolManualService', () => {
|
|
|
475
475
|
|
|
476
476
|
test('rejects a non-snake_case explicit id (file skipped)', async () => {
|
|
477
477
|
root = await mkdtemp(join(tmpdir(), 'toolsbadid-'));
|
|
478
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
478
|
+
const tools = join(root, wsId, KB_DIR, 'Plugins');
|
|
479
479
|
await mkdir(tools, { recursive: true });
|
|
480
480
|
await writeFile(join(tools, 'x.tool'), '---\nid: My-Tool\ntype: http\nurl: https://x/m\n---\n');
|
|
481
481
|
expect(await svc().listAccessible('user@x.eu')).toHaveLength(0);
|
|
@@ -483,7 +483,7 @@ describe('ToolManualService', () => {
|
|
|
483
483
|
|
|
484
484
|
test('config after the closing fence is ignored (the tool is the frontmatter)', async () => {
|
|
485
485
|
root = await mkdtemp(join(tmpdir(), 'toolsnotes-'));
|
|
486
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
486
|
+
const tools = join(root, wsId, KB_DIR, 'Plugins');
|
|
487
487
|
await mkdir(tools, { recursive: true });
|
|
488
488
|
// `type`/`url` live in the fence; the JSON after it is free-form notes.
|
|
489
489
|
await writeFile(
|
|
@@ -497,7 +497,7 @@ describe('ToolManualService', () => {
|
|
|
497
497
|
|
|
498
498
|
test('parses `description` onto the descriptor, trimmed', async () => {
|
|
499
499
|
root = await mkdtemp(join(tmpdir(), 'toolsdesc-'));
|
|
500
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
500
|
+
const tools = join(root, wsId, KB_DIR, 'Plugins');
|
|
501
501
|
await mkdir(tools, { recursive: true });
|
|
502
502
|
await writeFile(
|
|
503
503
|
join(tools, 'gh.tool'),
|
|
@@ -512,7 +512,7 @@ describe('ToolManualService', () => {
|
|
|
512
512
|
// one must not: `description` buys a reader one sentence, and no sentence is
|
|
513
513
|
// worth removing a working integration from the catalog.
|
|
514
514
|
root = await mkdtemp(join(tmpdir(), 'toolsdescbad-'));
|
|
515
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
515
|
+
const tools = join(root, wsId, KB_DIR, 'Plugins');
|
|
516
516
|
await mkdir(tools, { recursive: true });
|
|
517
517
|
await writeFile(join(tools, 'num.tool'), JSON.stringify({ name: 'num', type: 'http', url: 'https://x/m', description: 42 }));
|
|
518
518
|
await writeFile(join(tools, 'obj.tool'), JSON.stringify({ name: 'obj', type: 'http', url: 'https://x/m', description: { a: 1 } }));
|
|
@@ -31,7 +31,7 @@ const CATALOG = [
|
|
|
31
31
|
{
|
|
32
32
|
slug: 'weather',
|
|
33
33
|
name: 'weather',
|
|
34
|
-
path: '
|
|
34
|
+
path: 'Plugins/weather.tool',
|
|
35
35
|
type: 'mcp' as const,
|
|
36
36
|
setup: { kind: 'oauth-manual' as const, reason: 'no dynamic client registration' },
|
|
37
37
|
variables: [{ name: 'SHARED_KEY', scope: 'admin' as const, label: null }],
|
|
@@ -39,7 +39,7 @@ const CATALOG = [
|
|
|
39
39
|
{
|
|
40
40
|
slug: 'billing',
|
|
41
41
|
name: 'billing',
|
|
42
|
-
path: '
|
|
42
|
+
path: 'Plugins/billing.tool',
|
|
43
43
|
type: 'http' as const,
|
|
44
44
|
variables: [{ name: 'ORG_KEY', scope: 'admin' as const, label: null }],
|
|
45
45
|
},
|
|
@@ -54,7 +54,7 @@ const toolManualService = {
|
|
|
54
54
|
|
|
55
55
|
const accessControl = {
|
|
56
56
|
canWrite: vi.fn(
|
|
57
|
-
async (_ws: string, email: string, path: string) => email === ALICE.email && path === '
|
|
57
|
+
async (_ws: string, email: string, path: string) => email === ALICE.email && path === 'Plugins/weather.tool',
|
|
58
58
|
),
|
|
59
59
|
} as never;
|
|
60
60
|
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import {
|
|
2
|
+
HEXIS_EXTENSION_NS,
|
|
3
|
+
PLUGIN_MCP_FILE,
|
|
4
|
+
PLUGIN_MANIFEST_FILE,
|
|
5
|
+
PLUGINS_DIR,
|
|
6
|
+
} from '@bevel-software/platform-shared';
|
|
7
|
+
import type { ToolManualDescriptor, ToolVariable } from './tool-manuals.contract.js';
|
|
8
|
+
import { assertSafeFetchUrl } from '../../shared/ssrf.js';
|
|
9
|
+
import { RESERVED_VARIABLE_NAMES, findReservedVariableRef } from '../../shared/variable-refs.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The same reserved-reference policy `.tool` parsing enforces, applied to the
|
|
13
|
+
* extension block: `API_URL`/`CONNECTION_KEY` (bare or namespaced) are seeded
|
|
14
|
+
* by the platform for its own manuals, and an extension header referencing one
|
|
15
|
+
* would be asking the substitutor to hand a third-party server the caller's
|
|
16
|
+
* bearer. One rule, both declaration surfaces — and one GRAMMAR deciding what
|
|
17
|
+
* a reference is (shared/variable-refs.ts), so nothing the migration or the
|
|
18
|
+
* editor classifies as a portable literal can be reserved here.
|
|
19
|
+
*/
|
|
20
|
+
const referencesReservedVariable = findReservedVariableRef;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* MCP servers are declared in each plugin's `mcp.json` (the Agent Plugins
|
|
24
|
+
* fixed location), not in `.tool` files — `mcp.json` is AUTHORITATIVE. This
|
|
25
|
+
* module turns one plugin's `mcp.json` (+ its `plugin.json` extensions block)
|
|
26
|
+
* into the same `ToolManualDescriptor`s the `.tool` scanner produces, so
|
|
27
|
+
* everything downstream — call templates, the vault's variable scoping, OAuth
|
|
28
|
+
* auto-discovery, `list_tool_setup` — is unchanged.
|
|
29
|
+
*
|
|
30
|
+
* The split between the two files is the specification's:
|
|
31
|
+
*
|
|
32
|
+
* - `mcp.json` holds what is PORTABLE: the server's name, transport, url,
|
|
33
|
+
* and literal headers. No credentials, no `${VAR}` references — a
|
|
34
|
+
* conformant client must transmit header values verbatim and expand
|
|
35
|
+
* nothing beyond `${PLUGIN_ROOT}`/`${PLUGIN_DATA}`.
|
|
36
|
+
* - `plugin.json`'s `extensions["software.bevel.hexis"].mcpServers[<name>]`
|
|
37
|
+
* holds what is OURS: auth headers carrying `${VAR}` vault references,
|
|
38
|
+
* the `variables` declarations (scope/label/oauth), a `description`, and
|
|
39
|
+
* `local: true` for servers only reachable from a user's machine. The spec
|
|
40
|
+
* reserves `extensions` for exactly this, and other clients ignore it.
|
|
41
|
+
*
|
|
42
|
+
* The `mcpServers` KEY is the manual name — the namespace vault secrets bind
|
|
43
|
+
* to (`<name>_<VAR>`). The migration writes it from the old `.tool`'s id so
|
|
44
|
+
* configured secrets and completed OAuth grants stay bound; renaming a server
|
|
45
|
+
* key is renaming its secret namespace, and the editor should say so.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/** The extension block for one server, as we define it. */
|
|
49
|
+
export interface HexisMcpServerExtension {
|
|
50
|
+
/** Auth headers, values may carry `${VAR}` vault references. Merged OVER mcp.json's. */
|
|
51
|
+
headers?: Record<string, string>;
|
|
52
|
+
variables?: ToolVariable[];
|
|
53
|
+
description?: string;
|
|
54
|
+
/** Only reachable from a user's machine (e.g. localhost) — remote proxy skips it. */
|
|
55
|
+
local?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
59
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The `mcpServers` extension map from a parsed plugin.json, or `{}`. */
|
|
63
|
+
function extensionServers(pluginJson: unknown): Record<string, HexisMcpServerExtension> {
|
|
64
|
+
if (!isRecord(pluginJson)) return {};
|
|
65
|
+
const ext = pluginJson.extensions;
|
|
66
|
+
if (!isRecord(ext)) return {};
|
|
67
|
+
const ns = ext[HEXIS_EXTENSION_NS];
|
|
68
|
+
if (!isRecord(ns) || !isRecord(ns.mcpServers)) return {};
|
|
69
|
+
return ns.mcpServers as Record<string, HexisMcpServerExtension>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A manual name must be usable as a UTCP namespace + route slug. Same shape `.tool` ids use. */
|
|
73
|
+
const SERVER_NAME_RE = /^[a-z0-9][a-z0-9_-]*$/;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Validate an extension entry's `variables` declarations, or `null` when any
|
|
77
|
+
* entry is malformed. The `.tool` parser THROWS on a bad entry so the whole
|
|
78
|
+
* file is skipped — a variable silently dropped or mis-read is not cosmetic:
|
|
79
|
+
* an undeclared reference defaults to the shared `admin` scope, so losing a
|
|
80
|
+
* `scope: user` declaration would hand one caller's slot to everyone. The
|
|
81
|
+
* same stake applies here, at this file's per-server grain: a bad entry
|
|
82
|
+
* invalidates the SERVER, never its siblings.
|
|
83
|
+
*
|
|
84
|
+
* Exported for the mcp-server editor: what it saves is what this scan reads
|
|
85
|
+
* on the next pass, so the two must judge a declaration with ONE function —
|
|
86
|
+
* a shape saveable there but undiscoverable here would be a server that
|
|
87
|
+
* silently vanishes from the catalog the moment its edit lands.
|
|
88
|
+
*/
|
|
89
|
+
export function validatedVariables(raw: unknown): ToolVariable[] | null {
|
|
90
|
+
if (raw === undefined) return [];
|
|
91
|
+
if (!Array.isArray(raw)) return null;
|
|
92
|
+
const out: ToolVariable[] = [];
|
|
93
|
+
const declared = new Set<string>();
|
|
94
|
+
for (const entry of raw) {
|
|
95
|
+
if (!isRecord(entry)) return null;
|
|
96
|
+
if (typeof entry.name !== 'string' || !/^[A-Za-z0-9_]+$/.test(entry.name)) return null;
|
|
97
|
+
// The `.tool` parser's declaration rules, at this file's grain: a
|
|
98
|
+
// platform-seeded name may not be re-declared (it would shadow the
|
|
99
|
+
// seeding), and a duplicate would make later scope resolution depend on
|
|
100
|
+
// declaration order.
|
|
101
|
+
if (RESERVED_VARIABLE_NAMES.includes(entry.name) || declared.has(entry.name)) return null;
|
|
102
|
+
declared.add(entry.name);
|
|
103
|
+
const scope = entry.scope ?? 'admin';
|
|
104
|
+
if (scope !== 'admin' && scope !== 'user') return null;
|
|
105
|
+
let oauth: ToolVariable['oauth'];
|
|
106
|
+
if (entry.oauth !== undefined) {
|
|
107
|
+
// OAuth is inherently per-caller (same rule the `.tool` parser
|
|
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.
|
|
111
|
+
if (!isRecord(entry.oauth) || scope !== 'user') return null;
|
|
112
|
+
const o = entry.oauth;
|
|
113
|
+
// `clientId` is trimmed and must be non-empty, exactly as the `.tool`
|
|
114
|
+
// parser requires: a whitespace-only value would pass discovery and
|
|
115
|
+
// then fail the owner's client-secret setup with "clientId is
|
|
116
|
+
// 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
|
+
}
|
|
125
|
+
// The same https + SSRF gate the `.tool` parser runs on these URLs: a
|
|
126
|
+
// sign-in or token exchange aimed at an internal host is a declaration
|
|
127
|
+
// this surface must refuse exactly like the other one does.
|
|
128
|
+
try {
|
|
129
|
+
assertSafeFetchUrl(o.authorizationUrl, { requireHttps: true, label: `${entry.name} oauth.authorizationUrl` });
|
|
130
|
+
assertSafeFetchUrl(o.tokenUrl, { requireHttps: true, label: `${entry.name} oauth.tokenUrl` });
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
// Forwarded verbatim as query params later — a non-string value here is
|
|
135
|
+
// a malformed declaration, not something to coerce.
|
|
136
|
+
if (o.authParams !== undefined) {
|
|
137
|
+
if (!isRecord(o.authParams) || !Object.values(o.authParams).every((v) => typeof v === 'string')) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
oauth = {
|
|
142
|
+
authorizationUrl: o.authorizationUrl,
|
|
143
|
+
tokenUrl: o.tokenUrl,
|
|
144
|
+
clientId: o.clientId.trim(),
|
|
145
|
+
...(Array.isArray(o.scopes) && o.scopes.every((s) => typeof s === 'string')
|
|
146
|
+
? { scopes: o.scopes as string[] }
|
|
147
|
+
: {}),
|
|
148
|
+
...(o.authParams !== undefined ? { authParams: o.authParams as Record<string, string> } : {}),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
out.push({
|
|
152
|
+
name: entry.name,
|
|
153
|
+
scope,
|
|
154
|
+
...(typeof entry.label === 'string' && entry.label.trim() ? { label: entry.label.trim() } : {}),
|
|
155
|
+
...(oauth ? { oauth } : {}),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Descriptors for one plugin's `mcp.json`. Malformed entries are skipped with
|
|
163
|
+
* a logged reason — one bad server must not take the plugin's others offline —
|
|
164
|
+
* and a missing/unparsable file yields `[]` (the caller decides whether that
|
|
165
|
+
* is worth a log line; an absent mcp.json is the common case, not an error).
|
|
166
|
+
*
|
|
167
|
+
* `stdio` entries are inherently LOCAL (`remote: false`): the hosted proxy can
|
|
168
|
+
* never spawn a subprocess out of knowledge-base content, so they are served
|
|
169
|
+
* only to local consumers (hexis-mcp), whose UTCP mcp plugin spawns them.
|
|
170
|
+
* `${PLUGIN_ROOT}`/`${PLUGIN_DATA}` placeholders are NOT expanded yet — local
|
|
171
|
+
* materialization is a later phase — so a stdio server relying on them will
|
|
172
|
+
* fail to spawn until then; bare-command servers (`npx …`) work today.
|
|
173
|
+
*/
|
|
174
|
+
export function descriptorsFromMcpJson(
|
|
175
|
+
pluginFolder: string,
|
|
176
|
+
mcpJsonText: string,
|
|
177
|
+
pluginJsonText: string | null,
|
|
178
|
+
): ToolManualDescriptor[] {
|
|
179
|
+
let mcp: unknown;
|
|
180
|
+
try {
|
|
181
|
+
mcp = JSON.parse(mcpJsonText);
|
|
182
|
+
} catch {
|
|
183
|
+
console.warn(`[tool-manuals] ${PLUGINS_DIR}/${pluginFolder}/${PLUGIN_MCP_FILE} is not valid JSON — skipped.`);
|
|
184
|
+
return [];
|
|
185
|
+
}
|
|
186
|
+
if (!isRecord(mcp) || !isRecord(mcp.mcpServers)) return [];
|
|
187
|
+
|
|
188
|
+
let manifest: unknown = null;
|
|
189
|
+
if (pluginJsonText !== null) {
|
|
190
|
+
try {
|
|
191
|
+
manifest = JSON.parse(pluginJsonText);
|
|
192
|
+
} catch {
|
|
193
|
+
// A broken manifest costs the extension data (auth wiring), not the
|
|
194
|
+
// servers themselves — they are still listed, as the spec's own
|
|
195
|
+
// "invalid components are skipped, valid ones load" posture suggests.
|
|
196
|
+
console.warn(
|
|
197
|
+
`[tool-manuals] ${PLUGINS_DIR}/${pluginFolder}/${PLUGIN_MANIFEST_FILE} is not valid JSON — ` +
|
|
198
|
+
'its mcp-server auth/variable declarations are ignored.',
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const extensions = extensionServers(manifest);
|
|
203
|
+
const mcpJsonPath = `${PLUGINS_DIR}/${pluginFolder}/${PLUGIN_MCP_FILE}`;
|
|
204
|
+
|
|
205
|
+
const out: ToolManualDescriptor[] = [];
|
|
206
|
+
for (const [name, raw] of Object.entries(mcp.mcpServers)) {
|
|
207
|
+
if (!SERVER_NAME_RE.test(name)) {
|
|
208
|
+
console.warn(
|
|
209
|
+
`[tool-manuals] skipping mcp server "${name}" in ${mcpJsonPath}: the name is the secret ` +
|
|
210
|
+
'namespace and route slug, so it must be lowercase alphanumeric with `_`/`-`.',
|
|
211
|
+
);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (!isRecord(raw) || typeof raw.type !== 'string') {
|
|
215
|
+
console.warn(`[tool-manuals] skipping mcp server "${name}" in ${mcpJsonPath}: no transport type.`);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
// The extension entry is knowledge-base content too — same zero-trust
|
|
219
|
+
// parse as the rest: a non-object entry reads as "no extension data".
|
|
220
|
+
const ext: HexisMcpServerExtension = isRecord(extensions[name])
|
|
221
|
+
? (extensions[name] as HexisMcpServerExtension)
|
|
222
|
+
: {};
|
|
223
|
+
// The EFFECTIVE declaration, not just the extension block: a reserved
|
|
224
|
+
// reference in mcp.json's own url or literal headers would be expanded
|
|
225
|
+
// into outbound requests exactly the same way.
|
|
226
|
+
const reserved = referencesReservedVariable({ raw, ext });
|
|
227
|
+
if (reserved !== null) {
|
|
228
|
+
console.warn(
|
|
229
|
+
`[tool-manuals] skipping mcp server "${name}" in ${mcpJsonPath}: its declaration (mcp.json ` +
|
|
230
|
+
`entry or plugin.json extension) references the reserved variable "${reserved}" — API_URL and ` +
|
|
231
|
+
'CONNECTION_KEY are platform-seeded and may not appear in server declarations.',
|
|
232
|
+
);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const variables = validatedVariables(ext.variables);
|
|
236
|
+
if (variables === null) {
|
|
237
|
+
console.warn(
|
|
238
|
+
`[tool-manuals] skipping mcp server "${name}" in ${mcpJsonPath}: its plugin.json \`variables\` ` +
|
|
239
|
+
'declaration is malformed — a dropped declaration would silently re-scope a credential, so ' +
|
|
240
|
+
'the server stays offline until the manifest is fixed.',
|
|
241
|
+
);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const shared = {
|
|
245
|
+
slug: name,
|
|
246
|
+
name,
|
|
247
|
+
path: mcpJsonPath,
|
|
248
|
+
type: 'mcp' as const,
|
|
249
|
+
...(typeof ext.description === 'string' ? { description: ext.description } : {}),
|
|
250
|
+
...(variables.length > 0 ? { variables } : {}),
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
if (raw.type === 'stdio') {
|
|
254
|
+
if (typeof raw.command !== 'string' || raw.command.length === 0) {
|
|
255
|
+
console.warn(`[tool-manuals] skipping stdio server "${name}" in ${mcpJsonPath}: no command.`);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
out.push({
|
|
259
|
+
...shared,
|
|
260
|
+
remote: false,
|
|
261
|
+
stdio: {
|
|
262
|
+
command: raw.command,
|
|
263
|
+
args: Array.isArray(raw.args) ? raw.args.map(String) : [],
|
|
264
|
+
env: isRecord(raw.env) ? (raw.env as Record<string, string>) : undefined,
|
|
265
|
+
cwd: typeof raw.cwd === 'string' ? raw.cwd : undefined,
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (raw.type === 'streamable-http' || raw.type === 'sse') {
|
|
272
|
+
if (typeof raw.url !== 'string' || raw.url.length === 0) {
|
|
273
|
+
console.warn(`[tool-manuals] skipping mcp server "${name}" in ${mcpJsonPath}: no url.`);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
// Remote-capable servers get the same SSRF gate `.tool` urls pass —
|
|
277
|
+
// otherwise mcp.json becomes the way to point the backend at loopback,
|
|
278
|
+
// private ranges, or the cloud metadata endpoint. A `local: true` entry
|
|
279
|
+
// is exempt because loopback is exactly what local MEANS, and only the
|
|
280
|
+
// user's own machine ever dials it.
|
|
281
|
+
// Scheme first, for EVERYONE: `local: true` exempts a server from the
|
|
282
|
+
// private-network reachability policy, not from being http(s) at all.
|
|
283
|
+
let schemeOk = false;
|
|
284
|
+
try {
|
|
285
|
+
const u = new URL(raw.url);
|
|
286
|
+
schemeOk = u.protocol === 'http:' || u.protocol === 'https:';
|
|
287
|
+
} catch {
|
|
288
|
+
schemeOk = false;
|
|
289
|
+
}
|
|
290
|
+
if (!schemeOk) {
|
|
291
|
+
console.warn(`[tool-manuals] skipping mcp server "${name}" in ${mcpJsonPath}: url must be http(s).`);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (ext.local !== true) {
|
|
295
|
+
try {
|
|
296
|
+
assertSafeFetchUrl(raw.url, { label: `mcp server "${name}" url` });
|
|
297
|
+
} catch (err) {
|
|
298
|
+
console.warn(
|
|
299
|
+
`[tool-manuals] skipping mcp server "${name}" in ${mcpJsonPath}: ` +
|
|
300
|
+
`${err instanceof Error ? err.message : String(err)} (declare it \`local: true\` if it is deliberately private).`,
|
|
301
|
+
);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
// Extension headers (auth, `${VAR}` refs) win over mcp.json's literal
|
|
306
|
+
// ones on a key collision: the portable file cannot carry a credential,
|
|
307
|
+
// so when both name the same header the extension is the operative one.
|
|
308
|
+
// Both sides pass the isRecord gate — spreading a malformed non-object
|
|
309
|
+
// value (a string, say) would scatter its indices into header keys.
|
|
310
|
+
const headers = {
|
|
311
|
+
...(isRecord(raw.headers) ? (raw.headers as Record<string, string>) : {}),
|
|
312
|
+
...(isRecord(ext.headers) ? (ext.headers as Record<string, string>) : {}),
|
|
313
|
+
};
|
|
314
|
+
out.push({
|
|
315
|
+
...shared,
|
|
316
|
+
url: raw.url,
|
|
317
|
+
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
|
318
|
+
...(ext.local === true ? { remote: false } : {}),
|
|
319
|
+
});
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Unknown transport: the spec says an unknown `type` invalidates the
|
|
324
|
+
// ENTRY, not the file — skip it, keep its siblings.
|
|
325
|
+
console.warn(`[tool-manuals] skipping mcp server "${name}" in ${mcpJsonPath}: unknown type "${raw.type}".`);
|
|
326
|
+
}
|
|
327
|
+
return out;
|
|
328
|
+
}
|