@useagents/redop 0.1.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 +230 -0
- package/dist/adapters/schema.d.ts +7 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +925 -0
- package/dist/plugins/index.d.ts +98 -0
- package/dist/redop.d.ts +109 -0
- package/dist/transports/http.d.ts +7 -0
- package/dist/transports/stdio.d.ts +4 -0
- package/dist/types.d.ts +328 -0
- package/package.json +47 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,925 @@
|
|
|
1
|
+
// src/adapters/schema.ts
|
|
2
|
+
function hasRecordShape(value) {
|
|
3
|
+
return typeof value === "object" && value !== null;
|
|
4
|
+
}
|
|
5
|
+
function isStandardSchema(schema) {
|
|
6
|
+
if (!hasRecordShape(schema) || !("~standard" in schema))
|
|
7
|
+
return false;
|
|
8
|
+
const standard = schema["~standard"];
|
|
9
|
+
return hasRecordShape(standard) && typeof standard.validate === "function" && standard.version === 1;
|
|
10
|
+
}
|
|
11
|
+
function isJsonSchema(schema) {
|
|
12
|
+
return hasRecordShape(schema) && (("type" in schema) || ("properties" in schema) || ("$schema" in schema));
|
|
13
|
+
}
|
|
14
|
+
function hasJsonSchemaSupport(schema) {
|
|
15
|
+
return typeof schema["~standard"].jsonSchema?.input === "function";
|
|
16
|
+
}
|
|
17
|
+
function createValidationError(issues) {
|
|
18
|
+
const error = new Error("Validation failed");
|
|
19
|
+
error.issues = issues;
|
|
20
|
+
return error;
|
|
21
|
+
}
|
|
22
|
+
function standardSchemaAdapter() {
|
|
23
|
+
return {
|
|
24
|
+
toJsonSchema(schema) {
|
|
25
|
+
if (!hasJsonSchemaSupport(schema)) {
|
|
26
|
+
throw new Error("[redop] Schema provides validation but not JSON Schema generation. Pass an explicit schemaAdapter.");
|
|
27
|
+
}
|
|
28
|
+
return schema["~standard"].jsonSchema.input({
|
|
29
|
+
target: "draft-07"
|
|
30
|
+
});
|
|
31
|
+
},
|
|
32
|
+
async parse(schema, input) {
|
|
33
|
+
const result = await schema["~standard"].validate(input);
|
|
34
|
+
if (result.issues) {
|
|
35
|
+
throw createValidationError(result.issues);
|
|
36
|
+
}
|
|
37
|
+
return result.value;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function zodAdapter() {
|
|
42
|
+
return standardSchemaAdapter();
|
|
43
|
+
}
|
|
44
|
+
function jsonSchemaAdapter() {
|
|
45
|
+
return {
|
|
46
|
+
toJsonSchema: (schema) => schema,
|
|
47
|
+
parse: (_schema, input) => input
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function detectAdapter(schema) {
|
|
51
|
+
if (isStandardSchema(schema))
|
|
52
|
+
return standardSchemaAdapter();
|
|
53
|
+
if (isJsonSchema(schema))
|
|
54
|
+
return jsonSchemaAdapter();
|
|
55
|
+
throw new Error("[redop] Could not detect schema type. Pass a Standard Schema-compatible instance or a plain JSON Schema object.");
|
|
56
|
+
}
|
|
57
|
+
// src/transports/http.ts
|
|
58
|
+
function createSessionStore(timeoutMs) {
|
|
59
|
+
const sessions = new Map;
|
|
60
|
+
function gc() {
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
for (const [id, s] of sessions) {
|
|
63
|
+
if (now - s.lastSeen > timeoutMs)
|
|
64
|
+
sessions.delete(id);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const gcTimer = setInterval(gc, 30000);
|
|
68
|
+
return {
|
|
69
|
+
create() {
|
|
70
|
+
const id = crypto.randomUUID();
|
|
71
|
+
sessions.set(id, { id, createdAt: Date.now(), lastSeen: Date.now() });
|
|
72
|
+
return id;
|
|
73
|
+
},
|
|
74
|
+
touch(id) {
|
|
75
|
+
const s = sessions.get(id);
|
|
76
|
+
if (!s)
|
|
77
|
+
return false;
|
|
78
|
+
s.lastSeen = Date.now();
|
|
79
|
+
return true;
|
|
80
|
+
},
|
|
81
|
+
delete(id) {
|
|
82
|
+
sessions.delete(id);
|
|
83
|
+
},
|
|
84
|
+
stop() {
|
|
85
|
+
clearInterval(gcTimer);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function buildCorsHeaders(cors, requestOrigin) {
|
|
90
|
+
if (!cors)
|
|
91
|
+
return {};
|
|
92
|
+
if (cors === true) {
|
|
93
|
+
return {
|
|
94
|
+
"Access-Control-Allow-Origin": requestOrigin ?? "*",
|
|
95
|
+
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
|
96
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-API-Key, Mcp-Session-Id",
|
|
97
|
+
"Access-Control-Allow-Credentials": "true"
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
const origins = Array.isArray(cors.origins) ? cors.origins : cors.origins ? [cors.origins] : ["*"];
|
|
101
|
+
const allowedOrigin = requestOrigin && origins.includes(requestOrigin) ? requestOrigin : origins[0] ?? "*";
|
|
102
|
+
return {
|
|
103
|
+
"Access-Control-Allow-Origin": allowedOrigin,
|
|
104
|
+
"Access-Control-Allow-Methods": (cors.methods ?? ["GET", "POST", "DELETE", "OPTIONS"]).join(", "),
|
|
105
|
+
"Access-Control-Allow-Headers": (cors.headers ?? [
|
|
106
|
+
"Content-Type",
|
|
107
|
+
"Authorization",
|
|
108
|
+
"X-API-Key",
|
|
109
|
+
"Mcp-Session-Id"
|
|
110
|
+
]).join(", "),
|
|
111
|
+
"Access-Control-Allow-Credentials": String(cors.credentials ?? true)
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function getRequestHeaders(headers) {
|
|
115
|
+
return Object.fromEntries(Array.from(headers.entries()).map(([key, value]) => [
|
|
116
|
+
key.toLowerCase(),
|
|
117
|
+
value
|
|
118
|
+
]));
|
|
119
|
+
}
|
|
120
|
+
async function handleJsonRpc(body, tools, runner, requestMeta, serverInfo, sessionId) {
|
|
121
|
+
const { id, method, params } = body;
|
|
122
|
+
if (method === "initialize") {
|
|
123
|
+
return {
|
|
124
|
+
jsonrpc: "2.0",
|
|
125
|
+
id,
|
|
126
|
+
result: {
|
|
127
|
+
protocolVersion: "2024-11-05",
|
|
128
|
+
capabilities: { tools: { listChanged: false } },
|
|
129
|
+
serverInfo,
|
|
130
|
+
sessionId
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
if (method === "ping") {
|
|
135
|
+
return { jsonrpc: "2.0", id, result: {} };
|
|
136
|
+
}
|
|
137
|
+
if (method === "tools/list") {
|
|
138
|
+
return {
|
|
139
|
+
jsonrpc: "2.0",
|
|
140
|
+
id,
|
|
141
|
+
result: {
|
|
142
|
+
tools: Array.from(tools.values()).map((t) => ({
|
|
143
|
+
name: t.name,
|
|
144
|
+
description: t.description ?? "",
|
|
145
|
+
inputSchema: t.inputSchema,
|
|
146
|
+
...t.annotations ? { annotations: t.annotations } : {}
|
|
147
|
+
}))
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
if (method === "tools/call") {
|
|
152
|
+
const p = params;
|
|
153
|
+
const toolName = p?.name;
|
|
154
|
+
if (!toolName || !tools.has(toolName)) {
|
|
155
|
+
return {
|
|
156
|
+
jsonrpc: "2.0",
|
|
157
|
+
id,
|
|
158
|
+
error: {
|
|
159
|
+
code: -32602,
|
|
160
|
+
message: `Unknown tool: ${toolName ?? "(none)"}`
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
const result = await runner(toolName, p?.arguments ?? {}, requestMeta);
|
|
166
|
+
return {
|
|
167
|
+
jsonrpc: "2.0",
|
|
168
|
+
id,
|
|
169
|
+
result: {
|
|
170
|
+
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
171
|
+
isError: false
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
} catch (err) {
|
|
175
|
+
return {
|
|
176
|
+
jsonrpc: "2.0",
|
|
177
|
+
id,
|
|
178
|
+
result: {
|
|
179
|
+
content: [
|
|
180
|
+
{
|
|
181
|
+
type: "text",
|
|
182
|
+
text: String(err instanceof Error ? err.message : err)
|
|
183
|
+
}
|
|
184
|
+
],
|
|
185
|
+
isError: true
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
jsonrpc: "2.0",
|
|
192
|
+
id,
|
|
193
|
+
error: { code: -32601, message: `Method not found: ${method}` }
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
function startHttpTransport(tools, runner, opts, serverInfo) {
|
|
197
|
+
const port = Number(opts.port ?? 3000);
|
|
198
|
+
const hostname = opts.hostname ?? "localhost";
|
|
199
|
+
const mcpPath = opts.path ?? "/mcp";
|
|
200
|
+
const sessionTimeout = opts.sessionTimeout ?? 30000;
|
|
201
|
+
const maxBodySize = opts.maxBodySize ?? 4 * 1024 * 1024;
|
|
202
|
+
const sessions = createSessionStore(sessionTimeout);
|
|
203
|
+
const sseClients = new Map;
|
|
204
|
+
const server = Bun.serve({
|
|
205
|
+
port,
|
|
206
|
+
hostname,
|
|
207
|
+
...opts.tls ? { tls: opts.tls } : {},
|
|
208
|
+
async fetch(req, server2) {
|
|
209
|
+
const url2 = new URL(req.url);
|
|
210
|
+
const origin = req.headers.get("origin");
|
|
211
|
+
const corsHeaders = buildCorsHeaders(opts.cors, origin);
|
|
212
|
+
if (req.method === "OPTIONS") {
|
|
213
|
+
return new Response(null, { status: 204, headers: corsHeaders });
|
|
214
|
+
}
|
|
215
|
+
if (req.method === "GET" && url2.pathname === `${mcpPath}/health`) {
|
|
216
|
+
return Response.json({ ok: true, tools: tools.size }, { headers: corsHeaders });
|
|
217
|
+
}
|
|
218
|
+
if (req.method === "GET" && url2.pathname === `${mcpPath}/schema`) {
|
|
219
|
+
const schema = {
|
|
220
|
+
openapi: "3.1.0",
|
|
221
|
+
info: {
|
|
222
|
+
title: `${serverInfo.name} MCP server`,
|
|
223
|
+
version: serverInfo.version
|
|
224
|
+
},
|
|
225
|
+
paths: Object.fromEntries(Array.from(tools.values()).map((t) => [
|
|
226
|
+
`/tools/${t.name}`,
|
|
227
|
+
{
|
|
228
|
+
post: {
|
|
229
|
+
summary: t.description,
|
|
230
|
+
requestBody: {
|
|
231
|
+
content: { "application/json": { schema: t.inputSchema } }
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
]))
|
|
236
|
+
};
|
|
237
|
+
return Response.json(schema, { headers: corsHeaders });
|
|
238
|
+
}
|
|
239
|
+
if (req.method === "GET" && url2.pathname === mcpPath) {
|
|
240
|
+
let sessionId = req.headers.get("mcp-session-id") ?? "";
|
|
241
|
+
if (!sessions.touch(sessionId)) {
|
|
242
|
+
sessionId = sessions.create();
|
|
243
|
+
}
|
|
244
|
+
const stream = new ReadableStream({
|
|
245
|
+
start(controller) {
|
|
246
|
+
sseClients.set(sessionId, controller);
|
|
247
|
+
const event = `event: endpoint
|
|
248
|
+
data: ${JSON.stringify({
|
|
249
|
+
uri: `${url2.origin}${mcpPath}`,
|
|
250
|
+
sessionId
|
|
251
|
+
})}
|
|
252
|
+
|
|
253
|
+
`;
|
|
254
|
+
controller.enqueue(new TextEncoder().encode(event));
|
|
255
|
+
},
|
|
256
|
+
cancel() {
|
|
257
|
+
sseClients.delete(sessionId);
|
|
258
|
+
sessions.delete(sessionId);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
return new Response(stream, {
|
|
262
|
+
headers: {
|
|
263
|
+
...corsHeaders,
|
|
264
|
+
"Content-Type": "text/event-stream",
|
|
265
|
+
"Cache-Control": "no-cache",
|
|
266
|
+
Connection: "keep-alive",
|
|
267
|
+
"Mcp-Session-Id": sessionId
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
if (req.method === "POST" && url2.pathname === mcpPath) {
|
|
272
|
+
const contentLength = Number(req.headers.get("content-length") ?? 0);
|
|
273
|
+
if (contentLength > maxBodySize) {
|
|
274
|
+
return new Response("Payload Too Large", {
|
|
275
|
+
status: 413,
|
|
276
|
+
headers: corsHeaders
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
let body;
|
|
280
|
+
try {
|
|
281
|
+
body = await req.json();
|
|
282
|
+
} catch {
|
|
283
|
+
return Response.json({
|
|
284
|
+
jsonrpc: "2.0",
|
|
285
|
+
id: null,
|
|
286
|
+
error: { code: -32700, message: "Parse error" }
|
|
287
|
+
}, { status: 400, headers: corsHeaders });
|
|
288
|
+
}
|
|
289
|
+
let sessionId = req.headers.get("mcp-session-id") ?? "";
|
|
290
|
+
if (!sessions.touch(sessionId)) {
|
|
291
|
+
sessionId = sessions.create();
|
|
292
|
+
}
|
|
293
|
+
const result = await handleJsonRpc(body, tools, runner, {
|
|
294
|
+
headers: getRequestHeaders(req.headers),
|
|
295
|
+
ip: server2.requestIP(req)?.address,
|
|
296
|
+
method: req.method,
|
|
297
|
+
raw: req,
|
|
298
|
+
sessionId,
|
|
299
|
+
transport: "http",
|
|
300
|
+
url: req.url
|
|
301
|
+
}, serverInfo, sessionId);
|
|
302
|
+
return Response.json(result, {
|
|
303
|
+
headers: {
|
|
304
|
+
...corsHeaders,
|
|
305
|
+
"Mcp-Session-Id": sessionId
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
if (req.method === "DELETE" && url2.pathname === mcpPath) {
|
|
310
|
+
const sessionId = req.headers.get("mcp-session-id") ?? "";
|
|
311
|
+
const ctrl = sseClients.get(sessionId);
|
|
312
|
+
if (ctrl) {
|
|
313
|
+
try {
|
|
314
|
+
ctrl.close();
|
|
315
|
+
} catch {}
|
|
316
|
+
sseClients.delete(sessionId);
|
|
317
|
+
}
|
|
318
|
+
sessions.delete(sessionId);
|
|
319
|
+
return Response.json({ ok: true, sessionId: sessionId || null, terminated: true }, { headers: corsHeaders });
|
|
320
|
+
}
|
|
321
|
+
return new Response("Not Found", { status: 404, headers: corsHeaders });
|
|
322
|
+
},
|
|
323
|
+
error(err) {
|
|
324
|
+
console.error("[redop] server error:", err);
|
|
325
|
+
return new Response("Internal Server Error", { status: 500 });
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
const url = `http${opts.tls ? "s" : ""}://${hostname}:${port}${mcpPath}`;
|
|
329
|
+
opts.onListen?.({ hostname, port, url });
|
|
330
|
+
return {
|
|
331
|
+
stop() {
|
|
332
|
+
sessions.stop();
|
|
333
|
+
server.stop();
|
|
334
|
+
},
|
|
335
|
+
broadcast(sessionId, event, data) {
|
|
336
|
+
const ctrl = sseClients.get(sessionId);
|
|
337
|
+
if (ctrl) {
|
|
338
|
+
const msg = `event: ${event}
|
|
339
|
+
data: ${JSON.stringify(data)}
|
|
340
|
+
|
|
341
|
+
`;
|
|
342
|
+
ctrl.enqueue(new TextEncoder().encode(msg));
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// src/transports/stdio.ts
|
|
349
|
+
function buildToolList(tools) {
|
|
350
|
+
return Array.from(tools.values()).map((t) => ({
|
|
351
|
+
name: t.name,
|
|
352
|
+
description: t.description ?? "",
|
|
353
|
+
inputSchema: t.inputSchema,
|
|
354
|
+
...t.annotations ? { annotations: t.annotations } : {}
|
|
355
|
+
}));
|
|
356
|
+
}
|
|
357
|
+
async function handleMessage(msg, tools, runner, serverInfo) {
|
|
358
|
+
const { id, method, params } = msg;
|
|
359
|
+
const respond = (result) => process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + `
|
|
360
|
+
`);
|
|
361
|
+
const error = (code, message) => process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }) + `
|
|
362
|
+
`);
|
|
363
|
+
if (method === "initialize") {
|
|
364
|
+
respond({
|
|
365
|
+
protocolVersion: "2024-11-05",
|
|
366
|
+
capabilities: { tools: { listChanged: false } },
|
|
367
|
+
serverInfo
|
|
368
|
+
});
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (method === "notifications/initialized") {
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (method === "ping") {
|
|
375
|
+
respond({});
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (method === "tools/list") {
|
|
379
|
+
respond({ tools: buildToolList(tools) });
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (method === "tools/call") {
|
|
383
|
+
const p = params;
|
|
384
|
+
const toolName = p?.name;
|
|
385
|
+
if (!toolName || !tools.has(toolName)) {
|
|
386
|
+
error(-32602, `Unknown tool: ${toolName ?? "(none)"}`);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
try {
|
|
390
|
+
const result = await runner(toolName, p?.arguments ?? {}, {
|
|
391
|
+
headers: {},
|
|
392
|
+
transport: "stdio"
|
|
393
|
+
});
|
|
394
|
+
respond({
|
|
395
|
+
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
396
|
+
isError: false
|
|
397
|
+
});
|
|
398
|
+
} catch (err) {
|
|
399
|
+
respond({
|
|
400
|
+
content: [
|
|
401
|
+
{ type: "text", text: String(err instanceof Error ? err.message : err) }
|
|
402
|
+
],
|
|
403
|
+
isError: true
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
error(-32601, `Method not found: ${method}`);
|
|
409
|
+
}
|
|
410
|
+
function startStdioTransport(tools, runner, serverInfo) {
|
|
411
|
+
process.stdin.setEncoding("utf8");
|
|
412
|
+
let buffer = "";
|
|
413
|
+
process.stdin.on("data", async (chunk) => {
|
|
414
|
+
buffer += chunk;
|
|
415
|
+
const lines = buffer.split(`
|
|
416
|
+
`);
|
|
417
|
+
buffer = lines.pop() ?? "";
|
|
418
|
+
for (const line of lines) {
|
|
419
|
+
const trimmed = line.trim();
|
|
420
|
+
if (!trimmed)
|
|
421
|
+
continue;
|
|
422
|
+
let msg;
|
|
423
|
+
try {
|
|
424
|
+
msg = JSON.parse(trimmed);
|
|
425
|
+
} catch {
|
|
426
|
+
process.stdout.write(JSON.stringify({
|
|
427
|
+
jsonrpc: "2.0",
|
|
428
|
+
id: null,
|
|
429
|
+
error: { code: -32700, message: "Parse error" }
|
|
430
|
+
}) + `
|
|
431
|
+
`);
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
await handleMessage(msg, tools, runner, serverInfo);
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
process.stdin.on("end", () => process.exit(0));
|
|
438
|
+
process.stdin.resume();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// src/redop.ts
|
|
442
|
+
var DEFAULT_REQUEST_META = {
|
|
443
|
+
headers: {},
|
|
444
|
+
transport: "stdio"
|
|
445
|
+
};
|
|
446
|
+
var DEFAULT_SERVER_INFO = {
|
|
447
|
+
name: "redop",
|
|
448
|
+
version: "0.1.0"
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
class Redop {
|
|
452
|
+
_hooks = {
|
|
453
|
+
before: [],
|
|
454
|
+
after: [],
|
|
455
|
+
error: [],
|
|
456
|
+
transform: [],
|
|
457
|
+
mapResponse: []
|
|
458
|
+
};
|
|
459
|
+
_tools = new Map;
|
|
460
|
+
_middlewares = [];
|
|
461
|
+
_inputParsers = new Map;
|
|
462
|
+
_schemaAdapter;
|
|
463
|
+
_serverInfo = { ...DEFAULT_SERVER_INFO };
|
|
464
|
+
_prefix = "";
|
|
465
|
+
constructor(options = {}) {
|
|
466
|
+
const serverInfo = {
|
|
467
|
+
...DEFAULT_SERVER_INFO,
|
|
468
|
+
...options.name ? { name: options.name } : {},
|
|
469
|
+
...options.version ? { version: options.version } : {}
|
|
470
|
+
};
|
|
471
|
+
this._serverInfo = serverInfo;
|
|
472
|
+
if (options?.schemaAdapter) {
|
|
473
|
+
this._schemaAdapter = options.schemaAdapter;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
onBeforeHandle(hook) {
|
|
477
|
+
this._hooks.before.push(hook);
|
|
478
|
+
return this;
|
|
479
|
+
}
|
|
480
|
+
onAfterHandle(hook) {
|
|
481
|
+
this._hooks.after.push(hook);
|
|
482
|
+
return this;
|
|
483
|
+
}
|
|
484
|
+
onError(hook) {
|
|
485
|
+
this._hooks.error.push(hook);
|
|
486
|
+
return this;
|
|
487
|
+
}
|
|
488
|
+
onTransform(hook) {
|
|
489
|
+
this._hooks.transform.push(hook);
|
|
490
|
+
return this;
|
|
491
|
+
}
|
|
492
|
+
mapResponse(hook) {
|
|
493
|
+
this._hooks.mapResponse.push(hook);
|
|
494
|
+
return this;
|
|
495
|
+
}
|
|
496
|
+
middleware(mw) {
|
|
497
|
+
this._middlewares.push(mw);
|
|
498
|
+
return this;
|
|
499
|
+
}
|
|
500
|
+
tool(name, def) {
|
|
501
|
+
const fullName = this._prefix ? `${this._prefix}_${name}` : name;
|
|
502
|
+
let inputSchema = {
|
|
503
|
+
type: "object",
|
|
504
|
+
properties: {}
|
|
505
|
+
};
|
|
506
|
+
if (def.input) {
|
|
507
|
+
const adapter = this._schemaAdapter ?? detectAdapter(def.input);
|
|
508
|
+
inputSchema = adapter.toJsonSchema(def.input);
|
|
509
|
+
this._inputParsers.set(fullName, (input) => adapter.parse(def.input, input));
|
|
510
|
+
}
|
|
511
|
+
this._tools.set(fullName, {
|
|
512
|
+
name: fullName,
|
|
513
|
+
before: def.before,
|
|
514
|
+
after: def.after,
|
|
515
|
+
description: def.description,
|
|
516
|
+
annotations: def.annotations,
|
|
517
|
+
inputSchema,
|
|
518
|
+
handler: def.handler
|
|
519
|
+
});
|
|
520
|
+
return this;
|
|
521
|
+
}
|
|
522
|
+
group(prefix, callback) {
|
|
523
|
+
const scoped = new Redop({
|
|
524
|
+
name: this._serverInfo.name,
|
|
525
|
+
schemaAdapter: this._schemaAdapter,
|
|
526
|
+
version: this._serverInfo.version
|
|
527
|
+
});
|
|
528
|
+
scoped._prefix = this._prefix ? `${this._prefix}_${prefix}` : prefix;
|
|
529
|
+
scoped._hooks = this._hooks;
|
|
530
|
+
scoped._middlewares = this._middlewares;
|
|
531
|
+
callback(scoped);
|
|
532
|
+
for (const [name, tool] of scoped._tools) {
|
|
533
|
+
this._tools.set(name, tool);
|
|
534
|
+
}
|
|
535
|
+
for (const [name, parser] of scoped._inputParsers) {
|
|
536
|
+
this._inputParsers.set(name, parser);
|
|
537
|
+
}
|
|
538
|
+
return this;
|
|
539
|
+
}
|
|
540
|
+
use(plugin) {
|
|
541
|
+
this._hooks.before.push(...plugin._hooks.before);
|
|
542
|
+
this._hooks.after.push(...plugin._hooks.after);
|
|
543
|
+
this._hooks.error.push(...plugin._hooks.error);
|
|
544
|
+
this._hooks.transform.push(...plugin._hooks.transform);
|
|
545
|
+
this._hooks.mapResponse.push(...plugin._hooks.mapResponse);
|
|
546
|
+
this._middlewares.push(...plugin._middlewares);
|
|
547
|
+
for (const [name, tool] of plugin._tools) {
|
|
548
|
+
this._tools.set(name, tool);
|
|
549
|
+
}
|
|
550
|
+
for (const [name, parser] of plugin._inputParsers) {
|
|
551
|
+
this._inputParsers.set(name, parser);
|
|
552
|
+
}
|
|
553
|
+
return this;
|
|
554
|
+
}
|
|
555
|
+
async _runTool(toolName, rawArgs, request = DEFAULT_REQUEST_META) {
|
|
556
|
+
const tool = this._tools.get(toolName);
|
|
557
|
+
if (!tool)
|
|
558
|
+
throw new Error(`Unknown tool: ${toolName}`);
|
|
559
|
+
const ctx = {
|
|
560
|
+
headers: request.headers,
|
|
561
|
+
requestId: crypto.randomUUID(),
|
|
562
|
+
sessionId: request.sessionId,
|
|
563
|
+
tool: toolName,
|
|
564
|
+
transport: request.transport,
|
|
565
|
+
rawParams: rawArgs
|
|
566
|
+
};
|
|
567
|
+
let params = { ...rawArgs };
|
|
568
|
+
for (const hook of this._hooks.transform) {
|
|
569
|
+
const out = await hook({ tool: toolName, params, ctx, request });
|
|
570
|
+
if (out && typeof out === "object") {
|
|
571
|
+
params = out;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
let input = params;
|
|
575
|
+
const parser = this._inputParsers.get(toolName);
|
|
576
|
+
if (parser) {
|
|
577
|
+
try {
|
|
578
|
+
input = await parser(params);
|
|
579
|
+
} catch (err) {
|
|
580
|
+
const validationError = new Error(`Validation failed for tool "${toolName}": ${err instanceof Error ? err.message : String(err)}`);
|
|
581
|
+
validationError.cause = err;
|
|
582
|
+
if (typeof err === "object" && err !== null && "issues" in err) {
|
|
583
|
+
validationError.issues = err.issues;
|
|
584
|
+
}
|
|
585
|
+
throw validationError;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
const handlerEvent = {
|
|
589
|
+
ctx,
|
|
590
|
+
input,
|
|
591
|
+
request,
|
|
592
|
+
tool: toolName
|
|
593
|
+
};
|
|
594
|
+
try {
|
|
595
|
+
for (const hook of this._hooks.before) {
|
|
596
|
+
await hook({
|
|
597
|
+
tool: toolName,
|
|
598
|
+
ctx,
|
|
599
|
+
input,
|
|
600
|
+
params: input,
|
|
601
|
+
request
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
if (tool.before) {
|
|
605
|
+
await tool.before(handlerEvent);
|
|
606
|
+
}
|
|
607
|
+
const dispatch = async (index) => {
|
|
608
|
+
if (index >= this._middlewares.length) {
|
|
609
|
+
return tool.handler(handlerEvent);
|
|
610
|
+
}
|
|
611
|
+
const mw = this._middlewares[index];
|
|
612
|
+
if (!mw) {
|
|
613
|
+
return tool.handler(handlerEvent);
|
|
614
|
+
}
|
|
615
|
+
return mw({
|
|
616
|
+
...handlerEvent,
|
|
617
|
+
next: () => dispatch(index + 1)
|
|
618
|
+
});
|
|
619
|
+
};
|
|
620
|
+
let result = await dispatch(0);
|
|
621
|
+
if (tool.after) {
|
|
622
|
+
await tool.after({
|
|
623
|
+
...handlerEvent,
|
|
624
|
+
result
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
for (const hook of this._hooks.after) {
|
|
628
|
+
await hook({
|
|
629
|
+
tool: toolName,
|
|
630
|
+
ctx,
|
|
631
|
+
input,
|
|
632
|
+
params: input,
|
|
633
|
+
request,
|
|
634
|
+
result
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
for (const hook of this._hooks.mapResponse) {
|
|
638
|
+
result = await hook(result, toolName);
|
|
639
|
+
}
|
|
640
|
+
return result;
|
|
641
|
+
} catch (err) {
|
|
642
|
+
for (const hook of this._hooks.error) {
|
|
643
|
+
await hook({
|
|
644
|
+
tool: toolName,
|
|
645
|
+
ctx,
|
|
646
|
+
error: err,
|
|
647
|
+
input,
|
|
648
|
+
params: input,
|
|
649
|
+
request
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
throw err;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
listen(opts = {}) {
|
|
656
|
+
const runner = (name, args, requestMeta) => this._runTool(name, args, requestMeta);
|
|
657
|
+
const transport = opts.transport ?? (opts.port ? "http" : "stdio");
|
|
658
|
+
if (transport === "stdio") {
|
|
659
|
+
startStdioTransport(this._tools, runner, this._serverInfo);
|
|
660
|
+
return this;
|
|
661
|
+
}
|
|
662
|
+
if (transport === "http") {
|
|
663
|
+
startHttpTransport(this._tools, runner, opts, this._serverInfo);
|
|
664
|
+
return this;
|
|
665
|
+
}
|
|
666
|
+
throw new Error(`[redop] Unknown transport: ${transport}`);
|
|
667
|
+
}
|
|
668
|
+
get toolNames() {
|
|
669
|
+
return Array.from(this._tools.keys());
|
|
670
|
+
}
|
|
671
|
+
get serverInfo() {
|
|
672
|
+
return { ...this._serverInfo };
|
|
673
|
+
}
|
|
674
|
+
getTool(name) {
|
|
675
|
+
return this._tools.get(name);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
function middleware(fn) {
|
|
679
|
+
return new Redop().middleware(fn);
|
|
680
|
+
}
|
|
681
|
+
function definePlugin(definition) {
|
|
682
|
+
const factory = (options) => definition.setup(options);
|
|
683
|
+
const meta = {
|
|
684
|
+
name: definition.name,
|
|
685
|
+
version: definition.version,
|
|
686
|
+
...definition.description ? { description: definition.description } : {}
|
|
687
|
+
};
|
|
688
|
+
factory.meta = meta;
|
|
689
|
+
return factory;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// src/plugins/index.ts
|
|
693
|
+
var LOG_LEVELS = {
|
|
694
|
+
debug: 0,
|
|
695
|
+
info: 1,
|
|
696
|
+
warn: 2,
|
|
697
|
+
error: 3
|
|
698
|
+
};
|
|
699
|
+
function logger(opts = {}) {
|
|
700
|
+
const minLevel = LOG_LEVELS[opts.level ?? "info"];
|
|
701
|
+
const write = opts.write ?? ((e) => console.log(JSON.stringify(e)));
|
|
702
|
+
const log = (level, data) => {
|
|
703
|
+
if (LOG_LEVELS[level] >= minLevel) {
|
|
704
|
+
write({ ts: new Date().toISOString(), level, ...data });
|
|
705
|
+
}
|
|
706
|
+
};
|
|
707
|
+
return new Redop().onBeforeHandle(({ tool, ctx, request }) => {
|
|
708
|
+
log("info", {
|
|
709
|
+
event: "tool.start",
|
|
710
|
+
tool,
|
|
711
|
+
requestId: ctx.requestId,
|
|
712
|
+
transport: request.transport
|
|
713
|
+
});
|
|
714
|
+
}).onAfterHandle(({ tool, ctx }) => {
|
|
715
|
+
const ms = ctx.startedAt ? performance.now() - ctx.startedAt : undefined;
|
|
716
|
+
log("info", {
|
|
717
|
+
event: "tool.end",
|
|
718
|
+
tool,
|
|
719
|
+
requestId: ctx.requestId,
|
|
720
|
+
...ms != null ? { ms: +ms.toFixed(2) } : {}
|
|
721
|
+
});
|
|
722
|
+
}).onError(({ tool, error, ctx }) => {
|
|
723
|
+
log("error", {
|
|
724
|
+
event: "tool.error",
|
|
725
|
+
tool,
|
|
726
|
+
requestId: ctx.requestId,
|
|
727
|
+
error: error instanceof Error ? error.message : String(error)
|
|
728
|
+
});
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
function analytics(opts = {}) {
|
|
732
|
+
const { sink = "console", apiKey } = opts;
|
|
733
|
+
async function emit(event) {
|
|
734
|
+
if (typeof sink === "function") {
|
|
735
|
+
await sink(event);
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
if (sink === "console") {
|
|
739
|
+
console.log("[redop:analytics]", event);
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
if (sink === "posthog" && apiKey) {
|
|
743
|
+
fetch("https://app.posthog.com/capture/", {
|
|
744
|
+
method: "POST",
|
|
745
|
+
headers: { "Content-Type": "application/json" },
|
|
746
|
+
body: JSON.stringify({
|
|
747
|
+
api_key: apiKey,
|
|
748
|
+
event: "redop_tool_call",
|
|
749
|
+
distinct_id: event.requestId,
|
|
750
|
+
properties: event
|
|
751
|
+
})
|
|
752
|
+
}).catch(() => {});
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
return new Redop().onBeforeHandle(({ ctx }) => {
|
|
756
|
+
ctx.startedAt = performance.now();
|
|
757
|
+
ctx.analyticsSuccess = true;
|
|
758
|
+
}).onError(({ ctx }) => {
|
|
759
|
+
ctx.analyticsSuccess = false;
|
|
760
|
+
}).onAfterHandle(({ tool, ctx }) => {
|
|
761
|
+
const startedAt = ctx.startedAt;
|
|
762
|
+
const durationMs = startedAt != null ? +(performance.now() - startedAt).toFixed(2) : 0;
|
|
763
|
+
emit({
|
|
764
|
+
tool,
|
|
765
|
+
durationMs,
|
|
766
|
+
success: ctx.analyticsSuccess ?? true,
|
|
767
|
+
requestId: ctx.requestId
|
|
768
|
+
});
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
function apiKey(opts = {}) {
|
|
772
|
+
return createHeaderAuthPlugin({
|
|
773
|
+
...opts,
|
|
774
|
+
ctxKey: opts.ctxKey ?? "apiKey",
|
|
775
|
+
headerName: opts.headerName ?? "x-api-key"
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
function bearer(opts = {}) {
|
|
779
|
+
const scheme = opts.scheme ?? "Bearer";
|
|
780
|
+
return createHeaderAuthPlugin({
|
|
781
|
+
...opts,
|
|
782
|
+
ctxKey: opts.ctxKey ?? "token",
|
|
783
|
+
headerName: "authorization",
|
|
784
|
+
aliases: ["authToken"],
|
|
785
|
+
transform(value) {
|
|
786
|
+
const [providedScheme, ...rest] = value.trim().split(/\s+/);
|
|
787
|
+
if (!providedScheme || providedScheme.toLowerCase() !== scheme.toLowerCase()) {
|
|
788
|
+
throw new Error(`Unauthorized: expected ${scheme} token`);
|
|
789
|
+
}
|
|
790
|
+
const token = rest.join(" ").trim();
|
|
791
|
+
if (!token) {
|
|
792
|
+
throw new Error(`Unauthorized: missing ${scheme} token`);
|
|
793
|
+
}
|
|
794
|
+
return token;
|
|
795
|
+
}
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
function createHeaderAuthPlugin(opts) {
|
|
799
|
+
return middleware(async ({ ctx, request, input, tool, next }) => {
|
|
800
|
+
if (request.transport !== "http")
|
|
801
|
+
return next();
|
|
802
|
+
const headerName = (opts.headerName ?? "authorization").toLowerCase();
|
|
803
|
+
const headerValue = request.headers[headerName];
|
|
804
|
+
if (!opts.secret && !opts.validate) {
|
|
805
|
+
throw new Error(`[redop] ${headerName} auth requires either a secret or validate()`);
|
|
806
|
+
}
|
|
807
|
+
if (!headerValue) {
|
|
808
|
+
if (opts.required ?? true) {
|
|
809
|
+
throw new Error(`Unauthorized: missing ${headerName} header`);
|
|
810
|
+
}
|
|
811
|
+
return next();
|
|
812
|
+
}
|
|
813
|
+
const token = opts.transform ? opts.transform(headerValue) : headerValue.trim();
|
|
814
|
+
const valid = opts.validate ? await opts.validate(token, { ctx, input, request, tool }) : token === opts.secret;
|
|
815
|
+
if (!valid) {
|
|
816
|
+
throw new Error(`Unauthorized: invalid ${headerName}`);
|
|
817
|
+
}
|
|
818
|
+
ctx[opts.ctxKey ?? "auth"] = token;
|
|
819
|
+
for (const alias of opts.aliases ?? []) {
|
|
820
|
+
ctx[alias] = token;
|
|
821
|
+
}
|
|
822
|
+
return next();
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
function parseWindow(w) {
|
|
826
|
+
if (typeof w === "number")
|
|
827
|
+
return w;
|
|
828
|
+
const match = w.match(/^(\d+)(ms|s|m|h|d)$/);
|
|
829
|
+
if (!match)
|
|
830
|
+
return 60000;
|
|
831
|
+
const n = match[1];
|
|
832
|
+
const unit = match[2];
|
|
833
|
+
if (!n || !unit)
|
|
834
|
+
return 60000;
|
|
835
|
+
const multipliers = {
|
|
836
|
+
ms: 1,
|
|
837
|
+
s: 1000,
|
|
838
|
+
m: 60000,
|
|
839
|
+
h: 3600000,
|
|
840
|
+
d: 86400000
|
|
841
|
+
};
|
|
842
|
+
return parseInt(n) * (multipliers[unit] ?? 1);
|
|
843
|
+
}
|
|
844
|
+
function defaultRateLimitKey(event) {
|
|
845
|
+
return event.request.ip ?? event.request.headers["x-forwarded-for"]?.split(",")[0]?.trim() ?? event.request.sessionId ?? event.ctx.requestId.slice(0, 8);
|
|
846
|
+
}
|
|
847
|
+
function rateLimit(opts = {}) {
|
|
848
|
+
const max = opts.max ?? 60;
|
|
849
|
+
const windowMs = parseWindow(opts.window ?? "1m");
|
|
850
|
+
const buckets = new Map;
|
|
851
|
+
return middleware(async (event) => {
|
|
852
|
+
const key = opts.keyBy ? opts.keyBy(event) : defaultRateLimitKey(event);
|
|
853
|
+
const now = Date.now();
|
|
854
|
+
const timestamps = (buckets.get(key) ?? []).filter((ts) => now - ts < windowMs);
|
|
855
|
+
if (timestamps.length >= max) {
|
|
856
|
+
throw new Error(`Rate limit exceeded: ${max} calls per ${windowMs}ms`);
|
|
857
|
+
}
|
|
858
|
+
timestamps.push(now);
|
|
859
|
+
buckets.set(key, timestamps);
|
|
860
|
+
return event.next();
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
function cache(opts = {}) {
|
|
864
|
+
const ttl = opts.ttl ?? 60000;
|
|
865
|
+
const allowedTools = opts.tools ? new Set(opts.tools) : null;
|
|
866
|
+
const store = new Map;
|
|
867
|
+
function hashKey(tool, input) {
|
|
868
|
+
return `${tool}:${JSON.stringify(input)}`;
|
|
869
|
+
}
|
|
870
|
+
return middleware(async ({ tool, input, next }) => {
|
|
871
|
+
if (allowedTools && !allowedTools.has(tool)) {
|
|
872
|
+
return next();
|
|
873
|
+
}
|
|
874
|
+
const key = hashKey(tool, input);
|
|
875
|
+
const entry = store.get(key);
|
|
876
|
+
if (entry && Date.now() < entry.expiresAt) {
|
|
877
|
+
return entry.result;
|
|
878
|
+
}
|
|
879
|
+
const result = await next();
|
|
880
|
+
store.set(key, { result, expiresAt: Date.now() + ttl });
|
|
881
|
+
return result;
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
// src/types.ts
|
|
885
|
+
var McpErrorCode;
|
|
886
|
+
((McpErrorCode2) => {
|
|
887
|
+
McpErrorCode2[McpErrorCode2["ParseError"] = -32700] = "ParseError";
|
|
888
|
+
McpErrorCode2[McpErrorCode2["InvalidRequest"] = -32600] = "InvalidRequest";
|
|
889
|
+
McpErrorCode2[McpErrorCode2["MethodNotFound"] = -32601] = "MethodNotFound";
|
|
890
|
+
McpErrorCode2[McpErrorCode2["InvalidParams"] = -32602] = "InvalidParams";
|
|
891
|
+
McpErrorCode2[McpErrorCode2["InternalError"] = -32603] = "InternalError";
|
|
892
|
+
McpErrorCode2[McpErrorCode2["ToolNotFound"] = -32000] = "ToolNotFound";
|
|
893
|
+
McpErrorCode2[McpErrorCode2["ValidationFailed"] = -32001] = "ValidationFailed";
|
|
894
|
+
McpErrorCode2[McpErrorCode2["Unauthorized"] = -32002] = "Unauthorized";
|
|
895
|
+
McpErrorCode2[McpErrorCode2["RateLimited"] = -32003] = "RateLimited";
|
|
896
|
+
McpErrorCode2[McpErrorCode2["Timeout"] = -32004] = "Timeout";
|
|
897
|
+
})(McpErrorCode ||= {});
|
|
898
|
+
|
|
899
|
+
class McpError extends Error {
|
|
900
|
+
code;
|
|
901
|
+
data;
|
|
902
|
+
constructor(code, message, data) {
|
|
903
|
+
super(message);
|
|
904
|
+
this.code = code;
|
|
905
|
+
this.data = data;
|
|
906
|
+
this.name = "McpError";
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
export {
|
|
910
|
+
zodAdapter,
|
|
911
|
+
standardSchemaAdapter,
|
|
912
|
+
rateLimit,
|
|
913
|
+
middleware,
|
|
914
|
+
logger,
|
|
915
|
+
jsonSchemaAdapter,
|
|
916
|
+
detectAdapter,
|
|
917
|
+
definePlugin,
|
|
918
|
+
cache,
|
|
919
|
+
bearer,
|
|
920
|
+
apiKey,
|
|
921
|
+
analytics,
|
|
922
|
+
Redop,
|
|
923
|
+
McpErrorCode,
|
|
924
|
+
McpError
|
|
925
|
+
};
|