@absolutejs/mcp 0.1.0 → 0.3.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 +80 -0
- package/dist/index.js +493 -57
- package/dist/src/client.d.ts +7 -1
- package/dist/src/core.d.ts +4 -0
- package/dist/src/dispatch.d.ts +9 -4
- package/dist/src/feedback.d.ts +48 -0
- package/dist/src/index.d.ts +12 -2
- package/dist/src/jsonrpc.d.ts +1 -0
- package/dist/src/server.d.ts +4 -2
- package/dist/src/sessions.d.ts +34 -0
- package/dist/src/types.d.ts +52 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -107,6 +107,86 @@ onCall: ({ meta, name, ok }) =>
|
|
|
107
107
|
ledger.write({ tool: name, ok, member: meta.touched }),
|
|
108
108
|
```
|
|
109
109
|
|
|
110
|
+
## Feedback: the channel a client can't give you
|
|
111
|
+
|
|
112
|
+
A connected AI client renders no UI for your server. There is no button for the
|
|
113
|
+
user to press, so when they say _"that was wrong"_ the only path back to you is
|
|
114
|
+
the model relaying it. Every MCP server has this hole, and every one of them
|
|
115
|
+
hand-rolls the same two tools.
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
import { feedbackTools, FEEDBACK_INSTRUCTIONS } from "@absolutejs/mcp";
|
|
119
|
+
|
|
120
|
+
mcpServer<Caller>({
|
|
121
|
+
instructions: `${myInstructions} ${FEEDBACK_INSTRUCTIONS}`,
|
|
122
|
+
tools: ({ caller }) => ({
|
|
123
|
+
...myTools(caller),
|
|
124
|
+
...feedbackTools({
|
|
125
|
+
caller,
|
|
126
|
+
store: {
|
|
127
|
+
reportProblem: ({ caller, report }) => file(caller, report), // → "Filed as #42."
|
|
128
|
+
submitFeedback: ({ caller, feedback }) => record(caller, feedback),
|
|
129
|
+
},
|
|
130
|
+
}),
|
|
131
|
+
}),
|
|
132
|
+
});
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`FEEDBACK_INSTRUCTIONS` is the load-bearing half. Without it a model treats a
|
|
136
|
+
complaint as something to apologise for, and the signal dies where it was
|
|
137
|
+
spoken.
|
|
138
|
+
|
|
139
|
+
## Elicitation: ask the user a question mid-call
|
|
140
|
+
|
|
141
|
+
A tool that can't finish without something only the user knows can **ask them**
|
|
142
|
+
(`elicitation/create`) and wait for the answer.
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
mcpServer<Caller>({
|
|
146
|
+
elicitation: { enabled: true },
|
|
147
|
+
tools: () => ({
|
|
148
|
+
book_table: {
|
|
149
|
+
description: "Book a table.",
|
|
150
|
+
inputSchema: { type: "object" },
|
|
151
|
+
mayElicit: true, // opt in: this tool may ask
|
|
152
|
+
handler: async (args, { canElicit, elicit }) => {
|
|
153
|
+
if (!canElicit) return "Tell me the party size and I'll book it.";
|
|
154
|
+
const answer = await elicit({
|
|
155
|
+
message: "How many people?",
|
|
156
|
+
requestedSchema: {
|
|
157
|
+
type: "object",
|
|
158
|
+
properties: { people: { type: "integer", minimum: 1 } },
|
|
159
|
+
required: ["people"],
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
if (answer.action !== "accept") return "No problem — cancelled.";
|
|
163
|
+
return `Booked for ${answer.content.people}.`;
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
}),
|
|
167
|
+
});
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`requestedSchema` is a **flat object of primitives** (string / number / integer
|
|
171
|
+
/ boolean / enum) — the spec restricts it so any client can render a form. The
|
|
172
|
+
answer is `accept` (with `content`), `decline` (they said no), `cancel` (they
|
|
173
|
+
dismissed it), or `unsupported` (this client can't ask anyone — check
|
|
174
|
+
`canElicit` and take another path). Never fabricate an answer for the user; the
|
|
175
|
+
spec also forbids eliciting **sensitive information**.
|
|
176
|
+
|
|
177
|
+
**The trade-off, stated plainly.** Elicitation is the one MCP feature a
|
|
178
|
+
stateless server cannot do: the question goes out on the SSE stream of an
|
|
179
|
+
in-flight `tools/call`, and the client answers on a _separate_ HTTP POST. Two
|
|
180
|
+
requests have to meet, so the pending call is remembered in-process and the
|
|
181
|
+
endpoint becomes **session-stateful** (`Mcp-Session-Id`). Run one instance, or
|
|
182
|
+
pin sessions. Leave `elicitation` off — the default — and nothing changes: the
|
|
183
|
+
server stays stateless, `tools/call` keeps answering with a plain JSON body, and
|
|
184
|
+
only tools marked `mayElicit` ever stream.
|
|
185
|
+
|
|
186
|
+
Consuming a server that elicits? Pass `onElicit` to `createMcpClient` — that is
|
|
187
|
+
what declares the capability, and what the package uses to answer. Omit it and
|
|
188
|
+
servers are told you cannot ask anyone.
|
|
189
|
+
|
|
110
190
|
## A second, stricter endpoint
|
|
111
191
|
|
|
112
192
|
`mcpServer` is per-endpoint, so an admin console is the same call with a
|
package/dist/index.js
CHANGED
|
@@ -65,6 +65,50 @@ var parseBody = async (response, maxBytes) => {
|
|
|
65
65
|
}
|
|
66
66
|
throw new McpClientError("No JSON-RPC response in the event stream");
|
|
67
67
|
};
|
|
68
|
+
var consumeSseStream = async (response, maxBytes, onRequest) => {
|
|
69
|
+
const reader = response.body?.getReader();
|
|
70
|
+
if (!reader)
|
|
71
|
+
throw new McpClientError("The event stream had no body");
|
|
72
|
+
const decoder = new TextDecoder;
|
|
73
|
+
let buffer = "";
|
|
74
|
+
let seen = 0;
|
|
75
|
+
for (;; ) {
|
|
76
|
+
const { done, value } = await reader.read();
|
|
77
|
+
if (done)
|
|
78
|
+
break;
|
|
79
|
+
seen += value?.length ?? 0;
|
|
80
|
+
if (maxBytes > 0 && seen > maxBytes) {
|
|
81
|
+
await reader.cancel().catch(() => {
|
|
82
|
+
return;
|
|
83
|
+
});
|
|
84
|
+
throw new McpClientError("Response exceeded the size cap");
|
|
85
|
+
}
|
|
86
|
+
buffer += decoder.decode(value, { stream: true });
|
|
87
|
+
const frames = buffer.split(`
|
|
88
|
+
|
|
89
|
+
`);
|
|
90
|
+
buffer = frames.pop() ?? "";
|
|
91
|
+
for (const frame of frames) {
|
|
92
|
+
const line = frame.split(/\r?\n/).find((entry) => entry.startsWith("data:"));
|
|
93
|
+
if (!line)
|
|
94
|
+
continue;
|
|
95
|
+
const parsed = JSON.parse(line.slice("data:".length).trim());
|
|
96
|
+
if (!isRecord(parsed))
|
|
97
|
+
continue;
|
|
98
|
+
if (typeof parsed.method === "string") {
|
|
99
|
+
await onRequest(parsed);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if ("result" in parsed || "error" in parsed) {
|
|
103
|
+
await reader.cancel().catch(() => {
|
|
104
|
+
return;
|
|
105
|
+
});
|
|
106
|
+
return parsed;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
throw new McpClientError("No JSON-RPC response in the event stream");
|
|
111
|
+
};
|
|
68
112
|
var createMcpClient = (options) => {
|
|
69
113
|
const doFetch = options.request ?? fetch;
|
|
70
114
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
@@ -72,6 +116,32 @@ var createMcpClient = (options) => {
|
|
|
72
116
|
let protocolVersion = options.protocolVersion ?? DEFAULT_PROTOCOL;
|
|
73
117
|
let sessionId = null;
|
|
74
118
|
let nextId = 1;
|
|
119
|
+
const respond = async (id, result) => {
|
|
120
|
+
const headers = {
|
|
121
|
+
"content-type": "application/json",
|
|
122
|
+
"mcp-protocol-version": protocolVersion,
|
|
123
|
+
...options.headers
|
|
124
|
+
};
|
|
125
|
+
if (sessionId !== null)
|
|
126
|
+
headers["mcp-session-id"] = sessionId;
|
|
127
|
+
await doFetch(options.url, {
|
|
128
|
+
body: JSON.stringify({ id, jsonrpc: "2.0", result }),
|
|
129
|
+
headers,
|
|
130
|
+
method: "POST"
|
|
131
|
+
}).catch(() => {
|
|
132
|
+
return;
|
|
133
|
+
});
|
|
134
|
+
};
|
|
135
|
+
const answerServer = async (message) => {
|
|
136
|
+
if (message.method !== "elicitation/create")
|
|
137
|
+
return;
|
|
138
|
+
const request = isRecord(message.params) ? {
|
|
139
|
+
message: typeof message.params.message === "string" ? message.params.message : "",
|
|
140
|
+
requestedSchema: isRecord(message.params.requestedSchema) ? message.params.requestedSchema : {}
|
|
141
|
+
} : { message: "", requestedSchema: {} };
|
|
142
|
+
const result = options.onElicit ? await options.onElicit(request) : { action: "decline" };
|
|
143
|
+
await respond(message.id, result);
|
|
144
|
+
};
|
|
75
145
|
const rpc = async (method, params) => {
|
|
76
146
|
const controller = new AbortController;
|
|
77
147
|
const timer = setTimeout(() => {
|
|
@@ -105,7 +175,8 @@ var createMcpClient = (options) => {
|
|
|
105
175
|
status: 401
|
|
106
176
|
});
|
|
107
177
|
}
|
|
108
|
-
const
|
|
178
|
+
const isStream = (response.headers.get("content-type") ?? "").includes("text/event-stream");
|
|
179
|
+
const payload = isStream ? await consumeSseStream(response, maxBytes, answerServer) : await parseBody(response, maxBytes);
|
|
109
180
|
if (!isRecord(payload)) {
|
|
110
181
|
throw new McpClientError("Malformed JSON-RPC response");
|
|
111
182
|
}
|
|
@@ -137,7 +208,7 @@ var createMcpClient = (options) => {
|
|
|
137
208
|
};
|
|
138
209
|
const initialize = async () => {
|
|
139
210
|
const result = await rpc("initialize", {
|
|
140
|
-
capabilities: {},
|
|
211
|
+
capabilities: options.onElicit ? { elicitation: {} } : {},
|
|
141
212
|
clientInfo: options.clientInfo ?? {
|
|
142
213
|
name: "@absolutejs/mcp",
|
|
143
214
|
version: "0"
|
|
@@ -150,16 +221,26 @@ var createMcpClient = (options) => {
|
|
|
150
221
|
await notify("notifications/initialized");
|
|
151
222
|
return isRecord(result) ? result : {};
|
|
152
223
|
};
|
|
224
|
+
const MAX_LIST_PAGES = 40;
|
|
153
225
|
const listTools = async () => {
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
226
|
+
const collected = [];
|
|
227
|
+
let cursor;
|
|
228
|
+
for (let page = 0;page < MAX_LIST_PAGES; page += 1) {
|
|
229
|
+
const result = await rpc("tools/list", cursor === undefined ? undefined : { cursor });
|
|
230
|
+
const tools = isRecord(result) && Array.isArray(result.tools) ? result.tools : [];
|
|
231
|
+
collected.push(...tools.filter(isRecord).map((tool) => ({
|
|
232
|
+
annotations: isRecord(tool.annotations) ? tool.annotations : undefined,
|
|
233
|
+
description: typeof tool.description === "string" ? tool.description : undefined,
|
|
234
|
+
inputSchema: isRecord(tool.inputSchema) ? tool.inputSchema : undefined,
|
|
235
|
+
name: typeof tool.name === "string" ? tool.name : "",
|
|
236
|
+
outputSchema: isRecord(tool.outputSchema) ? tool.outputSchema : undefined
|
|
237
|
+
})));
|
|
238
|
+
const next = isRecord(result) && typeof result.nextCursor === "string" ? result.nextCursor : undefined;
|
|
239
|
+
if (next === undefined)
|
|
240
|
+
break;
|
|
241
|
+
cursor = next;
|
|
242
|
+
}
|
|
243
|
+
return collected;
|
|
163
244
|
};
|
|
164
245
|
const callTool = async (name, args) => {
|
|
165
246
|
const result = await rpc("tools/call", { arguments: args ?? {}, name });
|
|
@@ -169,8 +250,19 @@ var createMcpClient = (options) => {
|
|
|
169
250
|
return { content: [], isError: false };
|
|
170
251
|
};
|
|
171
252
|
const listResources = async () => {
|
|
172
|
-
const
|
|
173
|
-
|
|
253
|
+
const collected = [];
|
|
254
|
+
let cursor;
|
|
255
|
+
for (let page = 0;page < MAX_LIST_PAGES; page += 1) {
|
|
256
|
+
const result = await rpc("resources/list", cursor === undefined ? undefined : { cursor });
|
|
257
|
+
if (isRecord(result) && Array.isArray(result.resources)) {
|
|
258
|
+
collected.push(...result.resources);
|
|
259
|
+
}
|
|
260
|
+
const next = isRecord(result) && typeof result.nextCursor === "string" ? result.nextCursor : undefined;
|
|
261
|
+
if (next === undefined)
|
|
262
|
+
break;
|
|
263
|
+
cursor = next;
|
|
264
|
+
}
|
|
265
|
+
return collected;
|
|
174
266
|
};
|
|
175
267
|
const readResource = async (uri) => rpc("resources/read", { uri });
|
|
176
268
|
const ping = async () => {
|
|
@@ -185,6 +277,7 @@ var JSONRPC_METHOD_NOT_FOUND = -32601;
|
|
|
185
277
|
var JSONRPC_INVALID_PARAMS = -32602;
|
|
186
278
|
var JSONRPC_INTERNAL_ERROR = -32603;
|
|
187
279
|
var HTTP_ACCEPTED = 202;
|
|
280
|
+
var HTTP_NO_CONTENT = 204;
|
|
188
281
|
var HTTP_UNAUTHORIZED = 401;
|
|
189
282
|
var HTTP_METHOD_NOT_ALLOWED = 405;
|
|
190
283
|
var jsonHeaders = {
|
|
@@ -212,6 +305,26 @@ var unauthorized = (metadataUrl, detail) => new Response(JSON.stringify({
|
|
|
212
305
|
// src/dispatch.ts
|
|
213
306
|
var DEFAULT_PROTOCOLS = ["2025-06-18", "2025-03-26", "2024-11-05"];
|
|
214
307
|
var DEFAULT_RESOURCE_MIME = "text/markdown";
|
|
308
|
+
var DEFAULT_LIST_PAGE_SIZE = 50;
|
|
309
|
+
var decodeCursor = (params) => {
|
|
310
|
+
if (!isRecord(params) || typeof params.cursor !== "string")
|
|
311
|
+
return 0;
|
|
312
|
+
try {
|
|
313
|
+
const parsed = Number.parseInt(atob(params.cursor), 10);
|
|
314
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
|
315
|
+
} catch {
|
|
316
|
+
return 0;
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
var encodeCursor = (offset) => btoa(String(offset));
|
|
320
|
+
var paginate = (items, offset, pageSize) => {
|
|
321
|
+
const page = items.slice(offset, offset + pageSize);
|
|
322
|
+
const nextOffset = offset + pageSize;
|
|
323
|
+
return {
|
|
324
|
+
items: page,
|
|
325
|
+
...nextOffset < items.length ? { nextCursor: encodeCursor(nextOffset) } : {}
|
|
326
|
+
};
|
|
327
|
+
};
|
|
215
328
|
var idOf = (message) => typeof message.id === "string" || typeof message.id === "number" ? message.id : null;
|
|
216
329
|
var negotiateProtocol = (supported, params) => {
|
|
217
330
|
const preferred = supported[0] ?? DEFAULT_PROTOCOLS[0] ?? "";
|
|
@@ -227,7 +340,12 @@ var normalizeResult = (value) => {
|
|
|
227
340
|
return { content: value, isError: false };
|
|
228
341
|
return { isError: false, ...value };
|
|
229
342
|
};
|
|
230
|
-
var
|
|
343
|
+
var clientCanElicit = (params) => {
|
|
344
|
+
if (!isRecord(params) || !isRecord(params.capabilities))
|
|
345
|
+
return false;
|
|
346
|
+
return isRecord(params.capabilities.elicitation);
|
|
347
|
+
};
|
|
348
|
+
var initialize = (config, id, params, context) => {
|
|
231
349
|
const supported = config.supportedProtocols ?? DEFAULT_PROTOCOLS;
|
|
232
350
|
const capabilities = {
|
|
233
351
|
tools: { listChanged: false }
|
|
@@ -237,27 +355,114 @@ var initialize = (config, id, params) => {
|
|
|
237
355
|
if (config.resources) {
|
|
238
356
|
capabilities.resources = { listChanged: false, subscribe: false };
|
|
239
357
|
}
|
|
240
|
-
|
|
358
|
+
const response = rpcResult(id, {
|
|
241
359
|
capabilities,
|
|
242
360
|
...config.instructions === undefined ? {} : { instructions: config.instructions },
|
|
243
361
|
protocolVersion: negotiateProtocol(supported, params),
|
|
244
362
|
serverInfo: config.serverInfo
|
|
245
363
|
});
|
|
364
|
+
if (!config.elicitation?.enabled || !context.sessions)
|
|
365
|
+
return response;
|
|
366
|
+
const sessionId = context.sessions.create(clientCanElicit(params));
|
|
367
|
+
response.headers.set("Mcp-Session-Id", sessionId);
|
|
368
|
+
return response;
|
|
246
369
|
};
|
|
247
|
-
var toolsList = async (config, caller, scopes, id) => {
|
|
370
|
+
var toolsList = async (config, caller, scopes, id, params) => {
|
|
248
371
|
const tools = await config.tools({ caller, meta: {} });
|
|
372
|
+
const visible = Object.entries(tools).filter(([, tool]) => scopeAllows(tool, scopes)).map(([name, tool]) => ({
|
|
373
|
+
annotations: tool.annotations,
|
|
374
|
+
description: tool.description,
|
|
375
|
+
inputSchema: tool.inputSchema,
|
|
376
|
+
name,
|
|
377
|
+
...tool.outputSchema === undefined ? {} : { outputSchema: tool.outputSchema }
|
|
378
|
+
}));
|
|
379
|
+
const { items, nextCursor } = paginate(visible, decodeCursor(params), config.listPageSize ?? DEFAULT_LIST_PAGE_SIZE);
|
|
249
380
|
return rpcResult(id, {
|
|
250
|
-
tools:
|
|
251
|
-
|
|
252
|
-
description: tool.description,
|
|
253
|
-
inputSchema: tool.inputSchema,
|
|
254
|
-
name,
|
|
255
|
-
...tool.outputSchema === undefined ? {} : { outputSchema: tool.outputSchema }
|
|
256
|
-
}))
|
|
381
|
+
tools: items,
|
|
382
|
+
...nextCursor === undefined ? {} : { nextCursor }
|
|
257
383
|
});
|
|
258
384
|
};
|
|
259
385
|
var errorResult = (id, text) => rpcResult(id, { content: [{ text, type: "text" }], isError: true });
|
|
260
|
-
var
|
|
386
|
+
var noElicit = {
|
|
387
|
+
canElicit: false,
|
|
388
|
+
elicit: () => Promise.resolve({ action: "unsupported" })
|
|
389
|
+
};
|
|
390
|
+
var SSE_HEADERS = {
|
|
391
|
+
"cache-control": "no-cache",
|
|
392
|
+
connection: "keep-alive",
|
|
393
|
+
"content-type": "text/event-stream",
|
|
394
|
+
"x-accel-buffering": "no"
|
|
395
|
+
};
|
|
396
|
+
var sseFrame = (message) => `data: ${JSON.stringify(message)}
|
|
397
|
+
|
|
398
|
+
`;
|
|
399
|
+
var runTool = async (config, caller, id, name, args, meta, tool, context) => {
|
|
400
|
+
let ok = false;
|
|
401
|
+
let payload;
|
|
402
|
+
try {
|
|
403
|
+
const result = normalizeResult(await tool.handler(args, context));
|
|
404
|
+
ok = result.isError !== true;
|
|
405
|
+
payload = { id, jsonrpc: "2.0", result };
|
|
406
|
+
} catch (error) {
|
|
407
|
+
const detail = error instanceof Error ? error.message : "unknown error";
|
|
408
|
+
payload = {
|
|
409
|
+
id,
|
|
410
|
+
jsonrpc: "2.0",
|
|
411
|
+
result: {
|
|
412
|
+
content: [{ text: `Tool failed: ${detail}`, type: "text" }],
|
|
413
|
+
isError: true
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
if (config.onCall)
|
|
418
|
+
await config.onCall({ args, caller, meta, name, ok });
|
|
419
|
+
return payload;
|
|
420
|
+
};
|
|
421
|
+
var toolsCallStreaming = (config, caller, id, name, args, meta, tool, sessions, sessionId, canElicit) => {
|
|
422
|
+
const encoder = new TextEncoder;
|
|
423
|
+
const body = new ReadableStream({
|
|
424
|
+
async start(controller) {
|
|
425
|
+
let open = true;
|
|
426
|
+
const send = (message) => {
|
|
427
|
+
if (!open)
|
|
428
|
+
return;
|
|
429
|
+
try {
|
|
430
|
+
controller.enqueue(encoder.encode(sseFrame(message)));
|
|
431
|
+
} catch {
|
|
432
|
+
open = false;
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
const context = {
|
|
436
|
+
canElicit,
|
|
437
|
+
elicit: async (request) => {
|
|
438
|
+
if (!canElicit)
|
|
439
|
+
return { action: "unsupported" };
|
|
440
|
+
const pending = sessions.startElicit(sessionId, request);
|
|
441
|
+
if (!pending.id)
|
|
442
|
+
return { action: "cancel" };
|
|
443
|
+
send({
|
|
444
|
+
id: pending.id,
|
|
445
|
+
jsonrpc: "2.0",
|
|
446
|
+
method: "elicitation/create",
|
|
447
|
+
params: request
|
|
448
|
+
});
|
|
449
|
+
return await pending.answer;
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
const payload = await runTool(config, caller, id, name, args, meta, tool, context);
|
|
453
|
+
send(payload);
|
|
454
|
+
if (open) {
|
|
455
|
+
try {
|
|
456
|
+
controller.close();
|
|
457
|
+
} catch {
|
|
458
|
+
open = false;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
return new Response(body, { headers: SSE_HEADERS });
|
|
464
|
+
};
|
|
465
|
+
var toolsCall = async (config, caller, scopes, id, params, context) => {
|
|
261
466
|
if (!isRecord(params) || typeof params.name !== "string") {
|
|
262
467
|
return rpcError(id, JSONRPC_INVALID_PARAMS, "tools/call needs a name");
|
|
263
468
|
}
|
|
@@ -274,29 +479,28 @@ var toolsCall = async (config, caller, scopes, id, params) => {
|
|
|
274
479
|
if (!tool || !scopeAllows(tool, scopes)) {
|
|
275
480
|
return rpcError(id, JSONRPC_INVALID_PARAMS, `Unknown tool: ${name}`);
|
|
276
481
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
ok = result.isError !== true;
|
|
282
|
-
response = rpcResult(id, result);
|
|
283
|
-
} catch (error) {
|
|
284
|
-
const detail = error instanceof Error ? error.message : "unknown error";
|
|
285
|
-
response = errorResult(id, `Tool failed: ${detail}`);
|
|
482
|
+
const session = context.sessions?.get(context.sessionId ?? null);
|
|
483
|
+
const streaming = tool.mayElicit === true && config.elicitation?.enabled === true && context.sessions !== undefined && session !== null && session !== undefined && typeof context.sessionId === "string";
|
|
484
|
+
if (streaming && context.sessions && typeof context.sessionId === "string") {
|
|
485
|
+
return toolsCallStreaming(config, caller, id, name, args, meta, tool, context.sessions, context.sessionId, session?.canElicit === true);
|
|
286
486
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
487
|
+
const payload = await runTool(config, caller, id, name, args, meta, tool, noElicit);
|
|
488
|
+
return new Response(JSON.stringify(payload), {
|
|
489
|
+
headers: { "content-type": "application/json" }
|
|
490
|
+
});
|
|
290
491
|
};
|
|
291
|
-
var promptsList = (config, id) => {
|
|
492
|
+
var promptsList = (config, id, params) => {
|
|
292
493
|
const definitions = config.prompts?.definitions ?? {};
|
|
494
|
+
const all = Object.entries(definitions).map(([name, def]) => ({
|
|
495
|
+
arguments: def.arguments ?? [],
|
|
496
|
+
description: def.description,
|
|
497
|
+
name,
|
|
498
|
+
title: def.title
|
|
499
|
+
}));
|
|
500
|
+
const { items, nextCursor } = paginate(all, decodeCursor(params), config.listPageSize ?? DEFAULT_LIST_PAGE_SIZE);
|
|
293
501
|
return rpcResult(id, {
|
|
294
|
-
prompts:
|
|
295
|
-
|
|
296
|
-
description: def.description,
|
|
297
|
-
name,
|
|
298
|
-
title: def.title
|
|
299
|
-
}))
|
|
502
|
+
prompts: items,
|
|
503
|
+
...nextCursor === undefined ? {} : { nextCursor }
|
|
300
504
|
});
|
|
301
505
|
};
|
|
302
506
|
var promptsGet = async (config, caller, id, params) => {
|
|
@@ -320,11 +524,15 @@ var promptsGet = async (config, caller, id, params) => {
|
|
|
320
524
|
messages: [{ content: { text, type: "text" }, role: "user" }]
|
|
321
525
|
});
|
|
322
526
|
};
|
|
323
|
-
var resourcesList = async (config, caller, id) => {
|
|
527
|
+
var resourcesList = async (config, caller, id, params) => {
|
|
324
528
|
const resources = config.resources;
|
|
325
529
|
if (!resources)
|
|
326
530
|
return rpcResult(id, { resources: [] });
|
|
327
|
-
|
|
531
|
+
const { items, nextCursor } = paginate(await resources.list({ caller }), decodeCursor(params), config.listPageSize ?? DEFAULT_LIST_PAGE_SIZE);
|
|
532
|
+
return rpcResult(id, {
|
|
533
|
+
resources: items,
|
|
534
|
+
...nextCursor === undefined ? {} : { nextCursor }
|
|
535
|
+
});
|
|
328
536
|
};
|
|
329
537
|
var resourcesRead = async (config, caller, id, params) => {
|
|
330
538
|
const resources = config.resources;
|
|
@@ -344,35 +552,226 @@ var resourcesRead = async (config, caller, id, params) => {
|
|
|
344
552
|
]
|
|
345
553
|
});
|
|
346
554
|
};
|
|
347
|
-
var
|
|
555
|
+
var elicitAnswer = (message, context) => {
|
|
556
|
+
const requestId = typeof message.id === "string" ? message.id : null;
|
|
557
|
+
if (!requestId || !context.sessions)
|
|
558
|
+
return notificationAck();
|
|
559
|
+
const result = isRecord(message.result) ? message.result : null;
|
|
560
|
+
const action = result?.action;
|
|
561
|
+
const answer = action === "accept" && isRecord(result?.content) ? { action: "accept", content: result.content } : action === "decline" ? { action: "decline" } : { action: "cancel" };
|
|
562
|
+
context.sessions.resolveElicit(context.sessionId ?? null, requestId, answer);
|
|
563
|
+
return notificationAck();
|
|
564
|
+
};
|
|
565
|
+
var dispatchMcp = async (config, caller, scopes, message, context = {}) => {
|
|
348
566
|
if (!isRecord(message) || message.jsonrpc !== "2.0") {
|
|
349
567
|
return rpcError(null, JSONRPC_INVALID_REQUEST, "Not a JSON-RPC 2.0 message");
|
|
350
568
|
}
|
|
351
569
|
if (!("id" in message))
|
|
352
570
|
return notificationAck();
|
|
571
|
+
if (!("method" in message))
|
|
572
|
+
return elicitAnswer(message, context);
|
|
353
573
|
const id = idOf(message);
|
|
354
574
|
const method = typeof message.method === "string" ? message.method : "";
|
|
355
575
|
const { params } = message;
|
|
356
|
-
if (method === "initialize")
|
|
357
|
-
return initialize(config, id, params);
|
|
576
|
+
if (method === "initialize") {
|
|
577
|
+
return initialize(config, id, params, context);
|
|
578
|
+
}
|
|
358
579
|
if (method === "ping")
|
|
359
580
|
return rpcResult(id, {});
|
|
360
|
-
if (method === "tools/list")
|
|
361
|
-
return toolsList(config, caller, scopes, id);
|
|
581
|
+
if (method === "tools/list") {
|
|
582
|
+
return toolsList(config, caller, scopes, id, params);
|
|
583
|
+
}
|
|
362
584
|
if (method === "tools/call") {
|
|
363
|
-
return toolsCall(config, caller, scopes, id, params);
|
|
585
|
+
return toolsCall(config, caller, scopes, id, params, context);
|
|
364
586
|
}
|
|
365
587
|
if (method === "prompts/list")
|
|
366
|
-
return promptsList(config, id);
|
|
588
|
+
return promptsList(config, id, params);
|
|
367
589
|
if (method === "prompts/get")
|
|
368
590
|
return promptsGet(config, caller, id, params);
|
|
369
|
-
if (method === "resources/list")
|
|
370
|
-
return resourcesList(config, caller, id);
|
|
591
|
+
if (method === "resources/list") {
|
|
592
|
+
return resourcesList(config, caller, id, params);
|
|
593
|
+
}
|
|
371
594
|
if (method === "resources/read") {
|
|
372
595
|
return resourcesRead(config, caller, id, params);
|
|
373
596
|
}
|
|
374
597
|
return rpcError(id, JSONRPC_METHOD_NOT_FOUND, `Unknown method: ${method}`);
|
|
375
598
|
};
|
|
599
|
+
// src/feedback.ts
|
|
600
|
+
var FEEDBACK_INSTRUCTIONS = "FEEDBACK: you are the user's only channel back to this server's team \u2014 there are no buttons in this client. If the user says something was wrong, unhelpful, or not what they asked for, call submit_feedback with their reason in their own words; if something is outright broken, call report_problem. Do this INSTEAD of only apologising, and tell them you've passed it on. Log the good as well as the bad.";
|
|
601
|
+
var asText = (input, key) => {
|
|
602
|
+
if (typeof input !== "object" || input === null)
|
|
603
|
+
return;
|
|
604
|
+
const value = Reflect.get(input, key);
|
|
605
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
606
|
+
};
|
|
607
|
+
var isRating = (value) => value === "good" || value === "bad";
|
|
608
|
+
var feedbackTools = (config) => ({
|
|
609
|
+
report_problem: {
|
|
610
|
+
annotations: {
|
|
611
|
+
destructiveHint: false,
|
|
612
|
+
openWorldHint: false,
|
|
613
|
+
readOnlyHint: false,
|
|
614
|
+
title: "Report a problem"
|
|
615
|
+
},
|
|
616
|
+
description: "Report a bug or broken behaviour on the user's behalf. Use when the user says something is broken, wrong, or not working \u2014 confirm the details with them first, then file it. Reports as THIS user's account.",
|
|
617
|
+
handler: async (args) => {
|
|
618
|
+
const problem = asText(args, "problem");
|
|
619
|
+
if (!problem)
|
|
620
|
+
return "Tell me what's broken and I'll report it.";
|
|
621
|
+
const reply = await config.store.reportProblem({
|
|
622
|
+
caller: config.caller,
|
|
623
|
+
report: {
|
|
624
|
+
expected: asText(args, "expected"),
|
|
625
|
+
problem,
|
|
626
|
+
steps: asText(args, "steps"),
|
|
627
|
+
where: asText(args, "where")
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
return reply ?? "Reported. Tell the user it's been filed with the team.";
|
|
631
|
+
},
|
|
632
|
+
inputSchema: {
|
|
633
|
+
properties: {
|
|
634
|
+
expected: {
|
|
635
|
+
description: "What the user expected to happen",
|
|
636
|
+
type: "string"
|
|
637
|
+
},
|
|
638
|
+
problem: {
|
|
639
|
+
description: "What is broken, in the user's words \u2014 one sentence",
|
|
640
|
+
type: "string"
|
|
641
|
+
},
|
|
642
|
+
steps: { description: "How to reproduce it", type: "string" },
|
|
643
|
+
where: {
|
|
644
|
+
description: "Where it happened (page, feature, or tool)",
|
|
645
|
+
type: "string"
|
|
646
|
+
}
|
|
647
|
+
},
|
|
648
|
+
required: ["problem"],
|
|
649
|
+
type: "object"
|
|
650
|
+
}
|
|
651
|
+
},
|
|
652
|
+
submit_feedback: {
|
|
653
|
+
annotations: {
|
|
654
|
+
destructiveHint: false,
|
|
655
|
+
openWorldHint: false,
|
|
656
|
+
readOnlyHint: false,
|
|
657
|
+
title: "Pass on feedback"
|
|
658
|
+
},
|
|
659
|
+
description: "Record the user's verdict on how this is going \u2014 call whenever they say a result was wrong, unhelpful, or missed what they asked for (rating 'bad'), or that something worked well (rating 'good'). Pass their reason in their OWN words. This is the only way feedback from this client reaches the team, so use it rather than only apologising.",
|
|
660
|
+
handler: async (args) => {
|
|
661
|
+
const rating = Reflect.get(args ?? {}, "rating");
|
|
662
|
+
const reason = asText(args, "reason");
|
|
663
|
+
if (!isRating(rating) || !reason) {
|
|
664
|
+
return "Provide the rating ('good' or 'bad') and the user's reason.";
|
|
665
|
+
}
|
|
666
|
+
const reply = await config.store.submitFeedback({
|
|
667
|
+
caller: config.caller,
|
|
668
|
+
feedback: { rating, reason, tool: asText(args, "tool") }
|
|
669
|
+
});
|
|
670
|
+
return reply ?? (rating === "bad" ? "Passed on to the team, with the user's reason attached." : "Logged as positive feedback.");
|
|
671
|
+
},
|
|
672
|
+
inputSchema: {
|
|
673
|
+
properties: {
|
|
674
|
+
rating: {
|
|
675
|
+
description: "'good' or 'bad'",
|
|
676
|
+
enum: ["good", "bad"],
|
|
677
|
+
type: "string"
|
|
678
|
+
},
|
|
679
|
+
reason: {
|
|
680
|
+
description: "What was wrong (or right), in the user's own words",
|
|
681
|
+
type: "string"
|
|
682
|
+
},
|
|
683
|
+
tool: {
|
|
684
|
+
description: "The tool this is about, if it was about one",
|
|
685
|
+
type: "string"
|
|
686
|
+
}
|
|
687
|
+
},
|
|
688
|
+
required: ["rating", "reason"],
|
|
689
|
+
type: "object"
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
});
|
|
693
|
+
// src/sessions.ts
|
|
694
|
+
var DEFAULT_SESSION_TTL_MS = 3600000;
|
|
695
|
+
var DEFAULT_ELICIT_TIMEOUT_MS = 120000;
|
|
696
|
+
var SWEEP_EVERY = 50;
|
|
697
|
+
var createSessionRegistry = (options) => {
|
|
698
|
+
const ttlMs = options?.ttlMs ?? DEFAULT_SESSION_TTL_MS;
|
|
699
|
+
const elicitTimeoutMs = options?.elicitTimeoutMs ?? DEFAULT_ELICIT_TIMEOUT_MS;
|
|
700
|
+
const sessions = new Map;
|
|
701
|
+
let sinceSweep = 0;
|
|
702
|
+
const sweep = () => {
|
|
703
|
+
sinceSweep += 1;
|
|
704
|
+
if (sinceSweep < SWEEP_EVERY)
|
|
705
|
+
return;
|
|
706
|
+
sinceSweep = 0;
|
|
707
|
+
const cutoff = Date.now() - ttlMs;
|
|
708
|
+
sessions.forEach((session, id) => {
|
|
709
|
+
if (session.lastSeen >= cutoff)
|
|
710
|
+
return;
|
|
711
|
+
session.pending.forEach((pending) => {
|
|
712
|
+
clearTimeout(pending.timer);
|
|
713
|
+
pending.resolve({ action: "cancel" });
|
|
714
|
+
});
|
|
715
|
+
sessions.delete(id);
|
|
716
|
+
});
|
|
717
|
+
};
|
|
718
|
+
const touch = (id) => {
|
|
719
|
+
if (!id)
|
|
720
|
+
return null;
|
|
721
|
+
const session = sessions.get(id);
|
|
722
|
+
if (!session)
|
|
723
|
+
return null;
|
|
724
|
+
session.lastSeen = Date.now();
|
|
725
|
+
return session;
|
|
726
|
+
};
|
|
727
|
+
return {
|
|
728
|
+
create: (canElicit) => {
|
|
729
|
+
sweep();
|
|
730
|
+
const id = crypto.randomUUID();
|
|
731
|
+
sessions.set(id, { canElicit, lastSeen: Date.now(), pending: new Map });
|
|
732
|
+
return id;
|
|
733
|
+
},
|
|
734
|
+
drop: (id) => {
|
|
735
|
+
const session = sessions.get(id);
|
|
736
|
+
session?.pending.forEach((pending) => {
|
|
737
|
+
clearTimeout(pending.timer);
|
|
738
|
+
pending.resolve({ action: "cancel" });
|
|
739
|
+
});
|
|
740
|
+
sessions.delete(id);
|
|
741
|
+
},
|
|
742
|
+
get: (id) => touch(id),
|
|
743
|
+
resolveElicit: (sessionId, requestId, result) => {
|
|
744
|
+
const session = touch(sessionId);
|
|
745
|
+
const pending = session?.pending.get(requestId);
|
|
746
|
+
if (!session || !pending)
|
|
747
|
+
return false;
|
|
748
|
+
clearTimeout(pending.timer);
|
|
749
|
+
session.pending.delete(requestId);
|
|
750
|
+
pending.resolve(result);
|
|
751
|
+
return true;
|
|
752
|
+
},
|
|
753
|
+
startElicit: (sessionId, request) => {
|
|
754
|
+
const session = sessions.get(sessionId);
|
|
755
|
+
if (!session) {
|
|
756
|
+
return {
|
|
757
|
+
answer: Promise.resolve({ action: "cancel" }),
|
|
758
|
+
id: "",
|
|
759
|
+
request
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
const id = `elicit_${crypto.randomUUID()}`;
|
|
763
|
+
const answer = new Promise((resolve) => {
|
|
764
|
+
const timer = setTimeout(() => {
|
|
765
|
+
session.pending.delete(id);
|
|
766
|
+
resolve({ action: "cancel" });
|
|
767
|
+
}, elicitTimeoutMs);
|
|
768
|
+
session.pending.set(id, { resolve, timer });
|
|
769
|
+
});
|
|
770
|
+
return { answer, id, request };
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
};
|
|
774
|
+
|
|
376
775
|
// src/metadata.ts
|
|
377
776
|
var protectedResourceMetadata = (input) => ({
|
|
378
777
|
authorization_servers: [input.issuer],
|
|
@@ -386,6 +785,20 @@ var ROOT_METADATA_PATH = "/.well-known/oauth-protected-resource";
|
|
|
386
785
|
var JSON_HEADERS = {
|
|
387
786
|
"content-type": "application/json"
|
|
388
787
|
};
|
|
788
|
+
var HTTP_NOT_FOUND = 404;
|
|
789
|
+
var registries = new WeakMap;
|
|
790
|
+
var registryFor = (config) => {
|
|
791
|
+
if (!config.elicitation?.enabled)
|
|
792
|
+
return;
|
|
793
|
+
const existing = registries.get(config);
|
|
794
|
+
if (existing)
|
|
795
|
+
return existing;
|
|
796
|
+
const created = createSessionRegistry({
|
|
797
|
+
elicitTimeoutMs: config.elicitation.timeoutMs
|
|
798
|
+
});
|
|
799
|
+
registries.set(config, created);
|
|
800
|
+
return created;
|
|
801
|
+
};
|
|
389
802
|
var metadataResponse = (config) => new Response(JSON.stringify(protectedResourceMetadata({
|
|
390
803
|
issuer: config.issuer,
|
|
391
804
|
resource: `${config.issuer}${config.path}`,
|
|
@@ -402,7 +815,24 @@ var runMcpPost = async (config, request, body) => {
|
|
|
402
815
|
if (Array.isArray(body)) {
|
|
403
816
|
return rpcError(null, JSONRPC_INVALID_REQUEST, "Batching is not supported");
|
|
404
817
|
}
|
|
405
|
-
|
|
818
|
+
const sessions = registryFor(config);
|
|
819
|
+
const sessionId = request.headers.get("mcp-session-id");
|
|
820
|
+
if (sessions && sessionId && !sessions.get(sessionId)) {
|
|
821
|
+
return new Response(null, { status: HTTP_NOT_FOUND });
|
|
822
|
+
}
|
|
823
|
+
return dispatchMcp(config, auth.caller, auth.scopes ?? [], body, {
|
|
824
|
+
sessionId,
|
|
825
|
+
sessions
|
|
826
|
+
}).catch(() => rpcError(null, JSONRPC_INVALID_REQUEST, "Internal error"));
|
|
827
|
+
};
|
|
828
|
+
var runMcpDelete = (config, request) => {
|
|
829
|
+
const sessions = registryFor(config);
|
|
830
|
+
const sessionId = request.headers.get("mcp-session-id");
|
|
831
|
+
if (!sessions || !sessionId) {
|
|
832
|
+
return new Response(null, { status: HTTP_METHOD_NOT_ALLOWED });
|
|
833
|
+
}
|
|
834
|
+
sessions.drop(sessionId);
|
|
835
|
+
return new Response(null, { status: HTTP_NO_CONTENT });
|
|
406
836
|
};
|
|
407
837
|
var handleMcpRequest = async (config, request) => {
|
|
408
838
|
const { pathname } = new URL(request.url);
|
|
@@ -424,6 +854,9 @@ var handleMcpRequest = async (config, request) => {
|
|
|
424
854
|
});
|
|
425
855
|
return runMcpPost(config, request, body);
|
|
426
856
|
}
|
|
857
|
+
if (request.method === "DELETE" && pathname === config.path) {
|
|
858
|
+
return runMcpDelete(config, request);
|
|
859
|
+
}
|
|
427
860
|
return null;
|
|
428
861
|
};
|
|
429
862
|
|
|
@@ -433,7 +866,7 @@ var createMcpHandler = (config) => (request) => handleMcpRequest(config, request
|
|
|
433
866
|
import { Elysia } from "elysia";
|
|
434
867
|
var mcpServer = (config) => {
|
|
435
868
|
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));
|
|
869
|
+
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)).delete(config.path, ({ request }) => runMcpDelete(config, request));
|
|
437
870
|
const app = config.serveRootMetadata ? base.get(ROOT_METADATA_PATH, () => metadataResponse(config)) : base;
|
|
438
871
|
return app;
|
|
439
872
|
};
|
|
@@ -442,8 +875,11 @@ export {
|
|
|
442
875
|
protectedResourceMetadata,
|
|
443
876
|
metadataPathFor,
|
|
444
877
|
mcpServer,
|
|
878
|
+
feedbackTools,
|
|
445
879
|
dispatchMcp,
|
|
880
|
+
createSessionRegistry,
|
|
446
881
|
createMcpHandler,
|
|
447
882
|
createMcpClient,
|
|
448
|
-
McpClientError
|
|
883
|
+
McpClientError,
|
|
884
|
+
FEEDBACK_INSTRUCTIONS
|
|
449
885
|
};
|
package/dist/src/client.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { McpToolAnnotations, McpToolResult } from "./types";
|
|
1
|
+
import type { McpElicitationRequest, McpElicitResult, McpToolAnnotations, McpToolResult } from "./types";
|
|
2
2
|
export declare class McpClientError extends Error {
|
|
3
3
|
readonly code: number | undefined;
|
|
4
4
|
readonly status: number | undefined;
|
|
@@ -12,6 +12,12 @@ export type McpClientOptions = {
|
|
|
12
12
|
name: string;
|
|
13
13
|
version: string;
|
|
14
14
|
};
|
|
15
|
+
/** Answer a server's `elicitation/create` — a question for the USER, asked
|
|
16
|
+
* mid-tool-call. Supplying it declares the `elicitation` capability, so a
|
|
17
|
+
* server may then ask; omit it and servers are told you can't. Return
|
|
18
|
+
* `{action:"decline"}` (the user said no) or `{action:"cancel"}` (they
|
|
19
|
+
* dismissed it) — NEVER fabricate content on the user's behalf. */
|
|
20
|
+
onElicit?: (request: McpElicitationRequest) => Promise<McpElicitResult> | McpElicitResult;
|
|
15
21
|
/** Sent on every request (e.g. `{ authorization: "Bearer …" }`). */
|
|
16
22
|
headers?: Record<string, string>;
|
|
17
23
|
/** Reject responses whose body exceeds this many bytes (0 = no cap). */
|
package/dist/src/core.d.ts
CHANGED
|
@@ -5,6 +5,10 @@ export declare const metadataResponse: <Caller>(config: McpServerConfig<Caller>)
|
|
|
5
5
|
* The body is passed in because Elysia pre-parses it while a raw handler must
|
|
6
6
|
* parse it itself; both funnel through here so the logic lives in one place. */
|
|
7
7
|
export declare const runMcpPost: <Caller>(config: McpServerConfig<Caller>, request: Request, body: unknown) => Promise<Response>;
|
|
8
|
+
/** The client is done with its session and says so (spec: Session Management).
|
|
9
|
+
* Only meaningful when elicitation put us in a session at all — otherwise 405,
|
|
10
|
+
* which the spec explicitly allows for servers that don't do sessions. */
|
|
11
|
+
export declare const runMcpDelete: <Caller>(config: McpServerConfig<Caller>, request: Request) => Response;
|
|
8
12
|
/** The full path-aware handler over web-standard Request/Response. Returns a
|
|
9
13
|
* Response for any MCP route (POST endpoint, GET 405, discovery metadata) and
|
|
10
14
|
* `null` for anything else, so a host can compose it with its own routes. */
|
package/dist/src/dispatch.d.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
import type { SessionRegistry } from "./sessions";
|
|
1
2
|
import type { McpServerConfig } from "./types";
|
|
2
|
-
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
export
|
|
3
|
+
/** Everything a dispatch needs to know about the HTTP request it came in on.
|
|
4
|
+
* Only elicitation uses it; without it the dispatcher is exactly as stateless
|
|
5
|
+
* as it was. */
|
|
6
|
+
export type McpDispatchContext = {
|
|
7
|
+
sessionId?: string | null;
|
|
8
|
+
sessions?: SessionRegistry;
|
|
9
|
+
};
|
|
10
|
+
export declare const dispatchMcp: <Caller>(config: McpServerConfig<Caller>, caller: Caller, scopes: string[], message: unknown, context?: McpDispatchContext) => Promise<Response>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { McpToolRegistry } from "./types";
|
|
2
|
+
export type McpFeedbackRating = "bad" | "good";
|
|
3
|
+
export type McpProblemReport = {
|
|
4
|
+
/** What the user expected instead. */
|
|
5
|
+
expected?: string;
|
|
6
|
+
/** What is broken, in the user's words. */
|
|
7
|
+
problem: string;
|
|
8
|
+
/** How to reproduce it. */
|
|
9
|
+
steps?: string;
|
|
10
|
+
/** Where it happened — a page, a feature, a tool name. */
|
|
11
|
+
where?: string;
|
|
12
|
+
};
|
|
13
|
+
export type McpFeedbackReport = {
|
|
14
|
+
rating: McpFeedbackRating;
|
|
15
|
+
/** Why — in the USER's words, not the model's summary. */
|
|
16
|
+
reason: string;
|
|
17
|
+
/** The tool the feedback is about, if it was about one. */
|
|
18
|
+
tool?: string;
|
|
19
|
+
};
|
|
20
|
+
/** Where feedback goes. Both handlers return the sentence the model relays back
|
|
21
|
+
* to the user, so the host controls what it promises them (a ticket id, an
|
|
22
|
+
* SLA, a thank-you). Returning nothing is fine — a default is used. */
|
|
23
|
+
export type McpFeedbackStore<Caller> = {
|
|
24
|
+
reportProblem: (input: {
|
|
25
|
+
caller: Caller;
|
|
26
|
+
report: McpProblemReport;
|
|
27
|
+
}) => Promise<string | void> | string | void;
|
|
28
|
+
submitFeedback: (input: {
|
|
29
|
+
caller: Caller;
|
|
30
|
+
feedback: McpFeedbackReport;
|
|
31
|
+
}) => Promise<string | void> | string | void;
|
|
32
|
+
};
|
|
33
|
+
/** Append to your server's `instructions`. Without this the model treats a
|
|
34
|
+
* complaint as something to apologise for rather than something to report. */
|
|
35
|
+
export declare const FEEDBACK_INSTRUCTIONS = "FEEDBACK: you are the user's only channel back to this server's team \u2014 there are no buttons in this client. If the user says something was wrong, unhelpful, or not what they asked for, call submit_feedback with their reason in their own words; if something is outright broken, call report_problem. Do this INSTEAD of only apologising, and tell them you've passed it on. Log the good as well as the bad.";
|
|
36
|
+
/** The two tools, bound to one caller. Call inside your `tools` factory:
|
|
37
|
+
*
|
|
38
|
+
* ```ts
|
|
39
|
+
* tools: ({ caller }) => ({
|
|
40
|
+
* ...myTools(caller),
|
|
41
|
+
* ...feedbackTools({ caller, store }),
|
|
42
|
+
* })
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export declare const feedbackTools: <Caller>(config: {
|
|
46
|
+
caller: Caller;
|
|
47
|
+
store: McpFeedbackStore<Caller>;
|
|
48
|
+
}) => McpToolRegistry;
|
package/dist/src/index.d.ts
CHANGED
|
@@ -13,6 +13,14 @@
|
|
|
13
13
|
* `scope` gating, and rich tool results (text/image/structured) are all
|
|
14
14
|
* built in; the package ships no opinion about billing, storage, or access.
|
|
15
15
|
*
|
|
16
|
+
* **Feedback.** A connected AI client gives a server no UI, so a user's "that
|
|
17
|
+
* was wrong" can only come back through the model. {@link feedbackTools} is the
|
|
18
|
+
* pair of tools that carries it (report_problem / submit_feedback), and
|
|
19
|
+
* {@link FEEDBACK_INSTRUCTIONS} is the sentence that makes a model actually use
|
|
20
|
+
* them instead of just apologising. {@link McpServerConfig.elicitation} goes
|
|
21
|
+
* further: a tool marked `mayElicit` can ASK the user a question mid-call
|
|
22
|
+
* (`elicitation/create`) and wait for the answer.
|
|
23
|
+
*
|
|
16
24
|
* **Consume.** {@link createMcpClient} is a streamable-HTTP client for calling
|
|
17
25
|
* OTHER MCP servers — the half you need to expose a user's own connected tools
|
|
18
26
|
* to your agent. Safety wrapping around untrusted remote tools (namespacing,
|
|
@@ -24,8 +32,10 @@
|
|
|
24
32
|
*/
|
|
25
33
|
export { verifyBearer, type BearerResult, type BearerVerifier, type VerifiedJwt, type VerifyBearerConfig, } from "./auth";
|
|
26
34
|
export { createMcpClient, McpClientError, type McpClient, type McpClientOptions, type McpInitializeResult, type McpRemoteTool, } from "./client";
|
|
27
|
-
export { dispatchMcp } from "./dispatch";
|
|
35
|
+
export { dispatchMcp, type McpDispatchContext } from "./dispatch";
|
|
36
|
+
export { FEEDBACK_INSTRUCTIONS, feedbackTools, type McpFeedbackRating, type McpFeedbackReport, type McpFeedbackStore, type McpProblemReport, } from "./feedback";
|
|
28
37
|
export { createMcpHandler } from "./handler";
|
|
29
38
|
export { metadataPathFor, protectedResourceMetadata, type ProtectedResourceMetadata, } from "./metadata";
|
|
30
39
|
export { mcpServer } from "./server";
|
|
31
|
-
export
|
|
40
|
+
export { createSessionRegistry, type SessionRegistry } from "./sessions";
|
|
41
|
+
export type { McpAudioContent, McpElicitationRequest, McpElicitResult, McpAuthResult, McpCallGate, McpCallMeta, McpContent, McpImageContent, McpPromptArgument, McpPromptDefinition, McpPrompts, McpResource, McpResourceLink, McpResources, McpServerConfig, McpServerInfo, McpTextContent, McpTool, McpToolAnnotations, McpToolCallContext, McpToolContext, McpToolRegistry, McpToolResult, McpToolReturn, } from "./types";
|
package/dist/src/jsonrpc.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export declare const JSONRPC_METHOD_NOT_FOUND = -32601;
|
|
|
4
4
|
export declare const JSONRPC_INVALID_PARAMS = -32602;
|
|
5
5
|
export declare const JSONRPC_INTERNAL_ERROR = -32603;
|
|
6
6
|
export declare const HTTP_ACCEPTED = 202;
|
|
7
|
+
export declare const HTTP_NO_CONTENT = 204;
|
|
7
8
|
export declare const HTTP_UNAUTHORIZED = 401;
|
|
8
9
|
export declare const HTTP_METHOD_NOT_ALLOWED = 405;
|
|
9
10
|
export type JsonRpcId = string | number | null;
|
package/dist/src/server.d.ts
CHANGED
|
@@ -3,8 +3,10 @@ import type { McpServerConfig } from "./types";
|
|
|
3
3
|
/** Build the MCP endpoint as an Elysia plugin. Mount it with `.use(...)`.
|
|
4
4
|
*
|
|
5
5
|
* Serves:
|
|
6
|
-
* - `POST <path>` — the JSON-RPC endpoint (
|
|
7
|
-
*
|
|
6
|
+
* - `POST <path>` — the JSON-RPC endpoint (streamable HTTP; answers with a
|
|
7
|
+
* plain JSON body, or an SSE stream when a tool may elicit)
|
|
8
|
+
* - `GET <path>` — 405 (no standalone server-initiated stream)
|
|
9
|
+
* - `DELETE <path>` — end an elicitation session (405 when sessionless)
|
|
8
10
|
* - `GET /.well-known/oauth-protected-resource<path>` — RFC 9728 metadata
|
|
9
11
|
* - `GET /.well-known/oauth-protected-resource` — the same, if
|
|
10
12
|
* `serveRootMetadata` is set (only one endpoint per app should). */
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { McpElicitResult, McpElicitationRequest } from "./types";
|
|
2
|
+
type Pending = {
|
|
3
|
+
resolve: (result: McpElicitResult) => void;
|
|
4
|
+
timer: ReturnType<typeof setTimeout>;
|
|
5
|
+
};
|
|
6
|
+
type Session = {
|
|
7
|
+
/** The client declared the `elicitation` capability at initialize. */
|
|
8
|
+
canElicit: boolean;
|
|
9
|
+
lastSeen: number;
|
|
10
|
+
/** In-flight elicitations, keyed by the JSON-RPC id we sent. */
|
|
11
|
+
pending: Map<string, Pending>;
|
|
12
|
+
};
|
|
13
|
+
export type SessionRegistry = ReturnType<typeof createSessionRegistry>;
|
|
14
|
+
export declare const createSessionRegistry: (options?: {
|
|
15
|
+
elicitTimeoutMs?: number;
|
|
16
|
+
ttlMs?: number;
|
|
17
|
+
}) => {
|
|
18
|
+
/** A new session, returned to the client as `Mcp-Session-Id`. */
|
|
19
|
+
create: (canElicit: boolean) => `${string}-${string}-${string}-${string}-${string}`;
|
|
20
|
+
drop: (id: string) => void;
|
|
21
|
+
get: (id: string | null) => Session | null;
|
|
22
|
+
/** The client answered one of our elicitation requests. Returns false when
|
|
23
|
+
* the id is unknown (a stale answer, or a foreign session) — the caller
|
|
24
|
+
* should still 202 it, per the transport rules. */
|
|
25
|
+
resolveElicit: (sessionId: string | null, requestId: string, result: McpElicitResult) => boolean;
|
|
26
|
+
/** Register an outbound elicitation and get back the id to send with it,
|
|
27
|
+
* plus the promise that settles when the client answers (or gives up). */
|
|
28
|
+
startElicit: (sessionId: string, request: McpElicitationRequest) => {
|
|
29
|
+
answer: Promise<McpElicitResult>;
|
|
30
|
+
id: string;
|
|
31
|
+
request: McpElicitationRequest;
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
export {};
|
package/dist/src/types.d.ts
CHANGED
|
@@ -40,12 +40,52 @@ export type McpToolResult = {
|
|
|
40
40
|
};
|
|
41
41
|
/** What a tool handler may return. A bare string is the common case. */
|
|
42
42
|
export type McpToolReturn = McpContent[] | McpToolResult | string;
|
|
43
|
+
/** A question the SERVER asks the USER, mid-tool-call, through the client
|
|
44
|
+
* (`elicitation/create`). The schema is a deliberately restricted subset of
|
|
45
|
+
* JSON Schema — a FLAT object of primitives (string / number / integer /
|
|
46
|
+
* boolean / enum), so any client can render a form for it. Nested objects and
|
|
47
|
+
* arrays are not allowed by the spec.
|
|
48
|
+
*
|
|
49
|
+
* Servers MUST NOT elicit sensitive information (spec, Security). */
|
|
50
|
+
export type McpElicitationRequest = {
|
|
51
|
+
message: string;
|
|
52
|
+
requestedSchema: Record<string, unknown>;
|
|
53
|
+
};
|
|
54
|
+
/** What came back. `unsupported` is ours, not the spec's: it is what you get
|
|
55
|
+
* when the client never declared the elicitation capability, so a tool can
|
|
56
|
+
* fall back instead of pretending the user declined. */
|
|
57
|
+
export type McpElicitResult = {
|
|
58
|
+
action: "accept";
|
|
59
|
+
content: Record<string, unknown>;
|
|
60
|
+
} | {
|
|
61
|
+
action: "cancel";
|
|
62
|
+
} | {
|
|
63
|
+
action: "decline";
|
|
64
|
+
} | {
|
|
65
|
+
action: "unsupported";
|
|
66
|
+
};
|
|
67
|
+
/** Passed to a tool handler as its second argument. Ignore it and nothing
|
|
68
|
+
* changes — every existing handler keeps working. */
|
|
69
|
+
export type McpToolCallContext = {
|
|
70
|
+
/** True when this client can actually show the user a form. Check it before
|
|
71
|
+
* designing a flow around elicit(). */
|
|
72
|
+
canElicit: boolean;
|
|
73
|
+
/** Ask the user a question and wait for the answer. Resolves to
|
|
74
|
+
* `{action:"unsupported"}` immediately when the client can't elicit, and to
|
|
75
|
+
* `{action:"cancel"}` if they never answer. */
|
|
76
|
+
elicit: (request: McpElicitationRequest) => Promise<McpElicitResult>;
|
|
77
|
+
};
|
|
43
78
|
/** One callable tool. `inputSchema` is a JSON Schema object. */
|
|
44
79
|
export type McpTool = {
|
|
45
80
|
annotations?: McpToolAnnotations;
|
|
46
81
|
description: string;
|
|
47
|
-
handler: (args: unknown) => McpToolReturn | Promise<McpToolReturn>;
|
|
82
|
+
handler: (args: unknown, context: McpToolCallContext) => McpToolReturn | Promise<McpToolReturn>;
|
|
48
83
|
inputSchema: Record<string, unknown>;
|
|
84
|
+
/** Set when this tool may call `context.elicit`. It makes the server answer
|
|
85
|
+
* the `tools/call` with an SSE stream (the only way to send the user a
|
|
86
|
+
* question mid-call) instead of a plain JSON body — so it is opt-in per
|
|
87
|
+
* tool, and a server whose tools never elicit stays purely stateless. */
|
|
88
|
+
mayElicit?: boolean;
|
|
49
89
|
/** JSON Schema for `structuredContent`, advertised on `tools/list`. */
|
|
50
90
|
outputSchema?: Record<string, unknown>;
|
|
51
91
|
/** If set, the tool is only listed and callable when the caller's scopes
|
|
@@ -135,6 +175,17 @@ export type McpServerConfig<Caller> = {
|
|
|
135
175
|
instructions?: string;
|
|
136
176
|
/** The token issuer — used for discovery metadata and the challenge URL. */
|
|
137
177
|
issuer: string;
|
|
178
|
+
/** Turn on elicitation (server asks the USER a question mid-tool-call).
|
|
179
|
+
* Off by default, because it makes the endpoint SESSION-STATEFUL: the
|
|
180
|
+
* client answers on a separate HTTP request, so the pending call has to be
|
|
181
|
+
* remembered in-process. Run one instance, or pin `Mcp-Session-Id`. Tools
|
|
182
|
+
* must also opt in with `mayElicit`. */
|
|
183
|
+
elicitation?: {
|
|
184
|
+
enabled: true;
|
|
185
|
+
timeoutMs?: number;
|
|
186
|
+
};
|
|
187
|
+
/** Page size for tools/prompts/resources list pagination (default 50). */
|
|
188
|
+
listPageSize?: number;
|
|
138
189
|
/** Fired after every `tools/call` for auditing. `meta` carries anything the
|
|
139
190
|
* tool handler wrote during the call. */
|
|
140
191
|
onCall?: (record: {
|
package/package.json
CHANGED