mouaif 0.3.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 +21 -0
- package/README.md +140 -0
- package/bin/mouaif.js +281 -0
- package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
- package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
- package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
- package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
- package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
- package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
- package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
- package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
- package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
- package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
- package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
- package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
- package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
- package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
- package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
- package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
- package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
- package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
- package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
- package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
- package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
- package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
- package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
- package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
- package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
- package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
- package/frontend/dist/assets/index-CANPYzQg.css +1 -0
- package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
- package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
- package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
- package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
- package/frontend/dist/icons/favicon-32.png +0 -0
- package/frontend/dist/icons/icon-180-apple.png +0 -0
- package/frontend/dist/icons/icon-192.png +0 -0
- package/frontend/dist/icons/icon-512.png +0 -0
- package/frontend/dist/icons/icon-maskable-512.png +0 -0
- package/frontend/dist/index.html +83 -0
- package/frontend/dist/manifest.webmanifest +33 -0
- package/frontend/dist/sw.js +482 -0
- package/package.json +98 -0
- package/scripts/patch-zimmerframe.js +58 -0
- package/src/access-auth.js +515 -0
- package/src/agentFeatures.js +294 -0
- package/src/agentFiles.js +164 -0
- package/src/agentSkills.js +147 -0
- package/src/agents.js +230 -0
- package/src/ai-chat.js +21 -0
- package/src/ai-endpoints.js +1880 -0
- package/src/ai-stream.js +2048 -0
- package/src/ai.js +68 -0
- package/src/auth.js +391 -0
- package/src/chatdb.js +816 -0
- package/src/chats.js +275 -0
- package/src/custom-actions.js +65 -0
- package/src/files.js +431 -0
- package/src/hideFileContent.js +327 -0
- package/src/http-server.js +535 -0
- package/src/index.js +15 -0
- package/src/inspector.js +731 -0
- package/src/inspectorProfiles.js +503 -0
- package/src/live-chat.js +107 -0
- package/src/mcp.js +1517 -0
- package/src/messages.js +238 -0
- package/src/modelList.js +137 -0
- package/src/notifications.js +52 -0
- package/src/oauth-anthropic.js +280 -0
- package/src/oauth-github-copilot.js +417 -0
- package/src/oauth-mcp.js +216 -0
- package/src/oauth-openrouter.js +285 -0
- package/src/package-version.js +20 -0
- package/src/projects.js +285 -0
- package/src/promptProfiles.js +256 -0
- package/src/prompts.js +384 -0
- package/src/providerShapes.js +44 -0
- package/src/providers/base.js +41 -0
- package/src/providers/index.js +25 -0
- package/src/push.js +315 -0
- package/src/qr.js +192 -0
- package/src/restart.js +47 -0
- package/src/server-handlers-access.js +306 -0
- package/src/server-handlers-actions.js +100 -0
- package/src/server-handlers-ai.js +248 -0
- package/src/server-handlers-auth.js +273 -0
- package/src/server-handlers-chats.js +1436 -0
- package/src/server-handlers-git.js +467 -0
- package/src/server-handlers-mcp-oauth.js +56 -0
- package/src/server-handlers-misc.js +783 -0
- package/src/server-handlers-projects.js +289 -0
- package/src/server-handlers-prompts.js +259 -0
- package/src/server-handlers-push.js +102 -0
- package/src/server-handlers-settings.js +406 -0
- package/src/server-handlers-tools.js +654 -0
- package/src/server-handlers-transcribe.js +399 -0
- package/src/server-shared.js +780 -0
- package/src/server-web-static.js +191 -0
- package/src/settings.js +898 -0
- package/src/statusBar.js +541 -0
- package/src/tags.js +414 -0
- package/src/toolFeedback.js +225 -0
- package/src/tools/ask.js +154 -0
- package/src/tools/authorization.js +932 -0
- package/src/tools/files.js +1150 -0
- package/src/tools/progress.js +71 -0
- package/src/tools/restart.js +32 -0
- package/src/tools/searchEngine.js +957 -0
- package/src/tools/shell.js +341 -0
- package/src/tools/subagent.js +47 -0
- package/src/tools/task.js +234 -0
- package/src/tools/webpreview.js +448 -0
- package/src/trace.js +103 -0
- package/src/transcribe.js +683 -0
- package/src/usage.js +389 -0
- package/src/util.js +151 -0
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// MCP + restart + inspector + tool-authorization REST handlers.
|
|
4
|
+
// Extracted from the original single-file http-server.js. Shared
|
|
5
|
+
// helpers live in src/server-shared.js.
|
|
6
|
+
|
|
7
|
+
const {
|
|
8
|
+
sendJSON,
|
|
9
|
+
qs,
|
|
10
|
+
readJsonBody,
|
|
11
|
+
readJsonOr400,
|
|
12
|
+
runningKey,
|
|
13
|
+
runningChats,
|
|
14
|
+
runningChatCancels,
|
|
15
|
+
firstStringValue,
|
|
16
|
+
chats,
|
|
17
|
+
liveChat,
|
|
18
|
+
mcp,
|
|
19
|
+
inspector,
|
|
20
|
+
inspectorProfiles,
|
|
21
|
+
safeDecode
|
|
22
|
+
} = require('./server-shared.js');
|
|
23
|
+
const { requestRestart } = require('./restart.js');
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
// ---- MCP API ------------------------------------------------------------
|
|
27
|
+
// MCP server registry + lifecycle + tool dispatch (docs/decisions.md §18).
|
|
28
|
+
// Server entries live in one of two scopes:
|
|
29
|
+
// - project: <projectDir>/.mcp.json under servers (legacy .mouaif.json
|
|
30
|
+
// mcp.servers is read as a fallback);
|
|
31
|
+
// - app: the app SQLite store under mcp.servers.
|
|
32
|
+
// A project sees the union; project entries win on a slug collision.
|
|
33
|
+
// Runtime state is in-memory. The AI client dispatches through the
|
|
34
|
+
// in-process mcp module, so these endpoints are for the Settings UI and
|
|
35
|
+
// for tests.
|
|
36
|
+
//
|
|
37
|
+
// Routes that touch the *merged* view (list, patch, delete, lifecycle)
|
|
38
|
+
// take an optional `projectDir` — without one only app-scoped servers
|
|
39
|
+
// are visible. POST /api/mcp/servers takes an explicit `scope`
|
|
40
|
+
// ('project' | 'app'); project scope requires `projectDir`.
|
|
41
|
+
|
|
42
|
+
function mcpErrorStatus(err) {
|
|
43
|
+
switch (err && err.code) {
|
|
44
|
+
case 'EBADINPUT': return 400;
|
|
45
|
+
case 'EMCP_NOTFOUND': return 404;
|
|
46
|
+
case 'EOUTSIDE_PROJECT': return 403;
|
|
47
|
+
case 'EMCP_START': return 502;
|
|
48
|
+
case 'EMCP_RPC': return 502;
|
|
49
|
+
case 'EMCP_NOSESSION': return 409;
|
|
50
|
+
case 'EMCP_AUTH': return 409;
|
|
51
|
+
case 'EKEYRING': return 503;
|
|
52
|
+
case 'EMCP_TIMEOUT': return 504;
|
|
53
|
+
case 'EMCP_TRANSPORT': return 502;
|
|
54
|
+
case 'EMODULE': return 500;
|
|
55
|
+
case 'MOUAIF_PROJECT_PARSE_ERROR': return 422;
|
|
56
|
+
default: return 500;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readMcpProjectDir(q, body) {
|
|
61
|
+
const fromQuery = qs(q, 'projectDir');
|
|
62
|
+
const fromBody = body && typeof body.projectDir === 'string' ? body.projectDir : '';
|
|
63
|
+
return fromQuery || fromBody || '';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function handleMcp(req, res, parsed) {
|
|
67
|
+
const urlPath = parsed.pathname;
|
|
68
|
+
const method = req.method;
|
|
69
|
+
const q = parsed.query || {};
|
|
70
|
+
|
|
71
|
+
// GET /api/mcp/servers?projectDir=... -> { servers: [...] }
|
|
72
|
+
// projectDir is optional: without it only app-scoped servers are
|
|
73
|
+
// returned (the Settings "App" tab); with it the response is the
|
|
74
|
+
// merged app + project view.
|
|
75
|
+
if (urlPath === '/api/mcp/servers' && method === 'GET') {
|
|
76
|
+
const dir = readMcpProjectDir(q);
|
|
77
|
+
try {
|
|
78
|
+
return sendJSON(res, 200, { servers: mcp.listServers(dir || null) });
|
|
79
|
+
} catch (e) {
|
|
80
|
+
return sendJSON(res, mcpErrorStatus(e), { error: e.message, code: e.code || 'INTERNAL' });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// POST /api/mcp/servers body: { projectDir?, scope?, name, command, args?, env?, cwd?, enabled? }
|
|
85
|
+
if (urlPath === '/api/mcp/servers' && method === 'POST') {
|
|
86
|
+
const body = await readJsonOr400(req, res);
|
|
87
|
+
if (!body) return;
|
|
88
|
+
const dir = readMcpProjectDir(q, body);
|
|
89
|
+
const scope = body && body.scope === mcp.APP_SCOPE ? mcp.APP_SCOPE : mcp.PROJECT_SCOPE;
|
|
90
|
+
if (scope === mcp.PROJECT_SCOPE && !dir) return sendJSON(res, 400, { error: 'projectDir is required for a project-scoped server' });
|
|
91
|
+
try {
|
|
92
|
+
const server = mcp.addServer(dir || null, Object.assign({}, body, { scope }));
|
|
93
|
+
return sendJSON(res, 201, { server });
|
|
94
|
+
} catch (e) {
|
|
95
|
+
return sendJSON(res, mcpErrorStatus(e), { error: e.message, code: e.code || 'INTERNAL' });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// PATCH /api/mcp/servers/:id body: { projectDir?, ...patch }
|
|
100
|
+
let m = urlPath.match(/^\/api\/mcp\/servers\/([^/]+)$/);
|
|
101
|
+
if (m && method === 'PATCH') {
|
|
102
|
+
const id = safeDecode(m[1]);
|
|
103
|
+
const body = await readJsonOr400(req, res);
|
|
104
|
+
if (!body) return;
|
|
105
|
+
const dir = readMcpProjectDir(q, body);
|
|
106
|
+
try {
|
|
107
|
+
const server = mcp.updateServer(dir || null, id, body || {});
|
|
108
|
+
if (!server) return sendJSON(res, 404, { error: 'Server not found', id });
|
|
109
|
+
return sendJSON(res, 200, { server });
|
|
110
|
+
} catch (e) {
|
|
111
|
+
return sendJSON(res, mcpErrorStatus(e), { error: e.message, code: e.code || 'INTERNAL' });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// DELETE /api/mcp/servers/:id?projectDir=...
|
|
116
|
+
if (m && method === 'DELETE') {
|
|
117
|
+
const id = safeDecode(m[1]);
|
|
118
|
+
const dir = readMcpProjectDir(q);
|
|
119
|
+
try {
|
|
120
|
+
const ok = mcp.removeServer(dir || null, id);
|
|
121
|
+
if (!ok) return sendJSON(res, 404, { error: 'Server not found', id });
|
|
122
|
+
return sendJSON(res, 200, { ok: true, removed: id });
|
|
123
|
+
} catch (e) {
|
|
124
|
+
return sendJSON(res, mcpErrorStatus(e), { error: e.message, code: e.code || 'INTERNAL' });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// POST /api/mcp/servers/:id/start body: { projectDir? }
|
|
129
|
+
m = urlPath.match(/^\/api\/mcp\/servers\/([^/]+)\/start$/);
|
|
130
|
+
if (m && method === 'POST') {
|
|
131
|
+
const id = safeDecode(m[1]);
|
|
132
|
+
let body = {};
|
|
133
|
+
try { body = await readJsonBody(req); } catch (e) { /* body may be empty */ }
|
|
134
|
+
const dir = readMcpProjectDir(q, body);
|
|
135
|
+
try {
|
|
136
|
+
const server = await mcp.startServer(dir || null, id);
|
|
137
|
+
return sendJSON(res, 200, { server });
|
|
138
|
+
} catch (e) {
|
|
139
|
+
return sendJSON(res, mcpErrorStatus(e), { error: e.message, code: e.code || 'INTERNAL' });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// POST /api/mcp/servers/:id/stop body: { projectDir? }
|
|
144
|
+
m = urlPath.match(/^\/api\/mcp\/servers\/([^/]+)\/stop$/);
|
|
145
|
+
if (m && method === 'POST') {
|
|
146
|
+
const id = safeDecode(m[1]);
|
|
147
|
+
let body = {};
|
|
148
|
+
try { body = await readJsonBody(req); } catch (e) { /* body may be empty */ }
|
|
149
|
+
const dir = readMcpProjectDir(q, body);
|
|
150
|
+
try {
|
|
151
|
+
const ok = await mcp.stopServer(dir || null, id);
|
|
152
|
+
return sendJSON(res, 200, { ok, removed: ok });
|
|
153
|
+
} catch (e) {
|
|
154
|
+
return sendJSON(res, mcpErrorStatus(e), { error: e.message, code: e.code || 'INTERNAL' });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// GET /api/mcp/servers/:id/tools?projectDir=... -> forces a re-discovery
|
|
159
|
+
m = urlPath.match(/^\/api\/mcp\/servers\/([^/]+)\/tools$/);
|
|
160
|
+
if (m && method === 'GET') {
|
|
161
|
+
const id = safeDecode(m[1]);
|
|
162
|
+
const dir = readMcpProjectDir(q);
|
|
163
|
+
try {
|
|
164
|
+
const tools = await mcp.listDiscoveredTools(dir || null, id);
|
|
165
|
+
return sendJSON(res, 200, { tools });
|
|
166
|
+
} catch (e) {
|
|
167
|
+
return sendJSON(res, mcpErrorStatus(e), { error: e.message, code: e.code || 'INTERNAL' });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// GET /api/mcp/registry?search=...&cursor=...&limit=...
|
|
172
|
+
// Statelessly proxies the official MCP Registry API. Registry responses
|
|
173
|
+
// are never persisted or cached by mouaif.
|
|
174
|
+
if (urlPath === '/api/mcp/registry' && method === 'GET') {
|
|
175
|
+
const search = qs(q, 'search');
|
|
176
|
+
const cursor = qs(q, 'cursor');
|
|
177
|
+
const sortField = ['popularity', 'updatedAt', 'name'].includes(q.sort) ? q.sort : 'popularity';
|
|
178
|
+
const sortDir = q.dir === 'asc' ? 'asc' : 'desc';
|
|
179
|
+
const limit = Math.min(Math.max(parseInt(q.limit, 10) || 30, 1), 100);
|
|
180
|
+
try {
|
|
181
|
+
const registryUrl = new URL('https://registry.modelcontextprotocol.io/v0.1/servers');
|
|
182
|
+
if (search) registryUrl.searchParams.set('search', search);
|
|
183
|
+
if (cursor) registryUrl.searchParams.set('cursor', cursor);
|
|
184
|
+
registryUrl.searchParams.set('limit', String(limit));
|
|
185
|
+
registryUrl.searchParams.set('version', 'latest');
|
|
186
|
+
const registryRes = await fetch(registryUrl, {
|
|
187
|
+
headers: { 'Accept': 'application/json' }
|
|
188
|
+
});
|
|
189
|
+
if (!registryRes.ok) {
|
|
190
|
+
return sendJSON(res, registryRes.status, { error: 'Registry API error: HTTP ' + registryRes.status });
|
|
191
|
+
}
|
|
192
|
+
const registryBody = await registryRes.json();
|
|
193
|
+
// Enrich with a computed popularity score (0-100) based on
|
|
194
|
+
// recency of updates and the number of packages.
|
|
195
|
+
const now = Date.now();
|
|
196
|
+
const enrich = (entry) => {
|
|
197
|
+
const meta = entry && entry._meta && entry._meta['io.modelcontextprotocol.registry/official'];
|
|
198
|
+
const server = entry && entry.server || {};
|
|
199
|
+
const updatedAt = meta && meta.updatedAt ? new Date(meta.updatedAt).getTime() : null;
|
|
200
|
+
const publishedAt = meta && meta.publishedAt ? new Date(meta.publishedAt).getTime() : null;
|
|
201
|
+
const packages = Array.isArray(server.packages) ? server.packages : [];
|
|
202
|
+
// Score: 0-50 from update recency (within 30 days = max)
|
|
203
|
+
let recencyScore = 0;
|
|
204
|
+
if (updatedAt) {
|
|
205
|
+
const daysSinceUpdate = (now - updatedAt) / 86400000;
|
|
206
|
+
recencyScore = Math.max(0, Math.round(50 * (1 - Math.min(daysSinceUpdate / 90, 1))));
|
|
207
|
+
} else if (publishedAt) {
|
|
208
|
+
const daysSincePub = (now - publishedAt) / 86400000;
|
|
209
|
+
recencyScore = Math.max(0, Math.round(30 * (1 - Math.min(daysSincePub / 365, 1))));
|
|
210
|
+
}
|
|
211
|
+
// Score: 0-30 from number of packages (3+ packages = max)
|
|
212
|
+
const pkgScore = Math.min(30, packages.length * 10);
|
|
213
|
+
// Score: 0-20 from version count proxy (multiple versions = active)
|
|
214
|
+
let versionScore = 10; // baseline
|
|
215
|
+
if (meta && meta.isLatest !== undefined) versionScore += 10;
|
|
216
|
+
const score = Math.min(100, recencyScore + pkgScore + versionScore);
|
|
217
|
+
return Object.assign({}, entry, {
|
|
218
|
+
popularity: { score, recencyScore, pkgScore, versionScore }
|
|
219
|
+
});
|
|
220
|
+
};
|
|
221
|
+
const servers = Array.isArray(registryBody.servers)
|
|
222
|
+
? registryBody.servers.map(enrich)
|
|
223
|
+
: [];
|
|
224
|
+
|
|
225
|
+
// The Registry API does not expose sorting, so sort only this response
|
|
226
|
+
// in memory. No registry result is written to app or project storage.
|
|
227
|
+
const direction = sortDir === 'asc' ? 1 : -1;
|
|
228
|
+
const text = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true });
|
|
229
|
+
servers.sort((a, b) => {
|
|
230
|
+
if (sortField === 'popularity') {
|
|
231
|
+
return direction * ((a.popularity && a.popularity.score || 0) - (b.popularity && b.popularity.score || 0));
|
|
232
|
+
}
|
|
233
|
+
if (sortField === 'updatedAt') {
|
|
234
|
+
const metaA = a._meta && a._meta['io.modelcontextprotocol.registry/official'];
|
|
235
|
+
const metaB = b._meta && b._meta['io.modelcontextprotocol.registry/official'];
|
|
236
|
+
const valueA = metaA && Date.parse(metaA.updatedAt) || 0;
|
|
237
|
+
const valueB = metaB && Date.parse(metaB.updatedAt) || 0;
|
|
238
|
+
return direction * (valueA - valueB);
|
|
239
|
+
}
|
|
240
|
+
const nameA = a.server && a.server.name || a.name || '';
|
|
241
|
+
const nameB = b.server && b.server.name || b.name || '';
|
|
242
|
+
return direction * text.compare(nameA, nameB);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
return sendJSON(res, 200, {
|
|
246
|
+
servers,
|
|
247
|
+
metadata: registryBody.metadata || { count: servers.length, nextCursor: null }
|
|
248
|
+
});
|
|
249
|
+
} catch (e) {
|
|
250
|
+
return sendJSON(res, 502, { error: 'Registry proxy error: ' + (e.message || e) });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// POST /api/mcp/call body: { projectDir, serverId, toolName, args }
|
|
255
|
+
// Generic direct-dispatch endpoint used by the chat composer and tests.
|
|
256
|
+
// The AI client itself does not round-trip through HTTP; it calls
|
|
257
|
+
// mcp.callTool() in-process.
|
|
258
|
+
if (urlPath === '/api/mcp/call' && method === 'POST') {
|
|
259
|
+
const body = await readJsonOr400(req, res);
|
|
260
|
+
if (!body) return;
|
|
261
|
+
const dir = readMcpProjectDir(q, body);
|
|
262
|
+
if (!dir) return sendJSON(res, 400, { error: 'projectDir is required' });
|
|
263
|
+
if (!body || typeof body.serverId !== 'string' || !body.serverId) {
|
|
264
|
+
return sendJSON(res, 400, { error: 'serverId is required' });
|
|
265
|
+
}
|
|
266
|
+
if (typeof body.toolName !== 'string' || !body.toolName) {
|
|
267
|
+
return sendJSON(res, 400, { error: 'toolName is required' });
|
|
268
|
+
}
|
|
269
|
+
// Look up the server entry by id, then dispatch by its slug.
|
|
270
|
+
try {
|
|
271
|
+
const entry = mcp.getServer(dir, body.serverId);
|
|
272
|
+
if (!entry) return sendJSON(res, 404, { error: 'Server not found', id: body.serverId });
|
|
273
|
+
if (typeof body.chatId !== 'string' || typeof body.callId !== 'string') {
|
|
274
|
+
return sendJSON(res, 400, { error: 'chatId and callId are required' });
|
|
275
|
+
}
|
|
276
|
+
if (!chats.getChat(dir, body.chatId)) return sendJSON(res, 404, { error: 'Chat not found', chatId: body.chatId });
|
|
277
|
+
const tool = mcp.composedToolName(entry.slug, body.toolName);
|
|
278
|
+
const authorization = await require('./tools/authorization.js').authorize({
|
|
279
|
+
projectDir: dir,
|
|
280
|
+
chatId: body.chatId,
|
|
281
|
+
callId: body.callId,
|
|
282
|
+
tool,
|
|
283
|
+
summary: firstStringValue(body.args),
|
|
284
|
+
flow: 'retry'
|
|
285
|
+
});
|
|
286
|
+
if (authorization.decision === 'prompt') {
|
|
287
|
+
return sendJSON(res, 409, {
|
|
288
|
+
ok: false,
|
|
289
|
+
code: 'EAUTH_REQUIRED',
|
|
290
|
+
projectDir: dir,
|
|
291
|
+
chatId: body.chatId,
|
|
292
|
+
callId: body.callId,
|
|
293
|
+
tool,
|
|
294
|
+
timeoutMs: authorization.timeoutMs
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
const out = await mcp.callTool(dir, entry.slug, body.toolName, body.args || {});
|
|
298
|
+
return sendJSON(res, 200, out);
|
|
299
|
+
} catch (e) {
|
|
300
|
+
return sendJSON(res, mcpErrorStatus(e), { error: e.message, code: e.code || 'INTERNAL' });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return sendJSON(res, 404, { error: 'Not found', scope: 'mcp' });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// POST /api/restart body: { reason?: string, delayMs?: number }
|
|
308
|
+
// Graceful restart: stop running MCP children, flush the JSON response,
|
|
309
|
+
// close the listening socket, then ask the launcher to relaunch. If no
|
|
310
|
+
// launcher hook exists, exit with code 0 so a supervisor can relaunch.
|
|
311
|
+
async function handleRestart(req, res, parsed, lifecycle = {}) {
|
|
312
|
+
if (req.method !== 'POST') {
|
|
313
|
+
return sendJSON(res, 405, { error: 'POST only' });
|
|
314
|
+
}
|
|
315
|
+
let body = {};
|
|
316
|
+
try { body = await readJsonBody(req); } catch (e) {
|
|
317
|
+
if (e && e.status) return sendJSON(res, e.status, { error: e.message });
|
|
318
|
+
}
|
|
319
|
+
const result = requestRestart({
|
|
320
|
+
lifecycle,
|
|
321
|
+
reason: body && body.reason,
|
|
322
|
+
delayMs: body && body.delayMs,
|
|
323
|
+
defaultDelayMs: 150
|
|
324
|
+
});
|
|
325
|
+
return sendJSON(res, result.ok ? 200 : 409, result);
|
|
326
|
+
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function handleInspector(req, res, parsed) {
|
|
330
|
+
const urlPath = parsed.pathname;
|
|
331
|
+
const method = req.method;
|
|
332
|
+
const q = parsed.query || {};
|
|
333
|
+
|
|
334
|
+
// GET /api/inspector/config -> { url, defaultUrl, activeProfile }
|
|
335
|
+
// `activeProfile` is `{ id, label }` or null. It is read from the store
|
|
336
|
+
// (no scan), so the setup screen can name the active Chrome profile on
|
|
337
|
+
// mount without a second round-trip.
|
|
338
|
+
if (urlPath === '/api/inspector/config' && method === 'GET') {
|
|
339
|
+
return sendJSON(res, 200, {
|
|
340
|
+
url: inspector.getDebuggerUrl(),
|
|
341
|
+
defaultUrl: inspector.defaultDebuggerUrl(),
|
|
342
|
+
activeProfile: inspectorProfiles.activeProfile()
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// PUT /api/inspector/config body: { url } -> { url }
|
|
347
|
+
if (urlPath === '/api/inspector/config' && method === 'PUT') {
|
|
348
|
+
const body = await readJsonOr400(req, res);
|
|
349
|
+
if (!body) return;
|
|
350
|
+
if (!body || typeof body.url !== 'string') {
|
|
351
|
+
return sendJSON(res, 400, { error: 'url is required' });
|
|
352
|
+
}
|
|
353
|
+
// Light validation: must be http(s)://... for the host lookup; we
|
|
354
|
+
// do not check the port (could be anything).
|
|
355
|
+
let parsedUrl;
|
|
356
|
+
try { parsedUrl = new URL(body.url); }
|
|
357
|
+
catch { return sendJSON(res, 400, { error: 'url is not a valid URL' }); }
|
|
358
|
+
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
|
359
|
+
return sendJSON(res, 400, { error: 'url must be http or https' });
|
|
360
|
+
}
|
|
361
|
+
inspector.setDebuggerUrl(body.url);
|
|
362
|
+
// A hand-typed URL is no longer attributable to a Chrome profile, so
|
|
363
|
+
// drop the active badge rather than leave it claiming the profile
|
|
364
|
+
// still owns this endpoint (see inspectorProfiles.clearActive).
|
|
365
|
+
inspectorProfiles.clearActive();
|
|
366
|
+
return sendJSON(res, 200, { url: inspector.getDebuggerUrl(), activeProfile: null });
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// GET /api/inspector/version -> Chrome /json/version
|
|
370
|
+
if (urlPath === '/api/inspector/version' && method === 'GET') {
|
|
371
|
+
try {
|
|
372
|
+
const info = await inspector.fetchInspectorInfo(inspector.getDebuggerUrl());
|
|
373
|
+
return sendJSON(res, 200, info);
|
|
374
|
+
} catch (e) {
|
|
375
|
+
return sendJSON(res, inspectorErrorStatus(e), { error: e.message, code: e.code || 'EUPSTREAM' });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// GET /api/inspector/targets -> Chrome /json/list
|
|
380
|
+
if (urlPath === '/api/inspector/targets' && method === 'GET') {
|
|
381
|
+
try {
|
|
382
|
+
const list = await inspector.fetchInspectorTargets(inspector.getDebuggerUrl());
|
|
383
|
+
return sendJSON(res, 200, { targets: list });
|
|
384
|
+
} catch (e) {
|
|
385
|
+
return sendJSON(res, inspectorErrorStatus(e), { error: e.message, code: e.code || 'EUPSTREAM' });
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// POST /api/inspector/open body: { url } -> { target }
|
|
390
|
+
// Opens the page URL in a NEW tab of the debug Chrome (Chrome
|
|
391
|
+
// /json/new) and returns the fresh target record. The UI then
|
|
392
|
+
// attaches straight to it — the "inspect this URL" one-step flow.
|
|
393
|
+
if (urlPath === '/api/inspector/open' && method === 'POST') {
|
|
394
|
+
const body = await readJsonOr400(req, res);
|
|
395
|
+
if (!body) return;
|
|
396
|
+
if (!body || typeof body.url !== 'string' || !body.url.trim()) {
|
|
397
|
+
return sendJSON(res, 400, { error: 'url is required' });
|
|
398
|
+
}
|
|
399
|
+
let parsedUrl;
|
|
400
|
+
try { parsedUrl = new URL(body.url.trim()); }
|
|
401
|
+
catch { return sendJSON(res, 400, { error: 'url is not a valid URL' }); }
|
|
402
|
+
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
|
403
|
+
return sendJSON(res, 400, { error: 'url must be http or https' });
|
|
404
|
+
}
|
|
405
|
+
try {
|
|
406
|
+
const target = await inspector.openInspectorTarget(inspector.getDebuggerUrl(), parsedUrl.href);
|
|
407
|
+
return sendJSON(res, 200, { target });
|
|
408
|
+
} catch (e) {
|
|
409
|
+
return sendJSON(res, inspectorErrorStatus(e), { error: e.message, code: e.code || 'EUPSTREAM' });
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// POST /api/inspector/close body: { targetId } -> { ok: true }
|
|
414
|
+
// Closes a tab of the debug Chrome (Target.closeTarget on the
|
|
415
|
+
// browser-level WebSocket).
|
|
416
|
+
if (urlPath === '/api/inspector/close' && method === 'POST') {
|
|
417
|
+
const body = await readJsonOr400(req, res);
|
|
418
|
+
if (!body) return;
|
|
419
|
+
if (!body || typeof body.targetId !== 'string' || !body.targetId.trim()) {
|
|
420
|
+
return sendJSON(res, 400, { error: 'targetId is required' });
|
|
421
|
+
}
|
|
422
|
+
try {
|
|
423
|
+
const result = await inspector.closeInspectorTarget(inspector.getDebuggerUrl(), body.targetId.trim());
|
|
424
|
+
return sendJSON(res, 200, result);
|
|
425
|
+
} catch (e) {
|
|
426
|
+
return sendJSON(res, inspectorErrorStatus(e), { error: e.message, code: e.code || 'EUPSTREAM' });
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// POST /api/inspector/reload body: { targetId } -> { ok: true }
|
|
431
|
+
// Reloads a tab of the debug Chrome (Page.reload on the target's
|
|
432
|
+
// WebSocket).
|
|
433
|
+
if (urlPath === '/api/inspector/reload' && method === 'POST') {
|
|
434
|
+
const body = await readJsonOr400(req, res);
|
|
435
|
+
if (!body) return;
|
|
436
|
+
if (!body || typeof body.targetId !== 'string' || !body.targetId.trim()) {
|
|
437
|
+
return sendJSON(res, 400, { error: 'targetId is required' });
|
|
438
|
+
}
|
|
439
|
+
try {
|
|
440
|
+
const result = await inspector.reloadInspectorTarget(inspector.getDebuggerUrl(), body.targetId.trim());
|
|
441
|
+
return sendJSON(res, 200, result);
|
|
442
|
+
} catch (e) {
|
|
443
|
+
return sendJSON(res, inspectorErrorStatus(e), { error: e.message, code: e.code || 'EUPSTREAM' });
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// POST /api/inspector/navigate body: { targetId, url } -> { frameId, loaderId }
|
|
448
|
+
// Navigates a tab of the debug Chrome to a new URL (Page.navigate on
|
|
449
|
+
// the target's WebSocket).
|
|
450
|
+
if (urlPath === '/api/inspector/navigate' && method === 'POST') {
|
|
451
|
+
const body = await readJsonOr400(req, res);
|
|
452
|
+
if (!body) return;
|
|
453
|
+
if (!body || typeof body.targetId !== 'string' || !body.targetId.trim()) {
|
|
454
|
+
return sendJSON(res, 400, { error: 'targetId is required' });
|
|
455
|
+
}
|
|
456
|
+
if (typeof body.url !== 'string' || !body.url.trim()) {
|
|
457
|
+
return sendJSON(res, 400, { error: 'url is required' });
|
|
458
|
+
}
|
|
459
|
+
let parsedUrl;
|
|
460
|
+
try { parsedUrl = new URL(body.url.trim()); }
|
|
461
|
+
catch { return sendJSON(res, 400, { error: 'url is not a valid URL' }); }
|
|
462
|
+
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
|
463
|
+
return sendJSON(res, 400, { error: 'url must be http or https' });
|
|
464
|
+
}
|
|
465
|
+
try {
|
|
466
|
+
const result = await inspector.navigateInspectorTarget(inspector.getDebuggerUrl(), body.targetId.trim(), parsedUrl.href);
|
|
467
|
+
return sendJSON(res, 200, result);
|
|
468
|
+
} catch (e) {
|
|
469
|
+
return sendJSON(res, inspectorErrorStatus(e), { error: e.message, code: e.code || 'EUPSTREAM' });
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
// POST /api/inspector/history body: { targetId } -> { index, canGoBack, canGoForward, entries }
|
|
473
|
+
// Read-only session history for the attached tab (Page.getNavigationHistory
|
|
474
|
+
// on the target's WebSocket). The nav row uses it to enable/disable the two
|
|
475
|
+
// history arrows, so "there is nowhere to go" is visible before the tap
|
|
476
|
+
// instead of being reported as a no-op afterwards.
|
|
477
|
+
if (urlPath === '/api/inspector/history' && method === 'POST') {
|
|
478
|
+
const body = await readJsonOr400(req, res);
|
|
479
|
+
if (!body) return;
|
|
480
|
+
if (!body || typeof body.targetId !== 'string' || !body.targetId.trim()) {
|
|
481
|
+
return sendJSON(res, 400, { error: 'targetId is required' });
|
|
482
|
+
}
|
|
483
|
+
try {
|
|
484
|
+
const result = await inspector.historyInspectorTarget(inspector.getDebuggerUrl(), body.targetId.trim());
|
|
485
|
+
return sendJSON(res, 200, result);
|
|
486
|
+
} catch (e) {
|
|
487
|
+
return sendJSON(res, inspectorErrorStatus(e), { error: e.message, code: e.code || 'EUPSTREAM' });
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
// POST /api/inspector/back body: { targetId } -> { ok, wentBack }
|
|
491
|
+
// Navigates a tab one entry back in its history (Page.navigateToHistoryEntry
|
|
492
|
+
// on the target's WebSocket). `wentBack: false` means there was no previous
|
|
493
|
+
// entry — the UI shows that as a no-op, not an error.
|
|
494
|
+
if (urlPath === '/api/inspector/back' && method === 'POST') {
|
|
495
|
+
const body = await readJsonOr400(req, res);
|
|
496
|
+
if (!body) return;
|
|
497
|
+
if (!body || typeof body.targetId !== 'string' || !body.targetId.trim()) {
|
|
498
|
+
return sendJSON(res, 400, { error: 'targetId is required' });
|
|
499
|
+
}
|
|
500
|
+
try {
|
|
501
|
+
const result = await inspector.goBackInspectorTarget(inspector.getDebuggerUrl(), body.targetId.trim());
|
|
502
|
+
return sendJSON(res, 200, result);
|
|
503
|
+
} catch (e) {
|
|
504
|
+
return sendJSON(res, inspectorErrorStatus(e), { error: e.message, code: e.code || 'EUPSTREAM' });
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
// POST /api/inspector/forward body: { targetId } -> { ok, wentForward }
|
|
508
|
+
// The other direction of the entry above, so a tab the user stepped back
|
|
509
|
+
// from can be stepped forward again. Same no-op contract: `wentForward:
|
|
510
|
+
// false` means there was no entry ahead.
|
|
511
|
+
if (urlPath === '/api/inspector/forward' && method === 'POST') {
|
|
512
|
+
const body = await readJsonOr400(req, res);
|
|
513
|
+
if (!body) return;
|
|
514
|
+
if (!body || typeof body.targetId !== 'string' || !body.targetId.trim()) {
|
|
515
|
+
return sendJSON(res, 400, { error: 'targetId is required' });
|
|
516
|
+
}
|
|
517
|
+
try {
|
|
518
|
+
const result = await inspector.goForwardInspectorTarget(inspector.getDebuggerUrl(), body.targetId.trim());
|
|
519
|
+
return sendJSON(res, 200, result);
|
|
520
|
+
} catch (e) {
|
|
521
|
+
return sendJSON(res, inspectorErrorStatus(e), { error: e.message, code: e.code || 'EUPSTREAM' });
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// GET /api/inspector/profiles -> discovered Chrome profiles + endpoints
|
|
526
|
+
// Read-only discovery (see src/inspectorProfiles.js). Each profile
|
|
527
|
+
// carries the debug endpoint Inspector would attach to, so the UI can
|
|
528
|
+
// show the port and switch in one tap.
|
|
529
|
+
if (urlPath === '/api/inspector/profiles' && method === 'GET') {
|
|
530
|
+
try {
|
|
531
|
+
return sendJSON(res, 200, inspectorProfiles.listProfiles());
|
|
532
|
+
} catch (e) {
|
|
533
|
+
return sendJSON(res, 500, { error: e.message, code: e.code || 'EPROFILES' });
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// POST /api/inspector/profiles/switch body: { id } -> { url, profile }
|
|
538
|
+
// Makes a profile the Inspector's attach point by writing its endpoint
|
|
539
|
+
// into the existing global debugger URL. No Chrome is started or stopped.
|
|
540
|
+
if (urlPath === '/api/inspector/profiles/switch' && method === 'POST') {
|
|
541
|
+
const body = await readJsonOr400(req, res);
|
|
542
|
+
if (!body) return;
|
|
543
|
+
if (typeof body.id !== 'string' || !body.id.trim()) {
|
|
544
|
+
return sendJSON(res, 400, { error: 'id is required' });
|
|
545
|
+
}
|
|
546
|
+
try {
|
|
547
|
+
const result = inspectorProfiles.switchProfile(body.id.trim());
|
|
548
|
+
return sendJSON(res, 200, {
|
|
549
|
+
url: result.url,
|
|
550
|
+
profile: { id: result.profile.id, key: result.profile.key, label: result.profile.label }
|
|
551
|
+
});
|
|
552
|
+
} catch (e) {
|
|
553
|
+
return sendJSON(res, inspectorErrorStatus(e), { error: e.message, code: e.code || 'EPROFILES' });
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// POST /api/inspector/profiles/endpoint body: { id, url } -> { url }
|
|
558
|
+
// Remembers the endpoint for ONE profile without switching to it, so a
|
|
559
|
+
// user can pre-configure ports before the other Chrome is running.
|
|
560
|
+
if (urlPath === '/api/inspector/profiles/endpoint' && method === 'POST') {
|
|
561
|
+
const body = await readJsonOr400(req, res);
|
|
562
|
+
if (!body) return;
|
|
563
|
+
if (typeof body.id !== 'string' || !body.id.trim()) {
|
|
564
|
+
return sendJSON(res, 400, { error: 'id is required' });
|
|
565
|
+
}
|
|
566
|
+
if (typeof body.url !== 'string' || !body.url.trim()) {
|
|
567
|
+
return sendJSON(res, 400, { error: 'url is required' });
|
|
568
|
+
}
|
|
569
|
+
try {
|
|
570
|
+
const result = inspectorProfiles.setProfileEndpoint(body.id.trim(), body.url.trim());
|
|
571
|
+
return sendJSON(res, 200, {
|
|
572
|
+
url: result.url,
|
|
573
|
+
profile: { id: result.profile.id, key: result.profile.key, label: result.profile.label }
|
|
574
|
+
});
|
|
575
|
+
} catch (e) {
|
|
576
|
+
return sendJSON(res, inspectorErrorStatus(e), { error: e.message, code: e.code || 'EPROFILES' });
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// POST /api/inspector/profiles/dirs body: { dir } -> { dir, profiles }
|
|
581
|
+
// Registers an extra Chrome user-data-dir the automatic scan does not
|
|
582
|
+
// know about (portable Chrome, a profile tree on another volume).
|
|
583
|
+
if (urlPath === '/api/inspector/profiles/dirs' && method === 'POST') {
|
|
584
|
+
const body = await readJsonOr400(req, res);
|
|
585
|
+
if (!body) return;
|
|
586
|
+
if (typeof body.dir !== 'string' || !body.dir.trim()) {
|
|
587
|
+
return sendJSON(res, 400, { error: 'dir is required' });
|
|
588
|
+
}
|
|
589
|
+
try {
|
|
590
|
+
return sendJSON(res, 200, inspectorProfiles.addDir(body.dir.trim()));
|
|
591
|
+
} catch (e) {
|
|
592
|
+
return sendJSON(res, inspectorErrorStatus(e), { error: e.message, code: e.code || 'EPROFILES' });
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// DELETE /api/inspector/profiles/dirs?dir=<abs path> -> { dir, removed }
|
|
597
|
+
if (urlPath === '/api/inspector/profiles/dirs' && method === 'DELETE') {
|
|
598
|
+
const dir = q.dir ? safeDecode(q.dir) : '';
|
|
599
|
+
if (!dir) return sendJSON(res, 400, { error: 'dir is required' });
|
|
600
|
+
return sendJSON(res, 200, inspectorProfiles.removeDir(dir));
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return sendJSON(res, 404, { error: 'Not found', scope: 'inspector' });
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function inspectorErrorStatus(err) {
|
|
607
|
+
switch (err && err.code) {
|
|
608
|
+
case 'EBADURL':
|
|
609
|
+
case 'EBADINPUT':
|
|
610
|
+
return 400;
|
|
611
|
+
// A profile id the scan does not know: the UI is holding a stale
|
|
612
|
+
// list (Chrome profile removed, or another mouaif window changed the
|
|
613
|
+
// active one), which is a 404 and not a server fault.
|
|
614
|
+
case 'EPROFILE_NOT_FOUND':
|
|
615
|
+
case 'ENOTPROFILEDIR':
|
|
616
|
+
return 404;
|
|
617
|
+
case 'ECHROME_UNREACHABLE':
|
|
618
|
+
return 502;
|
|
619
|
+
case 'ETARGET_NOT_FOUND':
|
|
620
|
+
return 404;
|
|
621
|
+
case 'EUPSTREAM':
|
|
622
|
+
return 502;
|
|
623
|
+
case 'EPARSE':
|
|
624
|
+
return 502;
|
|
625
|
+
default:
|
|
626
|
+
return 500;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async function handleToolAuthorization(req, res, parsed) {
|
|
631
|
+
const urlPath = parsed.pathname;
|
|
632
|
+
const method = req.method;
|
|
633
|
+
const q = parsed.query || {};
|
|
634
|
+
const authGate = require('./tools/authorization.js');
|
|
635
|
+
|
|
636
|
+
// GET /api/tools/authorization?projectDir=<abs>[&chatId=<id>]
|
|
637
|
+
// GET /api/tools/authorization?scope=app -> the app-level MCP gate only.
|
|
638
|
+
//
|
|
639
|
+
// With `chatId` the response is scoped to ONE chat: the effective
|
|
640
|
+
// modes are the chat's own overrides layered over the project / app
|
|
641
|
+
// values, and `chat` carries those raw override maps so the chat's
|
|
642
|
+
// Tools card and tool popup can show what this chat pinned. Without
|
|
643
|
+
// `chatId` the response is the project view (what project settings
|
|
644
|
+
// must render and edit) and carries no `chat` block.
|
|
645
|
+
if (urlPath === '/api/tools/authorization' && method === 'GET') {
|
|
646
|
+
if (q.scope === 'app') {
|
|
647
|
+
try {
|
|
648
|
+
return sendJSON(res, 200, authGate.getAppMcpAuthorization());
|
|
649
|
+
} catch (e) {
|
|
650
|
+
return sendJSON(res, 500, { error: e.message });
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
const dir = qs(q, 'projectDir');
|
|
654
|
+
if (!dir) return sendJSON(res, 400, { error: 'projectDir query param is required' });
|
|
655
|
+
const chatId = qs(q, 'chatId');
|
|
656
|
+
try {
|
|
657
|
+
if (chatId && !chats.getChat(dir, chatId)) {
|
|
658
|
+
return sendJSON(res, 404, { error: 'Chat not found', chatId });
|
|
659
|
+
}
|
|
660
|
+
return sendJSON(res, 200, authGate.getAuthorization(dir, chatId || undefined));
|
|
661
|
+
} catch (e) {
|
|
662
|
+
return sendJSON(res, 500, { error: e.message });
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// PUT /api/tools/authorization
|
|
667
|
+
// Body shapes, in order of precedence:
|
|
668
|
+
// { chatId, scope: 'chat', chat } -> per-chat overrides
|
|
669
|
+
// { scope: 'app', mcp } -> app-level MCP gate
|
|
670
|
+
// { projectDir, tools, mcp } -> the project gate
|
|
671
|
+
//
|
|
672
|
+
// The chat scope is what the chat view's Tools card and tool popup
|
|
673
|
+
// write: those controls are per-chat, and they must never touch
|
|
674
|
+
// `.mouaif.json` / `.mcp.json`. Project settings keeps using the
|
|
675
|
+
// project scope. See docs/features/tool-authorization.md.
|
|
676
|
+
if (urlPath === '/api/tools/authorization' && method === 'PUT') {
|
|
677
|
+
const body = await readJsonOr400(req, res);
|
|
678
|
+
if (!body) return;
|
|
679
|
+
const { projectDir, scope, chatId, chat, tools, mcp: mcpAuthorization } = body || {};
|
|
680
|
+
if (scope === 'chat') {
|
|
681
|
+
if (!projectDir || typeof projectDir !== 'string') {
|
|
682
|
+
return sendJSON(res, 400, { error: 'projectDir is required' });
|
|
683
|
+
}
|
|
684
|
+
if (!chatId || typeof chatId !== 'string') {
|
|
685
|
+
return sendJSON(res, 400, { error: 'chatId is required for the chat scope' });
|
|
686
|
+
}
|
|
687
|
+
if (!chats.getChat(projectDir, chatId)) {
|
|
688
|
+
return sendJSON(res, 404, { error: 'Chat not found', chatId });
|
|
689
|
+
}
|
|
690
|
+
try {
|
|
691
|
+
// `null` keys inside `chat` clear that override (back to the
|
|
692
|
+
// project value); `chat: null` clears every override at once.
|
|
693
|
+
const next = authGate.setChatAuthorization(projectDir, chatId, chat);
|
|
694
|
+
return sendJSON(res, 200, next);
|
|
695
|
+
} catch (e) {
|
|
696
|
+
return sendJSON(res, 400, { error: e.message });
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
if (scope === 'app') {
|
|
700
|
+
try {
|
|
701
|
+
const next = authGate.setAppMcpAuthorization({ mcp: mcpAuthorization });
|
|
702
|
+
return sendJSON(res, 200, next);
|
|
703
|
+
} catch (e) {
|
|
704
|
+
return sendJSON(res, 400, { error: e.message });
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
if (!projectDir || typeof projectDir !== 'string') {
|
|
708
|
+
return sendJSON(res, 400, { error: 'projectDir is required' });
|
|
709
|
+
}
|
|
710
|
+
try {
|
|
711
|
+
const next = authGate.setAuthorization(projectDir, { tools, mcp: mcpAuthorization });
|
|
712
|
+
return sendJSON(res, 200, next);
|
|
713
|
+
} catch (e) {
|
|
714
|
+
return sendJSON(res, 400, { error: e.message });
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// GET /api/tools/authorization/pending?projectDir=<abs>&chatId=<id>
|
|
719
|
+
if (urlPath === '/api/tools/authorization/pending' && method === 'GET') {
|
|
720
|
+
const projectDir = qs(q, 'projectDir');
|
|
721
|
+
const chatId = qs(q, 'chatId');
|
|
722
|
+
if (!projectDir) return sendJSON(res, 400, { error: 'projectDir query param is required' });
|
|
723
|
+
if (!chatId) return sendJSON(res, 400, { error: 'chatId query param is required' });
|
|
724
|
+
try {
|
|
725
|
+
if (!chats.getChat(projectDir, chatId)) return sendJSON(res, 404, { error: 'Chat not found', chatId });
|
|
726
|
+
return sendJSON(res, 200, { pending: authGate.listPending(projectDir, chatId) });
|
|
727
|
+
} catch (e) {
|
|
728
|
+
return sendJSON(res, 400, { error: e.message });
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// POST /api/tools/authorization/cancel
|
|
733
|
+
if (urlPath === '/api/tools/authorization/cancel' && method === 'POST') {
|
|
734
|
+
const body = await readJsonOr400(req, res);
|
|
735
|
+
if (!body) return;
|
|
736
|
+
const { projectDir, chatId } = body || {};
|
|
737
|
+
if (!projectDir) return sendJSON(res, 400, { error: 'projectDir is required' });
|
|
738
|
+
if (!chatId) return sendJSON(res, 400, { error: 'chatId is required' });
|
|
739
|
+
try {
|
|
740
|
+
if (!chats.getChat(projectDir, chatId)) return sendJSON(res, 404, { error: 'Chat not found', chatId });
|
|
741
|
+
const key = runningKey(projectDir, chatId);
|
|
742
|
+
const controller = runningChatCancels.get(key);
|
|
743
|
+
if (controller) {
|
|
744
|
+
try { controller.abort(new Error('user cancelled chat')); } catch { /* already settled */ }
|
|
745
|
+
}
|
|
746
|
+
return sendJSON(res, 200, { ok: true, cancelled: authGate.cancelSession(projectDir, chatId), running: runningChats.has(key) });
|
|
747
|
+
} catch (e) {
|
|
748
|
+
return sendJSON(res, 400, { error: e.message });
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// POST /api/tools/authorization/decision
|
|
753
|
+
if (urlPath === '/api/tools/authorization/decision' && method === 'POST') {
|
|
754
|
+
const body = await readJsonOr400(req, res);
|
|
755
|
+
if (!body) return;
|
|
756
|
+
const { projectDir, chatId, callId, decision, payload } = body || {};
|
|
757
|
+
if (!projectDir) return sendJSON(res, 400, { error: 'projectDir is required' });
|
|
758
|
+
if (!chatId) return sendJSON(res, 400, { error: 'chatId is required' });
|
|
759
|
+
if (!callId) return sendJSON(res, 400, { error: 'callId is required' });
|
|
760
|
+
if (!decision) return sendJSON(res, 400, { error: 'decision is required' });
|
|
761
|
+
try {
|
|
762
|
+
if (!chats.getChat(projectDir, chatId)) return sendJSON(res, 404, { error: 'Chat not found', chatId });
|
|
763
|
+
// `payload` is an optional bag of structured data the chat UI
|
|
764
|
+
// hands back alongside the decision. Today only the `ask_user`
|
|
765
|
+
// tool reads it (the user's chosen option + free-form extra
|
|
766
|
+
// text), but the channel is generic so a future native tool can
|
|
767
|
+
// attach its own structured answer without a new endpoint.
|
|
768
|
+
const runKey = runningKey(projectDir, chatId);
|
|
769
|
+
const out = authGate.recordDecision(projectDir, chatId, callId, decision, payload);
|
|
770
|
+
// Tell follower tabs/pages to drop any replayed prompt immediately.
|
|
771
|
+
// Without this, a page that returned to a paused stream could keep a
|
|
772
|
+
// stale approval card until the next transcript sync.
|
|
773
|
+
liveChat.pruneLive(runKey, callId);
|
|
774
|
+
return sendJSON(res, 200, out);
|
|
775
|
+
} catch (e) {
|
|
776
|
+
return sendJSON(res, 400, { error: e.message });
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
return sendJSON(res, 404, { error: 'Not found', scope: 'tools-authorization' });
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
module.exports = { handleMcp, handleRestart, handleInspector, handleToolAuthorization };
|