@absolutejs/mcp 0.0.1 → 0.2.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/README.md +6 -8
- package/dist/index.js +298 -54
- package/dist/src/client.d.ts +50 -0
- package/dist/src/core.d.ts +11 -0
- package/dist/src/dispatch.d.ts +4 -3
- package/dist/src/handler.d.ts +2 -0
- package/dist/src/index.d.ts +22 -13
- package/dist/src/types.d.ts +45 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -114,14 +114,12 @@ different scope, a stricter `authorize` (role + MFA + a kill switch, re-checked
|
|
|
114
114
|
live), a rate-limit `beforeCall`, and an audit `onCall`:
|
|
115
115
|
|
|
116
116
|
```ts
|
|
117
|
-
app
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}),
|
|
124
|
-
);
|
|
117
|
+
app.use(mcpServer({ path: "/mcp" /* member */ })).use(
|
|
118
|
+
mcpServer({
|
|
119
|
+
path: "/mcp/admin",
|
|
120
|
+
scopesSupported: ["openid", "mcp:admin"] /* stricter */,
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
125
123
|
```
|
|
126
124
|
|
|
127
125
|
Only one endpoint per app should set `serveRootMetadata` (the un-suffixed alias).
|
package/dist/index.js
CHANGED
|
@@ -29,6 +29,176 @@ var verifyBearer = async (config) => {
|
|
|
29
29
|
return { error: "Token has no subject" };
|
|
30
30
|
return { payload, scopes, subject };
|
|
31
31
|
};
|
|
32
|
+
// src/client.ts
|
|
33
|
+
var DEFAULT_TIMEOUT_MS = 30000;
|
|
34
|
+
var DEFAULT_PROTOCOL = "2025-06-18";
|
|
35
|
+
|
|
36
|
+
class McpClientError extends Error {
|
|
37
|
+
code;
|
|
38
|
+
status;
|
|
39
|
+
constructor(message, options = {}) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = "McpClientError";
|
|
42
|
+
this.code = options.code;
|
|
43
|
+
this.status = options.status;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
var parseBody = async (response, maxBytes) => {
|
|
47
|
+
const text = await response.text();
|
|
48
|
+
if (maxBytes > 0 && text.length > maxBytes) {
|
|
49
|
+
throw new McpClientError("Response exceeded the size cap", {
|
|
50
|
+
status: response.status
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
54
|
+
if (!contentType.includes("text/event-stream")) {
|
|
55
|
+
return JSON.parse(text);
|
|
56
|
+
}
|
|
57
|
+
const messages = text.split(/\r?\n/).filter((line) => line.startsWith("data:")).map((line) => line.slice("data:".length).trim()).filter((chunk) => chunk.length > 0);
|
|
58
|
+
for (let index = messages.length - 1;index >= 0; index -= 1) {
|
|
59
|
+
try {
|
|
60
|
+
const parsed = JSON.parse(messages[index] ?? "");
|
|
61
|
+
if (isRecord(parsed) && (("result" in parsed) || ("error" in parsed))) {
|
|
62
|
+
return parsed;
|
|
63
|
+
}
|
|
64
|
+
} catch {}
|
|
65
|
+
}
|
|
66
|
+
throw new McpClientError("No JSON-RPC response in the event stream");
|
|
67
|
+
};
|
|
68
|
+
var createMcpClient = (options) => {
|
|
69
|
+
const doFetch = options.request ?? fetch;
|
|
70
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
71
|
+
const maxBytes = options.maxResponseBytes ?? 0;
|
|
72
|
+
let protocolVersion = options.protocolVersion ?? DEFAULT_PROTOCOL;
|
|
73
|
+
let sessionId = null;
|
|
74
|
+
let nextId = 1;
|
|
75
|
+
const rpc = async (method, params) => {
|
|
76
|
+
const controller = new AbortController;
|
|
77
|
+
const timer = setTimeout(() => {
|
|
78
|
+
controller.abort();
|
|
79
|
+
}, timeoutMs);
|
|
80
|
+
try {
|
|
81
|
+
const headers = {
|
|
82
|
+
accept: "application/json, text/event-stream",
|
|
83
|
+
"content-type": "application/json",
|
|
84
|
+
"mcp-protocol-version": protocolVersion,
|
|
85
|
+
...options.headers
|
|
86
|
+
};
|
|
87
|
+
if (sessionId !== null)
|
|
88
|
+
headers["mcp-session-id"] = sessionId;
|
|
89
|
+
const response = await doFetch(options.url, {
|
|
90
|
+
body: JSON.stringify({
|
|
91
|
+
id: nextId++,
|
|
92
|
+
jsonrpc: "2.0",
|
|
93
|
+
method,
|
|
94
|
+
...params === undefined ? {} : { params }
|
|
95
|
+
}),
|
|
96
|
+
headers,
|
|
97
|
+
method: "POST",
|
|
98
|
+
signal: controller.signal
|
|
99
|
+
});
|
|
100
|
+
const captured = response.headers.get("mcp-session-id");
|
|
101
|
+
if (captured)
|
|
102
|
+
sessionId = captured;
|
|
103
|
+
if (response.status === 401) {
|
|
104
|
+
throw new McpClientError("The MCP server rejected the credentials", {
|
|
105
|
+
status: 401
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
const payload = await parseBody(response, maxBytes);
|
|
109
|
+
if (!isRecord(payload)) {
|
|
110
|
+
throw new McpClientError("Malformed JSON-RPC response");
|
|
111
|
+
}
|
|
112
|
+
if (isRecord(payload.error)) {
|
|
113
|
+
const message = typeof payload.error.message === "string" ? payload.error.message : "MCP error";
|
|
114
|
+
const code = typeof payload.error.code === "number" ? payload.error.code : undefined;
|
|
115
|
+
throw new McpClientError(message, { code });
|
|
116
|
+
}
|
|
117
|
+
return payload.result;
|
|
118
|
+
} finally {
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const notify = async (method) => {
|
|
123
|
+
const headers = {
|
|
124
|
+
"content-type": "application/json",
|
|
125
|
+
"mcp-protocol-version": protocolVersion,
|
|
126
|
+
...options.headers
|
|
127
|
+
};
|
|
128
|
+
if (sessionId !== null)
|
|
129
|
+
headers["mcp-session-id"] = sessionId;
|
|
130
|
+
await doFetch(options.url, {
|
|
131
|
+
body: JSON.stringify({ jsonrpc: "2.0", method }),
|
|
132
|
+
headers,
|
|
133
|
+
method: "POST"
|
|
134
|
+
}).catch(() => {
|
|
135
|
+
return;
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
const initialize = async () => {
|
|
139
|
+
const result = await rpc("initialize", {
|
|
140
|
+
capabilities: {},
|
|
141
|
+
clientInfo: options.clientInfo ?? {
|
|
142
|
+
name: "@absolutejs/mcp",
|
|
143
|
+
version: "0"
|
|
144
|
+
},
|
|
145
|
+
protocolVersion
|
|
146
|
+
});
|
|
147
|
+
if (isRecord(result) && typeof result.protocolVersion === "string") {
|
|
148
|
+
protocolVersion = result.protocolVersion;
|
|
149
|
+
}
|
|
150
|
+
await notify("notifications/initialized");
|
|
151
|
+
return isRecord(result) ? result : {};
|
|
152
|
+
};
|
|
153
|
+
const MAX_LIST_PAGES = 40;
|
|
154
|
+
const listTools = async () => {
|
|
155
|
+
const collected = [];
|
|
156
|
+
let cursor;
|
|
157
|
+
for (let page = 0;page < MAX_LIST_PAGES; page += 1) {
|
|
158
|
+
const result = await rpc("tools/list", cursor === undefined ? undefined : { cursor });
|
|
159
|
+
const tools = isRecord(result) && Array.isArray(result.tools) ? result.tools : [];
|
|
160
|
+
collected.push(...tools.filter(isRecord).map((tool) => ({
|
|
161
|
+
annotations: isRecord(tool.annotations) ? tool.annotations : undefined,
|
|
162
|
+
description: typeof tool.description === "string" ? tool.description : undefined,
|
|
163
|
+
inputSchema: isRecord(tool.inputSchema) ? tool.inputSchema : undefined,
|
|
164
|
+
name: typeof tool.name === "string" ? tool.name : "",
|
|
165
|
+
outputSchema: isRecord(tool.outputSchema) ? tool.outputSchema : undefined
|
|
166
|
+
})));
|
|
167
|
+
const next = isRecord(result) && typeof result.nextCursor === "string" ? result.nextCursor : undefined;
|
|
168
|
+
if (next === undefined)
|
|
169
|
+
break;
|
|
170
|
+
cursor = next;
|
|
171
|
+
}
|
|
172
|
+
return collected;
|
|
173
|
+
};
|
|
174
|
+
const callTool = async (name, args) => {
|
|
175
|
+
const result = await rpc("tools/call", { arguments: args ?? {}, name });
|
|
176
|
+
if (isRecord(result) && Array.isArray(result.content)) {
|
|
177
|
+
return result;
|
|
178
|
+
}
|
|
179
|
+
return { content: [], isError: false };
|
|
180
|
+
};
|
|
181
|
+
const listResources = async () => {
|
|
182
|
+
const collected = [];
|
|
183
|
+
let cursor;
|
|
184
|
+
for (let page = 0;page < MAX_LIST_PAGES; page += 1) {
|
|
185
|
+
const result = await rpc("resources/list", cursor === undefined ? undefined : { cursor });
|
|
186
|
+
if (isRecord(result) && Array.isArray(result.resources)) {
|
|
187
|
+
collected.push(...result.resources);
|
|
188
|
+
}
|
|
189
|
+
const next = isRecord(result) && typeof result.nextCursor === "string" ? result.nextCursor : undefined;
|
|
190
|
+
if (next === undefined)
|
|
191
|
+
break;
|
|
192
|
+
cursor = next;
|
|
193
|
+
}
|
|
194
|
+
return collected;
|
|
195
|
+
};
|
|
196
|
+
const readResource = async (uri) => rpc("resources/read", { uri });
|
|
197
|
+
const ping = async () => {
|
|
198
|
+
await rpc("ping");
|
|
199
|
+
};
|
|
200
|
+
return { callTool, initialize, listResources, listTools, ping, readResource };
|
|
201
|
+
};
|
|
32
202
|
// src/jsonrpc.ts
|
|
33
203
|
var JSONRPC_PARSE_ERROR = -32700;
|
|
34
204
|
var JSONRPC_INVALID_REQUEST = -32600;
|
|
@@ -63,12 +233,41 @@ var unauthorized = (metadataUrl, detail) => new Response(JSON.stringify({
|
|
|
63
233
|
// src/dispatch.ts
|
|
64
234
|
var DEFAULT_PROTOCOLS = ["2025-06-18", "2025-03-26", "2024-11-05"];
|
|
65
235
|
var DEFAULT_RESOURCE_MIME = "text/markdown";
|
|
236
|
+
var DEFAULT_LIST_PAGE_SIZE = 50;
|
|
237
|
+
var decodeCursor = (params) => {
|
|
238
|
+
if (!isRecord(params) || typeof params.cursor !== "string")
|
|
239
|
+
return 0;
|
|
240
|
+
try {
|
|
241
|
+
const parsed = Number.parseInt(atob(params.cursor), 10);
|
|
242
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
|
243
|
+
} catch {
|
|
244
|
+
return 0;
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
var encodeCursor = (offset) => btoa(String(offset));
|
|
248
|
+
var paginate = (items, offset, pageSize) => {
|
|
249
|
+
const page = items.slice(offset, offset + pageSize);
|
|
250
|
+
const nextOffset = offset + pageSize;
|
|
251
|
+
return {
|
|
252
|
+
items: page,
|
|
253
|
+
...nextOffset < items.length ? { nextCursor: encodeCursor(nextOffset) } : {}
|
|
254
|
+
};
|
|
255
|
+
};
|
|
66
256
|
var idOf = (message) => typeof message.id === "string" || typeof message.id === "number" ? message.id : null;
|
|
67
257
|
var negotiateProtocol = (supported, params) => {
|
|
68
258
|
const preferred = supported[0] ?? DEFAULT_PROTOCOLS[0] ?? "";
|
|
69
259
|
const requested = isRecord(params) && typeof params.protocolVersion === "string" ? params.protocolVersion : preferred;
|
|
70
260
|
return supported.includes(requested) ? requested : preferred;
|
|
71
261
|
};
|
|
262
|
+
var scopeAllows = (tool, scopes) => tool.scope === undefined || scopes.includes(tool.scope);
|
|
263
|
+
var normalizeResult = (value) => {
|
|
264
|
+
if (typeof value === "string") {
|
|
265
|
+
return { content: [{ text: value, type: "text" }], isError: false };
|
|
266
|
+
}
|
|
267
|
+
if (Array.isArray(value))
|
|
268
|
+
return { content: value, isError: false };
|
|
269
|
+
return { isError: false, ...value };
|
|
270
|
+
};
|
|
72
271
|
var initialize = (config, id, params) => {
|
|
73
272
|
const supported = config.supportedProtocols ?? DEFAULT_PROTOCOLS;
|
|
74
273
|
const capabilities = {
|
|
@@ -86,19 +285,23 @@ var initialize = (config, id, params) => {
|
|
|
86
285
|
serverInfo: config.serverInfo
|
|
87
286
|
});
|
|
88
287
|
};
|
|
89
|
-
var toolsList = async (config, caller, id) => {
|
|
288
|
+
var toolsList = async (config, caller, scopes, id, params) => {
|
|
90
289
|
const tools = await config.tools({ caller, meta: {} });
|
|
290
|
+
const visible = Object.entries(tools).filter(([, tool]) => scopeAllows(tool, scopes)).map(([name, tool]) => ({
|
|
291
|
+
annotations: tool.annotations,
|
|
292
|
+
description: tool.description,
|
|
293
|
+
inputSchema: tool.inputSchema,
|
|
294
|
+
name,
|
|
295
|
+
...tool.outputSchema === undefined ? {} : { outputSchema: tool.outputSchema }
|
|
296
|
+
}));
|
|
297
|
+
const { items, nextCursor } = paginate(visible, decodeCursor(params), config.listPageSize ?? DEFAULT_LIST_PAGE_SIZE);
|
|
91
298
|
return rpcResult(id, {
|
|
92
|
-
tools:
|
|
93
|
-
|
|
94
|
-
description: tool.description,
|
|
95
|
-
inputSchema: tool.inputSchema,
|
|
96
|
-
name
|
|
97
|
-
}))
|
|
299
|
+
tools: items,
|
|
300
|
+
...nextCursor === undefined ? {} : { nextCursor }
|
|
98
301
|
});
|
|
99
302
|
};
|
|
100
303
|
var errorResult = (id, text) => rpcResult(id, { content: [{ text, type: "text" }], isError: true });
|
|
101
|
-
var toolsCall = async (config, caller, id, params) => {
|
|
304
|
+
var toolsCall = async (config, caller, scopes, id, params) => {
|
|
102
305
|
if (!isRecord(params) || typeof params.name !== "string") {
|
|
103
306
|
return rpcError(id, JSONRPC_INVALID_PARAMS, "tools/call needs a name");
|
|
104
307
|
}
|
|
@@ -112,17 +315,15 @@ var toolsCall = async (config, caller, id, params) => {
|
|
|
112
315
|
}
|
|
113
316
|
const tools = await config.tools({ caller, meta });
|
|
114
317
|
const tool = tools[name];
|
|
115
|
-
if (!tool)
|
|
318
|
+
if (!tool || !scopeAllows(tool, scopes)) {
|
|
116
319
|
return rpcError(id, JSONRPC_INVALID_PARAMS, `Unknown tool: ${name}`);
|
|
320
|
+
}
|
|
117
321
|
let ok = false;
|
|
118
322
|
let response;
|
|
119
323
|
try {
|
|
120
|
-
const
|
|
121
|
-
ok = true;
|
|
122
|
-
response = rpcResult(id,
|
|
123
|
-
content: [{ text, type: "text" }],
|
|
124
|
-
isError: false
|
|
125
|
-
});
|
|
324
|
+
const result = normalizeResult(await tool.handler(args));
|
|
325
|
+
ok = result.isError !== true;
|
|
326
|
+
response = rpcResult(id, result);
|
|
126
327
|
} catch (error) {
|
|
127
328
|
const detail = error instanceof Error ? error.message : "unknown error";
|
|
128
329
|
response = errorResult(id, `Tool failed: ${detail}`);
|
|
@@ -131,15 +332,18 @@ var toolsCall = async (config, caller, id, params) => {
|
|
|
131
332
|
await config.onCall({ args, caller, meta, name, ok });
|
|
132
333
|
return response;
|
|
133
334
|
};
|
|
134
|
-
var promptsList = (config, id) => {
|
|
335
|
+
var promptsList = (config, id, params) => {
|
|
135
336
|
const definitions = config.prompts?.definitions ?? {};
|
|
337
|
+
const all = Object.entries(definitions).map(([name, def]) => ({
|
|
338
|
+
arguments: def.arguments ?? [],
|
|
339
|
+
description: def.description,
|
|
340
|
+
name,
|
|
341
|
+
title: def.title
|
|
342
|
+
}));
|
|
343
|
+
const { items, nextCursor } = paginate(all, decodeCursor(params), config.listPageSize ?? DEFAULT_LIST_PAGE_SIZE);
|
|
136
344
|
return rpcResult(id, {
|
|
137
|
-
prompts:
|
|
138
|
-
|
|
139
|
-
description: def.description,
|
|
140
|
-
name,
|
|
141
|
-
title: def.title
|
|
142
|
-
}))
|
|
345
|
+
prompts: items,
|
|
346
|
+
...nextCursor === undefined ? {} : { nextCursor }
|
|
143
347
|
});
|
|
144
348
|
};
|
|
145
349
|
var promptsGet = async (config, caller, id, params) => {
|
|
@@ -163,11 +367,15 @@ var promptsGet = async (config, caller, id, params) => {
|
|
|
163
367
|
messages: [{ content: { text, type: "text" }, role: "user" }]
|
|
164
368
|
});
|
|
165
369
|
};
|
|
166
|
-
var resourcesList = async (config, caller, id) => {
|
|
370
|
+
var resourcesList = async (config, caller, id, params) => {
|
|
167
371
|
const resources = config.resources;
|
|
168
372
|
if (!resources)
|
|
169
373
|
return rpcResult(id, { resources: [] });
|
|
170
|
-
|
|
374
|
+
const { items, nextCursor } = paginate(await resources.list({ caller }), decodeCursor(params), config.listPageSize ?? DEFAULT_LIST_PAGE_SIZE);
|
|
375
|
+
return rpcResult(id, {
|
|
376
|
+
resources: items,
|
|
377
|
+
...nextCursor === undefined ? {} : { nextCursor }
|
|
378
|
+
});
|
|
171
379
|
};
|
|
172
380
|
var resourcesRead = async (config, caller, id, params) => {
|
|
173
381
|
const resources = config.resources;
|
|
@@ -187,7 +395,7 @@ var resourcesRead = async (config, caller, id, params) => {
|
|
|
187
395
|
]
|
|
188
396
|
});
|
|
189
397
|
};
|
|
190
|
-
var dispatchMcp = async (config, caller, message) => {
|
|
398
|
+
var dispatchMcp = async (config, caller, scopes, message) => {
|
|
191
399
|
if (!isRecord(message) || message.jsonrpc !== "2.0") {
|
|
192
400
|
return rpcError(null, JSONRPC_INVALID_REQUEST, "Not a JSON-RPC 2.0 message");
|
|
193
401
|
}
|
|
@@ -200,16 +408,19 @@ var dispatchMcp = async (config, caller, message) => {
|
|
|
200
408
|
return initialize(config, id, params);
|
|
201
409
|
if (method === "ping")
|
|
202
410
|
return rpcResult(id, {});
|
|
203
|
-
if (method === "tools/list")
|
|
204
|
-
return toolsList(config, caller, id);
|
|
205
|
-
|
|
206
|
-
|
|
411
|
+
if (method === "tools/list") {
|
|
412
|
+
return toolsList(config, caller, scopes, id, params);
|
|
413
|
+
}
|
|
414
|
+
if (method === "tools/call") {
|
|
415
|
+
return toolsCall(config, caller, scopes, id, params);
|
|
416
|
+
}
|
|
207
417
|
if (method === "prompts/list")
|
|
208
|
-
return promptsList(config, id);
|
|
418
|
+
return promptsList(config, id, params);
|
|
209
419
|
if (method === "prompts/get")
|
|
210
420
|
return promptsGet(config, caller, id, params);
|
|
211
|
-
if (method === "resources/list")
|
|
212
|
-
return resourcesList(config, caller, id);
|
|
421
|
+
if (method === "resources/list") {
|
|
422
|
+
return resourcesList(config, caller, id, params);
|
|
423
|
+
}
|
|
213
424
|
if (method === "resources/read") {
|
|
214
425
|
return resourcesRead(config, caller, id, params);
|
|
215
426
|
}
|
|
@@ -222,31 +433,61 @@ var protectedResourceMetadata = (input) => ({
|
|
|
222
433
|
scopes_supported: input.scopes ?? []
|
|
223
434
|
});
|
|
224
435
|
var metadataPathFor = (path) => `/.well-known/oauth-protected-resource${path}`;
|
|
225
|
-
|
|
226
|
-
|
|
436
|
+
|
|
437
|
+
// src/core.ts
|
|
227
438
|
var ROOT_METADATA_PATH = "/.well-known/oauth-protected-resource";
|
|
228
|
-
var
|
|
439
|
+
var JSON_HEADERS = {
|
|
440
|
+
"content-type": "application/json"
|
|
441
|
+
};
|
|
442
|
+
var metadataResponse = (config) => new Response(JSON.stringify(protectedResourceMetadata({
|
|
443
|
+
issuer: config.issuer,
|
|
444
|
+
resource: `${config.issuer}${config.path}`,
|
|
445
|
+
scopes: config.scopesSupported
|
|
446
|
+
})), { headers: JSON_HEADERS });
|
|
447
|
+
var runMcpPost = async (config, request, body) => {
|
|
448
|
+
const auth = await config.authorize(request);
|
|
449
|
+
if (!auth.ok) {
|
|
450
|
+
return unauthorized(`${config.issuer}${metadataPathFor(config.path)}`, auth.reason);
|
|
451
|
+
}
|
|
452
|
+
if (body === undefined || body === null) {
|
|
453
|
+
return rpcError(null, JSONRPC_PARSE_ERROR, "Invalid JSON");
|
|
454
|
+
}
|
|
455
|
+
if (Array.isArray(body)) {
|
|
456
|
+
return rpcError(null, JSONRPC_INVALID_REQUEST, "Batching is not supported");
|
|
457
|
+
}
|
|
458
|
+
return dispatchMcp(config, auth.caller, auth.scopes ?? [], body).catch(() => rpcError(null, JSONRPC_INVALID_REQUEST, "Internal error"));
|
|
459
|
+
};
|
|
460
|
+
var handleMcpRequest = async (config, request) => {
|
|
461
|
+
const { pathname } = new URL(request.url);
|
|
229
462
|
const metadataPath = metadataPathFor(config.path);
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
});
|
|
236
|
-
const base = new Elysia().get(metadataPath, metadata).get(config.path, () => new Response(null, { status: HTTP_METHOD_NOT_ALLOWED })).post(config.path, async ({ body, request }) => {
|
|
237
|
-
const auth = await config.authorize(request);
|
|
238
|
-
if (!auth.ok)
|
|
239
|
-
return unauthorized(metadataUrl, auth.reason);
|
|
240
|
-
const message = body;
|
|
241
|
-
if (message === undefined || message === null) {
|
|
242
|
-
return rpcError(null, JSONRPC_PARSE_ERROR, "Invalid JSON");
|
|
463
|
+
if (request.method === "GET") {
|
|
464
|
+
if (pathname === metadataPath)
|
|
465
|
+
return metadataResponse(config);
|
|
466
|
+
if (config.serveRootMetadata && pathname === ROOT_METADATA_PATH) {
|
|
467
|
+
return metadataResponse(config);
|
|
243
468
|
}
|
|
244
|
-
if (
|
|
245
|
-
return
|
|
469
|
+
if (pathname === config.path) {
|
|
470
|
+
return new Response(null, { status: HTTP_METHOD_NOT_ALLOWED });
|
|
246
471
|
}
|
|
247
|
-
return
|
|
248
|
-
}
|
|
249
|
-
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
if (request.method === "POST" && pathname === config.path) {
|
|
475
|
+
const body = await request.json().catch(() => {
|
|
476
|
+
return;
|
|
477
|
+
});
|
|
478
|
+
return runMcpPost(config, request, body);
|
|
479
|
+
}
|
|
480
|
+
return null;
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
// src/handler.ts
|
|
484
|
+
var createMcpHandler = (config) => (request) => handleMcpRequest(config, request);
|
|
485
|
+
// src/server.ts
|
|
486
|
+
import { Elysia } from "elysia";
|
|
487
|
+
var mcpServer = (config) => {
|
|
488
|
+
const metadataPath = metadataPathFor(config.path);
|
|
489
|
+
const base = new Elysia().get(metadataPath, () => metadataResponse(config)).get(config.path, () => new Response(null, { status: HTTP_METHOD_NOT_ALLOWED })).post(config.path, ({ body, request }) => runMcpPost(config, request, body));
|
|
490
|
+
const app = config.serveRootMetadata ? base.get(ROOT_METADATA_PATH, () => metadataResponse(config)) : base;
|
|
250
491
|
return app;
|
|
251
492
|
};
|
|
252
493
|
export {
|
|
@@ -254,5 +495,8 @@ export {
|
|
|
254
495
|
protectedResourceMetadata,
|
|
255
496
|
metadataPathFor,
|
|
256
497
|
mcpServer,
|
|
257
|
-
dispatchMcp
|
|
498
|
+
dispatchMcp,
|
|
499
|
+
createMcpHandler,
|
|
500
|
+
createMcpClient,
|
|
501
|
+
McpClientError
|
|
258
502
|
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { McpToolAnnotations, McpToolResult } from "./types";
|
|
2
|
+
export declare class McpClientError extends Error {
|
|
3
|
+
readonly code: number | undefined;
|
|
4
|
+
readonly status: number | undefined;
|
|
5
|
+
constructor(message: string, options?: {
|
|
6
|
+
code?: number;
|
|
7
|
+
status?: number;
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
export type McpClientOptions = {
|
|
11
|
+
clientInfo?: {
|
|
12
|
+
name: string;
|
|
13
|
+
version: string;
|
|
14
|
+
};
|
|
15
|
+
/** Sent on every request (e.g. `{ authorization: "Bearer …" }`). */
|
|
16
|
+
headers?: Record<string, string>;
|
|
17
|
+
/** Reject responses whose body exceeds this many bytes (0 = no cap). */
|
|
18
|
+
maxResponseBytes?: number;
|
|
19
|
+
protocolVersion?: string;
|
|
20
|
+
/** Inject a custom fetch (tests, proxies). Defaults to global fetch. */
|
|
21
|
+
request?: typeof fetch;
|
|
22
|
+
timeoutMs?: number;
|
|
23
|
+
url: string;
|
|
24
|
+
};
|
|
25
|
+
export type McpRemoteTool = {
|
|
26
|
+
annotations?: McpToolAnnotations;
|
|
27
|
+
description?: string;
|
|
28
|
+
inputSchema?: Record<string, unknown>;
|
|
29
|
+
name: string;
|
|
30
|
+
outputSchema?: Record<string, unknown>;
|
|
31
|
+
};
|
|
32
|
+
export type McpInitializeResult = {
|
|
33
|
+
capabilities?: Record<string, unknown>;
|
|
34
|
+
instructions?: string;
|
|
35
|
+
protocolVersion: string;
|
|
36
|
+
serverInfo?: {
|
|
37
|
+
name?: string;
|
|
38
|
+
title?: string;
|
|
39
|
+
version?: string;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
export type McpClient = {
|
|
43
|
+
callTool: (name: string, args?: unknown) => Promise<McpToolResult>;
|
|
44
|
+
initialize: () => Promise<McpInitializeResult>;
|
|
45
|
+
listResources: () => Promise<unknown[]>;
|
|
46
|
+
listTools: () => Promise<McpRemoteTool[]>;
|
|
47
|
+
ping: () => Promise<void>;
|
|
48
|
+
readResource: (uri: string) => Promise<unknown>;
|
|
49
|
+
};
|
|
50
|
+
export declare const createMcpClient: (options: McpClientOptions) => McpClient;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { McpServerConfig } from "./types";
|
|
2
|
+
export declare const ROOT_METADATA_PATH = "/.well-known/oauth-protected-resource";
|
|
3
|
+
export declare const metadataResponse: <Caller>(config: McpServerConfig<Caller>) => Response;
|
|
4
|
+
/** Run one POST: authorize → validate the (already-decoded) body → dispatch.
|
|
5
|
+
* The body is passed in because Elysia pre-parses it while a raw handler must
|
|
6
|
+
* parse it itself; both funnel through here so the logic lives in one place. */
|
|
7
|
+
export declare const runMcpPost: <Caller>(config: McpServerConfig<Caller>, request: Request, body: unknown) => Promise<Response>;
|
|
8
|
+
/** The full path-aware handler over web-standard Request/Response. Returns a
|
|
9
|
+
* Response for any MCP route (POST endpoint, GET 405, discovery metadata) and
|
|
10
|
+
* `null` for anything else, so a host can compose it with its own routes. */
|
|
11
|
+
export declare const handleMcpRequest: <Caller>(config: McpServerConfig<Caller>, request: Request) => Promise<Response | null>;
|
package/dist/src/dispatch.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { McpServerConfig } from "./types";
|
|
2
|
-
/** Route one decoded JSON-RPC message to its handler.
|
|
3
|
-
*
|
|
4
|
-
|
|
2
|
+
/** Route one decoded JSON-RPC message to its handler. `scopes` are the caller's
|
|
3
|
+
* granted scopes (from `authorize`); they gate scope-restricted tools.
|
|
4
|
+
* Notifications (no `id`) get a bare 202; unknown methods get method-not-found. */
|
|
5
|
+
export declare const dispatchMcp: <Caller>(config: McpServerConfig<Caller>, caller: Caller, scopes: string[], message: unknown) => Promise<Response>;
|
package/dist/src/index.d.ts
CHANGED
|
@@ -1,22 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@absolutejs/mcp` — serve a
|
|
3
|
-
*
|
|
2
|
+
* `@absolutejs/mcp` — serve (and consume) a Model Context Protocol endpoint
|
|
3
|
+
* over streamable HTTP, from a tool/prompt/resource registry.
|
|
4
4
|
*
|
|
5
|
-
* Define the endpoint once
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* **Serve.** Define the endpoint once and mount it: {@link mcpServer} returns an
|
|
6
|
+
* Elysia plugin, or {@link createMcpHandler} returns a framework-agnostic
|
|
7
|
+
* `(request) => Response | null` for Bun.serve / Hono / Next.js / Workers. You
|
|
8
|
+
* supply WHICH tools to expose and HOW to authorize a request into a caller
|
|
9
|
+
* ({@link verifyBearer} does the standard OAuth bearer checks against any
|
|
10
|
+
* authorization server); the package owns the JSON-RPC protocol, protocol
|
|
9
11
|
* negotiation, RFC 9728 discovery metadata, and the 401 challenge. Per-call
|
|
10
|
-
* guards (`beforeCall`
|
|
11
|
-
*
|
|
12
|
-
* billing, storage, or
|
|
12
|
+
* guards (`beforeCall`, `onCall`), a per-call `meta` scratchpad, per-tool
|
|
13
|
+
* `scope` gating, and rich tool results (text/image/structured) are all
|
|
14
|
+
* built in; the package ships no opinion about billing, storage, or access.
|
|
13
15
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
16
|
+
* **Consume.** {@link createMcpClient} is a streamable-HTTP client for calling
|
|
17
|
+
* OTHER MCP servers — the half you need to expose a user's own connected tools
|
|
18
|
+
* to your agent. Safety wrapping around untrusted remote tools (namespacing,
|
|
19
|
+
* injection defense, approval gating) is the host's job.
|
|
20
|
+
*
|
|
21
|
+
* The tool shape is structurally compatible with `@absolutejs/ai`'s `AIToolMap`,
|
|
22
|
+
* so an AI tool registry serves over MCP without conversion — but nothing here
|
|
23
|
+
* depends on a model.
|
|
17
24
|
*/
|
|
18
25
|
export { verifyBearer, type BearerResult, type BearerVerifier, type VerifiedJwt, type VerifyBearerConfig, } from "./auth";
|
|
26
|
+
export { createMcpClient, McpClientError, type McpClient, type McpClientOptions, type McpInitializeResult, type McpRemoteTool, } from "./client";
|
|
19
27
|
export { dispatchMcp } from "./dispatch";
|
|
28
|
+
export { createMcpHandler } from "./handler";
|
|
20
29
|
export { metadataPathFor, protectedResourceMetadata, type ProtectedResourceMetadata, } from "./metadata";
|
|
21
30
|
export { mcpServer } from "./server";
|
|
22
|
-
export type { McpAuthResult, McpCallGate, McpCallMeta, McpPromptArgument, McpPromptDefinition, McpPrompts, McpResource, McpResources, McpServerConfig, McpServerInfo, McpTool, McpToolAnnotations, McpToolContext, McpToolRegistry, } from "./types";
|
|
31
|
+
export type { McpAudioContent, McpAuthResult, McpCallGate, McpCallMeta, McpContent, McpImageContent, McpPromptArgument, McpPromptDefinition, McpPrompts, McpResource, McpResourceLink, McpResources, McpServerConfig, McpServerInfo, McpTextContent, McpTool, McpToolAnnotations, McpToolContext, McpToolRegistry, McpToolResult, McpToolReturn, } from "./types";
|
package/dist/src/types.d.ts
CHANGED
|
@@ -8,13 +8,50 @@ export type McpToolAnnotations = {
|
|
|
8
8
|
readOnlyHint?: boolean;
|
|
9
9
|
title?: string;
|
|
10
10
|
};
|
|
11
|
-
/**
|
|
12
|
-
*
|
|
11
|
+
/** Rich tool-result content blocks. A handler may return a bare string (wrapped
|
|
12
|
+
* as one text block), an array of these, or a full {@link McpToolResult}. */
|
|
13
|
+
export type McpTextContent = {
|
|
14
|
+
text: string;
|
|
15
|
+
type: "text";
|
|
16
|
+
};
|
|
17
|
+
export type McpImageContent = {
|
|
18
|
+
data: string;
|
|
19
|
+
mimeType: string;
|
|
20
|
+
type: "image";
|
|
21
|
+
};
|
|
22
|
+
export type McpAudioContent = {
|
|
23
|
+
data: string;
|
|
24
|
+
mimeType: string;
|
|
25
|
+
type: "audio";
|
|
26
|
+
};
|
|
27
|
+
export type McpResourceLink = {
|
|
28
|
+
description?: string;
|
|
29
|
+
mimeType?: string;
|
|
30
|
+
name?: string;
|
|
31
|
+
type: "resource_link";
|
|
32
|
+
uri: string;
|
|
33
|
+
};
|
|
34
|
+
export type McpContent = McpAudioContent | McpImageContent | McpResourceLink | McpTextContent;
|
|
35
|
+
export type McpToolResult = {
|
|
36
|
+
content: McpContent[];
|
|
37
|
+
isError?: boolean;
|
|
38
|
+
/** Structured output validated against the tool's `outputSchema`, if any. */
|
|
39
|
+
structuredContent?: Record<string, unknown>;
|
|
40
|
+
};
|
|
41
|
+
/** What a tool handler may return. A bare string is the common case. */
|
|
42
|
+
export type McpToolReturn = McpContent[] | McpToolResult | string;
|
|
43
|
+
/** One callable tool. `inputSchema` is a JSON Schema object. */
|
|
13
44
|
export type McpTool = {
|
|
14
45
|
annotations?: McpToolAnnotations;
|
|
15
46
|
description: string;
|
|
16
|
-
handler: (args: unknown) =>
|
|
47
|
+
handler: (args: unknown) => McpToolReturn | Promise<McpToolReturn>;
|
|
17
48
|
inputSchema: Record<string, unknown>;
|
|
49
|
+
/** JSON Schema for `structuredContent`, advertised on `tools/list`. */
|
|
50
|
+
outputSchema?: Record<string, unknown>;
|
|
51
|
+
/** If set, the tool is only listed and callable when the caller's scopes
|
|
52
|
+
* include this. Tools without a scope are always available. Fails closed:
|
|
53
|
+
* a scoped tool is hidden when the caller's scopes are unknown. */
|
|
54
|
+
scope?: string;
|
|
18
55
|
};
|
|
19
56
|
export type McpToolRegistry = Record<string, McpTool>;
|
|
20
57
|
/** A resource the client can list and read (`resources/list` / `resources/read`). */
|
|
@@ -38,10 +75,12 @@ export type McpPromptDefinition = {
|
|
|
38
75
|
* one `tools/call` request. A tool handler can write to it (e.g. record which
|
|
39
76
|
* entity it touched) and `onCall` can read it back for the audit row. */
|
|
40
77
|
export type McpCallMeta = Record<string, unknown>;
|
|
41
|
-
/** What `authorize` returns: the resolved caller
|
|
78
|
+
/** What `authorize` returns: the resolved caller (plus the caller's scopes, if
|
|
79
|
+
* any tools are scope-gated), or a reason for the 401. */
|
|
42
80
|
export type McpAuthResult<Caller> = {
|
|
43
81
|
caller: Caller;
|
|
44
82
|
ok: true;
|
|
83
|
+
scopes?: string[];
|
|
45
84
|
} | {
|
|
46
85
|
ok: false;
|
|
47
86
|
reason: string;
|
|
@@ -96,6 +135,8 @@ export type McpServerConfig<Caller> = {
|
|
|
96
135
|
instructions?: string;
|
|
97
136
|
/** The token issuer — used for discovery metadata and the challenge URL. */
|
|
98
137
|
issuer: string;
|
|
138
|
+
/** Page size for tools/prompts/resources list pagination (default 50). */
|
|
139
|
+
listPageSize?: number;
|
|
99
140
|
/** Fired after every `tools/call` for auditing. `meta` carries anything the
|
|
100
141
|
* tool handler wrote during the call. */
|
|
101
142
|
onCall?: (record: {
|
package/package.json
CHANGED