@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 +25 -1
- package/dist/diagnostics.js +122 -0
- package/dist/index.js +62 -14
- package/package.json +3 -2
- package/tools.json +153 -66
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 {
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
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 } },
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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/
|
|
37
|
+
"@modelcontextprotocol/client": "^2.4.0",
|
|
38
|
+
"@modelcontextprotocol/server": "^2.4.0"
|
|
38
39
|
},
|
|
39
40
|
"devDependencies": {
|
|
40
41
|
"@types/node": "^24.0.0",
|