@loicngr/kobo 2.1.26 → 2.1.28
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/AGENTS.md +6 -3
- package/CHANGELOG.md +11 -0
- package/CONFIGURATION.md +97 -49
- package/SECURITY.md +33 -6
- package/dist/mcp-server/kobo-tasks-server.js +36 -1021
- package/dist/mcp-server/managed-mcp-dispatcher.js +960 -0
- package/dist/mcp-server/workspace-dialogue-client.js +26 -1
- package/dist/server/index.js +52 -19
- package/dist/server/middleware/host-check-middleware.js +8 -5
- package/dist/server/middleware/network-auth-middleware.js +51 -29
- package/dist/server/middleware/security-headers-middleware.js +19 -0
- package/dist/server/middleware/traffic-limits-middleware.js +74 -0
- package/dist/server/routes/auth.js +73 -0
- package/dist/server/routes/internal-mcp.js +137 -0
- package/dist/server/routes/mcp-credentials.js +30 -0
- package/dist/server/routes/mcp.js +156 -76
- package/dist/server/routes/workspaces.js +19 -3
- package/dist/server/services/agent/orchestrator.js +27 -17
- package/dist/server/services/external-mcp-credentials-service.js +110 -0
- package/dist/server/services/forge/github/provider.js +15 -3
- package/dist/server/services/forge/types.js +8 -0
- package/dist/server/services/internal-mcp-capability-service.js +98 -0
- package/dist/server/services/mcp-connection-info-service.js +1 -1
- package/dist/server/services/memory-mcp-service.js +9 -7
- package/dist/server/services/memory-service.js +15 -0
- package/dist/server/services/network-access-service.js +43 -73
- package/dist/server/services/network-session-service.js +182 -0
- package/dist/server/services/settings-service.js +29 -7
- package/dist/server/services/terminal-websocket-service.js +5 -0
- package/dist/server/services/websocket-service.js +5 -0
- package/dist/server/utils/paths.js +2 -1
- package/dist/server/utils/startup-display.js +1 -2
- package/dist/shared/models.js +6 -0
- package/docker-compose.vps.yml +6 -0
- package/docs/deployment-vps.md +24 -12
- package/package.json +1 -1
- package/src/client/dist/pwa/assets/ActivityFeed-D58VS9o2.js +2 -0
- package/src/client/dist/pwa/assets/{AutoLoopFinalReviewSettings-DOr41E2Y.js → AutoLoopFinalReviewSettings-CK3r_fVB.js} +1 -1
- package/src/client/dist/pwa/assets/{ChangelogPage-DOCgZMpn.js → ChangelogPage-CAAUEn5X.js} +1 -1
- package/src/client/dist/pwa/assets/ComparisonPanel-BCb7vB5E.js +2 -0
- package/src/client/dist/pwa/assets/CreatePage-DasH1Uk4.js +2 -0
- package/src/client/dist/pwa/assets/{DashboardPage-dBpJaQSb.js → DashboardPage-XE52EABP.js} +1 -1
- package/src/client/dist/pwa/assets/DiffViewer-CncKlXne.js +8 -0
- package/src/client/dist/pwa/assets/{DocumentsPanel-AqLx0MzB.js → DocumentsPanel-L4pbQfz1.js} +1 -1
- package/src/client/dist/pwa/assets/{DrawerToggleButton-BLWaFgh0.js → DrawerToggleButton-DiOJm5he.js} +1 -1
- package/src/client/dist/pwa/assets/GitPanel-WJlzy2VJ.js +3 -0
- package/src/client/dist/pwa/assets/{GitPanel-CRaLJzjc.css → GitPanel-yHvU38dp.css} +1 -1
- package/src/client/dist/pwa/assets/{HealthPage-DeryVLyz.js → HealthPage-hJY5MurK.js} +1 -1
- package/src/client/dist/pwa/assets/MainLayout-DgWElK7w.js +2 -0
- package/src/client/dist/pwa/assets/{MemoryPanel-B34DENtz.js → MemoryPanel-DtEvh_b7.js} +1 -1
- package/src/client/dist/pwa/assets/{MemoryProposalList-BpN_OU8J.js → MemoryProposalList-kgFCm9zz.js} +1 -1
- package/src/client/dist/pwa/assets/{QBtnDropdown-BPcK_rVs.js → QBtnDropdown-CWPIugs8.js} +1 -1
- package/src/client/dist/pwa/assets/{QExpansionItem-CpwQ8L_F.js → QExpansionItem-D-EGL2b3.js} +1 -1
- package/src/client/dist/pwa/assets/{QScrollArea-BkNSVl7s.js → QScrollArea-BMrqK-xd.js} +1 -1
- package/src/client/dist/pwa/assets/QSelect-DFWyDWCE.js +1 -0
- package/src/client/dist/pwa/assets/{QTooltip-Dp8fitHV.js → QTooltip-Bk8dLUtU.js} +1 -1
- package/src/client/dist/pwa/assets/{SchedulePanel-Ck8LKDCa.js → SchedulePanel-CuxmxCKi.js} +1 -1
- package/src/client/dist/pwa/assets/{SearchPage-BAUZg1zq.js → SearchPage-DKnQPUqY.js} +1 -1
- package/src/client/dist/pwa/assets/{SettingsPage-Dcq1Jeoo.css → SettingsPage-B4rmd2WX.css} +1 -1
- package/src/client/dist/pwa/assets/SettingsPage-CO3vIpPd.js +9 -0
- package/src/client/dist/pwa/assets/{SplitWorkspacePage-CH8OHmtn.js → SplitWorkspacePage-WhQyX8fn.js} +1 -1
- package/src/client/dist/pwa/assets/{TerminalPanel-CBmZ0IzQ.js → TerminalPanel-BY0lPq6k.js} +10 -10
- package/src/client/dist/pwa/assets/{TerminalPanel-Dz9EE-Ph.css → TerminalPanel-BcbwuFVR.css} +1 -1
- package/src/client/dist/pwa/assets/{ToolCallItem-CLtGTV6w.js → ToolCallItem-DYubi-J0.js} +3 -3
- package/src/client/dist/pwa/assets/{TouchPan-9SH2dx6m.js → TouchPan-DDotyYLN.js} +1 -1
- package/src/client/dist/pwa/assets/{TourReplayButton-Dw4sGf9B.js → TourReplayButton-kAnEMcy2.js} +1 -1
- package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-wd_sObJa.js → WorkspaceAttentionLabels-DiYt3rP3.js} +1 -1
- package/src/client/dist/pwa/assets/WorkspacePage-K-1ce50d.js +4 -0
- package/src/client/dist/pwa/assets/{browser-CC4W8r76.js → browser-DVYLH8vl.js} +1 -1
- package/src/client/dist/pwa/assets/{build-path-tree-BBuyB-my.js → build-path-tree-DEuOdIIk.js} +1 -1
- package/src/client/dist/pwa/assets/custom-sounds-Ggw6V9iF.js +1 -0
- package/src/client/dist/pwa/assets/de-Bk3zQrin.js +17 -0
- package/src/client/dist/pwa/assets/{editor.api-BwfxvQ88.js → editor.api-BC38eXAS.js} +1 -1
- package/src/client/dist/pwa/assets/{efforts-B3I7w5bs.js → efforts-TGacUPcJ.js} +1 -1
- package/src/client/dist/pwa/assets/es-f3xuAWA9.js +17 -0
- package/src/client/dist/pwa/assets/{expand-template-Ci9UjnxZ.js → expand-template-DM8gq3Mo.js} +1 -1
- package/src/client/dist/pwa/assets/{format-CJIzi3Vf.js → format-OwxXBnDJ.js} +1 -1
- package/src/client/dist/pwa/assets/{formatters-DQ8cP7nO.js → formatters-YgaCZcwJ.js} +1 -1
- package/src/client/dist/pwa/assets/fr-B6c21SG3.js +17 -0
- package/src/client/dist/pwa/assets/{freemarker2-DbpVO-cL.js → freemarker2-l7R42fI2.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-BHve0PpA.js → handlebars-DFmPrgjH.js} +1 -1
- package/src/client/dist/pwa/assets/{html-BhUkdFPz.js → html-CERIAlPM.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-WaE4cZK9.js +1 -0
- package/src/client/dist/pwa/assets/index-CpWBGKdM.js +20 -0
- package/src/client/dist/pwa/assets/it-DUstDpC2.js +17 -0
- package/src/client/dist/pwa/assets/{javascript-BLAorPa3.js → javascript-BBYncplF.js} +1 -1
- package/src/client/dist/pwa/assets/{liquid-CJGF-h-f.js → liquid-C1HqYyYU.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-Cm0oNz_l.js → mdx-E74dsP22.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco-contributions-nBg6wf05.js → monaco-contributions-DHvm-kp5.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco.contribution-hr9s26Ud.js → monaco.contribution-DYdn9CwR.js} +2 -2
- package/src/client/dist/pwa/assets/network-auth-DTZUKbny.js +1 -0
- package/src/client/dist/pwa/assets/{notify-theme-EcbxuH_8.js → notify-theme-FuTL7HuS.js} +1 -1
- package/src/client/dist/pwa/assets/{permissionModes-DLWiBfdb.js → permissionModes-D-Nexnoo.js} +1 -1
- package/src/client/dist/pwa/assets/{project-color-vDuTaTJn.js → project-color-_snXWYUp.js} +1 -1
- package/src/client/dist/pwa/assets/{python-BEjbzFAQ.js → python-DNAAGBcX.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-BJPT7Wxx.js → razor-CB3g5p_T.js} +1 -1
- package/src/client/dist/pwa/assets/{typescript-DPqM46Ef.js → typescript-0kpz8bqP.js} +1 -1
- package/src/client/dist/pwa/assets/{use-search-index-status-BwwrB410.js → use-search-index-status-D38yFOD_.js} +1 -1
- package/src/client/dist/pwa/assets/{use-tours-B0KtCS9w.js → use-tours-BWkHp34X.js} +2 -2
- package/src/client/dist/pwa/assets/{use-worktree-restore-tzd2FESU.js → use-worktree-restore-DUjZr44N.js} +1 -1
- package/src/client/dist/pwa/assets/{workspace-templates-CubzsZUU.js → workspace-templates-D-lPJQ4G.js} +1 -1
- package/src/client/dist/pwa/assets/{xml-C6SVzw5_.js → xml-BbOuqdUe.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-OpCqnv4g.js → yaml-gasFQtfk.js} +1 -1
- package/src/client/dist/pwa/index.html +1 -1
- package/src/client/dist/pwa/sw.js +1 -1
- package/src/mcp-server/README.md +13 -19
- package/src/mcp-server/kobo-tasks-server.ts +42 -1146
- package/src/mcp-server/managed-mcp-dispatcher.ts +1071 -0
- package/src/mcp-server/workspace-dialogue-client.ts +26 -1
- package/src/client/dist/pwa/assets/ActivityFeed-DfYrVNEh.js +0 -2
- package/src/client/dist/pwa/assets/ComparisonPanel-b5nTBjK6.js +0 -2
- package/src/client/dist/pwa/assets/CreatePage-B7VPHEqm.js +0 -2
- package/src/client/dist/pwa/assets/DiffViewer-BLQz2Qyx.js +0 -8
- package/src/client/dist/pwa/assets/GitPanel-Ba7ltCsM.js +0 -3
- package/src/client/dist/pwa/assets/MainLayout-B2XvjdWi.js +0 -2
- package/src/client/dist/pwa/assets/QSelect-DrLpbvcr.js +0 -1
- package/src/client/dist/pwa/assets/SettingsPage-Cj6kSVT5.js +0 -9
- package/src/client/dist/pwa/assets/WorkspacePage-D4ODPOG-.js +0 -4
- package/src/client/dist/pwa/assets/custom-sounds-BbPNRzAs.js +0 -1
- package/src/client/dist/pwa/assets/de-D9VzPDGF.js +0 -17
- package/src/client/dist/pwa/assets/es-3R4MJuTt.js +0 -17
- package/src/client/dist/pwa/assets/fr-DXwDVnJ4.js +0 -17
- package/src/client/dist/pwa/assets/i18n-CGLQAADf.js +0 -1
- package/src/client/dist/pwa/assets/index-CNcXBpzz.js +0 -20
- package/src/client/dist/pwa/assets/it-T9AjnG42.js +0 -17
- package/src/client/dist/pwa/assets/network-auth-C850jGfI.js +0 -1
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { bodyLimit } from 'hono/body-limit';
|
|
3
|
+
import { createExternalMcpCredential, EXTERNAL_TOOL_CATALOGUE, listExternalMcpCredentials, revokeExternalMcpCredential, } from '../services/external-mcp-credentials-service.js';
|
|
4
|
+
// Mounted behind the operator authentication gate; MCP credentials cannot reach it.
|
|
5
|
+
const app = new Hono();
|
|
6
|
+
app.use('*', bodyLimit({ maxSize: 32 * 1024 }));
|
|
7
|
+
app.use('*', async (c, next) => {
|
|
8
|
+
c.header('Cache-Control', 'no-store');
|
|
9
|
+
await next();
|
|
10
|
+
});
|
|
11
|
+
app.get('/', (c) => c.json({
|
|
12
|
+
credentials: listExternalMcpCredentials(),
|
|
13
|
+
tools: EXTERNAL_TOOL_CATALOGUE.map((tool) => ({
|
|
14
|
+
name: tool.name,
|
|
15
|
+
readOnly: tool.annotations?.readOnlyHint === true,
|
|
16
|
+
})),
|
|
17
|
+
}));
|
|
18
|
+
app.post('/', async (c) => {
|
|
19
|
+
try {
|
|
20
|
+
return c.json(createExternalMcpCredential(await c.req.json()), 201);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
return c.json({ error: error instanceof Error ? error.message : 'Invalid credential request' }, 400);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
app.delete('/:id', (c) => {
|
|
27
|
+
revokeExternalMcpCredential(c.req.param('id'));
|
|
28
|
+
return c.json({ ok: true });
|
|
29
|
+
});
|
|
30
|
+
export default app;
|
|
@@ -4,14 +4,16 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
|
|
|
4
4
|
import { Hono } from 'hono';
|
|
5
5
|
import { bodyLimit } from 'hono/body-limit';
|
|
6
6
|
import { MAX_MCP_ATTACHMENT_REQUEST_BYTES } from '../../shared/mcp-attachments.js';
|
|
7
|
-
import {
|
|
8
|
-
import { isProgressBarTool
|
|
9
|
-
import { isWorkspaceCreationTool
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
7
|
+
import { isMemoryToolName } from '../../shared/memory-tools.js';
|
|
8
|
+
import { isProgressBarTool } from '../../shared/progress-bar-tools.js';
|
|
9
|
+
import { isWorkspaceCreationTool } from '../../shared/workspace-creation-tools.js';
|
|
10
|
+
import { isWorkspaceGroupMessageTool } from '../../shared/workspace-group-message-tools.js';
|
|
11
|
+
import { isWorkspaceLifecycleTool } from '../../shared/workspace-lifecycle-tools.js';
|
|
12
|
+
import { getDb } from '../db/index.js';
|
|
13
|
+
import { authenticateExternalMcpToken, EXTERNAL_TOOL_CATALOGUE, } from '../services/external-mcp-credentials-service.js';
|
|
13
14
|
import { createMcpClientContext } from '../services/mcp-client-context.js';
|
|
14
15
|
import { executeMemoryMcpTool } from '../services/memory-mcp-service.js';
|
|
16
|
+
import { resolveWorkspaceMemoryScope } from '../services/memory-service.js';
|
|
15
17
|
import { executeProgressBarTool, progressErrorData } from '../services/progress-bar-mcp-service.js';
|
|
16
18
|
import { ProgressBarError } from '../services/progress-bar-service.js';
|
|
17
19
|
import { executeWorkspaceCreationTool, WorkspaceCreationMcpError } from '../services/workspace-creation-mcp-service.js';
|
|
@@ -19,7 +21,16 @@ import { executeWorkspaceDialogueTool } from '../services/workspace-dialogue-ser
|
|
|
19
21
|
import { executeWorkspaceGroupMessageTool, WorkspaceGroupMessageMcpError, } from '../services/workspace-group-message-mcp-service.js';
|
|
20
22
|
import { executeWorkspaceLifecycleTool, WorkspaceLifecycleMcpError, } from '../services/workspace-lifecycle-mcp-service.js';
|
|
21
23
|
import { prepareMemoryToolEnvelope } from '../utils/memory-token-budget.js';
|
|
24
|
+
function authenticateRequest(authorization = '') {
|
|
25
|
+
return authenticateExternalMcpToken(authorization.startsWith('Bearer ') ? authorization.slice(7) : '');
|
|
26
|
+
}
|
|
22
27
|
const app = new Hono();
|
|
28
|
+
app.use('*', async (c, next) => {
|
|
29
|
+
if (!authenticateRequest(c.req.header('Authorization')))
|
|
30
|
+
return c.json({ error: 'Dedicated MCP credential required' }, 401);
|
|
31
|
+
c.header('Cache-Control', 'no-store');
|
|
32
|
+
return next();
|
|
33
|
+
});
|
|
23
34
|
// Only creation and message envelopes may carry the larger, base64 attachment payload.
|
|
24
35
|
// Other tools retain their existing 1 MiB limit, including malformed envelopes.
|
|
25
36
|
app.use('*', bodyLimit({ maxSize: MAX_MCP_ATTACHMENT_REQUEST_BYTES }));
|
|
@@ -44,6 +55,9 @@ app.use('*', async (c, next) => {
|
|
|
44
55
|
return c.json({ error: 'MCP request is too large' }, 413);
|
|
45
56
|
}
|
|
46
57
|
}
|
|
58
|
+
// Uploads can outlive their credential. Never retain the pre-body principal.
|
|
59
|
+
if (!authenticateRequest(c.req.header('Authorization')))
|
|
60
|
+
return c.json({ error: 'Dedicated MCP credential required' }, 401);
|
|
47
61
|
return next();
|
|
48
62
|
});
|
|
49
63
|
/** Dispatch only the supported MCP operations through the existing UI handlers. */
|
|
@@ -66,83 +80,27 @@ async function dispatchWorkspaceRequest(pathname, init) {
|
|
|
66
80
|
throw new Error('Unsupported workspace operation');
|
|
67
81
|
}
|
|
68
82
|
app.all('/', async (c) => {
|
|
83
|
+
const currentPrincipal = () => {
|
|
84
|
+
const principal = authenticateRequest(c.req.header('Authorization'));
|
|
85
|
+
if (!principal)
|
|
86
|
+
throw new Error('MCP credential expired or revoked');
|
|
87
|
+
return principal;
|
|
88
|
+
};
|
|
69
89
|
const server = new Server({ name: 'kobo-workspaces', version: '1.0.0' }, { capabilities: { tools: {} } });
|
|
70
90
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
71
91
|
sessionIdGenerator: undefined,
|
|
72
92
|
enableJsonResponse: true,
|
|
73
93
|
maxRequestBodySize: MAX_MCP_ATTACHMENT_REQUEST_BYTES,
|
|
74
94
|
});
|
|
75
|
-
server.setRequestHandler(ListToolsRequestSchema, async () =>
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
...WORKSPACE_LIFECYCLE_TOOLS,
|
|
80
|
-
...WORKSPACE_GROUP_MESSAGE_TOOLS,
|
|
81
|
-
...EXTERNAL_MEMORY_TOOL_DEFINITIONS,
|
|
82
|
-
...PROGRESS_BAR_TOOLS,
|
|
83
|
-
],
|
|
84
|
-
}));
|
|
95
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
96
|
+
const principal = currentPrincipal();
|
|
97
|
+
return { tools: EXTERNAL_TOOL_CATALOGUE.filter((tool) => canUseExternalMcpTool(principal, tool.name)) };
|
|
98
|
+
});
|
|
85
99
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
? executeProgressBarTool(request.params.name, request.params.arguments ?? {})
|
|
91
|
-
: isMemory
|
|
92
|
-
? executeMemoryMcpTool(request.params.name, request.params.arguments ?? {}, source)
|
|
93
|
-
: isWorkspaceCreationTool(request.params.name)
|
|
94
|
-
? await executeWorkspaceCreationTool(request.params.name, request.params.arguments ?? {}, dispatchWorkspaceRequest)
|
|
95
|
-
: isWorkspaceLifecycleTool(request.params.name)
|
|
96
|
-
? await executeWorkspaceLifecycleTool(request.params.name, request.params.arguments ?? {}, dispatchWorkspaceRequest)
|
|
97
|
-
: isWorkspaceGroupMessageTool(request.params.name)
|
|
98
|
-
? executeWorkspaceGroupMessageTool(request.params.name, request.params.arguments ?? {}, source)
|
|
99
|
-
: await executeWorkspaceDialogueTool(request.params.name, request.params.arguments ?? {}, source);
|
|
100
|
-
if (isMemory) {
|
|
101
|
-
const bounded = prepareMemoryToolEnvelope(data, { targetTokens: 1_000, transport: 'external' });
|
|
102
|
-
if (bounded.data.memoryOutputSuppressed === true)
|
|
103
|
-
return { content: [], isError: true };
|
|
104
|
-
const { __mcpError, ...publicData } = bounded.data;
|
|
105
|
-
return {
|
|
106
|
-
content: [{ type: 'text', text: JSON.stringify(publicData) }],
|
|
107
|
-
structuredContent: publicData,
|
|
108
|
-
...(__mcpError === true || publicData.budgetExhausted === true ? { isError: true } : {}),
|
|
109
|
-
};
|
|
110
|
-
}
|
|
111
|
-
const structuredContent = data && typeof data === 'object' && !Array.isArray(data) ? data : undefined;
|
|
112
|
-
return {
|
|
113
|
-
content: [{ type: 'text', text: JSON.stringify(data) }],
|
|
114
|
-
...(structuredContent ? { structuredContent } : {}),
|
|
115
|
-
...(structuredContent?.accepted === false ? { isError: true } : {}),
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
catch (error) {
|
|
119
|
-
if (error instanceof ProgressBarError) {
|
|
120
|
-
const data = progressErrorData(error);
|
|
121
|
-
return { isError: true, content: [{ type: 'text', text: JSON.stringify(data) }], structuredContent: data };
|
|
122
|
-
}
|
|
123
|
-
if (error instanceof WorkspaceGroupMessageMcpError) {
|
|
124
|
-
const data = { error: error.message, status: error.status };
|
|
125
|
-
return { isError: true, content: [{ type: 'text', text: JSON.stringify(data) }], structuredContent: data };
|
|
126
|
-
}
|
|
127
|
-
if (error instanceof WorkspaceCreationMcpError || error instanceof WorkspaceLifecycleMcpError) {
|
|
128
|
-
const data = { error: error.message, status: error.status, stage: error.stage, details: error.details };
|
|
129
|
-
return { isError: true, content: [{ type: 'text', text: JSON.stringify(data) }], structuredContent: data };
|
|
130
|
-
}
|
|
131
|
-
if (isMemory) {
|
|
132
|
-
const bounded = prepareMemoryToolEnvelope({
|
|
133
|
-
error: error instanceof Error ? error.message : String(error),
|
|
134
|
-
}, { targetTokens: 1_000, transport: 'external' });
|
|
135
|
-
return {
|
|
136
|
-
isError: true,
|
|
137
|
-
content: [{ type: 'text', text: JSON.stringify(bounded.data) }],
|
|
138
|
-
structuredContent: bounded.data,
|
|
139
|
-
};
|
|
140
|
-
}
|
|
141
|
-
return {
|
|
142
|
-
isError: true,
|
|
143
|
-
content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
|
|
144
|
-
};
|
|
145
|
-
}
|
|
100
|
+
const source = createMcpClientContext(c.req.header('X-Kobo-Client-Name'), c.req.header('X-Kobo-Client-Transport') === 'stdio' ? 'stdio' : 'http', c.req.header('X-Kobo-Client-Name-Encoding'));
|
|
101
|
+
// This is the acceptance boundary: already-dispatched operations are not
|
|
102
|
+
// cancelled on later revocation, but every new call reloads the live grant.
|
|
103
|
+
return executeExternalMcpTool(request.params.name, request.params.arguments ?? {}, source, currentPrincipal());
|
|
146
104
|
});
|
|
147
105
|
try {
|
|
148
106
|
await server.connect(transport);
|
|
@@ -155,4 +113,126 @@ app.all('/', async (c) => {
|
|
|
155
113
|
await server.close();
|
|
156
114
|
}
|
|
157
115
|
});
|
|
116
|
+
export async function executeExternalMcpTool(name, args, source, principal) {
|
|
117
|
+
const isMemory = isMemoryToolName(name);
|
|
118
|
+
try {
|
|
119
|
+
if (principal)
|
|
120
|
+
args = authorizeExternalMcpCall(principal, name, args);
|
|
121
|
+
let data = isProgressBarTool(name)
|
|
122
|
+
? executeProgressBarTool(name, args)
|
|
123
|
+
: isMemory
|
|
124
|
+
? executeMemoryMcpTool(name, args, source, principal && !principal.allWorkspaces ? principal.workspaceIds : undefined)
|
|
125
|
+
: isWorkspaceCreationTool(name)
|
|
126
|
+
? await executeWorkspaceCreationTool(name, args, dispatchWorkspaceRequest)
|
|
127
|
+
: isWorkspaceLifecycleTool(name)
|
|
128
|
+
? await executeWorkspaceLifecycleTool(name, args, dispatchWorkspaceRequest)
|
|
129
|
+
: isWorkspaceGroupMessageTool(name)
|
|
130
|
+
? executeWorkspaceGroupMessageTool(name, args, source)
|
|
131
|
+
: await executeWorkspaceDialogueTool(name, args, source);
|
|
132
|
+
if (principal && !principal.allWorkspaces && name === 'list_workspaces' && Array.isArray(data)) {
|
|
133
|
+
data = data.filter((workspace) => principal.workspaceIds.includes(workspace.id));
|
|
134
|
+
}
|
|
135
|
+
if (isMemory) {
|
|
136
|
+
const bounded = prepareMemoryToolEnvelope(data, { targetTokens: 1_000, transport: 'external' });
|
|
137
|
+
if (bounded.data.memoryOutputSuppressed === true)
|
|
138
|
+
return { content: [], isError: true };
|
|
139
|
+
const { __mcpError, ...publicData } = bounded.data;
|
|
140
|
+
return {
|
|
141
|
+
content: [{ type: 'text', text: JSON.stringify(publicData) }],
|
|
142
|
+
structuredContent: publicData,
|
|
143
|
+
...(__mcpError === true || publicData.budgetExhausted === true ? { isError: true } : {}),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
const structuredContent = data && typeof data === 'object' && !Array.isArray(data) ? data : undefined;
|
|
147
|
+
return {
|
|
148
|
+
content: [{ type: 'text', text: JSON.stringify(data) }],
|
|
149
|
+
...(structuredContent ? { structuredContent } : {}),
|
|
150
|
+
...(structuredContent?.accepted === false ? { isError: true } : {}),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
if (error instanceof ProgressBarError) {
|
|
155
|
+
const data = progressErrorData(error);
|
|
156
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify(data) }], structuredContent: data };
|
|
157
|
+
}
|
|
158
|
+
if (error instanceof WorkspaceGroupMessageMcpError) {
|
|
159
|
+
const data = { error: error.message, status: error.status };
|
|
160
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify(data) }], structuredContent: data };
|
|
161
|
+
}
|
|
162
|
+
if (error instanceof WorkspaceCreationMcpError || error instanceof WorkspaceLifecycleMcpError) {
|
|
163
|
+
const data = { error: error.message, status: error.status, stage: error.stage, details: error.details };
|
|
164
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify(data) }], structuredContent: data };
|
|
165
|
+
}
|
|
166
|
+
if (isMemory) {
|
|
167
|
+
const bounded = prepareMemoryToolEnvelope({
|
|
168
|
+
error: error instanceof Error ? error.message : String(error),
|
|
169
|
+
}, { targetTokens: 1_000, transport: 'external' });
|
|
170
|
+
return {
|
|
171
|
+
isError: true,
|
|
172
|
+
content: [{ type: 'text', text: JSON.stringify(bounded.data) }],
|
|
173
|
+
structuredContent: bounded.data,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
isError: true,
|
|
178
|
+
content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
158
182
|
export default app;
|
|
183
|
+
const allWorkspaceOnly = new Set([
|
|
184
|
+
'create_workspace',
|
|
185
|
+
'diagnose_workspace_pr',
|
|
186
|
+
'preview_workspace_group_message',
|
|
187
|
+
'get_workspace_group_message',
|
|
188
|
+
]);
|
|
189
|
+
export function canUseExternalMcpTool(principal, name) {
|
|
190
|
+
return principal.tools.includes(name) && (principal.allWorkspaces || !allWorkspaceOnly.has(name));
|
|
191
|
+
}
|
|
192
|
+
export function authorizeExternalMcpCall(principal, name, args) {
|
|
193
|
+
const deny = () => {
|
|
194
|
+
throw new Error('MCP credential does not authorize this operation');
|
|
195
|
+
};
|
|
196
|
+
if (!canUseExternalMcpTool(principal, name))
|
|
197
|
+
return deny();
|
|
198
|
+
// Resource-wide grants still use each service's normal argument validation.
|
|
199
|
+
if (principal.allWorkspaces)
|
|
200
|
+
return args;
|
|
201
|
+
const definition = EXTERNAL_TOOL_CATALOGUE.find((tool) => tool.name === name);
|
|
202
|
+
if (!definition || Object.keys(args).some((key) => !Object.hasOwn(definition.inputSchema.properties ?? {}, key)))
|
|
203
|
+
return deny();
|
|
204
|
+
if (name === 'list_workspaces')
|
|
205
|
+
return args;
|
|
206
|
+
if (name === 'send_workspace_group_message') {
|
|
207
|
+
if (!Array.isArray(args.workspace_ids) ||
|
|
208
|
+
args.workspace_ids.some((id) => typeof id !== 'string' || !principal.workspaceIds.includes(id)))
|
|
209
|
+
return deny();
|
|
210
|
+
return args;
|
|
211
|
+
}
|
|
212
|
+
if (isMemoryToolName(name)) {
|
|
213
|
+
if (name === 'list_memory_scopes') {
|
|
214
|
+
if (args.workspace_id !== undefined &&
|
|
215
|
+
(typeof args.workspace_id !== 'string' || !principal.workspaceIds.includes(args.workspace_id)))
|
|
216
|
+
return deny();
|
|
217
|
+
return args;
|
|
218
|
+
}
|
|
219
|
+
// Workspace aggregate memory also includes global/project entries. Restricted
|
|
220
|
+
// keys use only the workspace-owned scope, never inherited memory.
|
|
221
|
+
let scope;
|
|
222
|
+
if (typeof args.scope_id === 'string')
|
|
223
|
+
scope = getDb()
|
|
224
|
+
.prepare("SELECT id, workspace_id FROM memory_scopes WHERE id = ? AND level = 'workspace'")
|
|
225
|
+
.get(args.scope_id);
|
|
226
|
+
else if (typeof args.workspace_id === 'string' && principal.workspaceIds.includes(args.workspace_id)) {
|
|
227
|
+
const resolved = resolveWorkspaceMemoryScope(args.workspace_id);
|
|
228
|
+
scope = { id: resolved.id, workspace_id: args.workspace_id };
|
|
229
|
+
}
|
|
230
|
+
if (!scope?.workspace_id || !principal.workspaceIds.includes(scope.workspace_id))
|
|
231
|
+
return deny();
|
|
232
|
+
const { workspace_id: _workspace, ...rest } = args;
|
|
233
|
+
return { ...rest, scope_id: scope.id };
|
|
234
|
+
}
|
|
235
|
+
if (typeof args.workspace_id !== 'string' || !principal.workspaceIds.includes(args.workspace_id))
|
|
236
|
+
return deny();
|
|
237
|
+
return args;
|
|
238
|
+
}
|
|
@@ -36,7 +36,7 @@ import { buildEngineHandoff } from '../services/engine-handoff-service.js';
|
|
|
36
36
|
import { saveWorkspaceFile, shaOf } from '../services/file-editor-service.js';
|
|
37
37
|
import { getForgeProvider } from '../services/forge/registry.js';
|
|
38
38
|
import { resolveForge } from '../services/forge/resolve.js';
|
|
39
|
-
import { ForgeUnavailableError } from '../services/forge/types.js';
|
|
39
|
+
import { ForgeUnavailableError, MergePolicyError } from '../services/forge/types.js';
|
|
40
40
|
import { computeGitStats } from '../services/git-stats-service.js';
|
|
41
41
|
import { DEFAULT_NOTION_INITIAL_PROMPT, DEFAULT_SENTRY_INITIAL_PROMPT, renderNotionInitialPrompt, renderSentryInitialPrompt, } from '../services/initial-prompt-template-service.js';
|
|
42
42
|
import * as notionService from '../services/notion-service.js';
|
|
@@ -4233,11 +4233,18 @@ app.post('/:id/merge-pr', async (c) => {
|
|
|
4233
4233
|
}
|
|
4234
4234
|
body = parsed;
|
|
4235
4235
|
}
|
|
4236
|
+
if (body.admin !== undefined && typeof body.admin !== 'boolean') {
|
|
4237
|
+
return c.json({ error: 'admin must be a boolean' }, 400);
|
|
4238
|
+
}
|
|
4239
|
+
const admin = body.admin === true;
|
|
4236
4240
|
const method = body.method;
|
|
4237
4241
|
if (method !== undefined && !isMergeMethod(method)) {
|
|
4238
4242
|
return c.json({ error: 'method must be merge, squash or rebase' }, 400);
|
|
4239
4243
|
}
|
|
4240
4244
|
const provider = getForgeProvider(resolveForge(workspace.projectPath));
|
|
4245
|
+
if (admin && provider.id !== 'github') {
|
|
4246
|
+
return c.json({ error: 'Administrator merge is only supported for GitHub', code: 'merge_admin_unsupported' }, 409);
|
|
4247
|
+
}
|
|
4241
4248
|
if (!provider.capabilities.canMergeRequest) {
|
|
4242
4249
|
return c.json({ error: 'This project has no forge that can merge the PR', code: 'forge_unsupported' }, 409);
|
|
4243
4250
|
}
|
|
@@ -4255,10 +4262,16 @@ app.post('/:id/merge-pr', async (c) => {
|
|
|
4255
4262
|
if (snapshot?.state !== 'OPEN') {
|
|
4256
4263
|
return c.json({ error: 'No open PR found for this workspace', code: 'pr_not_open' }, 409);
|
|
4257
4264
|
}
|
|
4258
|
-
if (
|
|
4265
|
+
if (admin && snapshot.mergeable === 'CONFLICTING') {
|
|
4266
|
+
return c.json({ error: 'Resolve merge conflicts before merging', code: 'pr_not_ready' }, 409);
|
|
4267
|
+
}
|
|
4268
|
+
if (!snapshot.readyToMerge && !admin) {
|
|
4259
4269
|
return c.json({ error: 'This PR is not ready to merge', code: 'pr_not_ready' }, 409);
|
|
4260
4270
|
}
|
|
4261
|
-
if (
|
|
4271
|
+
if (admin) {
|
|
4272
|
+
await provider.mergeRequest(workspace.worktreePath, snapshot.number, method, { admin: true });
|
|
4273
|
+
}
|
|
4274
|
+
else if (method === undefined) {
|
|
4262
4275
|
await provider.mergeRequest(workspace.worktreePath, snapshot.number);
|
|
4263
4276
|
}
|
|
4264
4277
|
else {
|
|
@@ -4268,6 +4281,9 @@ app.post('/:id/merge-pr', async (c) => {
|
|
|
4268
4281
|
return c.json({ success: true, merged: merged?.state === 'MERGED', branch: workspace.workingBranch });
|
|
4269
4282
|
}
|
|
4270
4283
|
catch (err) {
|
|
4284
|
+
if (err instanceof MergePolicyError) {
|
|
4285
|
+
return c.json({ error: err.message, code: err.code }, 409);
|
|
4286
|
+
}
|
|
4271
4287
|
if (err instanceof ForgeUnavailableError) {
|
|
4272
4288
|
return c.json({ error: err.message, code: 'forge_unavailable' }, 409);
|
|
4273
4289
|
}
|
|
@@ -3,7 +3,7 @@ import { nanoid } from 'nanoid';
|
|
|
3
3
|
import { renderWorkflowPolicy } from '../../../shared/workflow-policy.js';
|
|
4
4
|
import { getDb } from '../../db/index.js';
|
|
5
5
|
import { assertAgentStopped } from '../../utils/agent-stop-result.js';
|
|
6
|
-
import { ensureKoboHome, getCompiledMcpServerPath,
|
|
6
|
+
import { ensureKoboHome, getCompiledMcpServerPath, getKoboHome, getMcpServerSourcePath, getSkillsPath, } from '../../utils/paths.js';
|
|
7
7
|
import { assertWorkspaceLifecycleAvailable, workspaceLifecycleReason } from '../../utils/workspace-lifecycle-guard.js';
|
|
8
8
|
import { cancelAutoLoopFinalReview, getAutoLoopReviewLaunch, isAutoLoopReview, prepareAutoLoopReviewReturn, retryAutoLoopReviewReturn, } from '../auto-loop-final-review-service.js';
|
|
9
9
|
import { listLoopMessages, settleLoopMessages } from '../auto-loop-message-service.js';
|
|
@@ -11,6 +11,7 @@ import * as autoLoopService from '../auto-loop-service.js';
|
|
|
11
11
|
import * as cleanupScriptService from '../cleanup-script-service.js';
|
|
12
12
|
import * as cronService from '../cron-service.js';
|
|
13
13
|
import { resolveForge } from '../forge/resolve.js';
|
|
14
|
+
import { createInternalMcpCapability, revokeInternalMcpCapability } from '../internal-mcp-capability-service.js';
|
|
14
15
|
import * as lifecycleHookService from '../lifecycle-hook-service.js';
|
|
15
16
|
import { advanceMemoryConversationEpoch, allocateMemoryConversationKey, bindMemoryNativeConversation, createMemoryCapability, revokeMemoryCapability, } from '../memory-agent-runtime.js';
|
|
16
17
|
import { buildMemoryContext, markMemoryContextFailed, markMemoryContextInitialized, markMemoryContextSubmitted, } from '../memory-context-service.js';
|
|
@@ -494,27 +495,19 @@ function readEffectiveSettingsSafe(projectPath) {
|
|
|
494
495
|
};
|
|
495
496
|
}
|
|
496
497
|
}
|
|
497
|
-
function buildMcpServers(workspaceId,
|
|
498
|
+
function buildMcpServers(workspaceId, internalCapability, launchEnv) {
|
|
498
499
|
const mcpServerCompiled = getCompiledMcpServerPath();
|
|
499
500
|
const mcpServerSource = getMcpServerSourcePath();
|
|
500
|
-
const global = getGlobalSettings();
|
|
501
501
|
return [
|
|
502
502
|
{
|
|
503
503
|
name: 'kobo-tasks',
|
|
504
504
|
command: mcpServerCompiled ? 'node' : 'npx',
|
|
505
505
|
args: mcpServerCompiled ? [mcpServerCompiled] : ['tsx', mcpServerSource],
|
|
506
506
|
env: {
|
|
507
|
+
...Object.fromEntries(Object.entries(launchEnv ?? {}).filter(([key, value]) => key !== 'KOBO_NETWORK_TOKEN' && typeof value === 'string')),
|
|
507
508
|
KOBO_WORKSPACE_ID: workspaceId,
|
|
508
|
-
...
|
|
509
|
-
...(memoryCapability ? { KOBO_MEMORY_SESSION_TOKEN: memoryCapability } : {}),
|
|
510
|
-
KOBO_DB_PATH: getDbPath(),
|
|
511
|
-
KOBO_SETTINGS_PATH: getSettingsPath(),
|
|
509
|
+
...(internalCapability ? { KOBO_MCP_SESSION_TOKEN: internalCapability } : {}),
|
|
512
510
|
KOBO_BACKEND_URL: `http://127.0.0.1:${backendPort}`,
|
|
513
|
-
// Behind a proxy, even internal loopback requests require network auth.
|
|
514
|
-
// Per-launch memory/progress capabilities remain independently required.
|
|
515
|
-
...(global.networkAccessBehindProxy && global.networkAccessToken
|
|
516
|
-
? { KOBO_NETWORK_TOKEN: global.networkAccessToken }
|
|
517
|
-
: {}),
|
|
518
511
|
},
|
|
519
512
|
},
|
|
520
513
|
];
|
|
@@ -1361,11 +1354,16 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
|
|
|
1361
1354
|
}
|
|
1362
1355
|
const launchOptions = new WeakMap();
|
|
1363
1356
|
const controllerProgressCapabilities = new WeakMap();
|
|
1357
|
+
const controllerInternalMcpCapabilities = new WeakMap();
|
|
1364
1358
|
const controllerMemoryCapabilities = new WeakMap();
|
|
1365
1359
|
const controllerMemoryConversationKeys = new WeakMap();
|
|
1366
1360
|
const controllerMemoryContextIds = new WeakMap();
|
|
1367
1361
|
const memoryCompactionStates = new WeakMap();
|
|
1368
1362
|
function revokeControllerCapabilities(controller) {
|
|
1363
|
+
const internalToken = controllerInternalMcpCapabilities.get(controller);
|
|
1364
|
+
if (internalToken)
|
|
1365
|
+
revokeInternalMcpCapability(internalToken);
|
|
1366
|
+
controllerInternalMcpCapabilities.delete(controller);
|
|
1369
1367
|
const progress = controllerProgressCapabilities.get(controller);
|
|
1370
1368
|
if (progress)
|
|
1371
1369
|
revokeProgressCapability(progress.token);
|
|
@@ -1574,6 +1572,17 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
1574
1572
|
}),
|
|
1575
1573
|
readOnly: effectivePermissionMode === 'plan' || !!launch?.handoffGeneration,
|
|
1576
1574
|
});
|
|
1575
|
+
const progressCapability = createProgressCapability(workspaceId, agentSessionId);
|
|
1576
|
+
const internalMcpCapability = createInternalMcpCapability({
|
|
1577
|
+
workspaceId,
|
|
1578
|
+
sessionId: agentSessionId,
|
|
1579
|
+
readOnly: effectivePermissionMode === 'plan' || !!launch?.handoffGeneration || !!launch?.mcpEnv?.KOBO_FINAL_REVIEW_TOKEN,
|
|
1580
|
+
env: {
|
|
1581
|
+
...launch?.mcpEnv,
|
|
1582
|
+
KOBO_MEMORY_SESSION_TOKEN: memoryCapability.token,
|
|
1583
|
+
KOBO_PROGRESS_SESSION_TOKEN: progressCapability.token,
|
|
1584
|
+
},
|
|
1585
|
+
});
|
|
1577
1586
|
const unavailableIntegrations = [];
|
|
1578
1587
|
const integrationServers = buildIntegrationMcpServers(getGlobalSettings(), engine.id, (name) => unavailableIntegrations.push(name));
|
|
1579
1588
|
const options = {
|
|
@@ -1598,7 +1607,10 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
1598
1607
|
}
|
|
1599
1608
|
})(),
|
|
1600
1609
|
settings,
|
|
1601
|
-
mcpServers: [
|
|
1610
|
+
mcpServers: [
|
|
1611
|
+
...buildMcpServers(workspaceId, internalMcpCapability.token, internalMcpCapability.env),
|
|
1612
|
+
...integrationServers,
|
|
1613
|
+
],
|
|
1602
1614
|
env: ws ? buildAgentEnv(ws.projectPath) : undefined,
|
|
1603
1615
|
};
|
|
1604
1616
|
// Snapshot only application-owned launch instructions. Memory is appended
|
|
@@ -1629,20 +1641,18 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
1629
1641
|
.run(new Date().toISOString(), workspaceId, prompt);
|
|
1630
1642
|
};
|
|
1631
1643
|
}
|
|
1632
|
-
const progressCapability = createProgressCapability(workspaceId, agentSessionId);
|
|
1633
|
-
const managedMcp = options.mcpServers?.find((server) => server.name === 'kobo-tasks');
|
|
1634
|
-
if (managedMcp)
|
|
1635
|
-
managedMcp.env.KOBO_PROGRESS_SESSION_TOKEN = progressCapability.token;
|
|
1636
1644
|
try {
|
|
1637
1645
|
beforeDispatch?.();
|
|
1638
1646
|
}
|
|
1639
1647
|
catch (error) {
|
|
1648
|
+
revokeInternalMcpCapability(internalMcpCapability.token);
|
|
1640
1649
|
revokeMemoryCapability(memoryCapability.token);
|
|
1641
1650
|
revokeProgressCapability(progressCapability.token);
|
|
1642
1651
|
throw error;
|
|
1643
1652
|
}
|
|
1644
1653
|
let controller;
|
|
1645
1654
|
controller = new SessionController(workspaceId, agentSessionId, engine, (ev) => handleEvent(workspaceId, agentSessionId, controller, ev));
|
|
1655
|
+
controllerInternalMcpCapabilities.set(controller, internalMcpCapability.token);
|
|
1646
1656
|
controllerProgressCapabilities.set(controller, progressCapability);
|
|
1647
1657
|
controllerMemoryCapabilities.set(controller, memoryCapability.token);
|
|
1648
1658
|
controllerMemoryConversationKeys.set(controller, memoryCapability.conversationKey);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { EXTERNAL_MEMORY_TOOL_DEFINITIONS } from '../../shared/memory-tools.js';
|
|
5
|
+
import { PROGRESS_BAR_TOOLS } from '../../shared/progress-bar-tools.js';
|
|
6
|
+
import { WORKSPACE_CREATION_TOOLS } from '../../shared/workspace-creation-tools.js';
|
|
7
|
+
import { EXTERNAL_MCP_TOOLS } from '../../shared/workspace-dialogue-tools.js';
|
|
8
|
+
import { WORKSPACE_GROUP_MESSAGE_TOOLS } from '../../shared/workspace-group-message-tools.js';
|
|
9
|
+
import { WORKSPACE_LIFECYCLE_TOOLS } from '../../shared/workspace-lifecycle-tools.js';
|
|
10
|
+
import { ensureKoboHome, getKoboHome } from '../utils/paths.js';
|
|
11
|
+
export const EXTERNAL_TOOL_CATALOGUE = [
|
|
12
|
+
...EXTERNAL_MCP_TOOLS,
|
|
13
|
+
...WORKSPACE_CREATION_TOOLS,
|
|
14
|
+
...WORKSPACE_LIFECYCLE_TOOLS,
|
|
15
|
+
...WORKSPACE_GROUP_MESSAGE_TOOLS,
|
|
16
|
+
...EXTERNAL_MEMORY_TOOL_DEFINITIONS,
|
|
17
|
+
...PROGRESS_BAR_TOOLS,
|
|
18
|
+
];
|
|
19
|
+
const MAX_KEYS = 100;
|
|
20
|
+
const filename = () => path.join(getKoboHome(), 'mcp-credentials.json');
|
|
21
|
+
function load() {
|
|
22
|
+
try {
|
|
23
|
+
const data = JSON.parse(fs.readFileSync(filename(), 'utf8'));
|
|
24
|
+
if (!Array.isArray(data) || data.length > MAX_KEYS)
|
|
25
|
+
throw new Error('Invalid MCP credential store');
|
|
26
|
+
return data;
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (error.code === 'ENOENT')
|
|
30
|
+
return [];
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function save(keys) {
|
|
35
|
+
ensureKoboHome();
|
|
36
|
+
const tmp = `${filename()}.${randomUUID()}.tmp`;
|
|
37
|
+
try {
|
|
38
|
+
fs.writeFileSync(tmp, JSON.stringify(keys), { mode: 0o600, flag: 'wx' });
|
|
39
|
+
fs.renameSync(tmp, filename());
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
if (fs.existsSync(tmp))
|
|
43
|
+
fs.unlinkSync(tmp);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function publicCredential({ hash: _hash, ...credential }) {
|
|
47
|
+
return credential;
|
|
48
|
+
}
|
|
49
|
+
export function listExternalMcpCredentials() {
|
|
50
|
+
return load().map(publicCredential);
|
|
51
|
+
}
|
|
52
|
+
export function createExternalMcpCredential(input) {
|
|
53
|
+
if (!input || typeof input !== 'object' || Array.isArray(input))
|
|
54
|
+
throw new Error('Invalid credential request');
|
|
55
|
+
const args = input;
|
|
56
|
+
if (Object.keys(args).some((key) => !['name', 'tools', 'allWorkspaces', 'workspaceIds', 'expiresAt'].includes(key)))
|
|
57
|
+
throw new Error('Unknown credential field');
|
|
58
|
+
if (typeof args.name !== 'string' || !args.name.trim() || args.name.length > 120)
|
|
59
|
+
throw new Error('Name must contain 1–120 characters');
|
|
60
|
+
if (args.allWorkspaces !== undefined && typeof args.allWorkspaces !== 'boolean')
|
|
61
|
+
throw new Error('Invalid workspace scope');
|
|
62
|
+
const allWorkspaces = args.allWorkspaces === true;
|
|
63
|
+
const workspaceIds = args.workspaceIds ?? [];
|
|
64
|
+
if (!Array.isArray(workspaceIds) ||
|
|
65
|
+
workspaceIds.length > 200 ||
|
|
66
|
+
workspaceIds.some((id) => typeof id !== 'string' || !/^[A-Za-z0-9_-]{1,200}$/.test(id)) ||
|
|
67
|
+
(!allWorkspaces && workspaceIds.length === 0) ||
|
|
68
|
+
(allWorkspaces && workspaceIds.length > 0))
|
|
69
|
+
throw new Error('Select workspace IDs or explicitly grant all workspaces');
|
|
70
|
+
const tools = args.tools ??
|
|
71
|
+
EXTERNAL_TOOL_CATALOGUE.filter((tool) => tool.annotations?.readOnlyHint === true).map((tool) => tool.name);
|
|
72
|
+
if (!Array.isArray(tools) ||
|
|
73
|
+
!tools.length ||
|
|
74
|
+
tools.length > EXTERNAL_TOOL_CATALOGUE.length ||
|
|
75
|
+
tools.some((name) => !EXTERNAL_TOOL_CATALOGUE.some((tool) => tool.name === name)))
|
|
76
|
+
throw new Error('Invalid tool grant');
|
|
77
|
+
let expiresAt = null;
|
|
78
|
+
if (args.expiresAt !== undefined && args.expiresAt !== null) {
|
|
79
|
+
const time = typeof args.expiresAt === 'string' ? Date.parse(args.expiresAt) : NaN;
|
|
80
|
+
if (!Number.isFinite(time) || time <= Date.now() || time > Date.now() + 366 * 86400000)
|
|
81
|
+
throw new Error('Expiry must be within the next 366 days');
|
|
82
|
+
expiresAt = new Date(time).toISOString();
|
|
83
|
+
}
|
|
84
|
+
const keys = load();
|
|
85
|
+
if (keys.length >= MAX_KEYS)
|
|
86
|
+
throw new Error('Revoke an existing MCP key before creating another');
|
|
87
|
+
const token = `kobo_mcp_${randomBytes(32).toString('base64url')}`;
|
|
88
|
+
const key = {
|
|
89
|
+
id: randomUUID(),
|
|
90
|
+
name: args.name.trim(),
|
|
91
|
+
tools: [...new Set(tools)],
|
|
92
|
+
allWorkspaces,
|
|
93
|
+
workspaceIds: [...new Set(workspaceIds)],
|
|
94
|
+
createdAt: new Date().toISOString(),
|
|
95
|
+
expiresAt,
|
|
96
|
+
hash: createHash('sha256').update(token).digest('hex'),
|
|
97
|
+
};
|
|
98
|
+
save([...keys, key]);
|
|
99
|
+
return { token, credential: publicCredential(key) };
|
|
100
|
+
}
|
|
101
|
+
export function revokeExternalMcpCredential(id) {
|
|
102
|
+
save(load().filter((key) => key.id !== id));
|
|
103
|
+
}
|
|
104
|
+
export function authenticateExternalMcpToken(token) {
|
|
105
|
+
if (!/^kobo_mcp_[A-Za-z0-9_-]{43}$/.test(token))
|
|
106
|
+
return null;
|
|
107
|
+
const hash = createHash('sha256').update(token).digest('hex');
|
|
108
|
+
const key = load().find((candidate) => candidate.hash === hash);
|
|
109
|
+
return key && (!key.expiresAt || Date.parse(key.expiresAt) > Date.now()) ? publicCredential(key) : null;
|
|
110
|
+
}
|
|
@@ -3,7 +3,7 @@ import { execFile } from 'node:child_process';
|
|
|
3
3
|
import { promisify } from 'node:util';
|
|
4
4
|
import { MERGE_METHODS } from '../../../../shared/forge-merge.js';
|
|
5
5
|
import { parseCliJson } from '../parse-cli-json.js';
|
|
6
|
-
import { deriveReadyToMerge, truncatePrBody } from '../types.js';
|
|
6
|
+
import { deriveReadyToMerge, MergePolicyError, truncatePrBody } from '../types.js';
|
|
7
7
|
const execFileAsync = promisify(execFile);
|
|
8
8
|
const CLI_TIMEOUT_MS = 30_000;
|
|
9
9
|
function cliOptions(repoPath) {
|
|
@@ -194,8 +194,20 @@ export const githubProvider = {
|
|
|
194
194
|
async changePrBase(repoPath, base) {
|
|
195
195
|
await execFileAsync('gh', ['pr', 'edit', '--base', base], cliOptions(repoPath));
|
|
196
196
|
},
|
|
197
|
-
async mergeRequest(repoPath, number, method = 'merge') {
|
|
198
|
-
|
|
197
|
+
async mergeRequest(repoPath, number, method = 'merge', options) {
|
|
198
|
+
const args = ['pr', 'merge', String(number), `--${method}`];
|
|
199
|
+
if (options?.admin === true)
|
|
200
|
+
args.push('--admin');
|
|
201
|
+
try {
|
|
202
|
+
await execFileAsync('gh', args, cliOptions(repoPath));
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
// Only known policy refusals offer an override, never network/auth/unknown failures.
|
|
206
|
+
const message = error instanceof Error ? error.message : '';
|
|
207
|
+
if (/the base branch policy prohibits the merge/i.test(message))
|
|
208
|
+
throw new MergePolicyError();
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
199
211
|
},
|
|
200
212
|
async deleteRemoteBranch(repoPath, branch) {
|
|
201
213
|
await execFileAsync('git', ['push', 'origin', '--delete', branch], cliOptions(repoPath));
|
|
@@ -35,3 +35,11 @@ export class ForgeUnavailableError extends Error {
|
|
|
35
35
|
this.name = 'ForgeUnavailableError';
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
|
+
/** A policy refusal that can be presented as an explicit administrator choice. */
|
|
39
|
+
export class MergePolicyError extends Error {
|
|
40
|
+
code = 'merge_policy_blocked';
|
|
41
|
+
constructor() {
|
|
42
|
+
super('GitHub branch protection rules prevent this merge.');
|
|
43
|
+
this.name = 'MergePolicyError';
|
|
44
|
+
}
|
|
45
|
+
}
|