@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 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
- .use(mcpServer({ path: "/mcp" /* member */ }))
119
- .use(
120
- mcpServer({
121
- path: "/mcp/admin",
122
- scopesSupported: ["openid", "mcp:admin"] /* stricter */,
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: Object.entries(tools).map(([name, tool]) => ({
93
- annotations: tool.annotations,
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 text = await tool.handler(args);
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: Object.entries(definitions).map(([name, def]) => ({
138
- arguments: def.arguments ?? [],
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
- return rpcResult(id, { resources: await resources.list({ caller }) });
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
- if (method === "tools/call")
206
- return toolsCall(config, caller, id, params);
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
- // src/server.ts
226
- import { Elysia } from "elysia";
436
+
437
+ // src/core.ts
227
438
  var ROOT_METADATA_PATH = "/.well-known/oauth-protected-resource";
228
- var mcpServer = (config) => {
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
- const metadataUrl = `${config.issuer}${metadataPath}`;
231
- const metadata = () => protectedResourceMetadata({
232
- issuer: config.issuer,
233
- resource: `${config.issuer}${config.path}`,
234
- scopes: config.scopesSupported
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 (Array.isArray(message)) {
245
- return rpcError(null, JSONRPC_INVALID_REQUEST, "Batching is not supported");
469
+ if (pathname === config.path) {
470
+ return new Response(null, { status: HTTP_METHOD_NOT_ALLOWED });
246
471
  }
247
- return dispatchMcp(config, auth.caller, message).catch(() => rpcError(null, JSONRPC_INVALID_REQUEST, "Internal error"));
248
- });
249
- const app = config.serveRootMetadata ? base.get(ROOT_METADATA_PATH, metadata) : base;
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>;
@@ -1,4 +1,5 @@
1
1
  import type { McpServerConfig } from "./types";
2
- /** Route one decoded JSON-RPC message to its handler. Notifications (no `id`)
3
- * get a bare 202. Unknown methods get JSON-RPC method-not-found. */
4
- export declare const dispatchMcp: <Caller>(config: McpServerConfig<Caller>, caller: Caller, message: unknown) => Promise<Response>;
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>;
@@ -0,0 +1,2 @@
1
+ import type { McpServerConfig } from "./types";
2
+ export declare const createMcpHandler: <Caller>(config: McpServerConfig<Caller>) => (request: Request) => Promise<Response | null>;
@@ -1,22 +1,31 @@
1
1
  /**
2
- * `@absolutejs/mcp` — serve a remote Model Context Protocol endpoint
3
- * (streamable HTTP, stateless) from a tool/prompt/resource registry.
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 with {@link mcpServer} and mount it on your Elysia
6
- * app. You supply WHICH tools to expose and HOW to authorize a request into a
7
- * caller ({@link verifyBearer} does the standard OAuth bearer checks against
8
- * any authorization server); the package owns the JSON-RPC protocol, protocol
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` to gate on credits/rate limits, `onCall` to audit) and a
11
- * per-call `meta` scratchpad are hooks the package ships no opinions about
12
- * billing, storage, or who is allowed in.
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
- * The tool shape is structurally compatible with `@absolutejs/ai`'s
15
- * `AIToolMap`, so an AI tool registry serves over MCP without conversion but
16
- * nothing here depends on a model: any typed tool registry works.
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";
@@ -8,13 +8,50 @@ export type McpToolAnnotations = {
8
8
  readOnlyHint?: boolean;
9
9
  title?: string;
10
10
  };
11
- /** One callable tool. `inputSchema` is a JSON Schema object; `handler` receives
12
- * the client's `arguments` and returns text (the tool's `content`). */
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) => Promise<string> | string;
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, or a reason for the 401. */
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
@@ -37,5 +37,5 @@
37
37
  "typecheck": "tsc --noEmit --project tsconfig.json"
38
38
  },
39
39
  "types": "./dist/src/index.d.ts",
40
- "version": "0.0.1"
40
+ "version": "0.2.0"
41
41
  }