@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.
@@ -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
- export const SERVER_VERSION = "2.0.0";
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({ client, cache, config, handlers: provided }) {
29
- const handlers = provided ?? createHandlers({ client, cache, config });
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(CallToolRequestSchema, async (request) => {
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 (typeof params !== "string") return params ?? {};
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