@nexusbloom/mcp-server 2.0.2 → 2.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 +149 -0
- package/index.js +13 -1
- package/package.json +5 -3
- package/src/config.js +5 -0
- package/src/handlers.js +343 -13
- package/src/history.js +285 -0
- package/src/local.js +164 -0
- package/src/progress.js +88 -0
- package/src/prompts.js +282 -0
- package/src/render.js +198 -9
- package/src/resources.js +318 -0
- package/src/server.js +106 -7
- package/src/validate.js +14 -1
package/src/resources.js
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Resources — read-only catalogue views an agent can pull without a tool call.
|
|
3
|
+
*
|
|
4
|
+
* Tools and resources answer different questions. A tool *does* something and
|
|
5
|
+
* costs a round trip; a resource *is* something the agent can read once and
|
|
6
|
+
* reason over. The catalogue is exactly the latter: 31 manifests, every schema,
|
|
7
|
+
* searchable offline — a host that supports resources can answer "what exists?"
|
|
8
|
+
* without spending a call, and a model can hold the whole surface in context
|
|
9
|
+
* instead of discovering it one slug at a time.
|
|
10
|
+
*
|
|
11
|
+
* Like handlers.js, this file imports no SDK: the handlers return plain objects
|
|
12
|
+
* and the transport adapter in server.js is the only place the protocol lives.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { groupByCategory, resolveSlugStrict } from "./discovery.js";
|
|
16
|
+
import { NexusBloomError, ErrorCode } from "./errors.js";
|
|
17
|
+
import { summariseRun } from "./history.js";
|
|
18
|
+
import { notFoundError } from "./handlers.js";
|
|
19
|
+
import { requiredCount, buildExampleArgs } from "./render.js";
|
|
20
|
+
|
|
21
|
+
/** The scheme every resource lives under. */
|
|
22
|
+
export const RESOURCE_SCHEME = "nexusbloom";
|
|
23
|
+
|
|
24
|
+
/** The three read paths, as constants so tests and the guide cannot drift. */
|
|
25
|
+
export const GUIDE_URI = "nexusbloom://guide";
|
|
26
|
+
export const CATALOGUE_URI = "nexusbloom://catalogue";
|
|
27
|
+
export const TOOL_URI_PREFIX = "nexusbloom://tools/";
|
|
28
|
+
export const TOOL_URI_TEMPLATE = "nexusbloom://tools/{slug}";
|
|
29
|
+
export const HISTORY_URI = "nexusbloom://history";
|
|
30
|
+
|
|
31
|
+
/** MIME types. JSON for data an agent parses, markdown for prose a model reads. */
|
|
32
|
+
const JSON_MIME = "application/json";
|
|
33
|
+
const MD_MIME = "text/markdown";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Assemble the resource handlers over a manifest cache.
|
|
37
|
+
*
|
|
38
|
+
* @param {object} deps
|
|
39
|
+
* @param {import("./manifests.js").ManifestCache} deps.cache
|
|
40
|
+
* @param {object} [deps.config]
|
|
41
|
+
* @returns {{listResources: Function, listResourceTemplates: Function, readResource: Function}}
|
|
42
|
+
*/
|
|
43
|
+
export function createResources({ cache, config, history } = {}) {
|
|
44
|
+
/**
|
|
45
|
+
* The static resources.
|
|
46
|
+
*
|
|
47
|
+
* Deliberately tiny. The catalogue is the only dynamic read, and a list of 31
|
|
48
|
+
* per-tool entries would just be the catalogue again with more tokens.
|
|
49
|
+
*/
|
|
50
|
+
async function listResources() {
|
|
51
|
+
return {
|
|
52
|
+
resources: [
|
|
53
|
+
{
|
|
54
|
+
uri: GUIDE_URI,
|
|
55
|
+
name: "NexusBloom usage guide",
|
|
56
|
+
description:
|
|
57
|
+
"How to discover, inspect and run NexusBloom tools: meta commands, resource URIs, " +
|
|
58
|
+
"rate limits, and how results are rendered.",
|
|
59
|
+
mimeType: MD_MIME,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
uri: HISTORY_URI,
|
|
63
|
+
name: "Run history",
|
|
64
|
+
description:
|
|
65
|
+
"What this session has run, newest first: run id, tool, status, duration. " +
|
|
66
|
+
"In memory only — never written to disk.",
|
|
67
|
+
mimeType: JSON_MIME,
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
uri: CATALOGUE_URI,
|
|
71
|
+
name: "Tool catalogue",
|
|
72
|
+
description:
|
|
73
|
+
"Every published tool as JSON: slug, description, category, tags, parameter counts, " +
|
|
74
|
+
"and the resource URI carrying its full manifest.",
|
|
75
|
+
mimeType: JSON_MIME,
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Per-tool manifests are one template, not N resources. */
|
|
82
|
+
async function listResourceTemplates() {
|
|
83
|
+
return {
|
|
84
|
+
resourceTemplates: [
|
|
85
|
+
{
|
|
86
|
+
uriTemplate: TOOL_URI_TEMPLATE,
|
|
87
|
+
name: "Tool manifest",
|
|
88
|
+
description:
|
|
89
|
+
"The full manifest for one tool: input and output JSON Schema, tags, pricing, " +
|
|
90
|
+
"and a ready-to-send example argument object.",
|
|
91
|
+
mimeType: JSON_MIME,
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Read one resource by URI.
|
|
99
|
+
*
|
|
100
|
+
* Unlike tool calls there is no `isError` channel here, so a bad URI has to
|
|
101
|
+
* raise: a JSON-RPC error the host can surface, rather than a resource whose
|
|
102
|
+
* body silently says "not found" and which an agent would quote as fact.
|
|
103
|
+
*/
|
|
104
|
+
async function readResource(params) {
|
|
105
|
+
const uri = typeof params === "string" ? params : params?.uri;
|
|
106
|
+
if (typeof uri !== "string" || !uri.trim()) {
|
|
107
|
+
throw new NexusBloomError(
|
|
108
|
+
`A resource URI is required. Available: ${GUIDE_URI}, ${CATALOGUE_URI}, ${TOOL_URI_TEMPLATE}.`,
|
|
109
|
+
ErrorCode.INVALID_ARGS,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
const wanted = uri.trim();
|
|
113
|
+
|
|
114
|
+
if (wanted === GUIDE_URI) return textResource(GUIDE_URI, renderGuide(config), MD_MIME);
|
|
115
|
+
if (wanted === HISTORY_URI) return jsonResource(HISTORY_URI, readHistory(history));
|
|
116
|
+
if (wanted === CATALOGUE_URI) return jsonResource(CATALOGUE_URI, await readCatalogue());
|
|
117
|
+
|
|
118
|
+
if (wanted.startsWith(TOOL_URI_PREFIX)) {
|
|
119
|
+
const slug = decodeURIComponent(wanted.slice(TOOL_URI_PREFIX.length));
|
|
120
|
+
return jsonResource(wanted, await readToolManifest(slug));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
throw new NexusBloomError(
|
|
124
|
+
`Unknown resource "${wanted}". Available: ${GUIDE_URI}, ${CATALOGUE_URI}, ${TOOL_URI_TEMPLATE}.`,
|
|
125
|
+
ErrorCode.NOT_FOUND,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The recorded runs, newest first.
|
|
131
|
+
*
|
|
132
|
+
* Summaries only — the payloads are reachable per run through the meta-tool's
|
|
133
|
+
* `history show`, and a host reading this resource wants an index, not a
|
|
134
|
+
* transcript. Returns an empty list rather than throwing when history is off or
|
|
135
|
+
* empty: "nothing ran yet" is an answer, not a failure.
|
|
136
|
+
*/
|
|
137
|
+
function readHistory(log) {
|
|
138
|
+
if (!log) return { runs: [], count: 0, note: "No run log is attached to this resource set." };
|
|
139
|
+
const runs = log.recent(50).map((r) => summariseRun(r));
|
|
140
|
+
return { runs, count: runs.length };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The whole catalogue, lean.
|
|
145
|
+
*
|
|
146
|
+
* Full schemas are excluded on purpose: this resource exists to be read into
|
|
147
|
+
* context in one go, and 31 manifests inline would blow the budget before the
|
|
148
|
+
* agent has chosen a tool. Each entry carries the URI that holds the detail.
|
|
149
|
+
*/
|
|
150
|
+
async function readCatalogue() {
|
|
151
|
+
const tools = await cache.tools();
|
|
152
|
+
return {
|
|
153
|
+
count: tools.length,
|
|
154
|
+
categories: groupByCategory(tools).map((g) => g.category),
|
|
155
|
+
tools: tools.map((t) => ({
|
|
156
|
+
slug: t.slug,
|
|
157
|
+
name: t.name,
|
|
158
|
+
description: t.short_description,
|
|
159
|
+
category: t.category,
|
|
160
|
+
tags: t.tags,
|
|
161
|
+
runtime: t.runtime,
|
|
162
|
+
price_type: t.price_type,
|
|
163
|
+
required_params: requiredCount(t),
|
|
164
|
+
total_params: Object.keys(t.input_schema?.properties || {}).length,
|
|
165
|
+
has_schema: t.hasSchema,
|
|
166
|
+
manifest: toolUri(t.slug),
|
|
167
|
+
})),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* One tool's full manifest.
|
|
173
|
+
*
|
|
174
|
+
* Resolution is strict, so an abbreviation that could mean two tools errors
|
|
175
|
+
* with both candidates rather than reading whichever sorted first.
|
|
176
|
+
*/
|
|
177
|
+
async function readToolManifest(slug) {
|
|
178
|
+
const wanted = (slug || "").trim();
|
|
179
|
+
if (!wanted) {
|
|
180
|
+
throw new NexusBloomError(
|
|
181
|
+
`${TOOL_URI_PREFIX} needs a slug, e.g. ${toolUri("env-validator")}.`,
|
|
182
|
+
ErrorCode.INVALID_ARGS,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const tools = await cache.tools();
|
|
187
|
+
const { tool, ambiguous } = resolveSlugStrict(tools, wanted);
|
|
188
|
+
|
|
189
|
+
if (!tool) {
|
|
190
|
+
// Reuse the tool-call message so an agent that guesses a slug from either
|
|
191
|
+
// path gets the same recovery instructions.
|
|
192
|
+
throw notFoundError(wanted, tools, ambiguous);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// A published schema is authoritative; fetch the manifest only when the list
|
|
196
|
+
// entry has none, which is the pre-reconciliation shape.
|
|
197
|
+
let manifest = tool;
|
|
198
|
+
if (!tool.hasSchema) {
|
|
199
|
+
try {
|
|
200
|
+
manifest = await cache.manifest(tool.slug);
|
|
201
|
+
} catch {
|
|
202
|
+
/* fall back to the list entry; hasSchema:false already says why */
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
...manifest,
|
|
208
|
+
call: { tool: manifest.slug, arguments: buildExampleArgs(manifest) },
|
|
209
|
+
manifest: toolUri(manifest.slug),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { listResources, listResourceTemplates, readResource };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The resource URI for one tool. */
|
|
217
|
+
export function toolUri(slug) {
|
|
218
|
+
return `${TOOL_URI_PREFIX}${encodeURIComponent(slug)}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Wrap a value as a JSON resource body. */
|
|
222
|
+
function jsonResource(uri, value) {
|
|
223
|
+
return {
|
|
224
|
+
contents: [
|
|
225
|
+
{
|
|
226
|
+
uri,
|
|
227
|
+
mimeType: JSON_MIME,
|
|
228
|
+
text: JSON.stringify(value, null, 2),
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Wrap prose as a text resource body. */
|
|
235
|
+
function textResource(uri, text, mimeType = "text/plain") {
|
|
236
|
+
return { contents: [{ uri, mimeType, text }] };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The usage guide.
|
|
241
|
+
*
|
|
242
|
+
* Written as prose rather than a JSON blob because the audience is a model
|
|
243
|
+
* choosing how to spend its next call. It names the recovery paths — the thing
|
|
244
|
+
* that actually prevents an agent from guessing slugs and failing.
|
|
245
|
+
*/
|
|
246
|
+
export function renderGuide(config = {}) {
|
|
247
|
+
const api = config?.apiBase || "the configured API base";
|
|
248
|
+
const key = config?.apiKey ? "authenticated" : "anonymous (30 requests/minute)";
|
|
249
|
+
|
|
250
|
+
return `# NexusBloom MCP
|
|
251
|
+
|
|
252
|
+
NexusBloom publishes 30+ browser and Node tools behind one API. This server lets an
|
|
253
|
+
agent discover them by intent, read their exact schemas, and run them.
|
|
254
|
+
|
|
255
|
+
## Three ways to find a tool
|
|
256
|
+
|
|
257
|
+
1. **Read the catalogue** — \`${CATALOGUE_URI}\` — every tool, one line each.
|
|
258
|
+
2. **Read a manifest** — \`${TOOL_URI_TEMPLATE}\`, e.g. \`${toolUri("env-validator")}\` — full JSON Schema plus a ready example.
|
|
259
|
+
3. **Call the meta tool** — \`nexusbloom\` — when you know the intent but not the slug.
|
|
260
|
+
|
|
261
|
+
Meta commands:
|
|
262
|
+
|
|
263
|
+
- \`{"command":"search","query":"validate environment file"}\` — rank tools by intent
|
|
264
|
+
- \`{"command":"list"}\` — every tool, one line each
|
|
265
|
+
- \`{"command":"schema","slug":"<slug>"}\` — parameters plus a ready-to-send example
|
|
266
|
+
- \`{"command":"run","slug":"<slug>","params":{…}}\` — execute
|
|
267
|
+
- \`{"command":"batch","runs":[…]}\` — up to 10 runs in one call
|
|
268
|
+
- \`{"command":"history"}\` / \`{"command":"history","show":"<id>"}\` — what this session ran
|
|
269
|
+
- \`{"command":"diff","from":"<id>","to":"<id>"}\` — compare two runs, field by field
|
|
270
|
+
|
|
271
|
+
And when several tools belong to one task, run them in a single turn:
|
|
272
|
+
|
|
273
|
+
- \`{"command":"batch","runs":[{"slug":"<slug>","params":{…}},…]}\` — up to 10 runs in one call
|
|
274
|
+
|
|
275
|
+
Every slug in a batch is resolved and validated **before** the first run, so a typo
|
|
276
|
+
costs no quota and the whole batch is rejected rather than half-run. Once running,
|
|
277
|
+
each entry is independent: one tool failing does not discard the results that
|
|
278
|
+
succeeded, and the response lists failures first.
|
|
279
|
+
|
|
280
|
+
Any published tool is also callable **directly by its slug**, with its own parameters
|
|
281
|
+
as arguments. Prefer the direct call once you know the name; the meta tool exists for
|
|
282
|
+
recovery.
|
|
283
|
+
|
|
284
|
+
## When a call fails
|
|
285
|
+
|
|
286
|
+
- **Unknown slug** — the error lists close matches. Try one, or \`search\` by intent.
|
|
287
|
+
- **Schema mismatch** — call \`schema\` and read the actual parameter names.
|
|
288
|
+
- **Abbreviation ambiguous** — the error names both candidates. Use the full slug.
|
|
289
|
+
|
|
290
|
+
## Results
|
|
291
|
+
|
|
292
|
+
Tool output is rendered for the host: text results are marked for the assistant,
|
|
293
|
+
visual results for the user. A result the server cannot classify is returned as raw
|
|
294
|
+
JSON rather than guessed at, so nothing is silently reshaped.
|
|
295
|
+
|
|
296
|
+
## Resources
|
|
297
|
+
|
|
298
|
+
| URI | Contents |
|
|
299
|
+
| --- | --- |
|
|
300
|
+
| \`${GUIDE_URI}\` | This guide. |
|
|
301
|
+
| \`${CATALOGUE_URI}\` | Every published tool, one entry each, with a manifest URI. |
|
|
302
|
+
| \`${TOOL_URI_TEMPLATE}\` | One tool's full manifest, e.g. \`${toolUri("env-validator")}\`. |
|
|
303
|
+
|
|
304
|
+
## Prompts
|
|
305
|
+
|
|
306
|
+
If your host offers prompts, four are available: \`find-tool\` (rank tools by intent),
|
|
307
|
+
\`use-tool\` (one tool's exact parameters and a valid call), \`plan-batch\` (shape a
|
|
308
|
+
multi-tool task into one batched call), and \`recover\` (what an error code means and
|
|
309
|
+
what to do next).
|
|
310
|
+
|
|
311
|
+
## Limits
|
|
312
|
+
|
|
313
|
+
- This session is ${key}.
|
|
314
|
+
- Requests go to ${api}.
|
|
315
|
+
- Per-call timeouts come from \`NEXUSBLOOM_MCP_TIMEOUT_MS\` (default 15s).
|
|
316
|
+
- Anonymous callers are capped at 30 requests/minute; authenticated callers are not.
|
|
317
|
+
`;
|
|
318
|
+
}
|
package/src/server.js
CHANGED
|
@@ -6,16 +6,47 @@
|
|
|
6
6
|
* without a transport, so this file is the only place the SDK is imported.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { createRequire } from "node:module";
|
|
10
|
+
|
|
9
11
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
12
|
+
import { McpError } from "@modelcontextprotocol/sdk/types.js";
|
|
10
13
|
import {
|
|
11
14
|
CallToolRequestSchema,
|
|
15
|
+
ErrorCode as McpErrorCode,
|
|
12
16
|
ListToolsRequestSchema,
|
|
17
|
+
ListPromptsRequestSchema,
|
|
18
|
+
GetPromptRequestSchema,
|
|
19
|
+
ListResourcesRequestSchema,
|
|
20
|
+
ListResourceTemplatesRequestSchema,
|
|
21
|
+
ReadResourceRequestSchema,
|
|
13
22
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
14
23
|
|
|
15
24
|
import { buildToolList, createHandlers } from "./handlers.js";
|
|
25
|
+
import { RunHistory } from "./history.js";
|
|
26
|
+
import { createProgressReporter, handlerProgress } from "./progress.js";
|
|
27
|
+
import { createPrompts } from "./prompts.js";
|
|
28
|
+
import { createResources } from "./resources.js";
|
|
29
|
+
import { asNexusBloomError, ErrorCode } from "./errors.js";
|
|
16
30
|
|
|
17
31
|
export const SERVER_NAME = "nexusbloom-mcp";
|
|
18
|
-
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The version reported to hosts.
|
|
35
|
+
*
|
|
36
|
+
* Read from package.json rather than hardcoded: a hardcoded literal drifts the
|
|
37
|
+
* moment a release is cut, and a server that under-reports its own version
|
|
38
|
+
* makes bug reports from clients unmatchable to code.
|
|
39
|
+
*/
|
|
40
|
+
export const SERVER_VERSION = readPackageVersion();
|
|
41
|
+
|
|
42
|
+
function readPackageVersion() {
|
|
43
|
+
try {
|
|
44
|
+
const require = createRequire(import.meta.url);
|
|
45
|
+
return require("../package.json").version;
|
|
46
|
+
} catch {
|
|
47
|
+
return "0.0.0-unknown";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
19
50
|
|
|
20
51
|
/**
|
|
21
52
|
* Wire a handler set onto an MCP Server.
|
|
@@ -25,20 +56,63 @@ export const SERVER_VERSION = "2.0.0";
|
|
|
25
56
|
* seam that lets the error-containment path be tested directly.
|
|
26
57
|
* @returns {{server: Server, handlers: object}}
|
|
27
58
|
*/
|
|
28
|
-
export function createServer({
|
|
29
|
-
|
|
59
|
+
export function createServer({
|
|
60
|
+
client,
|
|
61
|
+
cache,
|
|
62
|
+
config,
|
|
63
|
+
handlers: provided,
|
|
64
|
+
resources: providedResources,
|
|
65
|
+
prompts: providedPrompts,
|
|
66
|
+
}) {
|
|
67
|
+
// One log for the whole server: the handlers write it and the history resource
|
|
68
|
+
// reads it, so a resource showing an empty log while tools have run would be
|
|
69
|
+
// a lie. Created here rather than inside createHandlers so both get the same one.
|
|
70
|
+
const history = provided?.history ?? new RunHistory();
|
|
71
|
+
const handlers = provided ?? createHandlers({ client, cache, config, history });
|
|
30
72
|
const listTools = provided ? buildToolList(cache) : handlers.listTools;
|
|
73
|
+
const resources = providedResources ?? createResources({ cache, config, history });
|
|
74
|
+
const prompts = providedPrompts ?? createPrompts({ cache });
|
|
31
75
|
|
|
32
76
|
const server = new Server(
|
|
33
77
|
{ name: SERVER_NAME, version: SERVER_VERSION },
|
|
34
|
-
{ capabilities: { tools: {} } },
|
|
78
|
+
{ capabilities: { tools: {}, resources: {}, prompts: {} } },
|
|
35
79
|
);
|
|
36
80
|
|
|
37
81
|
server.setRequestHandler(ListToolsRequestSchema, async () => listTools());
|
|
38
82
|
|
|
39
|
-
server.setRequestHandler(
|
|
83
|
+
server.setRequestHandler(ListPromptsRequestSchema, async () => prompts.listPrompts());
|
|
84
|
+
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
|
|
85
|
+
// Same containment as resources: no isError channel, so a bad prompt name or
|
|
86
|
+
// a missing argument has to be a protocol error carrying the recovery.
|
|
87
|
+
try {
|
|
88
|
+
return await prompts.getPrompt(request.params ?? {});
|
|
89
|
+
} catch (err) {
|
|
90
|
+
throw toMcpError(err);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => resources.listResources());
|
|
95
|
+
server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => resources.listResourceTemplates());
|
|
96
|
+
|
|
97
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
98
|
+
// Resources have no isError channel, so a failure here is a protocol error.
|
|
99
|
+
// NexusBloomError carries the wording the agent needs; a bare TypeError here
|
|
100
|
+
// would reach the user as "request failed" with no route to recovery.
|
|
101
|
+
try {
|
|
102
|
+
return await resources.readResource(request.params ?? {});
|
|
103
|
+
} catch (err) {
|
|
104
|
+
throw toMcpError(err);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
40
109
|
const { name: toolName, arguments: args } = request.params ?? {};
|
|
41
110
|
|
|
111
|
+
// Progress is opt-in per request: a host that sends no progressToken has not
|
|
112
|
+
// asked for notifications, and sending them anyway is noise at best.
|
|
113
|
+
const reporter = createProgressReporter({ extra });
|
|
114
|
+
const onProgress = handlerProgress(reporter);
|
|
115
|
+
|
|
42
116
|
// No handler may throw: an exception here becomes a JSON-RPC protocol error,
|
|
43
117
|
// which a model cannot recover from, whereas an isError result is a normal
|
|
44
118
|
// tool outcome it can reason about.
|
|
@@ -49,15 +123,40 @@ export function createServer({ client, cache, config, handlers: provided }) {
|
|
|
49
123
|
isError: true,
|
|
50
124
|
};
|
|
51
125
|
}
|
|
126
|
+
|
|
127
|
+
// A named tool gets a "running…" tick so a slow call shows something. The
|
|
128
|
+
// batch command reports per item instead, so it is not double-counted.
|
|
129
|
+
if (onProgress && toolName !== "nexusbloom") {
|
|
130
|
+
await onProgress({ progress: 0, message: `Running ${toolName}…` });
|
|
131
|
+
}
|
|
132
|
+
|
|
52
133
|
return toolName === "nexusbloom"
|
|
53
|
-
? await handlers.meta(args ?? {})
|
|
134
|
+
? await handlers.meta(args ?? {}, { onProgress })
|
|
54
135
|
: await handlers.callDirect(toolName, args ?? {});
|
|
55
136
|
} catch (err) {
|
|
56
137
|
return handlers.handleError ? handlers.handleError(err) : fallbackError(err);
|
|
57
138
|
}
|
|
58
139
|
});
|
|
59
140
|
|
|
60
|
-
return { server, handlers };
|
|
141
|
+
return { server, handlers, resources, prompts, history };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Translate an internal error into a JSON-RPC error.
|
|
146
|
+
*
|
|
147
|
+
* Not-found and bad-argument map to InvalidParams because the agent sent
|
|
148
|
+
* something wrong and can fix it by reading the message; everything else is the
|
|
149
|
+
* server's fault and says so with InternalError.
|
|
150
|
+
*/
|
|
151
|
+
export function toMcpError(err) {
|
|
152
|
+
if (err instanceof McpError) return err;
|
|
153
|
+
const nxb = asNexusBloomError(err);
|
|
154
|
+
const agentFixable = nxb.code === ErrorCode.NOT_FOUND || nxb.code === ErrorCode.INVALID_ARGS;
|
|
155
|
+
return new McpError(
|
|
156
|
+
agentFixable ? McpErrorCode.InvalidParams : McpErrorCode.InternalError,
|
|
157
|
+
nxb.message,
|
|
158
|
+
{ data: { code: nxb.code, retryable: nxb.retryable ?? false } },
|
|
159
|
+
);
|
|
61
160
|
}
|
|
62
161
|
|
|
63
162
|
/**
|
package/src/validate.js
CHANGED
|
@@ -81,7 +81,20 @@ export function assertValidInput(params, inputSchema, slug) {
|
|
|
81
81
|
* as a bare string body.
|
|
82
82
|
*/
|
|
83
83
|
export function coerceParams(params, slug) {
|
|
84
|
-
if (
|
|
84
|
+
if (params === undefined || params === null) return {};
|
|
85
|
+
if (typeof params !== "string") {
|
|
86
|
+
// Guard the non-object case here rather than letting it reach the schema
|
|
87
|
+
// validator, which would report a missing-field error for a payload that was
|
|
88
|
+
// never an object at all. A host sending `42` deserves to be told what it
|
|
89
|
+
// sent, not told which field of a number is absent.
|
|
90
|
+
if (typeof params !== "object" || Array.isArray(params)) {
|
|
91
|
+
throw new NexusBloomError(
|
|
92
|
+
`"params" for "${slug}" must be a JSON object, got ${Array.isArray(params) ? "an array" : typeof params}.`,
|
|
93
|
+
ErrorCode.INVALID_ARGS,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return params;
|
|
97
|
+
}
|
|
85
98
|
const trimmed = params.trim();
|
|
86
99
|
if (!trimmed) return {};
|
|
87
100
|
|