@loicngr/kobo 2.1.25 → 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.
Files changed (133) hide show
  1. package/AGENTS.md +30 -3
  2. package/CHANGELOG.md +15 -0
  3. package/CONFIGURATION.md +156 -49
  4. package/README.md +2 -1
  5. package/SECURITY.md +33 -6
  6. package/dist/mcp-server/kobo-tasks-server.js +36 -1021
  7. package/dist/mcp-server/managed-mcp-dispatcher.js +960 -0
  8. package/dist/mcp-server/workspace-dialogue-client.js +26 -1
  9. package/dist/server/index.js +52 -19
  10. package/dist/server/middleware/host-check-middleware.js +8 -5
  11. package/dist/server/middleware/network-auth-middleware.js +51 -29
  12. package/dist/server/middleware/security-headers-middleware.js +19 -0
  13. package/dist/server/middleware/traffic-limits-middleware.js +74 -0
  14. package/dist/server/routes/auth.js +73 -0
  15. package/dist/server/routes/internal-mcp.js +137 -0
  16. package/dist/server/routes/mcp-credentials.js +30 -0
  17. package/dist/server/routes/mcp.js +156 -76
  18. package/dist/server/routes/usage.js +24 -0
  19. package/dist/server/routes/workspaces.js +19 -3
  20. package/dist/server/services/agent/orchestrator.js +27 -17
  21. package/dist/server/services/external-mcp-credentials-service.js +110 -0
  22. package/dist/server/services/forge/github/provider.js +15 -3
  23. package/dist/server/services/forge/types.js +8 -0
  24. package/dist/server/services/internal-mcp-capability-service.js +98 -0
  25. package/dist/server/services/mcp-connection-info-service.js +1 -1
  26. package/dist/server/services/memory-mcp-service.js +9 -7
  27. package/dist/server/services/memory-service.js +15 -0
  28. package/dist/server/services/network-access-service.js +43 -73
  29. package/dist/server/services/network-session-service.js +182 -0
  30. package/dist/server/services/provider-accounts-service.js +202 -0
  31. package/dist/server/services/settings-service.js +29 -7
  32. package/dist/server/services/terminal-websocket-service.js +5 -0
  33. package/dist/server/services/usage/poller.js +44 -3
  34. package/dist/server/services/websocket-service.js +5 -0
  35. package/dist/server/utils/paths.js +2 -1
  36. package/dist/server/utils/startup-display.js +1 -2
  37. package/dist/shared/models.js +6 -0
  38. package/dist/shared/provider-accounts.js +1 -0
  39. package/docker-compose.vps.yml +6 -0
  40. package/docs/deployment-vps.md +24 -12
  41. package/package.json +1 -1
  42. package/src/client/dist/pwa/assets/ActivityFeed-D58VS9o2.js +2 -0
  43. package/src/client/dist/pwa/assets/{AutoLoopFinalReviewSettings-Dafi7Tue.js → AutoLoopFinalReviewSettings-CK3r_fVB.js} +1 -1
  44. package/src/client/dist/pwa/assets/{ChangelogPage-DH-zUtlf.js → ChangelogPage-CAAUEn5X.js} +1 -1
  45. package/src/client/dist/pwa/assets/ComparisonPanel-BCb7vB5E.js +2 -0
  46. package/src/client/dist/pwa/assets/CreatePage-DasH1Uk4.js +2 -0
  47. package/src/client/dist/pwa/assets/{DashboardPage-B8gTooRi.js → DashboardPage-XE52EABP.js} +1 -1
  48. package/src/client/dist/pwa/assets/DiffViewer-CncKlXne.js +8 -0
  49. package/src/client/dist/pwa/assets/{DocumentsPanel-DXjjhGUg.js → DocumentsPanel-L4pbQfz1.js} +1 -1
  50. package/src/client/dist/pwa/assets/{DrawerToggleButton-CiVNMTbq.js → DrawerToggleButton-DiOJm5he.js} +1 -1
  51. package/src/client/dist/pwa/assets/GitPanel-WJlzy2VJ.js +3 -0
  52. package/src/client/dist/pwa/assets/{GitPanel-CRaLJzjc.css → GitPanel-yHvU38dp.css} +1 -1
  53. package/src/client/dist/pwa/assets/{HealthPage-lc2Cuuks.js → HealthPage-hJY5MurK.js} +1 -1
  54. package/src/client/dist/pwa/assets/MainLayout-DgWElK7w.js +2 -0
  55. package/src/client/dist/pwa/assets/{MemoryPanel-SrR94UVW.js → MemoryPanel-DtEvh_b7.js} +1 -1
  56. package/src/client/dist/pwa/assets/{MemoryProposalList-B5uVf3KU.js → MemoryProposalList-kgFCm9zz.js} +1 -1
  57. package/src/client/dist/pwa/assets/{QBtnDropdown-Cxr3lBPB.js → QBtnDropdown-CWPIugs8.js} +1 -1
  58. package/src/client/dist/pwa/assets/{QExpansionItem-xIXDevKz.js → QExpansionItem-D-EGL2b3.js} +1 -1
  59. package/src/client/dist/pwa/assets/{QScrollArea-DeczMHVs.js → QScrollArea-BMrqK-xd.js} +1 -1
  60. package/src/client/dist/pwa/assets/QSelect-DFWyDWCE.js +1 -0
  61. package/src/client/dist/pwa/assets/{QTooltip-be9mqhmB.js → QTooltip-Bk8dLUtU.js} +1 -1
  62. package/src/client/dist/pwa/assets/{SchedulePanel-R6H-D-m5.js → SchedulePanel-CuxmxCKi.js} +1 -1
  63. package/src/client/dist/pwa/assets/{SearchPage-CaeYoIDw.js → SearchPage-DKnQPUqY.js} +1 -1
  64. package/src/client/dist/pwa/assets/{SettingsPage-Dcq1Jeoo.css → SettingsPage-B4rmd2WX.css} +1 -1
  65. package/src/client/dist/pwa/assets/SettingsPage-CO3vIpPd.js +9 -0
  66. package/src/client/dist/pwa/assets/{SplitWorkspacePage-Dw1qUIFU.js → SplitWorkspacePage-WhQyX8fn.js} +1 -1
  67. package/src/client/dist/pwa/assets/{TerminalPanel-Dqw88QqH.js → TerminalPanel-BY0lPq6k.js} +10 -10
  68. package/src/client/dist/pwa/assets/{TerminalPanel-Dz9EE-Ph.css → TerminalPanel-BcbwuFVR.css} +1 -1
  69. package/src/client/dist/pwa/assets/{ToolCallItem-BMn7eela.js → ToolCallItem-DYubi-J0.js} +3 -3
  70. package/src/client/dist/pwa/assets/{TouchPan-BwCpe_Oh.js → TouchPan-DDotyYLN.js} +1 -1
  71. package/src/client/dist/pwa/assets/{TourReplayButton-yeGYf6xT.js → TourReplayButton-kAnEMcy2.js} +1 -1
  72. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-BJPw65vU.js → WorkspaceAttentionLabels-DiYt3rP3.js} +1 -1
  73. package/src/client/dist/pwa/assets/WorkspacePage-BEBwwr81.css +1 -0
  74. package/src/client/dist/pwa/assets/WorkspacePage-K-1ce50d.js +4 -0
  75. package/src/client/dist/pwa/assets/{browser-C9U-mSDm.js → browser-DVYLH8vl.js} +1 -1
  76. package/src/client/dist/pwa/assets/{build-path-tree-DYdRW2T4.js → build-path-tree-DEuOdIIk.js} +1 -1
  77. package/src/client/dist/pwa/assets/custom-sounds-Ggw6V9iF.js +1 -0
  78. package/src/client/dist/pwa/assets/de-Bk3zQrin.js +17 -0
  79. package/src/client/dist/pwa/assets/{editor.api-iu5YkEl3.js → editor.api-BC38eXAS.js} +1 -1
  80. package/src/client/dist/pwa/assets/{efforts-ja6uJadn.js → efforts-TGacUPcJ.js} +1 -1
  81. package/src/client/dist/pwa/assets/es-f3xuAWA9.js +17 -0
  82. package/src/client/dist/pwa/assets/{expand-template-BbgnJ8YH.js → expand-template-DM8gq3Mo.js} +1 -1
  83. package/src/client/dist/pwa/assets/{format-CLXLy6Ov.js → format-OwxXBnDJ.js} +1 -1
  84. package/src/client/dist/pwa/assets/{formatters-DeKgxYJe.js → formatters-YgaCZcwJ.js} +1 -1
  85. package/src/client/dist/pwa/assets/fr-B6c21SG3.js +17 -0
  86. package/src/client/dist/pwa/assets/{freemarker2-2IrnDDvu.js → freemarker2-l7R42fI2.js} +1 -1
  87. package/src/client/dist/pwa/assets/{handlebars-CeLN7FCP.js → handlebars-DFmPrgjH.js} +1 -1
  88. package/src/client/dist/pwa/assets/{html-Bn3rhcp3.js → html-CERIAlPM.js} +1 -1
  89. package/src/client/dist/pwa/assets/i18n-WaE4cZK9.js +1 -0
  90. package/src/client/dist/pwa/assets/index-CpWBGKdM.js +20 -0
  91. package/src/client/dist/pwa/assets/it-DUstDpC2.js +17 -0
  92. package/src/client/dist/pwa/assets/{javascript-BMvON8oT.js → javascript-BBYncplF.js} +1 -1
  93. package/src/client/dist/pwa/assets/{liquid-D_AQLvQl.js → liquid-C1HqYyYU.js} +1 -1
  94. package/src/client/dist/pwa/assets/{mdx-DDJzMB8Q.js → mdx-E74dsP22.js} +1 -1
  95. package/src/client/dist/pwa/assets/{monaco-contributions-DFKwR-_X.js → monaco-contributions-DHvm-kp5.js} +1 -1
  96. package/src/client/dist/pwa/assets/{monaco.contribution-4AwFLnjV.js → monaco.contribution-DYdn9CwR.js} +2 -2
  97. package/src/client/dist/pwa/assets/network-auth-DTZUKbny.js +1 -0
  98. package/src/client/dist/pwa/assets/{notify-theme-CMzdjL91.js → notify-theme-FuTL7HuS.js} +1 -1
  99. package/src/client/dist/pwa/assets/{permissionModes-DLWiBfdb.js → permissionModes-D-Nexnoo.js} +1 -1
  100. package/src/client/dist/pwa/assets/{project-color-C5mEKdlH.js → project-color-_snXWYUp.js} +1 -1
  101. package/src/client/dist/pwa/assets/{python-BOqcp9xc.js → python-DNAAGBcX.js} +1 -1
  102. package/src/client/dist/pwa/assets/{razor-BrGwBzd7.js → razor-CB3g5p_T.js} +1 -1
  103. package/src/client/dist/pwa/assets/{typescript-mOGf86uY.js → typescript-0kpz8bqP.js} +1 -1
  104. package/src/client/dist/pwa/assets/{use-search-index-status-P7O7EaV_.js → use-search-index-status-D38yFOD_.js} +1 -1
  105. package/src/client/dist/pwa/assets/{use-tours-CMFPIW6f.js → use-tours-BWkHp34X.js} +2 -2
  106. package/src/client/dist/pwa/assets/{use-worktree-restore-DF6P7h8g.js → use-worktree-restore-DUjZr44N.js} +1 -1
  107. package/src/client/dist/pwa/assets/{workspace-templates-C_bgIQvs.js → workspace-templates-D-lPJQ4G.js} +1 -1
  108. package/src/client/dist/pwa/assets/{xml-YcMBzYAm.js → xml-BbOuqdUe.js} +1 -1
  109. package/src/client/dist/pwa/assets/{yaml-BP_67Sds.js → yaml-gasFQtfk.js} +1 -1
  110. package/src/client/dist/pwa/index.html +1 -1
  111. package/src/client/dist/pwa/sw.js +1 -1
  112. package/src/mcp-server/README.md +13 -19
  113. package/src/mcp-server/kobo-tasks-server.ts +42 -1146
  114. package/src/mcp-server/managed-mcp-dispatcher.ts +1071 -0
  115. package/src/mcp-server/workspace-dialogue-client.ts +26 -1
  116. package/src/client/dist/pwa/assets/ActivityFeed-xJMPnXKA.js +0 -2
  117. package/src/client/dist/pwa/assets/ComparisonPanel-DwaQyEgb.js +0 -2
  118. package/src/client/dist/pwa/assets/CreatePage-DyF_efbs.js +0 -2
  119. package/src/client/dist/pwa/assets/DiffViewer-BivWNITj.js +0 -8
  120. package/src/client/dist/pwa/assets/GitPanel-DQjgGtoN.js +0 -3
  121. package/src/client/dist/pwa/assets/MainLayout-D3pGcTx8.js +0 -2
  122. package/src/client/dist/pwa/assets/QSelect-GQAsf7pB.js +0 -1
  123. package/src/client/dist/pwa/assets/SettingsPage-BCWdQHS5.js +0 -9
  124. package/src/client/dist/pwa/assets/WorkspacePage-BHO1Q8PG.js +0 -4
  125. package/src/client/dist/pwa/assets/WorkspacePage-BnklZAYZ.css +0 -1
  126. package/src/client/dist/pwa/assets/custom-sounds-DPsfYErA.js +0 -1
  127. package/src/client/dist/pwa/assets/de-Bqil7RJE.js +0 -17
  128. package/src/client/dist/pwa/assets/es-CNcMm1Zz.js +0 -17
  129. package/src/client/dist/pwa/assets/fr-BObHEohg.js +0 -17
  130. package/src/client/dist/pwa/assets/i18n-Df_y96N2.js +0 -1
  131. package/src/client/dist/pwa/assets/index-CH4JpI2a.js +0 -20
  132. package/src/client/dist/pwa/assets/it-DDMC7Emk.js +0 -17
  133. package/src/client/dist/pwa/assets/network-auth-CMERg4Qk.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 { EXTERNAL_MEMORY_TOOL_DEFINITIONS, isMemoryToolName } from '../../shared/memory-tools.js';
8
- import { isProgressBarTool, PROGRESS_BAR_TOOLS } from '../../shared/progress-bar-tools.js';
9
- import { isWorkspaceCreationTool, WORKSPACE_CREATION_TOOLS } from '../../shared/workspace-creation-tools.js';
10
- import { EXTERNAL_MCP_TOOLS } from '../../shared/workspace-dialogue-tools.js';
11
- import { isWorkspaceGroupMessageTool, WORKSPACE_GROUP_MESSAGE_TOOLS, } from '../../shared/workspace-group-message-tools.js';
12
- import { isWorkspaceLifecycleTool, WORKSPACE_LIFECYCLE_TOOLS } from '../../shared/workspace-lifecycle-tools.js';
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
- tools: [
77
- ...EXTERNAL_MCP_TOOLS,
78
- ...WORKSPACE_CREATION_TOOLS,
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 isMemory = isMemoryToolName(request.params.name);
87
- try {
88
- 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'));
89
- const data = isProgressBarTool(request.params.name)
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
+ }
@@ -1,5 +1,6 @@
1
1
  import { Hono } from 'hono';
2
2
  import { getDb } from '../db/index.js';
3
+ import { listProviderAccounts, ProviderAccountsError, switchProviderAccount, validateAccountId, } from '../services/provider-accounts-service.js';
3
4
  import { refreshNow } from '../services/usage/poller.js';
4
5
  import { clearReliabilityReset, computeEngineReliability, getReliabilityResetAt, resetReliability, } from '../services/usage/reliability.js';
5
6
  const app = new Hono();
@@ -39,6 +40,29 @@ app.delete('/reliability/reset', (c) => {
39
40
  return c.json({ error: message }, 500);
40
41
  }
41
42
  });
43
+ function accountError(error) {
44
+ return error instanceof ProviderAccountsError ? error : new ProviderAccountsError('accounts_command_failed');
45
+ }
46
+ app.get('/:providerId/accounts', async (c) => {
47
+ try {
48
+ return c.json(await listProviderAccounts(c.req.param('providerId')));
49
+ }
50
+ catch (error) {
51
+ const safe = accountError(error);
52
+ return c.json({ error: safe.message, code: safe.code }, safe.status);
53
+ }
54
+ });
55
+ app.post('/:providerId/accounts/switch', async (c) => {
56
+ try {
57
+ const body = await c.req.json().catch(() => null);
58
+ const accountId = validateAccountId(typeof body === 'object' && body !== null && 'accountId' in body ? body.accountId : undefined);
59
+ return c.json(await switchProviderAccount(c.req.param('providerId'), accountId));
60
+ }
61
+ catch (error) {
62
+ const safe = accountError(error);
63
+ return c.json({ error: safe.message, code: safe.code }, safe.status);
64
+ }
65
+ });
42
66
  app.post('/:providerId/refresh', async (c) => {
43
67
  const providerId = c.req.param('providerId');
44
68
  try {
@@ -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 (!snapshot.readyToMerge) {
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 (method === undefined) {
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, getDbPath, getKoboHome, getMcpServerSourcePath, getSettingsPath, getSkillsPath, } from '../../utils/paths.js';
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, extraEnv, memoryCapability) {
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
- ...extraEnv,
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: [...buildMcpServers(workspaceId, launch?.mcpEnv, memoryCapability.token), ...integrationServers],
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
- await execFileAsync('gh', ['pr', 'merge', String(number), `--${method}`], cliOptions(repoPath));
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));