@tradejs/app 3.1.29-beta.261 → 3.1.29-beta.263

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.
@@ -0,0 +1,465 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { z } from 'zod';
4
+ import { mcpStorage } from '@tradejs/infra/redis';
5
+ import {
6
+ cancelMcpJob,
7
+ enqueueMcpJob,
8
+ mcpId,
9
+ publicMcpJob,
10
+ readMcpBacktestPage,
11
+ readMcpBacktestResult,
12
+ readMcpChart,
13
+ readMcpDeployments,
14
+ readMcpEvaluationStats,
15
+ readMcpJob,
16
+ readMcpMarket,
17
+ readMcpRuntimePage,
18
+ readMcpSignal,
19
+ readMcpStrategies,
20
+ redactMcpData,
21
+ requireMcpDeployment,
22
+ } from '@tradejs/node/mcp';
23
+ import type { McpPrincipal, McpScope } from '@tradejs/types';
24
+ import {
25
+ listMcpArtifacts,
26
+ readMcpArtifactChunk,
27
+ readMcpDiagnosticReport,
28
+ resolveMcpArtifact,
29
+ } from './artifacts';
30
+ import { mcpResource } from './oauth';
31
+
32
+ const identifier = z
33
+ .string()
34
+ .min(1)
35
+ .max(160)
36
+ .regex(/^[A-Za-z0-9._:-]+$/);
37
+ const provider = z
38
+ .string()
39
+ .min(1)
40
+ .max(50)
41
+ .regex(/^[a-z0-9_-]+$/)
42
+ .default('bybit');
43
+ const universe = z.enum(['crypto', 'tradfi']).default('crypto');
44
+ const symbol = z
45
+ .string()
46
+ .min(1)
47
+ .max(40)
48
+ .regex(/^[A-Z0-9._-]+$/);
49
+ const interval = z
50
+ .enum([
51
+ '1',
52
+ '3',
53
+ '5',
54
+ '15',
55
+ '30',
56
+ '60',
57
+ '120',
58
+ '240',
59
+ '360',
60
+ '720',
61
+ 'D',
62
+ 'W',
63
+ 'M',
64
+ ])
65
+ .default('15');
66
+ const cursor = z
67
+ .string()
68
+ .regex(/^\d{1,30}$/)
69
+ .default('0');
70
+ const timestamp = z.number().int().positive();
71
+ const artifactId = z.string().regex(/^[a-f0-9]{64}$/);
72
+ const idempotencyKey = z.string().regex(/^[A-Za-z0-9_-]{8,100}$/);
73
+ const runtimePage = {
74
+ deploymentId: identifier,
75
+ cursor: z
76
+ .string()
77
+ .regex(/^(0|[a-f0-9]{64})$/)
78
+ .default('0'),
79
+ startTime: timestamp,
80
+ endTime: timestamp,
81
+ strategy: identifier.optional(),
82
+ symbol: symbol.optional(),
83
+ };
84
+
85
+ export const createTradejsMcpServer = (principal: McpPrincipal) => {
86
+ const server = new McpServer(
87
+ { name: 'tradejs', version: '1.0.0' },
88
+ {
89
+ instructions:
90
+ 'TradeJS tools read this host only. Check tradejs_info and runtime_get_status before interpreting production data. Missing records are not proof of no activity. Use explicit deployments and periods. Run jobs only on user request; poll job_get. Historical jobs use cached data. No tool places live orders, changes strategy config, deploys, or sends notifications. OAuth is performed by the human user. Never request secrets. Complete artifact downloads must match the advertised SHA-256.',
91
+ },
92
+ );
93
+
94
+ const tool = <S extends z.ZodRawShape>(
95
+ name: string,
96
+ description: string,
97
+ scopes: McpScope[],
98
+ shape: S,
99
+ handler: (args: z.output<z.ZodObject<S>>) => Promise<unknown>,
100
+ mutates = false,
101
+ ) => {
102
+ if (
103
+ scopes.length &&
104
+ !scopes.some((scope) => principal.scopes.includes(scope))
105
+ )
106
+ return;
107
+ const schema = z.object(shape).strict();
108
+ server.registerTool<z.ZodRawShape, typeof schema>(
109
+ name,
110
+ {
111
+ description,
112
+ inputSchema: schema,
113
+ annotations: {
114
+ readOnlyHint: !mutates,
115
+ destructiveHint: name === 'job_cancel',
116
+ idempotentHint: true,
117
+ openWorldHint: false,
118
+ },
119
+ },
120
+ async (args) => {
121
+ const auditId = `audit:${mcpId(principal.userName)}:${randomUUID()}`;
122
+ try {
123
+ if (
124
+ !scopes.some((scope) => principal.scopes.includes(scope)) &&
125
+ scopes.length
126
+ )
127
+ throw new Error('Insufficient scope');
128
+ if (mutates)
129
+ await mcpStorage.put(
130
+ auditId,
131
+ {
132
+ tool: name,
133
+ userName: principal.userName,
134
+ clientId: principal.clientId,
135
+ argumentSha256: mcpId(JSON.stringify(args)),
136
+ createdAt: Date.now(),
137
+ status: 'requested',
138
+ },
139
+ 30 * 86400,
140
+ );
141
+ const result = redactMcpData(
142
+ await handler(args as z.output<z.ZodObject<S>>),
143
+ );
144
+ const text = JSON.stringify(result);
145
+ if (Buffer.byteLength(text) > 128 * 1024)
146
+ throw new Error(
147
+ 'Response too large. Narrow the filters or use artifact_get for full reports.',
148
+ );
149
+ if (mutates)
150
+ await mcpStorage.put(
151
+ auditId,
152
+ {
153
+ tool: name,
154
+ userName: principal.userName,
155
+ clientId: principal.clientId,
156
+ createdAt: Date.now(),
157
+ status: 'accepted',
158
+ },
159
+ 30 * 86400,
160
+ );
161
+ return {
162
+ content: [{ type: 'text' as const, text }],
163
+ structuredContent: { data: result },
164
+ };
165
+ } catch (error) {
166
+ const message = error instanceof Error ? error.message : '';
167
+ // Filesystem, connector and database errors may contain secrets or paths.
168
+ const safe =
169
+ /^(?:Select |Provide |MCP |Backtest config not found|Deployment unavailable|Signal not found|Result not found|Job not found|Invalid (?:job|artifact|result|storage) |Artifact (?:not found|changed)|Only the submitting|Idempotency |Hourly job|Job changed|Insufficient scope|Response too large|Unknown market provider|Unsupported diagnostic)/.test(
170
+ message,
171
+ )
172
+ ? message
173
+ : 'TradeJS operation failed. Check server diagnostics.';
174
+ return {
175
+ isError: true,
176
+ content: [{ type: 'text' as const, text: safe }],
177
+ };
178
+ }
179
+ },
180
+ );
181
+ };
182
+
183
+ tool(
184
+ 'tradejs_info',
185
+ 'Identify this TradeJS server, granted permissions and worker availability. Check this before using runtime data.',
186
+ [],
187
+ {},
188
+ async () => ({
189
+ resource: mcpResource(),
190
+ userName: principal.userName,
191
+ scopes: principal.scopes,
192
+ observedAt: Date.now(),
193
+ worker: await mcpStorage.get('worker:heartbeat'),
194
+ liveTradingTools: false,
195
+ artifactEncoding: 'base64',
196
+ }),
197
+ );
198
+ tool(
199
+ 'market_list_symbols',
200
+ 'List market tickers with pagination. Provider reads may contact the market data service.',
201
+ ['market:read'],
202
+ {
203
+ provider,
204
+ universe,
205
+ offset: z.number().int().min(0).max(100000).default(0),
206
+ limit: z.number().int().min(1).max(100).default(50),
207
+ },
208
+ async (args) =>
209
+ readMcpMarket(
210
+ principal.userName,
211
+ args.provider,
212
+ args.universe,
213
+ args.offset,
214
+ args.limit,
215
+ ),
216
+ );
217
+ tool(
218
+ 'market_get_snapshot',
219
+ 'Read the current market ticker for one symbol, with observation time.',
220
+ ['market:read'],
221
+ { provider, universe, symbol },
222
+ async (args) =>
223
+ readMcpMarket(
224
+ principal.userName,
225
+ args.provider,
226
+ args.universe,
227
+ 0,
228
+ 1,
229
+ args.symbol,
230
+ ),
231
+ );
232
+ tool(
233
+ 'chart_get_context',
234
+ 'Get up to 300 closed candles and indicator series. Cached history is the default. Explicit cacheOnly=false permits a market history fetch. Saved strategy figures are available through runtime_get_signal.',
235
+ ['market:read'],
236
+ {
237
+ provider,
238
+ universe,
239
+ symbol,
240
+ interval,
241
+ endTime: timestamp.optional(),
242
+ bars: z.number().int().min(10).max(300).default(100),
243
+ cacheOnly: z.boolean().default(true),
244
+ indicatorNames: z.array(identifier).max(12).optional(),
245
+ },
246
+ async (args) =>
247
+ readMcpChart(principal.userName, {
248
+ ...args,
249
+ endTime: args.endTime ?? Date.now(),
250
+ }),
251
+ );
252
+ tool(
253
+ 'runtime_get_status',
254
+ 'Read accessible Git-owned deployments and observed runtime heartbeats. Missing heartbeat is unknown health, not proof that runtime never ran.',
255
+ ['runtime:read'],
256
+ {},
257
+ async () => readMcpDeployments(principal.userName),
258
+ );
259
+ tool(
260
+ 'runtime_list_strategies',
261
+ 'Read effective strategies, configs and immutable revisions for a deployment bound to this user account.',
262
+ ['runtime:read'],
263
+ { deploymentId: identifier },
264
+ async (args) => readMcpStrategies(principal.userName, args.deploymentId),
265
+ );
266
+ for (const kind of ['signals', 'evaluations', 'orders'] as const) {
267
+ tool(
268
+ `runtime_list_${kind}`,
269
+ `Read a bounded storage page of ${kind}. Window is half-open [startTime,endTime), at most 7 days. Continue even after an empty page until cursor=0.`,
270
+ ['runtime:read'],
271
+ runtimePage,
272
+ async (args) => {
273
+ if (
274
+ args.endTime <= args.startTime ||
275
+ args.endTime - args.startTime > 7 * 86400000
276
+ )
277
+ throw new Error('Select a window of at most 7 days');
278
+ return readMcpRuntimePage(principal.userName, kind, args);
279
+ },
280
+ );
281
+ }
282
+ tool(
283
+ 'runtime_get_signal',
284
+ 'Read one owned signal, including recorded indicators, figures, gate decisions and execution status.',
285
+ ['runtime:read'],
286
+ {
287
+ signalId: identifier,
288
+ deploymentId: identifier,
289
+ timestamp,
290
+ strategy: identifier,
291
+ },
292
+ async (args) =>
293
+ readMcpSignal(
294
+ principal.userName,
295
+ args.signalId,
296
+ args.deploymentId,
297
+ args.timestamp,
298
+ args.strategy,
299
+ ),
300
+ );
301
+ tool(
302
+ 'runtime_get_evaluation_summary',
303
+ 'Read aggregate evaluation/skip counters. These are debug telemetry and cannot substitute for composition-bound runtime evidence.',
304
+ ['runtime:read'],
305
+ { deploymentId: identifier, cursor },
306
+ async (args) =>
307
+ readMcpEvaluationStats(
308
+ principal.userName,
309
+ args.deploymentId,
310
+ args.cursor,
311
+ ),
312
+ );
313
+ for (const kind of ['configs', 'results'] as const)
314
+ tool(
315
+ `backtest_list_${kind}`,
316
+ `Read a page of saved backtest ${kind}. Continue until cursor=0.`,
317
+ ['backtests:read'],
318
+ { cursor },
319
+ async (args) =>
320
+ readMcpBacktestPage(principal.userName, kind, args.cursor),
321
+ );
322
+ tool(
323
+ 'backtest_get_result',
324
+ 'Read a saved backtest result by the exact id returned by backtest_list_results.',
325
+ ['backtests:read'],
326
+ {
327
+ id: identifier,
328
+ offset: z.number().int().min(0).default(0),
329
+ limit: z.number().int().min(1).max(50).default(20),
330
+ },
331
+ async (args) =>
332
+ readMcpBacktestResult(
333
+ principal.userName,
334
+ args.id,
335
+ args.offset,
336
+ args.limit,
337
+ ),
338
+ );
339
+ tool(
340
+ 'backtest_start',
341
+ 'Queue a cached backtest on explicit user request: at most 90 days, 20 symbols, 8 config combinations, one worker, no paid AI calls. Returns immediately. Reuse idempotencyKey only for the identical request.',
342
+ ['backtests:run'],
343
+ {
344
+ configId: identifier,
345
+ provider,
346
+ interval,
347
+ symbols: z.array(symbol).min(1).max(20),
348
+ startTime: timestamp,
349
+ endTime: timestamp,
350
+ idempotencyKey,
351
+ },
352
+ async ({ idempotencyKey: key, ...args }) =>
353
+ publicMcpJob(await enqueueMcpJob(principal, 'backtest', args, key)),
354
+ true,
355
+ );
356
+ tool(
357
+ 'job_get',
358
+ 'Read the state of an owned background job. Disconnecting the client does not pause it. Failed jobs are never automatically rerun.',
359
+ ['backtests:read', 'diagnostics:read', 'backtests:run', 'diagnostics:run'],
360
+ { jobId: artifactId },
361
+ async (args) => {
362
+ const job = await readMcpJob(principal.userName, args.jobId);
363
+ if (
364
+ !principal.scopes.some(
365
+ (scope) =>
366
+ scope ===
367
+ (job.kind === 'backtest'
368
+ ? 'backtests:read'
369
+ : 'diagnostics:read') ||
370
+ scope ===
371
+ (job.kind === 'backtest' ? 'backtests:run' : 'diagnostics:run'),
372
+ )
373
+ )
374
+ throw new Error('Insufficient scope');
375
+ return publicMcpJob(job);
376
+ },
377
+ );
378
+ tool(
379
+ 'job_cancel',
380
+ 'Cancel a queued/running job submitted by this OAuth client. Does not delete completed results.',
381
+ ['backtests:run', 'diagnostics:run'],
382
+ { jobId: artifactId },
383
+ async (args) => {
384
+ const job = await readMcpJob(principal.userName, args.jobId);
385
+ if (
386
+ !principal.scopes.includes(
387
+ job.kind === 'backtest' ? 'backtests:run' : 'diagnostics:run',
388
+ )
389
+ )
390
+ throw new Error('Insufficient scope');
391
+ return publicMcpJob(await cancelMcpJob(principal, args.jobId));
392
+ },
393
+ true,
394
+ );
395
+ tool(
396
+ 'diagnostics_list_reports',
397
+ 'List checksum-verified runtime evidence and feedback/parity bundles present on this host and owned by this user. Inaccessible/unverified bundles are excluded.',
398
+ ['diagnostics:read'],
399
+ {},
400
+ async () => listMcpArtifacts(principal.userName),
401
+ );
402
+ tool(
403
+ 'diagnostics_get_report',
404
+ 'Read a verified diagnostic report or its manifest/summary. Use artifact_get for the full original payload.',
405
+ ['diagnostics:read'],
406
+ { artifactId },
407
+ async (args) =>
408
+ readMcpDiagnosticReport(principal.userName, args.artifactId),
409
+ );
410
+ tool(
411
+ 'artifact_get',
412
+ 'Read an owned verified artifact in base64 chunks. Concatenate decoded bytes, verify total size and SHA-256 before local analysis. No filesystem path is accepted.',
413
+ ['diagnostics:read'],
414
+ {
415
+ artifactId,
416
+ offset: z.number().int().min(0).default(0),
417
+ length: z.number().int().min(1).max(48000).default(48000),
418
+ },
419
+ async (args) =>
420
+ readMcpArtifactChunk(
421
+ principal.userName,
422
+ args.artifactId,
423
+ args.offset,
424
+ args.length,
425
+ ),
426
+ );
427
+ tool(
428
+ 'diagnostics_start',
429
+ 'Queue runtime-evidence capture or isolated runtime-feedback replay/parity on explicit user request. Replay requires a verified evidence artifact and exact recorded package/image lineage. No notifications or live orders.',
430
+ ['diagnostics:run'],
431
+ {
432
+ kind: z.enum(['runtime-evidence', 'runtime-feedback-replay']),
433
+ deploymentId: identifier,
434
+ startTime: timestamp.optional(),
435
+ endTime: timestamp.optional(),
436
+ artifactId: artifactId.optional(),
437
+ idempotencyKey,
438
+ },
439
+ async ({ idempotencyKey: key, kind, ...args }) => {
440
+ await requireMcpDeployment(principal.userName, args.deploymentId);
441
+ if (kind === 'runtime-evidence')
442
+ return publicMcpJob(await enqueueMcpJob(principal, kind, args, key));
443
+ if (!args.artifactId) throw new Error('Select verified runtime evidence');
444
+ const artifact = await resolveMcpArtifact(
445
+ principal.userName,
446
+ args.artifactId,
447
+ );
448
+ if (
449
+ artifact.kind !== 'runtime-evidence' ||
450
+ artifact.deploymentId !== args.deploymentId
451
+ )
452
+ throw new Error('Select verified runtime evidence for this deployment');
453
+ return publicMcpJob(
454
+ await enqueueMcpJob(
455
+ principal,
456
+ kind,
457
+ { ...args, verifiedEvidencePath: artifact.payloadPath },
458
+ key,
459
+ ),
460
+ );
461
+ },
462
+ true,
463
+ );
464
+ return server;
465
+ };
@@ -0,0 +1,75 @@
1
+ import { readMcpBody } from '#app/lib/mcp/http';
2
+ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
3
+ import { mcpStorage } from '@tradejs/infra/redis';
4
+ import {
5
+ authenticateMcp,
6
+ fingerprint,
7
+ mcpEnabled,
8
+ mcpOrigin,
9
+ mcpResource,
10
+ oauthResponse,
11
+ } from '#app/lib/mcp/oauth';
12
+ import { createTradejsMcpServer } from '#app/lib/mcp/server';
13
+
14
+ export const runtime = 'nodejs';
15
+ export const dynamic = 'force-dynamic';
16
+
17
+ export const POST = async (request: Request) => {
18
+ if (!mcpEnabled()) return oauthResponse({ error: 'MCP disabled' }, 503);
19
+ const origin = request.headers.get('origin');
20
+ if (origin && origin !== mcpOrigin())
21
+ return oauthResponse({ error: 'Invalid origin' }, 403);
22
+ try {
23
+ const principal = await authenticateMcp(request);
24
+ if (!principal)
25
+ return oauthResponse({ error: 'Unauthorized' }, 401, {
26
+ 'WWW-Authenticate': `Bearer resource_metadata="${mcpOrigin()}/.well-known/oauth-protected-resource/mcp"`,
27
+ });
28
+ if (
29
+ !(await mcpStorage.rateLimit(
30
+ `requests:${fingerprint(`${principal.userName}:${principal.clientId}`)}`,
31
+ 120,
32
+ 60,
33
+ ))
34
+ )
35
+ return oauthResponse({ error: 'Rate limit exceeded' }, 429, {
36
+ 'Retry-After': '60',
37
+ });
38
+ const body = await readMcpBody(request, 65536);
39
+ if (Buffer.byteLength(body) > 65536)
40
+ return oauthResponse({ error: 'Request too large' }, 413);
41
+ const transport = new WebStandardStreamableHTTPServerTransport({
42
+ sessionIdGenerator: undefined,
43
+ enableJsonResponse: true,
44
+ });
45
+ const server = createTradejsMcpServer(principal);
46
+ await server.connect(transport);
47
+ try {
48
+ const forwarded = new Request(request.url, {
49
+ method: request.method,
50
+ headers: request.headers,
51
+ body,
52
+ });
53
+ return await transport.handleRequest(forwarded, {
54
+ authInfo: {
55
+ token: '',
56
+ clientId: principal.clientId,
57
+ scopes: principal.scopes,
58
+ resource: new URL(mcpResource()),
59
+ },
60
+ });
61
+ } finally {
62
+ await server.close();
63
+ }
64
+ } catch (error) {
65
+ if (error instanceof Error && error.message === 'Request too large')
66
+ return oauthResponse({ error: 'Request too large' }, 413);
67
+ return oauthResponse({ error: 'MCP unavailable' }, 503);
68
+ }
69
+ };
70
+
71
+ export const GET = () =>
72
+ oauthResponse({ error: 'Use POST for Streamable HTTP requests' }, 405, {
73
+ Allow: 'POST',
74
+ });
75
+ export const DELETE = GET;
@@ -0,0 +1,84 @@
1
+ import { readMcpBody } from '#app/lib/mcp/http';
2
+ import { getCurrentUserName } from '#app/lib/currentUser';
3
+ import {
4
+ authorizeConsent,
5
+ consumeConsent,
6
+ mcpEnabled,
7
+ mcpOrigin,
8
+ oauthResponse,
9
+ prepareConsent,
10
+ requireSameOrigin,
11
+ } from '#app/lib/mcp/oauth';
12
+
13
+ export const runtime = 'nodejs';
14
+ export const dynamic = 'force-dynamic';
15
+
16
+ export const GET = async (request: Request) => {
17
+ if (!mcpEnabled())
18
+ return oauthResponse({ error: 'temporarily_unavailable' }, 503);
19
+ const userName = await getCurrentUserName();
20
+ if (!userName) {
21
+ const signin = new URL('/routes/signin', mcpOrigin());
22
+ signin.searchParams.set(
23
+ 'callbackUrl',
24
+ `${mcpOrigin()}/oauth/authorize${new URL(request.url).search}`,
25
+ );
26
+ return Response.redirect(signin, 303);
27
+ }
28
+ try {
29
+ const ticket = await prepareConsent(
30
+ userName,
31
+ new URL(request.url).searchParams,
32
+ );
33
+ return Response.redirect(
34
+ `${mcpOrigin()}/routes/mcp/consent?ticket=${ticket}`,
35
+ 303,
36
+ );
37
+ } catch {
38
+ return oauthResponse(
39
+ {
40
+ error: 'invalid_request',
41
+ error_description: 'Invalid OAuth authorization request',
42
+ },
43
+ 400,
44
+ );
45
+ }
46
+ };
47
+
48
+ export const POST = async (request: Request) => {
49
+ if (!mcpEnabled())
50
+ return oauthResponse({ error: 'temporarily_unavailable' }, 503);
51
+ if (!requireSameOrigin(request))
52
+ return oauthResponse({ error: 'access_denied' }, 403);
53
+ const userName = await getCurrentUserName();
54
+ if (!userName) return oauthResponse({ error: 'access_denied' }, 401);
55
+ let form: URLSearchParams;
56
+ try {
57
+ form = new URLSearchParams(await readMcpBody(request, 8192));
58
+ } catch {
59
+ return oauthResponse({ error: 'invalid_request' }, 400);
60
+ }
61
+ const ticket = String(form.get('ticket') || '');
62
+ const consent = await consumeConsent(ticket);
63
+ if (!consent || consent.userName !== userName)
64
+ return oauthResponse({ error: 'invalid_request' }, 400);
65
+ if (form.get('decision') !== 'allow') {
66
+ const location = new URL(consent.query.redirect_uri);
67
+ location.searchParams.set('error', 'access_denied');
68
+ location.searchParams.set('state', consent.query.state);
69
+ location.searchParams.set('iss', mcpOrigin());
70
+ return Response.redirect(location, 303);
71
+ }
72
+ const selected = form.getAll('scope');
73
+ if (
74
+ !selected.length ||
75
+ selected.some(
76
+ (scope) =>
77
+ !consent.scopes.includes(scope as (typeof consent.scopes)[number]),
78
+ )
79
+ )
80
+ return oauthResponse({ error: 'invalid_scope' }, 400);
81
+ consent.scopes = consent.scopes.filter((scope) => selected.includes(scope));
82
+ consent.query.scope = consent.scopes.join(' ');
83
+ return Response.redirect(await authorizeConsent(consent), 303);
84
+ };
@@ -0,0 +1,33 @@
1
+ import { readMcpBody } from '#app/lib/mcp/http';
2
+ import { mcpStorage } from '@tradejs/infra/redis';
3
+ import {
4
+ fingerprint,
5
+ mcpEnabled,
6
+ oauthResponse,
7
+ registerOAuthClient,
8
+ } from '#app/lib/mcp/oauth';
9
+
10
+ export const runtime = 'nodejs';
11
+ export const dynamic = 'force-dynamic';
12
+
13
+ export const POST = async (request: Request) => {
14
+ if (!mcpEnabled())
15
+ return oauthResponse({ error: 'temporarily_unavailable' }, 503);
16
+ // Global cap also protects deployments without a trusted forwarding proxy.
17
+ if (!(await mcpStorage.rateLimit('register', 100, 3600)))
18
+ return oauthResponse({ error: 'temporarily_unavailable' }, 429);
19
+ try {
20
+ const text = await readMcpBody(request, 8192);
21
+ if (
22
+ !(await mcpStorage.rateLimit(
23
+ `register:${fingerprint(request.headers.get('x-forwarded-for') || 'local')}`,
24
+ 20,
25
+ 3600,
26
+ ))
27
+ )
28
+ return oauthResponse({ error: 'temporarily_unavailable' }, 429);
29
+ return oauthResponse(await registerOAuthClient(JSON.parse(text)), 201);
30
+ } catch {
31
+ return oauthResponse({ error: 'invalid_client_metadata' }, 400);
32
+ }
33
+ };
@@ -0,0 +1,42 @@
1
+ import { readMcpBody } from '#app/lib/mcp/http';
2
+ import {
3
+ authenticateMcp,
4
+ createOAuthServer,
5
+ oauthResponse,
6
+ revokeGrant,
7
+ } from '#app/lib/mcp/oauth';
8
+
9
+ export const runtime = 'nodejs';
10
+ export const dynamic = 'force-dynamic';
11
+
12
+ export const POST = async (request: Request) => {
13
+ const principal = await authenticateMcp(request);
14
+ if (principal) {
15
+ await revokeGrant(principal.userName, principal.grantId);
16
+ return oauthResponse({});
17
+ }
18
+ if (
19
+ !request.headers
20
+ .get('content-type')
21
+ ?.startsWith('application/x-www-form-urlencoded')
22
+ )
23
+ return oauthResponse({ error: 'invalid_request' }, 400);
24
+ let text: string;
25
+ try {
26
+ text = await readMcpBody(request, 8192);
27
+ } catch {
28
+ return oauthResponse({ error: 'invalid_request' }, 400);
29
+ }
30
+ if (text.length > 8192)
31
+ return oauthResponse({ error: 'invalid_request' }, 400);
32
+ const form = new URLSearchParams(text);
33
+ const model = createOAuthServer().options.model;
34
+ const token =
35
+ (await model.getAccessToken?.(form.get('token') || '')) ||
36
+ ('getRefreshToken' in model
37
+ ? await model.getRefreshToken(form.get('token') || '')
38
+ : null);
39
+ if (token && token.client.id === form.get('client_id'))
40
+ await revokeGrant(token.user.id, token.user.grantId);
41
+ return oauthResponse({});
42
+ };