deckide 3.5.33 → 3.5.35
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/README.md +42 -1
- package/bin/deckide.js +1 -1
- package/dist/middleware/cors.js +1 -1
- package/dist/routes/browser.js +40 -0
- package/dist/routes/decks.js +0 -2
- package/dist/routes/mcp.js +1241 -0
- package/dist/routes/terminals.js +180 -17
- package/dist/server.js +24 -4
- package/dist/utils/agent-browser.js +857 -0
- package/dist/utils/browser-audio.js +381 -0
- package/dist/utils/database.js +0 -40
- package/dist/utils/shell.js +34 -0
- package/dist/websocket.js +50 -3
- package/package.json +11 -7
- package/web/dist/assets/index-BwLHjU38.js +178 -0
- package/web/dist/assets/index-DnryGV5L.css +32 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-DINx38Yu.css +0 -32
- package/web/dist/assets/index-MGg98kDU.js +0 -65
|
@@ -0,0 +1,1241 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { Hono } from 'hono';
|
|
3
|
+
import { alignToUtf8Start, alignToUtf8End } from '../utils/utf8.js';
|
|
4
|
+
import { BASIC_AUTH_PASSWORD, BASIC_AUTH_USER, CORS_ORIGIN, NODE_ENV, PORT, TRUST_PROXY, } from '../config.js';
|
|
5
|
+
class ToolExecutionError extends Error {
|
|
6
|
+
status;
|
|
7
|
+
data;
|
|
8
|
+
constructor(message, status, data) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = 'ToolExecutionError';
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.data = data;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const PROTOCOL_VERSION = '2025-11-25';
|
|
16
|
+
const SUPPORTED_PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26'];
|
|
17
|
+
const ACCESS_TOKEN_TTL_SECONDS = 60 * 60;
|
|
18
|
+
const AUTH_CODE_TTL_MS = 5 * 60 * 1000;
|
|
19
|
+
const OAUTH_SCOPE = 'deckide:all';
|
|
20
|
+
const clients = new Map();
|
|
21
|
+
const authorizationCodes = new Map();
|
|
22
|
+
const accessTokens = new Map();
|
|
23
|
+
setInterval(() => {
|
|
24
|
+
const now = Date.now();
|
|
25
|
+
for (const [code, record] of authorizationCodes.entries()) {
|
|
26
|
+
if (record.expiresAt <= now)
|
|
27
|
+
authorizationCodes.delete(code);
|
|
28
|
+
}
|
|
29
|
+
for (const [token, record] of accessTokens.entries()) {
|
|
30
|
+
if (record.expiresAt <= now)
|
|
31
|
+
accessTokens.delete(token);
|
|
32
|
+
}
|
|
33
|
+
}, 60_000).unref();
|
|
34
|
+
function jsonRpcResult(id, result) {
|
|
35
|
+
return { jsonrpc: '2.0', id, result };
|
|
36
|
+
}
|
|
37
|
+
function jsonRpcError(id, code, message, data) {
|
|
38
|
+
return {
|
|
39
|
+
jsonrpc: '2.0',
|
|
40
|
+
id,
|
|
41
|
+
error: data === undefined ? { code, message } : { code, message, data },
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function isObject(value) {
|
|
45
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
46
|
+
}
|
|
47
|
+
function stringArg(args, key, required = true) {
|
|
48
|
+
const value = args[key];
|
|
49
|
+
if (value === undefined || value === null) {
|
|
50
|
+
if (required)
|
|
51
|
+
throw new ToolExecutionError(`${key} is required`, 400);
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
if (typeof value !== 'string') {
|
|
55
|
+
throw new ToolExecutionError(`${key} must be a string`, 400);
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
function booleanArg(args, key) {
|
|
60
|
+
const value = args[key];
|
|
61
|
+
if (value === undefined || value === null)
|
|
62
|
+
return undefined;
|
|
63
|
+
if (typeof value !== 'boolean') {
|
|
64
|
+
throw new ToolExecutionError(`${key} must be a boolean`, 400);
|
|
65
|
+
}
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
function numberArg(args, key, required = true) {
|
|
69
|
+
const value = args[key];
|
|
70
|
+
if (value === undefined || value === null) {
|
|
71
|
+
if (required)
|
|
72
|
+
throw new ToolExecutionError(`${key} is required`, 400);
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
76
|
+
throw new ToolExecutionError(`${key} must be a finite number`, 400);
|
|
77
|
+
}
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
function stringArrayArg(args, key) {
|
|
81
|
+
const value = args[key];
|
|
82
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
|
|
83
|
+
throw new ToolExecutionError(`${key} must be an array of strings`, 400);
|
|
84
|
+
}
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
function objectArg(args, key) {
|
|
88
|
+
const value = args[key];
|
|
89
|
+
if (value === undefined || value === null)
|
|
90
|
+
return undefined;
|
|
91
|
+
if (!isObject(value)) {
|
|
92
|
+
throw new ToolExecutionError(`${key} must be an object`, 400);
|
|
93
|
+
}
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
function schema(properties, required = [], additionalProperties = false) {
|
|
97
|
+
return {
|
|
98
|
+
type: 'object',
|
|
99
|
+
properties,
|
|
100
|
+
required,
|
|
101
|
+
additionalProperties,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function emptySchema() {
|
|
105
|
+
return { type: 'object', additionalProperties: false };
|
|
106
|
+
}
|
|
107
|
+
function getBaseUrl(c) {
|
|
108
|
+
const requestUrl = new URL(c.req.url);
|
|
109
|
+
const forwardedProto = TRUST_PROXY ? c.req.header('x-forwarded-proto') : undefined;
|
|
110
|
+
const forwardedHost = TRUST_PROXY ? c.req.header('x-forwarded-host') : undefined;
|
|
111
|
+
const proto = (forwardedProto || requestUrl.protocol.replace(':', '')).split(',')[0].trim();
|
|
112
|
+
const host = (forwardedHost || c.req.header('host') || requestUrl.host).split(',')[0].trim();
|
|
113
|
+
return `${proto}://${host}`;
|
|
114
|
+
}
|
|
115
|
+
function getMcpResource(c) {
|
|
116
|
+
return `${getBaseUrl(c)}/mcp`;
|
|
117
|
+
}
|
|
118
|
+
function getProtectedResourceMetadataUrl(c) {
|
|
119
|
+
return `${getBaseUrl(c)}/.well-known/oauth-protected-resource/mcp`;
|
|
120
|
+
}
|
|
121
|
+
function bearerChallenge(c, error) {
|
|
122
|
+
const params = [
|
|
123
|
+
`resource_metadata="${getProtectedResourceMetadataUrl(c)}"`,
|
|
124
|
+
`scope="${OAUTH_SCOPE}"`,
|
|
125
|
+
];
|
|
126
|
+
if (error)
|
|
127
|
+
params.unshift(`error="${error}"`);
|
|
128
|
+
return `Bearer ${params.join(', ')}`;
|
|
129
|
+
}
|
|
130
|
+
function parseBearerToken(c) {
|
|
131
|
+
const auth = c.req.header('authorization');
|
|
132
|
+
if (!auth)
|
|
133
|
+
return null;
|
|
134
|
+
const [scheme, token] = auth.split(/\s+/, 2);
|
|
135
|
+
if (!scheme || scheme.toLowerCase() !== 'bearer' || !token)
|
|
136
|
+
return null;
|
|
137
|
+
return token;
|
|
138
|
+
}
|
|
139
|
+
function validateAccessToken(c) {
|
|
140
|
+
const token = parseBearerToken(c);
|
|
141
|
+
if (!token)
|
|
142
|
+
return null;
|
|
143
|
+
const record = accessTokens.get(token);
|
|
144
|
+
if (!record || record.expiresAt <= Date.now()) {
|
|
145
|
+
accessTokens.delete(token);
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
if (record.resource !== getMcpResource(c) || !record.scopes.includes(OAUTH_SCOPE)) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
return record;
|
|
152
|
+
}
|
|
153
|
+
function validRedirectUri(uri) {
|
|
154
|
+
try {
|
|
155
|
+
const parsed = new URL(uri);
|
|
156
|
+
if (parsed.protocol === 'https:')
|
|
157
|
+
return true;
|
|
158
|
+
if (parsed.protocol === 'http:') {
|
|
159
|
+
return parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1' || parsed.hostname === '::1';
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
function isKnownRedirectUri(client, redirectUri) {
|
|
168
|
+
return client.redirectUris.includes(redirectUri);
|
|
169
|
+
}
|
|
170
|
+
function normalizeScopes(scopeValue) {
|
|
171
|
+
if (!scopeValue?.trim())
|
|
172
|
+
return [OAUTH_SCOPE];
|
|
173
|
+
const scopes = scopeValue.split(/\s+/).filter(Boolean);
|
|
174
|
+
if (scopes.length === 0)
|
|
175
|
+
return [OAUTH_SCOPE];
|
|
176
|
+
if (scopes.some((scope) => scope !== OAUTH_SCOPE)) {
|
|
177
|
+
throw new ToolExecutionError(`Unsupported scope. Supported scope: ${OAUTH_SCOPE}`, 400);
|
|
178
|
+
}
|
|
179
|
+
return scopes;
|
|
180
|
+
}
|
|
181
|
+
function hashCodeVerifier(verifier) {
|
|
182
|
+
return crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
183
|
+
}
|
|
184
|
+
function redirectWithParams(redirectUri, params) {
|
|
185
|
+
const target = new URL(redirectUri);
|
|
186
|
+
for (const [key, value] of Object.entries(params)) {
|
|
187
|
+
target.searchParams.set(key, value);
|
|
188
|
+
}
|
|
189
|
+
return Response.redirect(target.toString(), 302);
|
|
190
|
+
}
|
|
191
|
+
function escapeHtml(value) {
|
|
192
|
+
return value
|
|
193
|
+
.replace(/&/g, '&')
|
|
194
|
+
.replace(/</g, '<')
|
|
195
|
+
.replace(/>/g, '>')
|
|
196
|
+
.replace(/"/g, '"')
|
|
197
|
+
.replace(/'/g, ''');
|
|
198
|
+
}
|
|
199
|
+
async function readForm(c) {
|
|
200
|
+
const text = await c.req.text();
|
|
201
|
+
return new URLSearchParams(text);
|
|
202
|
+
}
|
|
203
|
+
function renderConsentPage(params, client) {
|
|
204
|
+
const hiddenInputs = Array.from(params.entries())
|
|
205
|
+
.map(([key, value]) => `<input type="hidden" name="${escapeHtml(key)}" value="${escapeHtml(value)}">`)
|
|
206
|
+
.join('\n');
|
|
207
|
+
const scope = params.get('scope') || OAUTH_SCOPE;
|
|
208
|
+
return `<!doctype html>
|
|
209
|
+
<html lang="ja">
|
|
210
|
+
<head>
|
|
211
|
+
<meta charset="utf-8">
|
|
212
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
213
|
+
<title>Deck IDE OAuth</title>
|
|
214
|
+
<style>
|
|
215
|
+
body { font-family: system-ui, sans-serif; margin: 2rem; line-height: 1.5; color: #111; background: #fff; }
|
|
216
|
+
main { max-width: 680px; }
|
|
217
|
+
code { background: #eee; padding: 0.1rem 0.3rem; border-radius: 4px; }
|
|
218
|
+
button { border: 1px solid #111; border-radius: 6px; background: #111; color: #fff; padding: 0.6rem 0.9rem; cursor: pointer; }
|
|
219
|
+
button.secondary { background: #fff; color: #111; margin-left: 0.5rem; }
|
|
220
|
+
</style>
|
|
221
|
+
</head>
|
|
222
|
+
<body>
|
|
223
|
+
<main>
|
|
224
|
+
<h1>Deck IDE MCP access</h1>
|
|
225
|
+
<p><strong>${escapeHtml(client.clientName || client.clientId)}</strong> にDeck IDEのMCPツール使用を許可します。</p>
|
|
226
|
+
<p>Scope: <code>${escapeHtml(scope)}</code></p>
|
|
227
|
+
<p>許可すると、このクライアントはワークスペース、ファイル、デッキ、端末、Git、設定、サーバー操作をMCP経由で実行できます。</p>
|
|
228
|
+
<form method="post" action="/oauth/authorize">
|
|
229
|
+
${hiddenInputs}
|
|
230
|
+
<button type="submit" name="decision" value="approve">許可</button>
|
|
231
|
+
<button class="secondary" type="submit" name="decision" value="deny">拒否</button>
|
|
232
|
+
</form>
|
|
233
|
+
</main>
|
|
234
|
+
</body>
|
|
235
|
+
</html>`;
|
|
236
|
+
}
|
|
237
|
+
function validateAuthorizationRequest(params, c) {
|
|
238
|
+
const responseType = params.get('response_type');
|
|
239
|
+
const clientId = params.get('client_id');
|
|
240
|
+
const redirectUri = params.get('redirect_uri');
|
|
241
|
+
const codeChallenge = params.get('code_challenge');
|
|
242
|
+
const codeChallengeMethod = params.get('code_challenge_method');
|
|
243
|
+
const resource = params.get('resource') || getMcpResource(c);
|
|
244
|
+
if (responseType !== 'code')
|
|
245
|
+
throw new ToolExecutionError('response_type must be code', 400);
|
|
246
|
+
if (!clientId)
|
|
247
|
+
throw new ToolExecutionError('client_id is required', 400);
|
|
248
|
+
if (!redirectUri)
|
|
249
|
+
throw new ToolExecutionError('redirect_uri is required', 400);
|
|
250
|
+
if (!codeChallenge)
|
|
251
|
+
throw new ToolExecutionError('code_challenge is required', 400);
|
|
252
|
+
if (codeChallengeMethod !== 'S256')
|
|
253
|
+
throw new ToolExecutionError('code_challenge_method must be S256', 400);
|
|
254
|
+
if (resource !== getMcpResource(c))
|
|
255
|
+
throw new ToolExecutionError('resource does not match this MCP server', 400);
|
|
256
|
+
const client = clients.get(clientId);
|
|
257
|
+
if (!client)
|
|
258
|
+
throw new ToolExecutionError('Unknown client_id', 400);
|
|
259
|
+
if (!isKnownRedirectUri(client, redirectUri)) {
|
|
260
|
+
throw new ToolExecutionError('redirect_uri is not registered for this client', 400);
|
|
261
|
+
}
|
|
262
|
+
const scopes = normalizeScopes(params.get('scope'));
|
|
263
|
+
return { client, clientId, redirectUri, codeChallenge, scopes, resource };
|
|
264
|
+
}
|
|
265
|
+
function basicAuthHeader() {
|
|
266
|
+
if (!BASIC_AUTH_USER || !BASIC_AUTH_PASSWORD)
|
|
267
|
+
return undefined;
|
|
268
|
+
return `Basic ${Buffer.from(`${BASIC_AUTH_USER}:${BASIC_AUTH_PASSWORD}`).toString('base64')}`;
|
|
269
|
+
}
|
|
270
|
+
function appendQuery(path, query) {
|
|
271
|
+
if (!query)
|
|
272
|
+
return path;
|
|
273
|
+
const url = new URL(path, 'http://deckide.local');
|
|
274
|
+
for (const [key, value] of Object.entries(query)) {
|
|
275
|
+
if (value === undefined || value === null)
|
|
276
|
+
continue;
|
|
277
|
+
if (Array.isArray(value)) {
|
|
278
|
+
value.forEach((item) => url.searchParams.append(key, String(item)));
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
url.searchParams.set(key, String(value));
|
|
282
|
+
}
|
|
283
|
+
return `${url.pathname}${url.search}`;
|
|
284
|
+
}
|
|
285
|
+
async function apiJson(apiFetch, method, path, body) {
|
|
286
|
+
const headers = new Headers();
|
|
287
|
+
headers.set('Accept', 'application/json');
|
|
288
|
+
const auth = basicAuthHeader();
|
|
289
|
+
if (auth)
|
|
290
|
+
headers.set('Authorization', auth);
|
|
291
|
+
let requestBody;
|
|
292
|
+
if (body !== undefined) {
|
|
293
|
+
headers.set('Content-Type', 'application/json');
|
|
294
|
+
requestBody = JSON.stringify(body);
|
|
295
|
+
}
|
|
296
|
+
const response = await apiFetch(new Request(`http://deckide.local${path}`, {
|
|
297
|
+
method,
|
|
298
|
+
headers,
|
|
299
|
+
body: requestBody,
|
|
300
|
+
}));
|
|
301
|
+
const text = await response.text();
|
|
302
|
+
let data = null;
|
|
303
|
+
if (text) {
|
|
304
|
+
try {
|
|
305
|
+
data = JSON.parse(text);
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
data = text;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
else if (response.status === 204) {
|
|
312
|
+
data = { success: true, status: 204 };
|
|
313
|
+
}
|
|
314
|
+
if (!response.ok) {
|
|
315
|
+
const message = isObject(data) && typeof data.error === 'string'
|
|
316
|
+
? data.error
|
|
317
|
+
: `HTTP ${response.status}`;
|
|
318
|
+
throw new ToolExecutionError(message, response.status, data);
|
|
319
|
+
}
|
|
320
|
+
return data ?? { success: true, status: response.status };
|
|
321
|
+
}
|
|
322
|
+
function apiTool(apiFetch, method, path, body) {
|
|
323
|
+
return (args) => {
|
|
324
|
+
const resolvedPath = typeof path === 'function' ? path(args) : path;
|
|
325
|
+
return apiJson(apiFetch, method, resolvedPath, body ? body(args) : undefined);
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
function terminalReadBuffer(terminals, args) {
|
|
329
|
+
const terminalId = stringArg(args, 'terminalId');
|
|
330
|
+
const maxBytesArg = numberArg(args, 'maxBytes', false);
|
|
331
|
+
const offsetArg = numberArg(args, 'offset', false);
|
|
332
|
+
const session = terminals.get(terminalId);
|
|
333
|
+
if (!session)
|
|
334
|
+
throw new ToolExecutionError('Terminal not found', 404);
|
|
335
|
+
const buffer = Buffer.concat(session.bufferChunks, session.bufferLength);
|
|
336
|
+
const maxBytes = Math.min(Math.max(Math.floor(maxBytesArg ?? 50_000), 1), 200_000);
|
|
337
|
+
let start = 0;
|
|
338
|
+
let truncated = false;
|
|
339
|
+
if (offsetArg !== undefined) {
|
|
340
|
+
start = Math.floor(offsetArg) - session.bufferBase;
|
|
341
|
+
if (start < 0) {
|
|
342
|
+
start = 0;
|
|
343
|
+
truncated = true;
|
|
344
|
+
}
|
|
345
|
+
if (start > buffer.length)
|
|
346
|
+
start = buffer.length;
|
|
347
|
+
}
|
|
348
|
+
else if (buffer.length > maxBytes) {
|
|
349
|
+
start = buffer.length - maxBytes;
|
|
350
|
+
truncated = true;
|
|
351
|
+
}
|
|
352
|
+
const end = Math.min(buffer.length, start + maxBytes);
|
|
353
|
+
// マルチバイト文字を途中で切らないよう、両端を UTF-8 文字境界に丸める。
|
|
354
|
+
// (任意オフセットの切り出しで継続バイトだけが残ると文字化けするため。)
|
|
355
|
+
const alignedStart = alignToUtf8Start(buffer, start);
|
|
356
|
+
const alignedEnd = alignToUtf8End(buffer, end);
|
|
357
|
+
const safeEnd = Math.max(alignedStart, alignedEnd);
|
|
358
|
+
return {
|
|
359
|
+
terminalId,
|
|
360
|
+
bufferBase: session.bufferBase,
|
|
361
|
+
bufferLength: session.bufferLength,
|
|
362
|
+
returnedOffset: session.bufferBase + alignedStart,
|
|
363
|
+
nextOffset: session.bufferBase + safeEnd,
|
|
364
|
+
truncated,
|
|
365
|
+
text: buffer.subarray(alignedStart, safeEnd).toString('utf8'),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
// 1回の write で PTY に流せる最大バイト数。WebSocket 経路(64KB/フレーム)と
|
|
369
|
+
// 揃え、巨大ペイロードでの一気書き込みを防ぐ。
|
|
370
|
+
const MAX_MCP_WRITE_BYTES = 1024 * 1024;
|
|
371
|
+
function terminalWrite(terminals, args) {
|
|
372
|
+
const terminalId = stringArg(args, 'terminalId');
|
|
373
|
+
const data = stringArg(args, 'data');
|
|
374
|
+
const appendNewline = booleanArg(args, 'appendNewline') === true;
|
|
375
|
+
const session = terminals.get(terminalId);
|
|
376
|
+
if (!session)
|
|
377
|
+
throw new ToolExecutionError('Terminal not found', 404);
|
|
378
|
+
const payload = appendNewline ? `${data}\n` : data;
|
|
379
|
+
if (Buffer.byteLength(payload, 'utf8') > MAX_MCP_WRITE_BYTES) {
|
|
380
|
+
throw new ToolExecutionError(`data exceeds the maximum write size of ${MAX_MCP_WRITE_BYTES} bytes`, 400);
|
|
381
|
+
}
|
|
382
|
+
session.write(payload);
|
|
383
|
+
session.lastActive = Date.now();
|
|
384
|
+
return { terminalId, bytesWritten: Buffer.byteLength(payload, 'utf8') };
|
|
385
|
+
}
|
|
386
|
+
function terminalResize(terminals, args) {
|
|
387
|
+
// 注意: ブラウザクライアントが接続中の場合、そのクライアントの
|
|
388
|
+
// ResizeObserver/fit が次のフレームでサイズを再設定するため、この
|
|
389
|
+
// リサイズは一時的になる。クライアント未接続(ヘッドレス)の端末では
|
|
390
|
+
// 指定サイズがそのまま維持される。
|
|
391
|
+
const terminalId = stringArg(args, 'terminalId');
|
|
392
|
+
const cols = numberArg(args, 'cols');
|
|
393
|
+
const rows = numberArg(args, 'rows');
|
|
394
|
+
const session = terminals.get(terminalId);
|
|
395
|
+
if (!session)
|
|
396
|
+
throw new ToolExecutionError('Terminal not found', 404);
|
|
397
|
+
// WebSocket 経路(MIN 1 / MAX 500)と同じ範囲でバリデーションする。
|
|
398
|
+
if (!cols || !rows || cols < 1 || rows < 1 || cols > 500 || rows > 500) {
|
|
399
|
+
throw new ToolExecutionError('cols and rows must be within a reasonable terminal size', 400);
|
|
400
|
+
}
|
|
401
|
+
session.resize(Math.floor(cols), Math.floor(rows));
|
|
402
|
+
session.lastActive = Date.now();
|
|
403
|
+
return { terminalId, cols: Math.floor(cols), rows: Math.floor(rows) };
|
|
404
|
+
}
|
|
405
|
+
function buildTools(options) {
|
|
406
|
+
const api = options.apiFetch;
|
|
407
|
+
const idParam = { type: 'string' };
|
|
408
|
+
const pathParam = { type: 'string' };
|
|
409
|
+
const repoPathParam = { type: 'string', description: 'Optional path to a Git repo relative to the workspace root.' };
|
|
410
|
+
const gitBody = (args) => ({
|
|
411
|
+
workspaceId: stringArg(args, 'workspaceId'),
|
|
412
|
+
paths: stringArrayArg(args, 'paths'),
|
|
413
|
+
repoPath: stringArg(args, 'repoPath', false),
|
|
414
|
+
});
|
|
415
|
+
return [
|
|
416
|
+
{
|
|
417
|
+
name: 'deckide.config.get',
|
|
418
|
+
title: 'Get Deck IDE config',
|
|
419
|
+
description: 'Return server defaults such as the default workspace root.',
|
|
420
|
+
inputSchema: emptySchema(),
|
|
421
|
+
handler: apiTool(api, 'GET', '/api/config'),
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
name: 'deckide.settings.get',
|
|
425
|
+
title: 'Get settings',
|
|
426
|
+
description: 'Return Deck IDE settings with any configured Basic Auth password masked.',
|
|
427
|
+
inputSchema: emptySchema(),
|
|
428
|
+
handler: apiTool(api, 'GET', '/api/settings'),
|
|
429
|
+
},
|
|
430
|
+
{
|
|
431
|
+
name: 'deckide.settings.update',
|
|
432
|
+
title: 'Update settings',
|
|
433
|
+
description: 'Update Deck IDE settings. A server restart may be required for auth and port changes.',
|
|
434
|
+
inputSchema: schema({
|
|
435
|
+
port: { type: 'number' },
|
|
436
|
+
basicAuthEnabled: { type: 'boolean' },
|
|
437
|
+
basicAuthUser: { type: 'string' },
|
|
438
|
+
basicAuthPassword: { type: 'string' },
|
|
439
|
+
}, ['port', 'basicAuthEnabled', 'basicAuthUser', 'basicAuthPassword']),
|
|
440
|
+
annotations: { destructiveHint: true },
|
|
441
|
+
handler: apiTool(api, 'POST', '/api/settings', (args) => args),
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
name: 'deckide.server.shutdown',
|
|
445
|
+
title: 'Shutdown server',
|
|
446
|
+
description: 'Request graceful Deck IDE server shutdown.',
|
|
447
|
+
inputSchema: emptySchema(),
|
|
448
|
+
annotations: { destructiveHint: true },
|
|
449
|
+
handler: apiTool(api, 'POST', '/api/shutdown', () => ({})),
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
name: 'deckide.workspace.list',
|
|
453
|
+
title: 'List workspaces',
|
|
454
|
+
description: 'List all registered workspaces.',
|
|
455
|
+
inputSchema: emptySchema(),
|
|
456
|
+
handler: apiTool(api, 'GET', '/api/workspaces'),
|
|
457
|
+
},
|
|
458
|
+
{
|
|
459
|
+
name: 'deckide.workspace.create',
|
|
460
|
+
title: 'Create workspace',
|
|
461
|
+
description: 'Register a workspace path.',
|
|
462
|
+
inputSchema: schema({ path: pathParam, name: { type: 'string' } }, ['path']),
|
|
463
|
+
handler: apiTool(api, 'POST', '/api/workspaces', (args) => ({
|
|
464
|
+
path: stringArg(args, 'path'),
|
|
465
|
+
name: stringArg(args, 'name', false),
|
|
466
|
+
})),
|
|
467
|
+
},
|
|
468
|
+
{
|
|
469
|
+
name: 'deckide.workspace.delete',
|
|
470
|
+
title: 'Delete workspace',
|
|
471
|
+
description: 'Remove a workspace registration. Files on disk are not deleted.',
|
|
472
|
+
inputSchema: schema({ workspaceId: idParam }, ['workspaceId']),
|
|
473
|
+
annotations: { destructiveHint: true },
|
|
474
|
+
handler: apiTool(api, 'DELETE', (args) => `/api/workspaces/${encodeURIComponent(stringArg(args, 'workspaceId'))}`),
|
|
475
|
+
},
|
|
476
|
+
{
|
|
477
|
+
name: 'deckide.file.preview',
|
|
478
|
+
title: 'Preview directory',
|
|
479
|
+
description: 'List a directory before it has been registered as a workspace.',
|
|
480
|
+
inputSchema: schema({ path: pathParam, subpath: pathParam }),
|
|
481
|
+
handler: apiTool(api, 'GET', (args) => appendQuery('/api/preview', {
|
|
482
|
+
path: stringArg(args, 'path', false),
|
|
483
|
+
subpath: stringArg(args, 'subpath', false),
|
|
484
|
+
})),
|
|
485
|
+
},
|
|
486
|
+
{
|
|
487
|
+
name: 'deckide.file.list',
|
|
488
|
+
title: 'List files',
|
|
489
|
+
description: 'List files in a workspace directory.',
|
|
490
|
+
inputSchema: schema({ workspaceId: idParam, path: pathParam }, ['workspaceId']),
|
|
491
|
+
handler: apiTool(api, 'GET', (args) => appendQuery('/api/files', {
|
|
492
|
+
workspaceId: stringArg(args, 'workspaceId'),
|
|
493
|
+
path: stringArg(args, 'path', false),
|
|
494
|
+
})),
|
|
495
|
+
},
|
|
496
|
+
{
|
|
497
|
+
name: 'deckide.file.read',
|
|
498
|
+
title: 'Read file',
|
|
499
|
+
description: 'Read a UTF-8 file from a workspace.',
|
|
500
|
+
inputSchema: schema({ workspaceId: idParam, path: pathParam }, ['workspaceId', 'path']),
|
|
501
|
+
handler: apiTool(api, 'GET', (args) => appendQuery('/api/file', {
|
|
502
|
+
workspaceId: stringArg(args, 'workspaceId'),
|
|
503
|
+
path: stringArg(args, 'path'),
|
|
504
|
+
})),
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
name: 'deckide.file.write',
|
|
508
|
+
title: 'Write file',
|
|
509
|
+
description: 'Write a UTF-8 file in a workspace, creating parent directories as needed.',
|
|
510
|
+
inputSchema: schema({
|
|
511
|
+
workspaceId: idParam,
|
|
512
|
+
path: pathParam,
|
|
513
|
+
contents: { type: 'string' },
|
|
514
|
+
}, ['workspaceId', 'path', 'contents']),
|
|
515
|
+
annotations: { destructiveHint: true },
|
|
516
|
+
handler: apiTool(api, 'PUT', '/api/file', (args) => ({
|
|
517
|
+
workspaceId: stringArg(args, 'workspaceId'),
|
|
518
|
+
path: stringArg(args, 'path'),
|
|
519
|
+
contents: stringArg(args, 'contents'),
|
|
520
|
+
})),
|
|
521
|
+
},
|
|
522
|
+
{
|
|
523
|
+
name: 'deckide.file.create',
|
|
524
|
+
title: 'Create file',
|
|
525
|
+
description: 'Create a new UTF-8 file in a workspace.',
|
|
526
|
+
inputSchema: schema({
|
|
527
|
+
workspaceId: idParam,
|
|
528
|
+
path: pathParam,
|
|
529
|
+
contents: { type: 'string' },
|
|
530
|
+
}, ['workspaceId', 'path']),
|
|
531
|
+
annotations: { destructiveHint: true },
|
|
532
|
+
handler: apiTool(api, 'POST', '/api/file', (args) => ({
|
|
533
|
+
workspaceId: stringArg(args, 'workspaceId'),
|
|
534
|
+
path: stringArg(args, 'path'),
|
|
535
|
+
contents: stringArg(args, 'contents', false) ?? '',
|
|
536
|
+
})),
|
|
537
|
+
},
|
|
538
|
+
{
|
|
539
|
+
name: 'deckide.file.delete',
|
|
540
|
+
title: 'Delete file',
|
|
541
|
+
description: 'Delete a file in a workspace.',
|
|
542
|
+
inputSchema: schema({ workspaceId: idParam, path: pathParam }, ['workspaceId', 'path']),
|
|
543
|
+
annotations: { destructiveHint: true },
|
|
544
|
+
handler: apiTool(api, 'DELETE', (args) => appendQuery('/api/file', {
|
|
545
|
+
workspaceId: stringArg(args, 'workspaceId'),
|
|
546
|
+
path: stringArg(args, 'path'),
|
|
547
|
+
})),
|
|
548
|
+
},
|
|
549
|
+
{
|
|
550
|
+
name: 'deckide.directory.create',
|
|
551
|
+
title: 'Create directory',
|
|
552
|
+
description: 'Create a directory in a workspace.',
|
|
553
|
+
inputSchema: schema({ workspaceId: idParam, path: pathParam }, ['workspaceId', 'path']),
|
|
554
|
+
annotations: { destructiveHint: true },
|
|
555
|
+
handler: apiTool(api, 'POST', '/api/dir', (args) => ({
|
|
556
|
+
workspaceId: stringArg(args, 'workspaceId'),
|
|
557
|
+
path: stringArg(args, 'path'),
|
|
558
|
+
})),
|
|
559
|
+
},
|
|
560
|
+
{
|
|
561
|
+
name: 'deckide.directory.delete',
|
|
562
|
+
title: 'Delete directory',
|
|
563
|
+
description: 'Recursively delete a directory in a workspace.',
|
|
564
|
+
inputSchema: schema({ workspaceId: idParam, path: pathParam }, ['workspaceId', 'path']),
|
|
565
|
+
annotations: { destructiveHint: true },
|
|
566
|
+
handler: apiTool(api, 'DELETE', (args) => appendQuery('/api/dir', {
|
|
567
|
+
workspaceId: stringArg(args, 'workspaceId'),
|
|
568
|
+
path: stringArg(args, 'path'),
|
|
569
|
+
})),
|
|
570
|
+
},
|
|
571
|
+
{
|
|
572
|
+
name: 'deckide.deck.list',
|
|
573
|
+
title: 'List decks',
|
|
574
|
+
description: 'List all decks.',
|
|
575
|
+
inputSchema: emptySchema(),
|
|
576
|
+
handler: apiTool(api, 'GET', '/api/decks'),
|
|
577
|
+
},
|
|
578
|
+
{
|
|
579
|
+
name: 'deckide.deck.create',
|
|
580
|
+
title: 'Create deck',
|
|
581
|
+
description: 'Create a deck for a workspace.',
|
|
582
|
+
inputSchema: schema({ workspaceId: idParam, name: { type: 'string' } }, ['workspaceId']),
|
|
583
|
+
handler: apiTool(api, 'POST', '/api/decks', (args) => ({
|
|
584
|
+
workspaceId: stringArg(args, 'workspaceId'),
|
|
585
|
+
name: stringArg(args, 'name', false),
|
|
586
|
+
})),
|
|
587
|
+
},
|
|
588
|
+
{
|
|
589
|
+
name: 'deckide.deck.reorder',
|
|
590
|
+
title: 'Reorder decks',
|
|
591
|
+
description: 'Persist deck ordering.',
|
|
592
|
+
inputSchema: schema({ deckIds: { type: 'array', items: { type: 'string' } } }, ['deckIds']),
|
|
593
|
+
handler: apiTool(api, 'PUT', '/api/decks/order', (args) => ({ deckIds: stringArrayArg(args, 'deckIds') })),
|
|
594
|
+
},
|
|
595
|
+
{
|
|
596
|
+
name: 'deckide.deck.delete',
|
|
597
|
+
title: 'Delete deck',
|
|
598
|
+
description: 'Delete a deck and terminate its terminals.',
|
|
599
|
+
inputSchema: schema({ deckId: idParam }, ['deckId']),
|
|
600
|
+
annotations: { destructiveHint: true },
|
|
601
|
+
handler: apiTool(api, 'DELETE', (args) => `/api/decks/${encodeURIComponent(stringArg(args, 'deckId'))}`),
|
|
602
|
+
},
|
|
603
|
+
{
|
|
604
|
+
name: 'deckide.terminal.list',
|
|
605
|
+
title: 'List terminals',
|
|
606
|
+
description: 'List terminals in a deck.',
|
|
607
|
+
inputSchema: schema({ deckId: idParam }, ['deckId']),
|
|
608
|
+
handler: apiTool(api, 'GET', (args) => appendQuery('/api/terminals', { deckId: stringArg(args, 'deckId') })),
|
|
609
|
+
},
|
|
610
|
+
{
|
|
611
|
+
name: 'deckide.terminal.create',
|
|
612
|
+
title: 'Create terminal',
|
|
613
|
+
description: 'Create a terminal in a deck. Optional command runs in the deck workspace shell.',
|
|
614
|
+
inputSchema: schema({
|
|
615
|
+
deckId: idParam,
|
|
616
|
+
title: { type: 'string' },
|
|
617
|
+
command: { type: 'string' },
|
|
618
|
+
}, ['deckId']),
|
|
619
|
+
handler: apiTool(api, 'POST', '/api/terminals', (args) => ({
|
|
620
|
+
deckId: stringArg(args, 'deckId'),
|
|
621
|
+
title: stringArg(args, 'title', false),
|
|
622
|
+
command: stringArg(args, 'command', false),
|
|
623
|
+
})),
|
|
624
|
+
},
|
|
625
|
+
{
|
|
626
|
+
name: 'deckide.terminal.delete',
|
|
627
|
+
title: 'Delete terminal',
|
|
628
|
+
description: 'Delete and kill a terminal.',
|
|
629
|
+
inputSchema: schema({ terminalId: idParam }, ['terminalId']),
|
|
630
|
+
annotations: { destructiveHint: true },
|
|
631
|
+
handler: apiTool(api, 'DELETE', (args) => `/api/terminals/${encodeURIComponent(stringArg(args, 'terminalId'))}`),
|
|
632
|
+
},
|
|
633
|
+
{
|
|
634
|
+
name: 'deckide.terminal.write',
|
|
635
|
+
title: 'Write terminal input',
|
|
636
|
+
description: 'Write keyboard input to a terminal PTY.',
|
|
637
|
+
inputSchema: schema({
|
|
638
|
+
terminalId: idParam,
|
|
639
|
+
data: { type: 'string' },
|
|
640
|
+
appendNewline: { type: 'boolean' },
|
|
641
|
+
}, ['terminalId', 'data']),
|
|
642
|
+
annotations: { destructiveHint: true },
|
|
643
|
+
handler: (args) => terminalWrite(options.terminals, args),
|
|
644
|
+
},
|
|
645
|
+
{
|
|
646
|
+
name: 'deckide.terminal.read_buffer',
|
|
647
|
+
title: 'Read terminal buffer',
|
|
648
|
+
description: 'Read buffered terminal output. Use nextOffset for incremental reads.',
|
|
649
|
+
inputSchema: schema({
|
|
650
|
+
terminalId: idParam,
|
|
651
|
+
offset: { type: 'number' },
|
|
652
|
+
maxBytes: { type: 'number', description: '1 to 200000 bytes. Default: 50000.' },
|
|
653
|
+
}, ['terminalId']),
|
|
654
|
+
handler: (args) => terminalReadBuffer(options.terminals, args),
|
|
655
|
+
},
|
|
656
|
+
{
|
|
657
|
+
name: 'deckide.terminal.resize',
|
|
658
|
+
title: 'Resize terminal',
|
|
659
|
+
description: 'Resize a terminal PTY.',
|
|
660
|
+
inputSchema: schema({
|
|
661
|
+
terminalId: idParam,
|
|
662
|
+
cols: { type: 'number' },
|
|
663
|
+
rows: { type: 'number' },
|
|
664
|
+
}, ['terminalId', 'cols', 'rows']),
|
|
665
|
+
handler: (args) => terminalResize(options.terminals, args),
|
|
666
|
+
},
|
|
667
|
+
{
|
|
668
|
+
name: 'deckide.git.status',
|
|
669
|
+
title: 'Get Git status',
|
|
670
|
+
description: 'Return Git status for the workspace root or a nested repo.',
|
|
671
|
+
inputSchema: schema({ workspaceId: idParam, repoPath: repoPathParam }, ['workspaceId']),
|
|
672
|
+
handler: apiTool(api, 'GET', (args) => appendQuery('/api/git/status', {
|
|
673
|
+
workspaceId: stringArg(args, 'workspaceId'),
|
|
674
|
+
repoPath: stringArg(args, 'repoPath', false),
|
|
675
|
+
})),
|
|
676
|
+
},
|
|
677
|
+
{
|
|
678
|
+
name: 'deckide.git.repos',
|
|
679
|
+
title: 'Find Git repos',
|
|
680
|
+
description: 'Find Git repositories inside a workspace.',
|
|
681
|
+
inputSchema: schema({ workspaceId: idParam }, ['workspaceId']),
|
|
682
|
+
handler: apiTool(api, 'GET', (args) => appendQuery('/api/git/repos', { workspaceId: stringArg(args, 'workspaceId') })),
|
|
683
|
+
},
|
|
684
|
+
{
|
|
685
|
+
name: 'deckide.git.multi_status',
|
|
686
|
+
title: 'Get multi-repo Git status',
|
|
687
|
+
description: 'Return aggregated status across all Git repos in a workspace.',
|
|
688
|
+
inputSchema: schema({ workspaceId: idParam }, ['workspaceId']),
|
|
689
|
+
handler: apiTool(api, 'GET', (args) => appendQuery('/api/git/multi-status', { workspaceId: stringArg(args, 'workspaceId') })),
|
|
690
|
+
},
|
|
691
|
+
{
|
|
692
|
+
name: 'deckide.git.stage',
|
|
693
|
+
title: 'Stage Git files',
|
|
694
|
+
description: 'Stage files in a Git repository.',
|
|
695
|
+
inputSchema: schema({
|
|
696
|
+
workspaceId: idParam,
|
|
697
|
+
paths: { type: 'array', items: { type: 'string' } },
|
|
698
|
+
repoPath: repoPathParam,
|
|
699
|
+
}, ['workspaceId', 'paths']),
|
|
700
|
+
annotations: { destructiveHint: true },
|
|
701
|
+
handler: apiTool(api, 'POST', '/api/git/stage', gitBody),
|
|
702
|
+
},
|
|
703
|
+
{
|
|
704
|
+
name: 'deckide.git.unstage',
|
|
705
|
+
title: 'Unstage Git files',
|
|
706
|
+
description: 'Unstage files in a Git repository.',
|
|
707
|
+
inputSchema: schema({
|
|
708
|
+
workspaceId: idParam,
|
|
709
|
+
paths: { type: 'array', items: { type: 'string' } },
|
|
710
|
+
repoPath: repoPathParam,
|
|
711
|
+
}, ['workspaceId', 'paths']),
|
|
712
|
+
annotations: { destructiveHint: true },
|
|
713
|
+
handler: apiTool(api, 'POST', '/api/git/unstage', gitBody),
|
|
714
|
+
},
|
|
715
|
+
{
|
|
716
|
+
name: 'deckide.git.commit',
|
|
717
|
+
title: 'Commit Git changes',
|
|
718
|
+
description: 'Create a Git commit.',
|
|
719
|
+
inputSchema: schema({
|
|
720
|
+
workspaceId: idParam,
|
|
721
|
+
message: { type: 'string' },
|
|
722
|
+
repoPath: repoPathParam,
|
|
723
|
+
}, ['workspaceId', 'message']),
|
|
724
|
+
annotations: { destructiveHint: true },
|
|
725
|
+
handler: apiTool(api, 'POST', '/api/git/commit', (args) => ({
|
|
726
|
+
workspaceId: stringArg(args, 'workspaceId'),
|
|
727
|
+
message: stringArg(args, 'message'),
|
|
728
|
+
repoPath: stringArg(args, 'repoPath', false),
|
|
729
|
+
})),
|
|
730
|
+
},
|
|
731
|
+
{
|
|
732
|
+
name: 'deckide.git.discard',
|
|
733
|
+
title: 'Discard Git changes',
|
|
734
|
+
description: 'Discard working-tree changes for selected files. Untracked files are deleted.',
|
|
735
|
+
inputSchema: schema({
|
|
736
|
+
workspaceId: idParam,
|
|
737
|
+
paths: { type: 'array', items: { type: 'string' } },
|
|
738
|
+
repoPath: repoPathParam,
|
|
739
|
+
}, ['workspaceId', 'paths']),
|
|
740
|
+
annotations: { destructiveHint: true },
|
|
741
|
+
handler: apiTool(api, 'POST', '/api/git/discard', gitBody),
|
|
742
|
+
},
|
|
743
|
+
{
|
|
744
|
+
name: 'deckide.git.diff',
|
|
745
|
+
title: 'Get Git diff',
|
|
746
|
+
description: 'Return original and modified file contents for diff display.',
|
|
747
|
+
inputSchema: schema({
|
|
748
|
+
workspaceId: idParam,
|
|
749
|
+
path: pathParam,
|
|
750
|
+
staged: { type: 'boolean' },
|
|
751
|
+
repoPath: repoPathParam,
|
|
752
|
+
}, ['workspaceId', 'path']),
|
|
753
|
+
handler: apiTool(api, 'GET', (args) => appendQuery('/api/git/diff', {
|
|
754
|
+
workspaceId: stringArg(args, 'workspaceId'),
|
|
755
|
+
path: stringArg(args, 'path'),
|
|
756
|
+
staged: booleanArg(args, 'staged'),
|
|
757
|
+
repoPath: stringArg(args, 'repoPath', false),
|
|
758
|
+
})),
|
|
759
|
+
},
|
|
760
|
+
{
|
|
761
|
+
name: 'deckide.git.push',
|
|
762
|
+
title: 'Push Git branch',
|
|
763
|
+
description: 'Push the current branch to origin.',
|
|
764
|
+
inputSchema: schema({ workspaceId: idParam, repoPath: repoPathParam }, ['workspaceId']),
|
|
765
|
+
annotations: { destructiveHint: true },
|
|
766
|
+
handler: apiTool(api, 'POST', '/api/git/push', (args) => ({
|
|
767
|
+
workspaceId: stringArg(args, 'workspaceId'),
|
|
768
|
+
repoPath: stringArg(args, 'repoPath', false),
|
|
769
|
+
})),
|
|
770
|
+
},
|
|
771
|
+
{
|
|
772
|
+
name: 'deckide.git.pull',
|
|
773
|
+
title: 'Pull Git branch',
|
|
774
|
+
description: 'Pull from the current upstream.',
|
|
775
|
+
inputSchema: schema({ workspaceId: idParam, repoPath: repoPathParam }, ['workspaceId']),
|
|
776
|
+
annotations: { destructiveHint: true },
|
|
777
|
+
handler: apiTool(api, 'POST', '/api/git/pull', (args) => ({
|
|
778
|
+
workspaceId: stringArg(args, 'workspaceId'),
|
|
779
|
+
repoPath: stringArg(args, 'repoPath', false),
|
|
780
|
+
})),
|
|
781
|
+
},
|
|
782
|
+
{
|
|
783
|
+
name: 'deckide.git.fetch',
|
|
784
|
+
title: 'Fetch Git remotes',
|
|
785
|
+
description: 'Fetch Git remote refs.',
|
|
786
|
+
inputSchema: schema({ workspaceId: idParam, repoPath: repoPathParam }, ['workspaceId']),
|
|
787
|
+
handler: apiTool(api, 'POST', '/api/git/fetch', (args) => ({
|
|
788
|
+
workspaceId: stringArg(args, 'workspaceId'),
|
|
789
|
+
repoPath: stringArg(args, 'repoPath', false),
|
|
790
|
+
})),
|
|
791
|
+
},
|
|
792
|
+
{
|
|
793
|
+
name: 'deckide.git.remotes',
|
|
794
|
+
title: 'List Git remotes',
|
|
795
|
+
description: 'List configured remotes for a Git repository.',
|
|
796
|
+
inputSchema: schema({ workspaceId: idParam, repoPath: repoPathParam }, ['workspaceId']),
|
|
797
|
+
handler: apiTool(api, 'GET', (args) => appendQuery('/api/git/remotes', {
|
|
798
|
+
workspaceId: stringArg(args, 'workspaceId'),
|
|
799
|
+
repoPath: stringArg(args, 'repoPath', false),
|
|
800
|
+
})),
|
|
801
|
+
},
|
|
802
|
+
{
|
|
803
|
+
name: 'deckide.git.branch_status',
|
|
804
|
+
title: 'Get Git branch status',
|
|
805
|
+
description: 'Return ahead/behind information for a Git repository.',
|
|
806
|
+
inputSchema: schema({ workspaceId: idParam, repoPath: repoPathParam }, ['workspaceId']),
|
|
807
|
+
handler: apiTool(api, 'GET', (args) => appendQuery('/api/git/branch-status', {
|
|
808
|
+
workspaceId: stringArg(args, 'workspaceId'),
|
|
809
|
+
repoPath: stringArg(args, 'repoPath', false),
|
|
810
|
+
})),
|
|
811
|
+
},
|
|
812
|
+
{
|
|
813
|
+
name: 'deckide.git.branches',
|
|
814
|
+
title: 'List Git branches',
|
|
815
|
+
description: 'List local branches for a Git repository.',
|
|
816
|
+
inputSchema: schema({ workspaceId: idParam, repoPath: repoPathParam }, ['workspaceId']),
|
|
817
|
+
handler: apiTool(api, 'GET', (args) => appendQuery('/api/git/branches', {
|
|
818
|
+
workspaceId: stringArg(args, 'workspaceId'),
|
|
819
|
+
repoPath: stringArg(args, 'repoPath', false),
|
|
820
|
+
})),
|
|
821
|
+
},
|
|
822
|
+
{
|
|
823
|
+
name: 'deckide.git.checkout',
|
|
824
|
+
title: 'Checkout Git branch',
|
|
825
|
+
description: 'Checkout an existing local branch.',
|
|
826
|
+
inputSchema: schema({
|
|
827
|
+
workspaceId: idParam,
|
|
828
|
+
branchName: { type: 'string' },
|
|
829
|
+
repoPath: repoPathParam,
|
|
830
|
+
}, ['workspaceId', 'branchName']),
|
|
831
|
+
annotations: { destructiveHint: true },
|
|
832
|
+
handler: apiTool(api, 'POST', '/api/git/checkout', (args) => ({
|
|
833
|
+
workspaceId: stringArg(args, 'workspaceId'),
|
|
834
|
+
branchName: stringArg(args, 'branchName'),
|
|
835
|
+
repoPath: stringArg(args, 'repoPath', false),
|
|
836
|
+
})),
|
|
837
|
+
},
|
|
838
|
+
{
|
|
839
|
+
name: 'deckide.git.create_branch',
|
|
840
|
+
title: 'Create Git branch',
|
|
841
|
+
description: 'Create a local Git branch and optionally check it out.',
|
|
842
|
+
inputSchema: schema({
|
|
843
|
+
workspaceId: idParam,
|
|
844
|
+
branchName: { type: 'string' },
|
|
845
|
+
checkout: { type: 'boolean' },
|
|
846
|
+
repoPath: repoPathParam,
|
|
847
|
+
}, ['workspaceId', 'branchName']),
|
|
848
|
+
annotations: { destructiveHint: true },
|
|
849
|
+
handler: apiTool(api, 'POST', '/api/git/create-branch', (args) => ({
|
|
850
|
+
workspaceId: stringArg(args, 'workspaceId'),
|
|
851
|
+
branchName: stringArg(args, 'branchName'),
|
|
852
|
+
checkout: booleanArg(args, 'checkout'),
|
|
853
|
+
repoPath: stringArg(args, 'repoPath', false),
|
|
854
|
+
})),
|
|
855
|
+
},
|
|
856
|
+
{
|
|
857
|
+
name: 'deckide.git.log',
|
|
858
|
+
title: 'Get Git log',
|
|
859
|
+
description: 'Return recent Git log entries.',
|
|
860
|
+
inputSchema: schema({
|
|
861
|
+
workspaceId: idParam,
|
|
862
|
+
limit: { type: 'number' },
|
|
863
|
+
repoPath: repoPathParam,
|
|
864
|
+
}, ['workspaceId']),
|
|
865
|
+
handler: apiTool(api, 'GET', (args) => appendQuery('/api/git/log', {
|
|
866
|
+
workspaceId: stringArg(args, 'workspaceId'),
|
|
867
|
+
limit: numberArg(args, 'limit', false),
|
|
868
|
+
repoPath: stringArg(args, 'repoPath', false),
|
|
869
|
+
})),
|
|
870
|
+
},
|
|
871
|
+
{
|
|
872
|
+
name: 'deckide.websocket.stats',
|
|
873
|
+
title: 'Get WebSocket stats',
|
|
874
|
+
description: 'Return WebSocket connection limit and active connection counts.',
|
|
875
|
+
inputSchema: emptySchema(),
|
|
876
|
+
handler: apiTool(api, 'GET', '/api/ws/stats'),
|
|
877
|
+
},
|
|
878
|
+
{
|
|
879
|
+
name: 'deckide.websocket.set_limit',
|
|
880
|
+
title: 'Set WebSocket limit',
|
|
881
|
+
description: 'Set per-IP WebSocket connection limit.',
|
|
882
|
+
inputSchema: schema({ limit: { type: 'number' } }, ['limit']),
|
|
883
|
+
annotations: { destructiveHint: true },
|
|
884
|
+
handler: apiTool(api, 'PUT', '/api/ws/limit', (args) => ({ limit: numberArg(args, 'limit') })),
|
|
885
|
+
},
|
|
886
|
+
{
|
|
887
|
+
name: 'deckide.websocket.clear',
|
|
888
|
+
title: 'Clear WebSocket connections',
|
|
889
|
+
description: 'Close all active WebSocket terminal connections.',
|
|
890
|
+
inputSchema: emptySchema(),
|
|
891
|
+
annotations: { destructiveHint: true },
|
|
892
|
+
handler: apiTool(api, 'POST', '/api/ws/clear', () => ({})),
|
|
893
|
+
},
|
|
894
|
+
{
|
|
895
|
+
name: 'deckide.websocket.create_token',
|
|
896
|
+
title: 'Create WebSocket token',
|
|
897
|
+
description: 'Create a short-lived one-time WebSocket auth token.',
|
|
898
|
+
inputSchema: emptySchema(),
|
|
899
|
+
handler: apiTool(api, 'GET', '/api/ws-token'),
|
|
900
|
+
},
|
|
901
|
+
{
|
|
902
|
+
name: 'deckide.api.request',
|
|
903
|
+
title: 'Call Deck IDE API',
|
|
904
|
+
description: 'Low-level fallback for Deck IDE HTTP API routes. Path must start with /api/.',
|
|
905
|
+
inputSchema: schema({
|
|
906
|
+
method: { type: 'string', enum: ['GET', 'POST', 'PUT', 'DELETE'] },
|
|
907
|
+
path: { type: 'string', description: 'An /api/... path.' },
|
|
908
|
+
query: { type: 'object' },
|
|
909
|
+
body: { type: 'object' },
|
|
910
|
+
}, ['method', 'path']),
|
|
911
|
+
annotations: { destructiveHint: true },
|
|
912
|
+
handler: (args) => {
|
|
913
|
+
const method = stringArg(args, 'method').toUpperCase();
|
|
914
|
+
const path = stringArg(args, 'path');
|
|
915
|
+
if (!['GET', 'POST', 'PUT', 'DELETE'].includes(method)) {
|
|
916
|
+
throw new ToolExecutionError('method must be GET, POST, PUT, or DELETE', 400);
|
|
917
|
+
}
|
|
918
|
+
if (!path.startsWith('/api/')) {
|
|
919
|
+
throw new ToolExecutionError('path must start with /api/', 400);
|
|
920
|
+
}
|
|
921
|
+
const query = objectArg(args, 'query');
|
|
922
|
+
const body = objectArg(args, 'body');
|
|
923
|
+
return apiJson(api, method, appendQuery(path, query), body);
|
|
924
|
+
},
|
|
925
|
+
},
|
|
926
|
+
];
|
|
927
|
+
}
|
|
928
|
+
function toolResult(data) {
|
|
929
|
+
const structuredContent = isObject(data) ? data : { result: data };
|
|
930
|
+
return {
|
|
931
|
+
content: [
|
|
932
|
+
{
|
|
933
|
+
type: 'text',
|
|
934
|
+
text: JSON.stringify(data, null, 2),
|
|
935
|
+
},
|
|
936
|
+
],
|
|
937
|
+
structuredContent,
|
|
938
|
+
isError: false,
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
function toolErrorResult(error) {
|
|
942
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
943
|
+
const status = error instanceof ToolExecutionError ? error.status : undefined;
|
|
944
|
+
const data = error instanceof ToolExecutionError ? error.data : undefined;
|
|
945
|
+
return {
|
|
946
|
+
content: [{ type: 'text', text: message }],
|
|
947
|
+
structuredContent: { error: message, status, data },
|
|
948
|
+
isError: true,
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
function isAllowedMcpOrigin(c) {
|
|
952
|
+
const origin = c.req.header('origin');
|
|
953
|
+
if (!origin)
|
|
954
|
+
return true;
|
|
955
|
+
const allowed = new Set([
|
|
956
|
+
`http://localhost:${PORT}`,
|
|
957
|
+
`http://127.0.0.1:${PORT}`,
|
|
958
|
+
`http://[::1]:${PORT}`,
|
|
959
|
+
]);
|
|
960
|
+
if (CORS_ORIGIN)
|
|
961
|
+
allowed.add(CORS_ORIGIN);
|
|
962
|
+
if (NODE_ENV === 'development') {
|
|
963
|
+
allowed.add('http://localhost:5173');
|
|
964
|
+
allowed.add('http://localhost:3000');
|
|
965
|
+
}
|
|
966
|
+
return allowed.has(origin);
|
|
967
|
+
}
|
|
968
|
+
export function createMcpRouter(options) {
|
|
969
|
+
const router = new Hono();
|
|
970
|
+
const tools = buildTools(options);
|
|
971
|
+
const toolByName = new Map(tools.map((tool) => [tool.name, tool]));
|
|
972
|
+
async function handleMcpRequest(message, resource) {
|
|
973
|
+
const id = (message.id ?? null);
|
|
974
|
+
const method = message.method;
|
|
975
|
+
if (message.jsonrpc !== '2.0' || typeof method !== 'string') {
|
|
976
|
+
return jsonRpcError(id, -32600, 'Invalid JSON-RPC request');
|
|
977
|
+
}
|
|
978
|
+
switch (method) {
|
|
979
|
+
case 'initialize': {
|
|
980
|
+
const params = isObject(message.params) ? message.params : {};
|
|
981
|
+
const requestedVersion = typeof params.protocolVersion === 'string' ? params.protocolVersion : PROTOCOL_VERSION;
|
|
982
|
+
const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion)
|
|
983
|
+
? requestedVersion
|
|
984
|
+
: PROTOCOL_VERSION;
|
|
985
|
+
return jsonRpcResult(id, {
|
|
986
|
+
protocolVersion,
|
|
987
|
+
capabilities: {
|
|
988
|
+
tools: { listChanged: false },
|
|
989
|
+
},
|
|
990
|
+
serverInfo: {
|
|
991
|
+
name: 'deckide',
|
|
992
|
+
title: 'Deck IDE',
|
|
993
|
+
version: '3.5.33',
|
|
994
|
+
description: 'Deck IDE MCP server for workspace, file, terminal, Git, and settings operations.',
|
|
995
|
+
},
|
|
996
|
+
instructions: 'Use Deck IDE tools to inspect and operate registered workspaces. Destructive file, Git, terminal, settings, and shutdown tools can change local state.',
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
case 'ping':
|
|
1000
|
+
return jsonRpcResult(id, {});
|
|
1001
|
+
case 'tools/list':
|
|
1002
|
+
return jsonRpcResult(id, {
|
|
1003
|
+
tools: tools.map(({ handler: _handler, ...tool }) => tool),
|
|
1004
|
+
});
|
|
1005
|
+
case 'tools/call': {
|
|
1006
|
+
const params = isObject(message.params) ? message.params : {};
|
|
1007
|
+
const name = params.name;
|
|
1008
|
+
const args = isObject(params.arguments) ? params.arguments : {};
|
|
1009
|
+
if (typeof name !== 'string') {
|
|
1010
|
+
return jsonRpcError(id, -32602, 'tools/call params.name is required');
|
|
1011
|
+
}
|
|
1012
|
+
const tool = toolByName.get(name);
|
|
1013
|
+
if (!tool) {
|
|
1014
|
+
return jsonRpcError(id, -32602, `Unknown tool: ${name}`);
|
|
1015
|
+
}
|
|
1016
|
+
try {
|
|
1017
|
+
const result = await tool.handler(args, { resource });
|
|
1018
|
+
return jsonRpcResult(id, toolResult(result));
|
|
1019
|
+
}
|
|
1020
|
+
catch (error) {
|
|
1021
|
+
return jsonRpcResult(id, toolErrorResult(error));
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
default:
|
|
1025
|
+
if (method.startsWith('notifications/')) {
|
|
1026
|
+
return null;
|
|
1027
|
+
}
|
|
1028
|
+
return jsonRpcError(id, -32601, `Method not found: ${method}`);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
router.get('/.well-known/oauth-protected-resource', (c) => {
|
|
1032
|
+
const baseUrl = getBaseUrl(c);
|
|
1033
|
+
return c.json({
|
|
1034
|
+
resource: getMcpResource(c),
|
|
1035
|
+
authorization_servers: [baseUrl],
|
|
1036
|
+
scopes_supported: [OAUTH_SCOPE],
|
|
1037
|
+
bearer_methods_supported: ['header'],
|
|
1038
|
+
resource_documentation: `${baseUrl}/`,
|
|
1039
|
+
});
|
|
1040
|
+
});
|
|
1041
|
+
router.get('/.well-known/oauth-protected-resource/mcp', (c) => {
|
|
1042
|
+
const baseUrl = getBaseUrl(c);
|
|
1043
|
+
return c.json({
|
|
1044
|
+
resource: getMcpResource(c),
|
|
1045
|
+
authorization_servers: [baseUrl],
|
|
1046
|
+
scopes_supported: [OAUTH_SCOPE],
|
|
1047
|
+
bearer_methods_supported: ['header'],
|
|
1048
|
+
resource_documentation: `${baseUrl}/`,
|
|
1049
|
+
});
|
|
1050
|
+
});
|
|
1051
|
+
router.get('/.well-known/oauth-authorization-server', (c) => {
|
|
1052
|
+
const baseUrl = getBaseUrl(c);
|
|
1053
|
+
return c.json({
|
|
1054
|
+
issuer: baseUrl,
|
|
1055
|
+
authorization_endpoint: `${baseUrl}/oauth/authorize`,
|
|
1056
|
+
token_endpoint: `${baseUrl}/oauth/token`,
|
|
1057
|
+
registration_endpoint: `${baseUrl}/oauth/register`,
|
|
1058
|
+
response_types_supported: ['code'],
|
|
1059
|
+
grant_types_supported: ['authorization_code'],
|
|
1060
|
+
token_endpoint_auth_methods_supported: ['none'],
|
|
1061
|
+
code_challenge_methods_supported: ['S256'],
|
|
1062
|
+
scopes_supported: [OAUTH_SCOPE],
|
|
1063
|
+
resource_parameter_supported: true,
|
|
1064
|
+
});
|
|
1065
|
+
});
|
|
1066
|
+
router.post('/oauth/register', async (c) => {
|
|
1067
|
+
let body;
|
|
1068
|
+
try {
|
|
1069
|
+
body = await c.req.json();
|
|
1070
|
+
}
|
|
1071
|
+
catch {
|
|
1072
|
+
return c.json({ error: 'invalid_client_metadata', error_description: 'Expected JSON body' }, 400);
|
|
1073
|
+
}
|
|
1074
|
+
const redirectUris = Array.isArray(body.redirect_uris)
|
|
1075
|
+
? body.redirect_uris.filter((uri) => typeof uri === 'string')
|
|
1076
|
+
: [];
|
|
1077
|
+
if (redirectUris.length === 0 || redirectUris.some((uri) => !validRedirectUri(uri))) {
|
|
1078
|
+
return c.json({
|
|
1079
|
+
error: 'invalid_redirect_uri',
|
|
1080
|
+
error_description: 'redirect_uris must contain HTTPS or localhost HTTP redirect URIs',
|
|
1081
|
+
}, 400);
|
|
1082
|
+
}
|
|
1083
|
+
const clientId = `deckide-${crypto.randomUUID()}`;
|
|
1084
|
+
const client = {
|
|
1085
|
+
clientId,
|
|
1086
|
+
clientName: typeof body.client_name === 'string' ? body.client_name : undefined,
|
|
1087
|
+
redirectUris,
|
|
1088
|
+
issuedAt: Math.floor(Date.now() / 1000),
|
|
1089
|
+
};
|
|
1090
|
+
clients.set(clientId, client);
|
|
1091
|
+
return c.json({
|
|
1092
|
+
client_id: client.clientId,
|
|
1093
|
+
client_id_issued_at: client.issuedAt,
|
|
1094
|
+
client_name: client.clientName,
|
|
1095
|
+
redirect_uris: client.redirectUris,
|
|
1096
|
+
grant_types: ['authorization_code'],
|
|
1097
|
+
response_types: ['code'],
|
|
1098
|
+
token_endpoint_auth_method: 'none',
|
|
1099
|
+
scope: OAUTH_SCOPE,
|
|
1100
|
+
}, 201);
|
|
1101
|
+
});
|
|
1102
|
+
router.get('/oauth/authorize', (c) => {
|
|
1103
|
+
try {
|
|
1104
|
+
const params = new URL(c.req.url).searchParams;
|
|
1105
|
+
const { client } = validateAuthorizationRequest(params, c);
|
|
1106
|
+
return c.html(renderConsentPage(params, client));
|
|
1107
|
+
}
|
|
1108
|
+
catch (error) {
|
|
1109
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1110
|
+
return c.text(message, error instanceof ToolExecutionError && error.status ? error.status : 400);
|
|
1111
|
+
}
|
|
1112
|
+
});
|
|
1113
|
+
router.post('/oauth/authorize', async (c) => {
|
|
1114
|
+
try {
|
|
1115
|
+
const params = await readForm(c);
|
|
1116
|
+
const state = params.get('state') || undefined;
|
|
1117
|
+
const decision = params.get('decision');
|
|
1118
|
+
const validated = validateAuthorizationRequest(params, c);
|
|
1119
|
+
if (decision !== 'approve') {
|
|
1120
|
+
return redirectWithParams(validated.redirectUri, {
|
|
1121
|
+
error: 'access_denied',
|
|
1122
|
+
...(state ? { state } : {}),
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
const code = crypto.randomBytes(32).toString('base64url');
|
|
1126
|
+
authorizationCodes.set(code, {
|
|
1127
|
+
code,
|
|
1128
|
+
clientId: validated.clientId,
|
|
1129
|
+
redirectUri: validated.redirectUri,
|
|
1130
|
+
codeChallenge: validated.codeChallenge,
|
|
1131
|
+
scopes: validated.scopes,
|
|
1132
|
+
resource: validated.resource,
|
|
1133
|
+
expiresAt: Date.now() + AUTH_CODE_TTL_MS,
|
|
1134
|
+
});
|
|
1135
|
+
return redirectWithParams(validated.redirectUri, {
|
|
1136
|
+
code,
|
|
1137
|
+
...(state ? { state } : {}),
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
catch (error) {
|
|
1141
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1142
|
+
return c.text(message, error instanceof ToolExecutionError && error.status ? error.status : 400);
|
|
1143
|
+
}
|
|
1144
|
+
});
|
|
1145
|
+
router.post('/oauth/token', async (c) => {
|
|
1146
|
+
const form = await readForm(c);
|
|
1147
|
+
const grantType = form.get('grant_type');
|
|
1148
|
+
const code = form.get('code');
|
|
1149
|
+
const redirectUri = form.get('redirect_uri');
|
|
1150
|
+
const clientId = form.get('client_id');
|
|
1151
|
+
const codeVerifier = form.get('code_verifier');
|
|
1152
|
+
const resource = form.get('resource');
|
|
1153
|
+
if (grantType !== 'authorization_code') {
|
|
1154
|
+
return c.json({ error: 'unsupported_grant_type' }, 400);
|
|
1155
|
+
}
|
|
1156
|
+
if (!code || !redirectUri || !clientId || !codeVerifier) {
|
|
1157
|
+
return c.json({ error: 'invalid_request' }, 400);
|
|
1158
|
+
}
|
|
1159
|
+
const record = authorizationCodes.get(code);
|
|
1160
|
+
if (!record || record.expiresAt <= Date.now()) {
|
|
1161
|
+
authorizationCodes.delete(code);
|
|
1162
|
+
return c.json({ error: 'invalid_grant' }, 400);
|
|
1163
|
+
}
|
|
1164
|
+
if (record.clientId !== clientId || record.redirectUri !== redirectUri) {
|
|
1165
|
+
return c.json({ error: 'invalid_grant' }, 400);
|
|
1166
|
+
}
|
|
1167
|
+
if (resource && resource !== record.resource) {
|
|
1168
|
+
return c.json({ error: 'invalid_target' }, 400);
|
|
1169
|
+
}
|
|
1170
|
+
if (hashCodeVerifier(codeVerifier) !== record.codeChallenge) {
|
|
1171
|
+
return c.json({ error: 'invalid_grant' }, 400);
|
|
1172
|
+
}
|
|
1173
|
+
authorizationCodes.delete(code);
|
|
1174
|
+
const token = crypto.randomBytes(32).toString('base64url');
|
|
1175
|
+
accessTokens.set(token, {
|
|
1176
|
+
token,
|
|
1177
|
+
clientId,
|
|
1178
|
+
scopes: record.scopes,
|
|
1179
|
+
resource: record.resource,
|
|
1180
|
+
expiresAt: Date.now() + ACCESS_TOKEN_TTL_SECONDS * 1000,
|
|
1181
|
+
});
|
|
1182
|
+
return c.json({
|
|
1183
|
+
access_token: token,
|
|
1184
|
+
token_type: 'Bearer',
|
|
1185
|
+
expires_in: ACCESS_TOKEN_TTL_SECONDS,
|
|
1186
|
+
scope: record.scopes.join(' '),
|
|
1187
|
+
});
|
|
1188
|
+
});
|
|
1189
|
+
router.all('/mcp', async (c) => {
|
|
1190
|
+
if (!isAllowedMcpOrigin(c)) {
|
|
1191
|
+
return c.json(jsonRpcError(null, -32000, 'Invalid Origin header'), 403);
|
|
1192
|
+
}
|
|
1193
|
+
if (c.req.method === 'GET') {
|
|
1194
|
+
if (!validateAccessToken(c)) {
|
|
1195
|
+
c.header('WWW-Authenticate', bearerChallenge(c, 'invalid_token'));
|
|
1196
|
+
return c.json({ error: 'unauthorized' }, 401);
|
|
1197
|
+
}
|
|
1198
|
+
c.header('Allow', 'POST, GET');
|
|
1199
|
+
return c.body(null, 405);
|
|
1200
|
+
}
|
|
1201
|
+
if (c.req.method === 'DELETE') {
|
|
1202
|
+
c.header('Allow', 'POST, GET');
|
|
1203
|
+
return c.body(null, 405);
|
|
1204
|
+
}
|
|
1205
|
+
if (c.req.method !== 'POST') {
|
|
1206
|
+
c.header('Allow', 'POST, GET');
|
|
1207
|
+
return c.body(null, 405);
|
|
1208
|
+
}
|
|
1209
|
+
const protocolVersion = c.req.header('mcp-protocol-version');
|
|
1210
|
+
if (protocolVersion && !SUPPORTED_PROTOCOL_VERSIONS.includes(protocolVersion)) {
|
|
1211
|
+
return c.json(jsonRpcError(null, -32600, `Unsupported MCP protocol version: ${protocolVersion}`), 400);
|
|
1212
|
+
}
|
|
1213
|
+
const token = validateAccessToken(c);
|
|
1214
|
+
if (!token) {
|
|
1215
|
+
c.header('WWW-Authenticate', bearerChallenge(c, 'invalid_token'));
|
|
1216
|
+
return c.json({ error: 'unauthorized' }, 401);
|
|
1217
|
+
}
|
|
1218
|
+
let payload;
|
|
1219
|
+
try {
|
|
1220
|
+
payload = await c.req.json();
|
|
1221
|
+
}
|
|
1222
|
+
catch {
|
|
1223
|
+
return c.json(jsonRpcError(null, -32700, 'Parse error'), 400);
|
|
1224
|
+
}
|
|
1225
|
+
const resource = getMcpResource(c);
|
|
1226
|
+
if (Array.isArray(payload)) {
|
|
1227
|
+
const responses = (await Promise.all(payload.map((item) => isObject(item) ? handleMcpRequest(item, resource) : jsonRpcError(null, -32600, 'Invalid JSON-RPC request')))).filter(Boolean);
|
|
1228
|
+
if (responses.length === 0)
|
|
1229
|
+
return c.body(null, 202);
|
|
1230
|
+
return c.json(responses);
|
|
1231
|
+
}
|
|
1232
|
+
if (!isObject(payload)) {
|
|
1233
|
+
return c.json(jsonRpcError(null, -32600, 'Invalid JSON-RPC request'), 400);
|
|
1234
|
+
}
|
|
1235
|
+
const response = await handleMcpRequest(payload, resource);
|
|
1236
|
+
if (!response)
|
|
1237
|
+
return c.body(null, 202);
|
|
1238
|
+
return c.json(response);
|
|
1239
|
+
});
|
|
1240
|
+
return router;
|
|
1241
|
+
}
|