mcpspan-mcp 0.0.0-stage → 0.9.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/LICENSE +21 -0
- package/README.md +106 -2
- package/dist/index.mjs +407 -0
- package/package.json +54 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kacper Zatoń
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,107 @@
|
|
|
1
|
-
#
|
|
1
|
+
# mcpspan-mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Ask your AI agent about your MCP servers. Claude Code, Cursor or any other MCP
|
|
4
|
+
client answers from your mcpspan numbers:
|
|
5
|
+
|
|
6
|
+
- *Which tools failed most this week, and why?*
|
|
7
|
+
- *How does 1.4.1 compare with 1.4.0?*
|
|
8
|
+
- *What do agents ask for that my server does not have?*
|
|
9
|
+
- *Does Cursor fail more often than Claude?*
|
|
10
|
+
- *Which tools are agents shown but never call?*
|
|
11
|
+
|
|
12
|
+
It reads with a token that sees what you see in the dashboard and changes
|
|
13
|
+
nothing. It sends nothing anywhere but your mcpspan.
|
|
14
|
+
|
|
15
|
+
## Get a token
|
|
16
|
+
|
|
17
|
+
In mcpspan, open **Settings → Read tokens** and make one. It is shown once,
|
|
18
|
+
already filled into the lines below, ready to copy.
|
|
19
|
+
|
|
20
|
+
## Add it to your agent
|
|
21
|
+
|
|
22
|
+
### mcpspan Cloud
|
|
23
|
+
|
|
24
|
+
Claude Code:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
claude mcp add mcpspan -e MCPSPAN_READ_TOKEN=mcpspan_read_... -- npx -y mcpspan-mcp
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Cursor, Claude Desktop, Windsurf and others, in their MCP settings:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"mcpServers": {
|
|
35
|
+
"mcpspan": {
|
|
36
|
+
"command": "npx",
|
|
37
|
+
"args": ["-y", "mcpspan-mcp"],
|
|
38
|
+
"env": { "MCPSPAN_READ_TOKEN": "mcpspan_read_..." }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Self-hosted
|
|
45
|
+
|
|
46
|
+
The same, with the address your MCP servers report to:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
claude mcp add mcpspan \
|
|
50
|
+
-e MCPSPAN_READ_TOKEN=mcpspan_read_... \
|
|
51
|
+
-e MCPSPAN_URL=http://localhost:6271 \
|
|
52
|
+
-- npx -y mcpspan-mcp
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"mcpServers": {
|
|
58
|
+
"mcpspan": {
|
|
59
|
+
"command": "npx",
|
|
60
|
+
"args": ["-y", "mcpspan-mcp"],
|
|
61
|
+
"env": {
|
|
62
|
+
"MCPSPAN_READ_TOKEN": "mcpspan_read_...",
|
|
63
|
+
"MCPSPAN_URL": "http://localhost:6271"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Needs mcpspan 0.9.0 or newer. If the agent reaches your installation over the
|
|
71
|
+
internet, put HTTPS in front of it: the token travels with every question.
|
|
72
|
+
|
|
73
|
+
## Settings
|
|
74
|
+
|
|
75
|
+
| Variable | |
|
|
76
|
+
|---|---|
|
|
77
|
+
| `MCPSPAN_READ_TOKEN` | The read token. Required. |
|
|
78
|
+
| `MCPSPAN_URL` | Your installation's API, for self-hosted mcpspan. `https://api.mcpspan.com` when left out. |
|
|
79
|
+
|
|
80
|
+
Needs Node 20 or newer.
|
|
81
|
+
|
|
82
|
+
## Tools
|
|
83
|
+
|
|
84
|
+
Every tool only reads. Each takes the server by name or id (left out when the
|
|
85
|
+
account has one) and a window: `days` back from now (7 when left out), or
|
|
86
|
+
`from` and `to`.
|
|
87
|
+
|
|
88
|
+
| Tool | Answers |
|
|
89
|
+
|---|---|
|
|
90
|
+
| `list_servers` | The servers the account reports on. |
|
|
91
|
+
| `get_overview` | Calls, failures, error rate, response times and clients. |
|
|
92
|
+
| `list_tools` | Tools ranked by calls, errors, response time or name. |
|
|
93
|
+
| `get_tool` | One tool: why it failed, refused arguments, answer sizes, definition changes, repeats, and the call before each failure. |
|
|
94
|
+
| `list_errors` | The latest failed calls, with the kind of failure and the message. |
|
|
95
|
+
| `list_unknown_tools` | Tools agents asked for that the server lacks, the closest one it has, and what the agent did next. |
|
|
96
|
+
| `list_clients` | Each client's calls, error rate and response times. |
|
|
97
|
+
| `compare_versions` | The server's releases side by side. |
|
|
98
|
+
| `get_tool_listing` | What agents were shown: tools never used, and what their definitions cost. |
|
|
99
|
+
| `list_sessions` | Recent sessions, with failures and repeated calls. |
|
|
100
|
+
| `get_session` | One session's calls, in order. |
|
|
101
|
+
|
|
102
|
+
A token can be revoked at any time under **Settings → Read tokens**; the agent
|
|
103
|
+
loses access at once.
|
|
104
|
+
|
|
105
|
+
## Licence
|
|
106
|
+
|
|
107
|
+
MIT
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
/** How long one question may take before the agent is told it did not come back. */
|
|
6
|
+
const TIMEOUT_MS = 3e4;
|
|
7
|
+
/** A failure worded for the agent, and through it the person, to act on. */
|
|
8
|
+
var ApiError = class extends Error {
|
|
9
|
+
name = "ApiError";
|
|
10
|
+
};
|
|
11
|
+
function createApi({ token, url, fetch: send = fetch }) {
|
|
12
|
+
const base = url.replace(/\/+$/, "");
|
|
13
|
+
return { async get(path, query = {}) {
|
|
14
|
+
if (token === void 0 || token.trim().length === 0) throw new ApiError("MCPSPAN_READ_TOKEN is not set. Make a read token in mcpspan, under Settings → Read tokens, and give it to this MCP server.");
|
|
15
|
+
const target = new URL(`${base}${path}`);
|
|
16
|
+
for (const [key, value] of Object.entries(query)) if (value !== void 0) target.searchParams.set(key, String(value));
|
|
17
|
+
let response;
|
|
18
|
+
try {
|
|
19
|
+
response = await send(target, {
|
|
20
|
+
headers: {
|
|
21
|
+
authorization: `Bearer ${token.trim()}`,
|
|
22
|
+
accept: "application/json"
|
|
23
|
+
},
|
|
24
|
+
signal: AbortSignal.timeout(TIMEOUT_MS)
|
|
25
|
+
});
|
|
26
|
+
} catch {
|
|
27
|
+
throw new ApiError(`Could not reach mcpspan at ${base}. Check MCPSPAN_URL and that the installation is running.`);
|
|
28
|
+
}
|
|
29
|
+
const body = await response.json().catch(() => void 0);
|
|
30
|
+
const said = typeof body?.["error"] === "string" ? body["error"] : void 0;
|
|
31
|
+
if (response.ok && body !== void 0) return body;
|
|
32
|
+
switch (response.status) {
|
|
33
|
+
case 401: throw new ApiError("mcpspan refused the read token: it was revoked or mistyped. Make a new one under Settings → Read tokens.");
|
|
34
|
+
case 404: throw new ApiError(said?.startsWith("No route") === true || said === void 0 ? `${base} does not answer read tokens. It needs mcpspan 0.9.0 or newer, and MCPSPAN_URL must be the address your MCP servers report to.` : said);
|
|
35
|
+
case 429: throw new ApiError(said ?? "Too many questions at once. Wait a minute and ask again.");
|
|
36
|
+
case 503: throw new ApiError(said ?? "mcpspan cannot reach its database right now. Try again shortly.");
|
|
37
|
+
default: throw new ApiError(said ?? `mcpspan answered ${response.status}.`);
|
|
38
|
+
}
|
|
39
|
+
} };
|
|
40
|
+
}
|
|
41
|
+
//#endregion
|
|
42
|
+
//#region src/tools.ts
|
|
43
|
+
/** Every tool here only reads, and reads only what its owner's dashboard shows. */
|
|
44
|
+
const READ_ONLY = {
|
|
45
|
+
readOnlyHint: true,
|
|
46
|
+
destructiveHint: false,
|
|
47
|
+
idempotentHint: true,
|
|
48
|
+
openWorldHint: false
|
|
49
|
+
};
|
|
50
|
+
const server = z.string().optional().describe("The MCP server to ask about, by name or id. Leave out when the account has only one.");
|
|
51
|
+
const days = z.number().int().min(1).max(365).optional().describe("How many days back to look, ending now. 7 when left out. Ignored when from is given.");
|
|
52
|
+
const from = z.string().optional().describe("Start of the window, as an ISO 8601 date or time. Overrides days.");
|
|
53
|
+
const to = z.string().optional().describe("End of the window, as an ISO 8601 date or time. Now when left out.");
|
|
54
|
+
const client = z.string().optional().describe("Only calls from this client type, as list_clients names them, such as claude-code or cursor.");
|
|
55
|
+
const scope = {
|
|
56
|
+
server,
|
|
57
|
+
days,
|
|
58
|
+
from,
|
|
59
|
+
to
|
|
60
|
+
};
|
|
61
|
+
/** The tools an agent gets, each answering from one or a few of the dashboard's endpoints. */
|
|
62
|
+
function registerTools(mcp, api) {
|
|
63
|
+
mcp.registerTool("list_servers", {
|
|
64
|
+
title: "List servers",
|
|
65
|
+
description: "The MCP servers this mcpspan account reports on, with their names and ids.",
|
|
66
|
+
annotations: READ_ONLY
|
|
67
|
+
}, () => answer(async () => {
|
|
68
|
+
const { servers } = await api.get("/v1/servers");
|
|
69
|
+
return { servers: servers.map((s) => ({
|
|
70
|
+
id: s.id,
|
|
71
|
+
name: s.name,
|
|
72
|
+
createdAt: s.createdAt,
|
|
73
|
+
...s.hasActiveKey ? {} : { note: "No API key: it cannot report until one is generated." }
|
|
74
|
+
})) };
|
|
75
|
+
}));
|
|
76
|
+
mcp.registerTool("get_overview", {
|
|
77
|
+
title: "Overview",
|
|
78
|
+
description: "Calls, failures, error rate, response times (median and 95th percentile) and the share of each client, for one server over a window.",
|
|
79
|
+
inputSchema: z.object({
|
|
80
|
+
...scope,
|
|
81
|
+
client
|
|
82
|
+
}),
|
|
83
|
+
annotations: READ_ONLY
|
|
84
|
+
}, (args) => answer(async () => {
|
|
85
|
+
const at = await locate(api, args);
|
|
86
|
+
return at.describe(await api.get("/v1/dashboard/summary", at.query({ clientType: args.client })));
|
|
87
|
+
}));
|
|
88
|
+
mcp.registerTool("list_tools", {
|
|
89
|
+
title: "List tools",
|
|
90
|
+
description: "A server's tools ranked by calls, errors, response time or name, each with its calls, errors, error rate and response times.",
|
|
91
|
+
inputSchema: z.object({
|
|
92
|
+
...scope,
|
|
93
|
+
client,
|
|
94
|
+
sort: z.enum([
|
|
95
|
+
"calls",
|
|
96
|
+
"errors",
|
|
97
|
+
"duration",
|
|
98
|
+
"name"
|
|
99
|
+
]).optional().describe("How to rank them. calls when left out."),
|
|
100
|
+
limit: z.number().int().min(1).max(100).optional().describe("How many to return. 20 when left out.")
|
|
101
|
+
}),
|
|
102
|
+
annotations: READ_ONLY
|
|
103
|
+
}, (args) => answer(async () => {
|
|
104
|
+
const at = await locate(api, args);
|
|
105
|
+
const result = await api.get("/v1/dashboard/tools", at.query({
|
|
106
|
+
clientType: args.client,
|
|
107
|
+
sort: args.sort,
|
|
108
|
+
limit: args.limit ?? 20
|
|
109
|
+
}));
|
|
110
|
+
return at.describe({
|
|
111
|
+
sort: result.sort,
|
|
112
|
+
total: result.total,
|
|
113
|
+
tools: result.tools
|
|
114
|
+
});
|
|
115
|
+
}));
|
|
116
|
+
mcp.registerTool("get_tool", {
|
|
117
|
+
title: "Tool details",
|
|
118
|
+
description: "Everything recorded about one tool: why its calls failed and the commonest error messages, which arguments the server refused, its parameters, how large its answers are, when its definition changed, how often agents repeated a call, and which call came right before the ones that repeated or failed.",
|
|
119
|
+
inputSchema: z.object({
|
|
120
|
+
...scope,
|
|
121
|
+
tool: z.string().describe("The tool's name, as list_tools gives it.")
|
|
122
|
+
}),
|
|
123
|
+
annotations: READ_ONLY
|
|
124
|
+
}, (args) => answer(async () => {
|
|
125
|
+
const at = await locate(api, args);
|
|
126
|
+
const details = await api.get("/v1/dashboard/tool-details", at.query({
|
|
127
|
+
toolName: args.tool,
|
|
128
|
+
messagesLimit: 10,
|
|
129
|
+
parametersLimit: 30,
|
|
130
|
+
beforeLimit: 10
|
|
131
|
+
}));
|
|
132
|
+
return at.describe(pick(details, [
|
|
133
|
+
"toolName",
|
|
134
|
+
"failures",
|
|
135
|
+
"messages",
|
|
136
|
+
"refusedArguments",
|
|
137
|
+
"parameters",
|
|
138
|
+
"responseSizes",
|
|
139
|
+
"definitionChanges",
|
|
140
|
+
"repeats",
|
|
141
|
+
"before"
|
|
142
|
+
]));
|
|
143
|
+
}));
|
|
144
|
+
mcp.registerTool("list_errors", {
|
|
145
|
+
title: "List failed calls",
|
|
146
|
+
description: "The most recent failed calls, newest first: which tool, which client, when, what kind of failure (a handler that threw, a tool that answered with an error, refused arguments, an unknown tool) and the error message when the server records them.",
|
|
147
|
+
inputSchema: z.object({
|
|
148
|
+
...scope,
|
|
149
|
+
client,
|
|
150
|
+
tool: z.string().optional().describe("Only failures of this tool."),
|
|
151
|
+
limit: z.number().int().min(1).max(100).optional().describe("How many to return. 20 when left out.")
|
|
152
|
+
}),
|
|
153
|
+
annotations: READ_ONLY
|
|
154
|
+
}, (args) => answer(async () => {
|
|
155
|
+
const at = await locate(api, args);
|
|
156
|
+
const result = await api.get("/v1/dashboard/errors", at.query({
|
|
157
|
+
clientType: args.client,
|
|
158
|
+
toolName: args.tool,
|
|
159
|
+
limit: args.limit ?? 20
|
|
160
|
+
}));
|
|
161
|
+
return at.describe({
|
|
162
|
+
failures: result.failures.map((call) => pick(call, [
|
|
163
|
+
"occurredAt",
|
|
164
|
+
"kind",
|
|
165
|
+
"toolName",
|
|
166
|
+
"clientType",
|
|
167
|
+
"clientName",
|
|
168
|
+
"errorSource",
|
|
169
|
+
"errorType",
|
|
170
|
+
"errorMessage",
|
|
171
|
+
"durationMs",
|
|
172
|
+
"serverVersion",
|
|
173
|
+
"sessionId",
|
|
174
|
+
"repeated"
|
|
175
|
+
])),
|
|
176
|
+
more: result.nextCursor !== null
|
|
177
|
+
});
|
|
178
|
+
}));
|
|
179
|
+
mcp.registerTool("list_unknown_tools", {
|
|
180
|
+
title: "List unknown tools",
|
|
181
|
+
description: "Tools that agents asked for but the server does not have: how often, which clients, the closest tool the server does have, and what the agent did next.",
|
|
182
|
+
inputSchema: z.object({ ...scope }),
|
|
183
|
+
annotations: READ_ONLY
|
|
184
|
+
}, (args) => answer(async () => {
|
|
185
|
+
const at = await locate(api, args);
|
|
186
|
+
const result = await api.get("/v1/dashboard/unknown-tools", at.query({ limit: 20 }));
|
|
187
|
+
return at.describe({
|
|
188
|
+
tools: result.tools,
|
|
189
|
+
more: result.hasMore
|
|
190
|
+
});
|
|
191
|
+
}));
|
|
192
|
+
mcp.registerTool("list_clients", {
|
|
193
|
+
title: "List clients",
|
|
194
|
+
description: "The clients that called the server (Claude, Claude Code, Cursor, VS Code, ChatGPT and others), each with its calls, error rate and response times.",
|
|
195
|
+
inputSchema: z.object({ ...scope }),
|
|
196
|
+
annotations: READ_ONLY
|
|
197
|
+
}, (args) => answer(async () => {
|
|
198
|
+
const at = await locate(api, args);
|
|
199
|
+
const summary = await api.get("/v1/dashboard/summary", at.query());
|
|
200
|
+
const clients = await Promise.all(summary.clients.slice(0, 15).map(async ({ clientType }) => {
|
|
201
|
+
return {
|
|
202
|
+
clientType,
|
|
203
|
+
...pick(await api.get("/v1/dashboard/summary", at.query({ clientType })), [
|
|
204
|
+
"totalCalls",
|
|
205
|
+
"failedCalls",
|
|
206
|
+
"errorRate",
|
|
207
|
+
"durationMs"
|
|
208
|
+
])
|
|
209
|
+
};
|
|
210
|
+
}));
|
|
211
|
+
return at.describe({ clients });
|
|
212
|
+
}));
|
|
213
|
+
mcp.registerTool("compare_versions", {
|
|
214
|
+
title: "Compare versions",
|
|
215
|
+
description: "The server's versions in the window, newest first, each with when it was first seen, its calls, error rate and response times: how a release compares with the one before.",
|
|
216
|
+
inputSchema: z.object({
|
|
217
|
+
...scope,
|
|
218
|
+
client
|
|
219
|
+
}),
|
|
220
|
+
annotations: READ_ONLY
|
|
221
|
+
}, (args) => answer(async () => {
|
|
222
|
+
const at = await locate(api, args);
|
|
223
|
+
const result = await api.get("/v1/dashboard/versions", at.query({ clientType: args.client }));
|
|
224
|
+
return at.describe(pick(result, ["versions", "unversionedCalls"]));
|
|
225
|
+
}));
|
|
226
|
+
mcp.registerTool("get_tool_listing", {
|
|
227
|
+
title: "What agents are shown",
|
|
228
|
+
description: "Every tool as agents were shown it when they listed the tools: in how many sessions, how many of those called it, and how large its definition is. Finds tools agents see but never use, and definitions that cost context.",
|
|
229
|
+
inputSchema: z.object({
|
|
230
|
+
...scope,
|
|
231
|
+
client
|
|
232
|
+
}),
|
|
233
|
+
annotations: READ_ONLY
|
|
234
|
+
}, (args) => answer(async () => {
|
|
235
|
+
const at = await locate(api, args);
|
|
236
|
+
const result = await api.get("/v1/dashboard/listing", at.query({
|
|
237
|
+
clientType: args.client,
|
|
238
|
+
listingLimit: 100
|
|
239
|
+
}));
|
|
240
|
+
return at.describe(omit(result, [
|
|
241
|
+
"range",
|
|
242
|
+
"offset",
|
|
243
|
+
"limit"
|
|
244
|
+
]));
|
|
245
|
+
}));
|
|
246
|
+
mcp.registerTool("list_sessions", {
|
|
247
|
+
title: "List sessions",
|
|
248
|
+
description: "Recent sessions (one connection each), newest first, with their calls, failures, tools used and calls an agent repeated with the same arguments.",
|
|
249
|
+
inputSchema: z.object({
|
|
250
|
+
...scope,
|
|
251
|
+
client,
|
|
252
|
+
tool: z.string().optional().describe("Only sessions that called this tool."),
|
|
253
|
+
with: z.enum(["failures", "repeats"]).optional().describe("Only sessions with failures, or with repeated calls."),
|
|
254
|
+
limit: z.number().int().min(1).max(100).optional().describe("How many to return. 20 when left out.")
|
|
255
|
+
}),
|
|
256
|
+
annotations: READ_ONLY
|
|
257
|
+
}, (args) => answer(async () => {
|
|
258
|
+
const at = await locate(api, args);
|
|
259
|
+
const result = await api.get("/v1/dashboard/sessions", at.query({
|
|
260
|
+
clientType: args.client,
|
|
261
|
+
toolName: args.tool,
|
|
262
|
+
with: args.with,
|
|
263
|
+
limit: args.limit ?? 20
|
|
264
|
+
}));
|
|
265
|
+
return at.describe({
|
|
266
|
+
sessions: result.sessions,
|
|
267
|
+
more: result.hasMore,
|
|
268
|
+
sampled: result.sampled
|
|
269
|
+
});
|
|
270
|
+
}));
|
|
271
|
+
mcp.registerTool("get_session", {
|
|
272
|
+
title: "Session",
|
|
273
|
+
description: "One session's calls in the order the agent made them, with each call's outcome and error.",
|
|
274
|
+
inputSchema: z.object({
|
|
275
|
+
...scope,
|
|
276
|
+
session: z.string().describe("The session id, as list_sessions or list_errors gives it.")
|
|
277
|
+
}),
|
|
278
|
+
annotations: READ_ONLY
|
|
279
|
+
}, (args) => answer(async () => {
|
|
280
|
+
const at = await locate(api, {
|
|
281
|
+
...args,
|
|
282
|
+
days: args.days ?? 30
|
|
283
|
+
});
|
|
284
|
+
const result = await api.get(`/v1/dashboard/sessions/${encodeURIComponent(args.session)}`, at.query());
|
|
285
|
+
return at.describe({
|
|
286
|
+
sessionId: args.session,
|
|
287
|
+
calls: result.calls.map((call) => omit(call, ["id"])),
|
|
288
|
+
more: result.nextCursor !== null
|
|
289
|
+
});
|
|
290
|
+
}));
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Which server and which window a question is about, worked out once.
|
|
294
|
+
*
|
|
295
|
+
* The server is looked up by name as well as id, since that is how a person
|
|
296
|
+
* names it; with only one, it can be left out, as in the dashboard.
|
|
297
|
+
*/
|
|
298
|
+
async function locate(api, args) {
|
|
299
|
+
const { servers } = await api.get("/v1/servers");
|
|
300
|
+
const chosen = pickServer(servers, args.server);
|
|
301
|
+
const window = windowOf(args);
|
|
302
|
+
return {
|
|
303
|
+
query: (extra = {}) => ({
|
|
304
|
+
serverId: chosen.id,
|
|
305
|
+
from: window.from,
|
|
306
|
+
to: window.to,
|
|
307
|
+
...extra
|
|
308
|
+
}),
|
|
309
|
+
describe: (result) => ({
|
|
310
|
+
server: chosen.name,
|
|
311
|
+
window,
|
|
312
|
+
...omit(result, ["range"])
|
|
313
|
+
})
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
function pickServer(servers, wanted) {
|
|
317
|
+
if (servers.length === 0) throw new ApiError("This account has no servers yet. Add one in mcpspan and connect its SDK.");
|
|
318
|
+
const names = servers.map((s) => s.name).join(", ");
|
|
319
|
+
if (wanted === void 0 || wanted.trim().length === 0) {
|
|
320
|
+
if (servers.length === 1) return servers[0];
|
|
321
|
+
throw new ApiError(`This account has several servers; name one with server: ${names}.`);
|
|
322
|
+
}
|
|
323
|
+
const asked = wanted.trim().toLowerCase();
|
|
324
|
+
const found = servers.find((s) => s.id === wanted.trim()) ?? servers.find((s) => s.name.toLowerCase() === asked) ?? onlyOne(servers.filter((s) => s.name.toLowerCase().includes(asked)));
|
|
325
|
+
if (found === void 0) throw new ApiError(`No server called "${wanted}". The servers are: ${names}.`);
|
|
326
|
+
return found;
|
|
327
|
+
}
|
|
328
|
+
function windowOf(args) {
|
|
329
|
+
const end = args.to === void 0 ? /* @__PURE__ */ new Date() : new Date(args.to);
|
|
330
|
+
if (Number.isNaN(end.getTime())) throw new ApiError(`to is not a date: ${args.to}`);
|
|
331
|
+
const start = args.from === void 0 ? /* @__PURE__ */ new Date(end.getTime() - (args.days ?? 7) * 24 * 60 * 60 * 1e3) : new Date(args.from);
|
|
332
|
+
if (Number.isNaN(start.getTime())) throw new ApiError(`from is not a date: ${args.from}`);
|
|
333
|
+
if (start >= end) throw new ApiError("from must be earlier than to.");
|
|
334
|
+
return {
|
|
335
|
+
from: start.toISOString(),
|
|
336
|
+
to: end.toISOString()
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Fractions cut to what a person would read: a rate to four places, a time
|
|
341
|
+
* or a size to one. Fifteen digits of an error rate cost the agent context
|
|
342
|
+
* and tell it nothing more.
|
|
343
|
+
*/
|
|
344
|
+
function rounded(_key, value) {
|
|
345
|
+
if (typeof value !== "number" || Number.isInteger(value)) return value;
|
|
346
|
+
return Number(value.toFixed(Math.abs(value) < 1 ? 4 : 1));
|
|
347
|
+
}
|
|
348
|
+
function onlyOne(candidates) {
|
|
349
|
+
return candidates.length === 1 ? candidates[0] : void 0;
|
|
350
|
+
}
|
|
351
|
+
function pick(source, keys) {
|
|
352
|
+
return Object.fromEntries(keys.filter((key) => key in source).map((key) => [key, source[key]]));
|
|
353
|
+
}
|
|
354
|
+
function omit(source, keys) {
|
|
355
|
+
return Object.fromEntries(Object.entries(source).filter(([key]) => !keys.includes(key)));
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* A tool's result as text the agent reads: compact JSON, or what went wrong
|
|
359
|
+
* marked as an error, so it can tell the person rather than guess.
|
|
360
|
+
*/
|
|
361
|
+
async function answer(work) {
|
|
362
|
+
try {
|
|
363
|
+
return { content: [{
|
|
364
|
+
type: "text",
|
|
365
|
+
text: JSON.stringify(await work(), rounded)
|
|
366
|
+
}] };
|
|
367
|
+
} catch (error) {
|
|
368
|
+
if (error instanceof ApiError) return {
|
|
369
|
+
content: [{
|
|
370
|
+
type: "text",
|
|
371
|
+
text: error.message
|
|
372
|
+
}],
|
|
373
|
+
isError: true
|
|
374
|
+
};
|
|
375
|
+
throw error;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
//#endregion
|
|
379
|
+
//#region src/version.ts
|
|
380
|
+
/** This package's version, as its server names itself to clients. Kept in step by the release script. */
|
|
381
|
+
const VERSION = "0.9.0";
|
|
382
|
+
//#endregion
|
|
383
|
+
//#region src/server.ts
|
|
384
|
+
/** The MCP server, answering from one mcpspan account through its read token. */
|
|
385
|
+
function buildServer(api) {
|
|
386
|
+
const server = new McpServer({
|
|
387
|
+
name: "mcpspan",
|
|
388
|
+
version: VERSION
|
|
389
|
+
}, {
|
|
390
|
+
capabilities: { tools: {} },
|
|
391
|
+
instructions: "Answers questions about the user's MCP servers from mcpspan: calls, failures, response times, clients, releases and sessions. Every tool reads; none changes anything. Start with list_servers when the account may have several."
|
|
392
|
+
});
|
|
393
|
+
registerTools(server, api);
|
|
394
|
+
return server;
|
|
395
|
+
}
|
|
396
|
+
//#endregion
|
|
397
|
+
//#region src/index.ts
|
|
398
|
+
const token = process.env["MCPSPAN_READ_TOKEN"];
|
|
399
|
+
const url = process.env["MCPSPAN_URL"]?.trim() || "https://api.mcpspan.com";
|
|
400
|
+
if (token === void 0 || token.trim().length === 0) console.error("mcpspan-mcp: MCPSPAN_READ_TOKEN is not set; make one in mcpspan under Settings → Read tokens.");
|
|
401
|
+
const api = createApi({
|
|
402
|
+
token,
|
|
403
|
+
url
|
|
404
|
+
});
|
|
405
|
+
serveStdio(() => buildServer(api));
|
|
406
|
+
//#endregion
|
|
407
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcpspan-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"description": "Ask your AI agent about your MCP servers: which tools fail, how a release compares, what clients look for and do not find. Reads from mcpspan with a read token.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"mcp",
|
|
7
|
+
"mcp-server",
|
|
8
|
+
"model-context-protocol",
|
|
9
|
+
"analytics",
|
|
10
|
+
"observability",
|
|
11
|
+
"mcpspan"
|
|
12
|
+
],
|
|
13
|
+
"mcpName": "io.github.mcpspan/mcpspan",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"author": "Kacper Zatoń",
|
|
16
|
+
"homepage": "https://github.com/mcpspan/mcpspan/tree/main/packages/mcpspan-mcp#readme",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/mcpspan/mcpspan.git",
|
|
20
|
+
"directory": "packages/mcpspan-mcp"
|
|
21
|
+
},
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/mcpspan/mcpspan/issues"
|
|
24
|
+
},
|
|
25
|
+
"type": "module",
|
|
26
|
+
"bin": {
|
|
27
|
+
"mcpspan-mcp": "./dist/index.mjs"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist"
|
|
31
|
+
],
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=20"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@modelcontextprotocol/server": "^2.3.1",
|
|
40
|
+
"zod": "^4.6.5"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@mcpspan/core-api": "0.9.0",
|
|
44
|
+
"@modelcontextprotocol/client": "^2.3.1",
|
|
45
|
+
"@types/node": "^20.19.0",
|
|
46
|
+
"@types/pg": "^8.23.1",
|
|
47
|
+
"pg": "^8.23.0",
|
|
48
|
+
"tsdown": "^0.23.0",
|
|
49
|
+
"typescript": "^6.0.3",
|
|
50
|
+
"vitest": "4.1.11"
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"build": "tsdown",
|
|
54
|
+
"typecheck": "tsc --noEmit",
|
|
55
|
+
"test": "vitest run"
|
|
56
|
+
}
|
|
6
57
|
}
|