@vruum/mcp 0.14.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -75,6 +75,30 @@ Verify it works without configuring anything:
75
75
  npx -y @modelcontextprotocol/inspector --cli npx -y @vruum/mcp --method tools/list
76
76
  ```
77
77
 
78
+ ## Reliability diagnostics
79
+
80
+ The bridge records content-free operational metadata: generated attempt IDs,
81
+ the declared client family (Claude/Codex/ChatGPT/unknown), known tool name,
82
+ start/outcome, timestamp and duration. It never records tool arguments, results,
83
+ credentials, raw error text or conversations. The private outbox is under
84
+ `~/.vruum/mcp-diagnostics/` (or `VRUUM_CONFIG_DIR`), separated by endpoint.
85
+
86
+ Records survive bridge restarts and upload to that same endpoint's authenticated
87
+ `/diagnostics` route after connectivity returns. Only a durable acknowledgement
88
+ removes them. A restored connection drains the backlog in bounded batches.
89
+ Transient failures retry once per minute while the bridge is alive; authentication
90
+ or unsupported-endpoint responses wait for subsequent activity/startup.
91
+ Uploads never follow redirects or transmit credentials over non-local HTTP.
92
+ The queue retains at most 500 records per endpoint and reports overflow;
93
+ disk failures print a coverage warning on stderr. Stdout remains MCP JSON-RPC.
94
+
95
+ Missing-token, connection, protocol, cancellation, invalid-cursor and call
96
+ failures are observable when the bridge runs. An offline machine cannot deliver
97
+ reports until it reconnects, and an old installed package has none of these
98
+ diagnostics. A bridge success means it received the remote result, not that the
99
+ host used it correctly. Business calls are never automatically replayed after
100
+ an ambiguous failure. Server and bridge evidence correlate by attempt ID.
101
+
78
102
  ## What you can ask for
79
103
 
80
104
  Once connected, these are ordinary requests to your agent:
@@ -198,7 +222,7 @@ npm test # builds, then runs the black-box suite
198
222
  npm run typecheck
199
223
  ```
200
224
 
201
- Tests drive the built binary over stdin/stdout and assert **at the wire level** rather than through an SDK client — an SDK-mediated test hides protocol mistakes, since it will happily hand back a result object whether the server returned `result` or `error`. They cover the initialize handshake, credential-free listing, protocol-vs-execution error semantics, cursor rejection, capability declaration, and stdout hygiene.
225
+ Tests drive the built binary over stdin/stdout and assert **at the wire level** rather than through an SDK client — an SDK-mediated test hides protocol mistakes, since it will happily hand back a result object whether the server returned `result` or `error`. They cover the initialize handshake (including negotiation down to older clients on earlier spec revisions), credential-free listing, protocol-vs-execution error semantics, cursor rejection, capability declaration, and stdout hygiene.
202
226
 
203
227
  **Issues and PRs are welcome here.** Maintainers apply accepted changes upstream in the monorepo and they flow back on the next sync — so a merged fix here may appear as part of a sync commit rather than your original one. Changes to `tools.json` must come from the generator, not by hand.
204
228
 
@@ -0,0 +1,122 @@
1
+ /** Bounded, private metadata outbox. Never stores tokens, arguments or results. */
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { mkdir, readFile, readdir, rename, unlink, writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ export function family(name) {
6
+ const value = (name ?? "").toLowerCase();
7
+ for (const prefix of ["claude", "codex", "chatgpt", "vruum-mcp-bridge"]) {
8
+ if (value === prefix || value.startsWith(prefix + "-") || value.startsWith(prefix + "/"))
9
+ return prefix;
10
+ }
11
+ return "unknown";
12
+ }
13
+ const MAX_PENDING = 500;
14
+ const FILE = /^\d{13}-[0-9a-f-]{36}\.json$/;
15
+ export class Diagnostics {
16
+ endpoint;
17
+ token;
18
+ tools;
19
+ getClient;
20
+ directory;
21
+ active = null;
22
+ writing = Promise.resolve();
23
+ retry = null;
24
+ constructor(root, endpoint, token, tools, getClient) {
25
+ this.endpoint = endpoint;
26
+ this.token = token;
27
+ this.tools = tools;
28
+ this.getClient = getClient;
29
+ // Keep environments separate without retaining the configured URL.
30
+ this.directory = join(root, "mcp-diagnostics", createHash("sha256").update(endpoint).digest("hex"));
31
+ }
32
+ async record(attempt, method, tool, outcome, duration = 0) {
33
+ const observation = {
34
+ id: randomUUID(), attempt_id: attempt, observed_at: new Date().toISOString(),
35
+ client: this.getClient(), method, tool_name: this.tools.has(tool) ? tool : "unknown",
36
+ outcome, duration_ms: Math.min(86400000, Math.max(0, Math.round(duration))),
37
+ };
38
+ this.writing = this.writing.then(async () => {
39
+ await mkdir(this.directory, { recursive: true, mode: 0o700 });
40
+ const names = (await readdir(this.directory)).filter((name) => FILE.test(name)).sort();
41
+ if (names.length >= MAX_PENDING) {
42
+ // Preserve an explicit loss marker, even when connectivity is absent.
43
+ await Promise.all(names.slice(0, 2).map((name) => unlink(join(this.directory, name)).catch(() => { })));
44
+ await this.write({ ...observation, id: randomUUID(), outcome: "queue_overflow" });
45
+ console.error("vruum-mcp diagnostics queue overflow; coverage is incomplete");
46
+ }
47
+ await this.write(observation);
48
+ }).catch(() => {
49
+ console.error("vruum-mcp diagnostics storage unavailable; coverage is incomplete");
50
+ });
51
+ await this.writing;
52
+ }
53
+ async write(observation) {
54
+ const destination = join(this.directory, `${Date.now()}-${observation.id}.json`);
55
+ const temporary = destination + ".tmp";
56
+ await writeFile(temporary, JSON.stringify(observation), { mode: 0o600, flag: "wx" });
57
+ await rename(temporary, destination);
58
+ }
59
+ flush() {
60
+ if (!this.token)
61
+ return Promise.resolve();
62
+ if (!this.active) {
63
+ if (this.retry)
64
+ clearTimeout(this.retry);
65
+ this.retry = null;
66
+ this.active = this.upload().finally(() => { this.active = null; });
67
+ }
68
+ return this.active;
69
+ }
70
+ retryLater() {
71
+ if (this.retry)
72
+ return;
73
+ this.retry = setTimeout(() => {
74
+ this.retry = null;
75
+ void this.flush();
76
+ }, 60000).unref();
77
+ }
78
+ async upload() {
79
+ try {
80
+ const url = new URL(this.endpoint);
81
+ // A diagnostic must not leak credentials through a redirect or downgrade.
82
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)))
83
+ return;
84
+ url.pathname = url.pathname.replace(/\/+$/, "") + "/diagnostics";
85
+ url.search = "";
86
+ url.hash = "";
87
+ // Drain the bounded backlog without requiring another business call.
88
+ // Ten batches match the collector's per-minute allowance.
89
+ for (let batch = 0; batch < 10; batch++) {
90
+ await this.writing;
91
+ const names = (await readdir(this.directory).catch(() => []))
92
+ .filter((name) => FILE.test(name)).sort().slice(0, 50);
93
+ if (!names.length)
94
+ return;
95
+ const events = await Promise.all(names.map(async (name) => {
96
+ const raw = await readFile(join(this.directory, name), "utf8");
97
+ if (raw.length > 4096)
98
+ throw new Error("invalid diagnostic");
99
+ return JSON.parse(raw);
100
+ }));
101
+ const response = await fetch(url, {
102
+ method: "POST", redirect: "error", signal: AbortSignal.timeout(2000),
103
+ headers: { Authorization: `Bearer ${this.token}`, "Content-Type": "application/json", "X-Vruum-Client": this.getClient() },
104
+ body: JSON.stringify({ events }),
105
+ });
106
+ if (response.status !== 200) {
107
+ if (response.status === 429 || response.status >= 500)
108
+ this.retryLater();
109
+ return;
110
+ }
111
+ await Promise.all(names.map((name) => unlink(join(this.directory, name)).catch(() => { })));
112
+ }
113
+ if ((await readdir(this.directory)).some((name) => FILE.test(name)))
114
+ this.retryLater();
115
+ }
116
+ catch {
117
+ // Durable records stay for a later connection. Never log the exception:
118
+ // fetch errors and endpoint URLs can carry credentials.
119
+ this.retryLater();
120
+ }
121
+ }
122
+ }
package/dist/index.js CHANGED
@@ -29,15 +29,17 @@ import { readFileSync } from "node:fs";
29
29
  import { homedir } from "node:os";
30
30
  import { dirname, join } from "node:path";
31
31
  import { fileURLToPath } from "node:url";
32
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
33
- import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
34
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
35
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
36
- import { CallToolRequestSchema, CallToolResultSchema, ErrorCode, ListToolsRequestSchema, McpError, } from "@modelcontextprotocol/sdk/types.js";
32
+ import { randomUUID } from "node:crypto";
33
+ import { AsyncLocalStorage } from "node:async_hooks";
34
+ import { Diagnostics, family } from "./diagnostics.js";
35
+ import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
36
+ import { ProtocolError, ProtocolErrorCode, Server, } from "@modelcontextprotocol/server";
37
+ import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
37
38
  const DEFAULT_MCP_URL = "https://api.vruum.ai/mcp";
38
39
  // The SDK's per-request default is 60s, which real Vruum tools (research,
39
40
  // imports) legitimately exceed. Overridable via VRUUM_MCP_TIMEOUT_MS.
40
41
  const DEFAULT_CALL_TIMEOUT_MS = 300_000;
42
+ const attempts = new AsyncLocalStorage();
41
43
  function resolveCallTimeoutMs() {
42
44
  const raw = Number(process.env.VRUUM_MCP_TIMEOUT_MS);
43
45
  return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_CALL_TIMEOUT_MS;
@@ -102,15 +104,19 @@ class RemoteProxy {
102
104
  url;
103
105
  token;
104
106
  version;
107
+ diagnostics;
108
+ clientName;
105
109
  client = null;
106
110
  /** Serializes connection creation so concurrent first calls share one
107
111
  * client instead of racing to overwrite `this.client` (and later closing
108
112
  * each other's healthy connections). */
109
113
  connecting = null;
110
- constructor(url, token, version) {
114
+ constructor(url, token, version, diagnostics, clientName) {
111
115
  this.url = url;
112
116
  this.token = token;
113
117
  this.version = version;
118
+ this.diagnostics = diagnostics;
119
+ this.clientName = clientName;
114
120
  }
115
121
  getClient() {
116
122
  if (this.client)
@@ -118,6 +124,14 @@ class RemoteProxy {
118
124
  if (!this.connecting) {
119
125
  this.connecting = (async () => {
120
126
  const transport = new StreamableHTTPClientTransport(new URL(this.url), {
127
+ fetch: (url, init) => {
128
+ const headers = new Headers(init?.headers);
129
+ headers.set("X-Vruum-Client", this.clientName());
130
+ const attempt = attempts.getStore();
131
+ if (attempt)
132
+ headers.set("X-Vruum-Attempt-Id", attempt);
133
+ return fetch(url, { ...init, headers });
134
+ },
121
135
  requestInit: {
122
136
  headers: { Authorization: `Bearer ${this.token}` },
123
137
  },
@@ -150,7 +164,7 @@ class RemoteProxy {
150
164
  * Vruum tools (research, imports) legitimately run longer.
151
165
  */
152
166
  rawCall(client, name, args) {
153
- return client.request({ method: "tools/call", params: { name, arguments: args } }, CallToolResultSchema, { timeout: resolveCallTimeoutMs(), resetTimeoutOnProgress: true });
167
+ return client.request({ method: "tools/call", params: { name, arguments: args } }, { timeout: resolveCallTimeoutMs(), resetTimeoutOnProgress: true });
154
168
  }
155
169
  async call(name, args) {
156
170
  let client;
@@ -158,6 +172,7 @@ class RemoteProxy {
158
172
  client = await this.getClient();
159
173
  }
160
174
  catch (connectErr) {
175
+ await this.diagnostics.record(attempts.getStore(), "tools/call", name, "connect_error");
161
176
  // First-connect failures (DNS, invalid token, endpoint down) get the
162
177
  // same structured error path as call failures — never a raw handler
163
178
  // throw that surfaces as an opaque JSON-RPC internal error.
@@ -170,6 +185,7 @@ class RemoteProxy {
170
185
  return await this.rawCall(client, name, args);
171
186
  }
172
187
  catch (callErr) {
188
+ await this.diagnostics.record(attempts.getStore(), "tools/call", name, "call_error");
173
189
  this.discard(client);
174
190
  const msg = callErr instanceof Error ? callErr.message : String(callErr);
175
191
  return errorResult(`Vruum MCP call '${name}' failed against ${this.url}: ${msg}\n` +
@@ -186,7 +202,6 @@ async function main() {
186
202
  const creds = readCredentials();
187
203
  const token = resolveToken(creds);
188
204
  const mcpUrl = resolveMcpUrl(creds);
189
- const proxy = token ? new RemoteProxy(mcpUrl, token, pkg.version) : null;
190
205
  const server = new Server({ name: "vruum-mcp", version: pkg.version }, {
191
206
  // `listChanged: false` is the honest declaration: the listing is a
192
207
  // static snapshot bundled at release time, so this server never emits
@@ -196,32 +211,65 @@ async function main() {
196
211
  "served locally; tool calls execute against " +
197
212
  `${mcpUrl} under your Vruum token and are authorized server-side.`,
198
213
  });
199
- server.setRequestHandler(ListToolsRequestSchema, async (request) => {
214
+ const diagnostics = new Diagnostics(process.env.VRUUM_CONFIG_DIR || join(homedir(), ".vruum"), mcpUrl, token, new Set(toolsDoc.tools.map((tool) => tool.name)), () => family(server.getClientVersion()?.name));
215
+ const proxy = token ? new RemoteProxy(mcpUrl, token, pkg.version, diagnostics, () => family(server.getClientVersion()?.name)) : null;
216
+ server.onerror = () => {
217
+ void diagnostics.record(randomUUID(), "unknown", "unknown", "protocol_error")
218
+ .then(() => diagnostics.flush());
219
+ };
220
+ server.setRequestHandler("tools/list", async (request) => {
221
+ const attempt = randomUUID();
222
+ await diagnostics.record(attempt, "tools/list", "unknown", "started");
200
223
  // The full list is returned in one page, so `nextCursor` is never set and
201
224
  // a client can never legitimately hold a cursor for this server. Spec:
202
225
  // "Invalid cursors SHOULD result in an error with code -32602."
203
226
  const cursor = request.params?.cursor;
204
227
  if (cursor !== undefined) {
205
- throw new McpError(ErrorCode.InvalidParams, "Invalid cursor: this server returns all tools in a single page and " +
228
+ await diagnostics.record(attempt, "tools/list", "unknown", "invalid_cursor");
229
+ void diagnostics.flush();
230
+ throw new ProtocolError(ProtocolErrorCode.InvalidParams, "Invalid cursor: this server returns all tools in a single page and " +
206
231
  "never issues a nextCursor.");
207
232
  }
233
+ await diagnostics.record(attempt, "tools/list", "unknown", "success");
234
+ void diagnostics.flush();
208
235
  return { tools: toolsDoc.tools };
209
236
  });
210
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
237
+ server.setRequestHandler("tools/call", async (request, ctx) => {
211
238
  const { name, arguments: args } = request.params;
239
+ const attempt = randomUUID();
240
+ const start = performance.now();
241
+ ctx.mcpReq.signal.addEventListener("abort", () => {
242
+ void diagnostics.record(attempt, "tools/call", name, "cancelled")
243
+ .then(() => diagnostics.flush());
244
+ }, { once: true });
245
+ await diagnostics.record(attempt, "tools/call", name, "started");
212
246
  // Spec (server/tools#error-handling): errors in *finding* the tool are
213
247
  // PROTOCOL errors, not `isError` results — only errors originating from a
214
248
  // tool's execution use `isError: true` (so the model can self-correct).
215
249
  if (!toolsDoc.tools.some((t) => t.name === name)) {
216
- throw new McpError(ErrorCode.InvalidParams, `Unknown tool: ${name}`);
250
+ await diagnostics.record(attempt, "tools/call", name, "unknown_tool");
251
+ void diagnostics.flush();
252
+ throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Unknown tool: ${name}`);
217
253
  }
218
254
  // A missing token IS an execution error — the tool exists and the model
219
255
  // should see why it could not run.
220
- if (!proxy)
256
+ if (!proxy) {
257
+ await diagnostics.record(attempt, "tools/call", name, "missing_token");
221
258
  return errorResult(NO_TOKEN_HELP);
222
- return proxy.call(name, (args ?? {}));
259
+ }
260
+ const result = await attempts.run(attempt, () => proxy.call(name, (args ?? {})));
261
+ await diagnostics.record(attempt, "tools/call", name, result.isError ? "tool_error" : "success", performance.now() - start);
262
+ void diagnostics.flush();
263
+ return result;
223
264
  });
265
+ // A Server connected straight to StdioServerTransport speaks only the
266
+ // 2025-era protocol (initialize handshake, 2024-10-07 … 2025-11-25), which
267
+ // is every client deployed today. Serving 2026-07-28 too would mean
268
+ // `serveStdio()`, where getClientVersion() is undefined on modern
269
+ // connections and the diagnostics client family needs the request envelope.
224
270
  await server.connect(new StdioServerTransport());
271
+ // Old offline reports may be uploaded even before a new tool is called.
272
+ void diagnostics.flush();
225
273
  }
226
274
  main().catch((err) => {
227
275
  console.error(`vruum-mcp fatal: ${err instanceof Error ? err.message : err}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vruum/mcp",
3
- "version": "0.14.0",
3
+ "version": "0.15.1",
4
4
  "mcpName": "ai.vruum/mcp",
5
5
  "description": "Stdio MCP bridge for the hosted Vruum revenue-platform MCP (https://api.vruum.ai/mcp): serves the advertised tool surface for credential-free introspection and proxies tool calls with your Vruum token.",
6
6
  "license": "MIT",
@@ -34,7 +34,8 @@
34
34
  "prepublishOnly": "npm run typecheck && npm run test"
35
35
  },
36
36
  "dependencies": {
37
- "@modelcontextprotocol/sdk": "^1.30.0"
37
+ "@modelcontextprotocol/client": "^2.4.0",
38
+ "@modelcontextprotocol/server": "^2.4.0"
38
39
  },
39
40
  "devDependencies": {
40
41
  "@types/node": "^24.0.0",