@absolutejs/mcp 0.0.1 → 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/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,155 @@ 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 listTools = async () => {
154
+ const result = await rpc("tools/list");
155
+ const tools = isRecord(result) && Array.isArray(result.tools) ? result.tools : [];
156
+ return tools.filter(isRecord).map((tool) => ({
157
+ annotations: isRecord(tool.annotations) ? tool.annotations : undefined,
158
+ description: typeof tool.description === "string" ? tool.description : undefined,
159
+ inputSchema: isRecord(tool.inputSchema) ? tool.inputSchema : undefined,
160
+ name: typeof tool.name === "string" ? tool.name : "",
161
+ outputSchema: isRecord(tool.outputSchema) ? tool.outputSchema : undefined
162
+ }));
163
+ };
164
+ const callTool = async (name, args) => {
165
+ const result = await rpc("tools/call", { arguments: args ?? {}, name });
166
+ if (isRecord(result) && Array.isArray(result.content)) {
167
+ return result;
168
+ }
169
+ return { content: [], isError: false };
170
+ };
171
+ const listResources = async () => {
172
+ const result = await rpc("resources/list");
173
+ return isRecord(result) && Array.isArray(result.resources) ? result.resources : [];
174
+ };
175
+ const readResource = async (uri) => rpc("resources/read", { uri });
176
+ const ping = async () => {
177
+ await rpc("ping");
178
+ };
179
+ return { callTool, initialize, listResources, listTools, ping, readResource };
180
+ };
32
181
  // src/jsonrpc.ts
33
182
  var JSONRPC_PARSE_ERROR = -32700;
34
183
  var JSONRPC_INVALID_REQUEST = -32600;
@@ -69,6 +218,15 @@ var negotiateProtocol = (supported, params) => {
69
218
  const requested = isRecord(params) && typeof params.protocolVersion === "string" ? params.protocolVersion : preferred;
70
219
  return supported.includes(requested) ? requested : preferred;
71
220
  };
221
+ var scopeAllows = (tool, scopes) => tool.scope === undefined || scopes.includes(tool.scope);
222
+ var normalizeResult = (value) => {
223
+ if (typeof value === "string") {
224
+ return { content: [{ text: value, type: "text" }], isError: false };
225
+ }
226
+ if (Array.isArray(value))
227
+ return { content: value, isError: false };
228
+ return { isError: false, ...value };
229
+ };
72
230
  var initialize = (config, id, params) => {
73
231
  const supported = config.supportedProtocols ?? DEFAULT_PROTOCOLS;
74
232
  const capabilities = {
@@ -86,19 +244,20 @@ var initialize = (config, id, params) => {
86
244
  serverInfo: config.serverInfo
87
245
  });
88
246
  };
89
- var toolsList = async (config, caller, id) => {
247
+ var toolsList = async (config, caller, scopes, id) => {
90
248
  const tools = await config.tools({ caller, meta: {} });
91
249
  return rpcResult(id, {
92
- tools: Object.entries(tools).map(([name, tool]) => ({
250
+ tools: Object.entries(tools).filter(([, tool]) => scopeAllows(tool, scopes)).map(([name, tool]) => ({
93
251
  annotations: tool.annotations,
94
252
  description: tool.description,
95
253
  inputSchema: tool.inputSchema,
96
- name
254
+ name,
255
+ ...tool.outputSchema === undefined ? {} : { outputSchema: tool.outputSchema }
97
256
  }))
98
257
  });
99
258
  };
100
259
  var errorResult = (id, text) => rpcResult(id, { content: [{ text, type: "text" }], isError: true });
101
- var toolsCall = async (config, caller, id, params) => {
260
+ var toolsCall = async (config, caller, scopes, id, params) => {
102
261
  if (!isRecord(params) || typeof params.name !== "string") {
103
262
  return rpcError(id, JSONRPC_INVALID_PARAMS, "tools/call needs a name");
104
263
  }
@@ -112,17 +271,15 @@ var toolsCall = async (config, caller, id, params) => {
112
271
  }
113
272
  const tools = await config.tools({ caller, meta });
114
273
  const tool = tools[name];
115
- if (!tool)
274
+ if (!tool || !scopeAllows(tool, scopes)) {
116
275
  return rpcError(id, JSONRPC_INVALID_PARAMS, `Unknown tool: ${name}`);
276
+ }
117
277
  let ok = false;
118
278
  let response;
119
279
  try {
120
- const text = await tool.handler(args);
121
- ok = true;
122
- response = rpcResult(id, {
123
- content: [{ text, type: "text" }],
124
- isError: false
125
- });
280
+ const result = normalizeResult(await tool.handler(args));
281
+ ok = result.isError !== true;
282
+ response = rpcResult(id, result);
126
283
  } catch (error) {
127
284
  const detail = error instanceof Error ? error.message : "unknown error";
128
285
  response = errorResult(id, `Tool failed: ${detail}`);
@@ -187,7 +344,7 @@ var resourcesRead = async (config, caller, id, params) => {
187
344
  ]
188
345
  });
189
346
  };
190
- var dispatchMcp = async (config, caller, message) => {
347
+ var dispatchMcp = async (config, caller, scopes, message) => {
191
348
  if (!isRecord(message) || message.jsonrpc !== "2.0") {
192
349
  return rpcError(null, JSONRPC_INVALID_REQUEST, "Not a JSON-RPC 2.0 message");
193
350
  }
@@ -201,9 +358,10 @@ var dispatchMcp = async (config, caller, message) => {
201
358
  if (method === "ping")
202
359
  return rpcResult(id, {});
203
360
  if (method === "tools/list")
204
- return toolsList(config, caller, id);
205
- if (method === "tools/call")
206
- return toolsCall(config, caller, id, params);
361
+ return toolsList(config, caller, scopes, id);
362
+ if (method === "tools/call") {
363
+ return toolsCall(config, caller, scopes, id, params);
364
+ }
207
365
  if (method === "prompts/list")
208
366
  return promptsList(config, id);
209
367
  if (method === "prompts/get")
@@ -222,31 +380,61 @@ var protectedResourceMetadata = (input) => ({
222
380
  scopes_supported: input.scopes ?? []
223
381
  });
224
382
  var metadataPathFor = (path) => `/.well-known/oauth-protected-resource${path}`;
225
- // src/server.ts
226
- import { Elysia } from "elysia";
383
+
384
+ // src/core.ts
227
385
  var ROOT_METADATA_PATH = "/.well-known/oauth-protected-resource";
228
- var mcpServer = (config) => {
386
+ var JSON_HEADERS = {
387
+ "content-type": "application/json"
388
+ };
389
+ var metadataResponse = (config) => new Response(JSON.stringify(protectedResourceMetadata({
390
+ issuer: config.issuer,
391
+ resource: `${config.issuer}${config.path}`,
392
+ scopes: config.scopesSupported
393
+ })), { headers: JSON_HEADERS });
394
+ var runMcpPost = async (config, request, body) => {
395
+ const auth = await config.authorize(request);
396
+ if (!auth.ok) {
397
+ return unauthorized(`${config.issuer}${metadataPathFor(config.path)}`, auth.reason);
398
+ }
399
+ if (body === undefined || body === null) {
400
+ return rpcError(null, JSONRPC_PARSE_ERROR, "Invalid JSON");
401
+ }
402
+ if (Array.isArray(body)) {
403
+ return rpcError(null, JSONRPC_INVALID_REQUEST, "Batching is not supported");
404
+ }
405
+ return dispatchMcp(config, auth.caller, auth.scopes ?? [], body).catch(() => rpcError(null, JSONRPC_INVALID_REQUEST, "Internal error"));
406
+ };
407
+ var handleMcpRequest = async (config, request) => {
408
+ const { pathname } = new URL(request.url);
229
409
  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");
410
+ if (request.method === "GET") {
411
+ if (pathname === metadataPath)
412
+ return metadataResponse(config);
413
+ if (config.serveRootMetadata && pathname === ROOT_METADATA_PATH) {
414
+ return metadataResponse(config);
243
415
  }
244
- if (Array.isArray(message)) {
245
- return rpcError(null, JSONRPC_INVALID_REQUEST, "Batching is not supported");
416
+ if (pathname === config.path) {
417
+ return new Response(null, { status: HTTP_METHOD_NOT_ALLOWED });
246
418
  }
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;
419
+ return null;
420
+ }
421
+ if (request.method === "POST" && pathname === config.path) {
422
+ const body = await request.json().catch(() => {
423
+ return;
424
+ });
425
+ return runMcpPost(config, request, body);
426
+ }
427
+ return null;
428
+ };
429
+
430
+ // src/handler.ts
431
+ var createMcpHandler = (config) => (request) => handleMcpRequest(config, request);
432
+ // src/server.ts
433
+ import { Elysia } from "elysia";
434
+ var mcpServer = (config) => {
435
+ const metadataPath = metadataPathFor(config.path);
436
+ 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));
437
+ const app = config.serveRootMetadata ? base.get(ROOT_METADATA_PATH, () => metadataResponse(config)) : base;
250
438
  return app;
251
439
  };
252
440
  export {
@@ -254,5 +442,8 @@ export {
254
442
  protectedResourceMetadata,
255
443
  metadataPathFor,
256
444
  mcpServer,
257
- dispatchMcp
445
+ dispatchMcp,
446
+ createMcpHandler,
447
+ createMcpClient,
448
+ McpClientError
258
449
  };
@@ -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;
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.1.0"
41
41
  }