mcp-tollbooth 0.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/LICENSE +21 -0
- package/README.md +93 -0
- package/dist/auditor.js +122 -0
- package/dist/configLocator.js +97 -0
- package/dist/heuristics.js +80 -0
- package/dist/index.js +55 -0
- package/dist/registryInfo.js +111 -0
- package/dist/report.js +96 -0
- package/dist/types.js +1 -0
- package/package.json +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mcp-tollbooth contributors
|
|
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
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# MCP Tollbooth ๐งพ
|
|
2
|
+
|
|
3
|
+
**How much context window are your MCP servers actually costing you โ and are any of them doing the same job twice?**
|
|
4
|
+
|
|
5
|
+
MCP Tollbooth scans your local [Model Context Protocol](https://modelcontextprotocol.io) configuration (Claude Desktop, Claude Code, Cursor, VS Code) and answers two questions nobody else is answering well:
|
|
6
|
+
|
|
7
|
+
- ๐งฎ **Token budget** โ how many tokens each server silently costs you before your agent does anything useful, and what the total toll is across your whole setup
|
|
8
|
+
- ๐ **Overlap detection** โ do you have three different servers all fighting to do "GitHub stuff"? Pick one, save the budget.
|
|
9
|
+
- ๐ A lightweight **trust signal** (repo activity, stars, license) as a secondary check, clearly labeled as a heuristic โ not a security scanner
|
|
10
|
+
|
|
11
|
+
## Why this, and not a security scanner
|
|
12
|
+
|
|
13
|
+
By mid-2026 the MCP ecosystem has real security tooling already: [`@wigu/mcp-doctor`](https://www.npmjs.com/package/@wigu/mcp-doctor) does secret-leak and permission scanning with a GitHub Action; other tools do agent-friendliness diagnostics. That space is getting crowded, and doing it shallowly wouldn't add much.
|
|
14
|
+
|
|
15
|
+
What's still missing is the boring-but-real cost problem: **every server you add eats context before your agent types a word.** A handful of servers can burn 50,000+ tokens of budget, and most people have no idea which ones are the expensive ones โ or that they've installed the same capability twice under two different names. Tollbooth answers exactly that, and nothing more than that, well.
|
|
16
|
+
|
|
17
|
+
## Install & use
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npx mcp-tollbooth
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
No install, no config. It scans the standard locations:
|
|
24
|
+
|
|
25
|
+
- `.mcp.json` (Claude Code, project-scoped)
|
|
26
|
+
- `~/.claude.json` (Claude Code, user-scoped)
|
|
27
|
+
- `claude_desktop_config.json` (Claude Desktop โ macOS/Windows/Linux paths)
|
|
28
|
+
- `.cursor/mcp.json` and `~/.cursor/mcp.json` (Cursor)
|
|
29
|
+
- `.vscode/mcp.json` (VS Code)
|
|
30
|
+
|
|
31
|
+
Or point it at a specific file:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npx mcp-tollbooth --config ./my-config.json
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Raw JSON for scripting / CI:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npx mcp-tollbooth --json
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Example output
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
MCP Tollbooth โ what your MCP servers are costing your context window
|
|
47
|
+
|
|
48
|
+
Scanned config files:
|
|
49
|
+
โข Claude Code (project .mcp.json) (/you/project/.mcp.json) โ 5 server(s)
|
|
50
|
+
|
|
51
|
+
โโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโฌโโโโโโโโโโโโโฌโโโโโโโโโโโโฌโโโโโโโโโ
|
|
52
|
+
โ Server โ Category โ Est. Tokens โ Trust* โ Last Push โ Stars โ
|
|
53
|
+
โโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโโโโผโโโโโโโโโโโโผโโโโโโโโโค
|
|
54
|
+
โ playwright โ browser-automation โ ~3,200 โ 95 Healthy โ 8d ago โ 34,813 โ
|
|
55
|
+
โ github โ vcs โ ~2,400 โ 60 Caution โ unknown โ โ โ
|
|
56
|
+
โ filesystem โ filesystem โ ~900 โ 95 Healthy โ 1d ago โ 88,173 โ
|
|
57
|
+
โ some-random-tool โ other โ ~800* โ 40 Risky โ unknown โ โ โ
|
|
58
|
+
โโโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโดโโโโโโโโโโโโโดโโโโโโโโโโโโดโโโโโโโโโ
|
|
59
|
+
|
|
60
|
+
Overlap detected โ you're paying twice for the same job:
|
|
61
|
+
โข vcs: github, some-other-git-tool โ do you need all of these?
|
|
62
|
+
|
|
63
|
+
Total context toll: ~7,300 tokens across 4 server(s) ยท avg trust 72/100
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## How it works
|
|
67
|
+
|
|
68
|
+
**Token estimates** for well-known servers are based on their documented tool counts. For everything else, Tollbooth falls back to a conservative heuristic based on the server's transport type โ flagged with `*`, and clearly an *estimate, not a measurement*. Live connection mode (actually counting tool schemas) is on the roadmap.
|
|
69
|
+
|
|
70
|
+
**Overlap detection** groups servers by inferred category (vcs, filesystem, browser-automation, etc.) and flags any category with more than one server.
|
|
71
|
+
|
|
72
|
+
**Trust score** (0-100) is a secondary, clearly-labeled heuristic: official npm scope, days since last commit, GitHub stars, archived status, license presence. It is not a security scan โ it doesn't read source code, run the server, or detect malicious behavior. If you want real security scanning, use a dedicated tool for that (see above).
|
|
73
|
+
|
|
74
|
+
## Limitations
|
|
75
|
+
|
|
76
|
+
- Token estimates for unlisted servers are heuristics, not measurements.
|
|
77
|
+
- GitHub/npm lookups depend on the package's `repository` field being accurate.
|
|
78
|
+
- Unauthenticated GitHub API calls are rate-limited โ set `GITHUB_TOKEN` if auditing a lot of servers at once.
|
|
79
|
+
|
|
80
|
+
## Roadmap
|
|
81
|
+
|
|
82
|
+
- [ ] `--precise` live connection mode for exact token counts (connect and count real tool schemas)
|
|
83
|
+
- [ ] `--fix` mode to interactively disable low-value / redundant servers
|
|
84
|
+
- [ ] GitHub Action for CI-time token-budget regression checks on team MCP configs
|
|
85
|
+
- [ ] Per-server historical token trend (did that update just double your bill?)
|
|
86
|
+
|
|
87
|
+
## Contributing
|
|
88
|
+
|
|
89
|
+
Issues and PRs welcome โ especially the known-server token cost table in `src/heuristics.ts` and category keyword coverage.
|
|
90
|
+
|
|
91
|
+
## License
|
|
92
|
+
|
|
93
|
+
MIT
|
package/dist/auditor.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { categorize, estimateTokens } from "./heuristics.js";
|
|
2
|
+
import { classifyServer, fetchGithubRepoInfo, fetchNpmPackageInfo } from "./registryInfo.js";
|
|
3
|
+
const OFFICIAL_SCOPES = ["@modelcontextprotocol", "@anthropic-ai", "@upstash", "@notionhq", "@playwright"];
|
|
4
|
+
function computeTrustScore(params) {
|
|
5
|
+
const notes = [];
|
|
6
|
+
let score = 50; // neutral baseline
|
|
7
|
+
const { githubLookup, kind, packageName } = params;
|
|
8
|
+
if (kind === "unknown") {
|
|
9
|
+
notes.push("Could not determine how this server runs โ treat as untrusted until verified manually.");
|
|
10
|
+
return { score: 20, notes, rateLimited: false };
|
|
11
|
+
}
|
|
12
|
+
if (packageName && OFFICIAL_SCOPES.some((scope) => packageName.startsWith(scope))) {
|
|
13
|
+
score += 20;
|
|
14
|
+
notes.push("Published under a recognized official/vendor npm scope.");
|
|
15
|
+
}
|
|
16
|
+
if (githubLookup?.status === "rate-limited") {
|
|
17
|
+
notes.push("GitHub rate limit hit โ repo activity/stars not verified this run (set GITHUB_TOKEN).");
|
|
18
|
+
return { score: clamp(score), notes, rateLimited: true };
|
|
19
|
+
}
|
|
20
|
+
if (githubLookup?.status === "error") {
|
|
21
|
+
notes.push("Could not reach GitHub to verify repo activity โ network or API error.");
|
|
22
|
+
return { score: clamp(score), notes, rateLimited: false };
|
|
23
|
+
}
|
|
24
|
+
if (!githubLookup || githubLookup.status === "not-found") {
|
|
25
|
+
notes.push("No public GitHub repo found โ could not verify maintenance status.");
|
|
26
|
+
score -= 10;
|
|
27
|
+
return { score: clamp(score), notes, rateLimited: false };
|
|
28
|
+
}
|
|
29
|
+
const githubInfo = githubLookup.info;
|
|
30
|
+
if (githubInfo.archived) {
|
|
31
|
+
score -= 40;
|
|
32
|
+
notes.push("Repository is archived โ no further security fixes expected.");
|
|
33
|
+
}
|
|
34
|
+
if (githubInfo.daysSinceLastPush > 365) {
|
|
35
|
+
score -= 25;
|
|
36
|
+
notes.push(`No commits in ${Math.floor(githubInfo.daysSinceLastPush / 30)}+ months โ likely unmaintained.`);
|
|
37
|
+
}
|
|
38
|
+
else if (githubInfo.daysSinceLastPush > 180) {
|
|
39
|
+
score -= 10;
|
|
40
|
+
notes.push(`Last commit was ${Math.floor(githubInfo.daysSinceLastPush / 30)} months ago.`);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
score += 10;
|
|
44
|
+
notes.push("Actively maintained (commit within the last 6 months).");
|
|
45
|
+
}
|
|
46
|
+
if (githubInfo.stars >= 1000) {
|
|
47
|
+
score += 15;
|
|
48
|
+
notes.push(`Well-established: ${githubInfo.stars.toLocaleString()} GitHub stars.`);
|
|
49
|
+
}
|
|
50
|
+
else if (githubInfo.stars < 20) {
|
|
51
|
+
score -= 10;
|
|
52
|
+
notes.push(`Low visibility: only ${githubInfo.stars} GitHub stars โ limited community scrutiny.`);
|
|
53
|
+
}
|
|
54
|
+
if (!githubInfo.hasLicense) {
|
|
55
|
+
score -= 10;
|
|
56
|
+
notes.push("No OSS license detected on the repo.");
|
|
57
|
+
}
|
|
58
|
+
return { score: clamp(score), notes, rateLimited: false };
|
|
59
|
+
}
|
|
60
|
+
function clamp(n) {
|
|
61
|
+
return Math.max(0, Math.min(100, Math.round(n)));
|
|
62
|
+
}
|
|
63
|
+
export async function auditConfigFiles(configFiles) {
|
|
64
|
+
const warnings = [];
|
|
65
|
+
const jobs = [];
|
|
66
|
+
for (const file of configFiles) {
|
|
67
|
+
for (const [serverName, rawCfg] of Object.entries(file.servers)) {
|
|
68
|
+
jobs.push({ file, serverName, rawCfg });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const results = await Promise.all(jobs.map(async ({ file, serverName, rawCfg }) => {
|
|
72
|
+
const { kind, packageName, rawCommandLine } = classifyServer(rawCfg);
|
|
73
|
+
let packageInfo;
|
|
74
|
+
let githubLookup;
|
|
75
|
+
if (packageName) {
|
|
76
|
+
packageInfo = (await fetchNpmPackageInfo(packageName)) ?? { name: packageName };
|
|
77
|
+
githubLookup = await fetchGithubRepoInfo(packageInfo.repositoryUrl);
|
|
78
|
+
}
|
|
79
|
+
const { tokens, confidence } = estimateTokens(packageName, kind);
|
|
80
|
+
const category = categorize(serverName, packageName);
|
|
81
|
+
const { score, notes } = computeTrustScore({ githubLookup, kind, packageName });
|
|
82
|
+
return {
|
|
83
|
+
configSource: file.source,
|
|
84
|
+
serverName,
|
|
85
|
+
kind,
|
|
86
|
+
rawCommandLine,
|
|
87
|
+
packageInfo,
|
|
88
|
+
githubInfo: githubLookup?.status === "found" ? githubLookup.info : null,
|
|
89
|
+
category,
|
|
90
|
+
estimatedTokens: tokens,
|
|
91
|
+
tokenConfidence: confidence,
|
|
92
|
+
trustScore: score,
|
|
93
|
+
trustNotes: notes,
|
|
94
|
+
rateLimited: githubLookup?.status === "rate-limited",
|
|
95
|
+
};
|
|
96
|
+
}));
|
|
97
|
+
const servers = results.map(({ rateLimited, ...rest }) => rest);
|
|
98
|
+
const anyRateLimited = results.some((r) => r.rateLimited);
|
|
99
|
+
if (anyRateLimited) {
|
|
100
|
+
warnings.push("GitHub API rate limit was hit while checking repo activity โ some trust scores are less precise this run. Set a GITHUB_TOKEN env var for higher limits.");
|
|
101
|
+
}
|
|
102
|
+
const totalEstimatedTokens = servers.reduce((sum, s) => sum + s.estimatedTokens, 0);
|
|
103
|
+
const overallScore = servers.length
|
|
104
|
+
? Math.round(servers.reduce((sum, s) => sum + s.trustScore, 0) / servers.length)
|
|
105
|
+
: 100;
|
|
106
|
+
// Overlap detection: same category, more than one server, excluding "other"
|
|
107
|
+
const byCategory = new Map();
|
|
108
|
+
for (const s of servers) {
|
|
109
|
+
if (s.category === "other")
|
|
110
|
+
continue;
|
|
111
|
+
const list = byCategory.get(s.category) ?? [];
|
|
112
|
+
list.push(s.serverName);
|
|
113
|
+
byCategory.set(s.category, list);
|
|
114
|
+
}
|
|
115
|
+
const overlapGroups = [...byCategory.entries()]
|
|
116
|
+
.filter(([, names]) => names.length > 1)
|
|
117
|
+
.map(([category, serverNames]) => ({ category: category, serverNames }));
|
|
118
|
+
if (totalEstimatedTokens > 40000) {
|
|
119
|
+
warnings.push(`Your MCP servers use an estimated ~${totalEstimatedTokens.toLocaleString()} tokens of context before you type a single message. Consider trimming to your 3-5 most-used servers.`);
|
|
120
|
+
}
|
|
121
|
+
return { servers, totalEstimatedTokens, overallScore, overlapGroups, warnings };
|
|
122
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir, platform } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
function candidatePaths() {
|
|
5
|
+
const home = homedir();
|
|
6
|
+
const cwd = process.cwd();
|
|
7
|
+
const plat = platform();
|
|
8
|
+
const candidates = [
|
|
9
|
+
// Project-scoped configs (Claude Code / Cursor convention)
|
|
10
|
+
{ source: "Claude Code (project .mcp.json)", path: join(cwd, ".mcp.json") },
|
|
11
|
+
{ source: "Cursor (project .cursor/mcp.json)", path: join(cwd, ".cursor", "mcp.json") },
|
|
12
|
+
{ source: "VS Code (project .vscode/mcp.json)", path: join(cwd, ".vscode", "mcp.json") },
|
|
13
|
+
// User-scoped configs
|
|
14
|
+
{ source: "Claude Code (user ~/.claude.json)", path: join(home, ".claude.json") },
|
|
15
|
+
{ source: "Cursor (user ~/.cursor/mcp.json)", path: join(home, ".cursor", "mcp.json") },
|
|
16
|
+
];
|
|
17
|
+
if (plat === "darwin") {
|
|
18
|
+
candidates.push({
|
|
19
|
+
source: "Claude Desktop (macOS)",
|
|
20
|
+
path: join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json"),
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
else if (plat === "win32") {
|
|
24
|
+
const appData = process.env.APPDATA ?? join(home, "AppData", "Roaming");
|
|
25
|
+
candidates.push({
|
|
26
|
+
source: "Claude Desktop (Windows)",
|
|
27
|
+
path: join(appData, "Claude", "claude_desktop_config.json"),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
candidates.push({
|
|
32
|
+
source: "Claude Desktop (Linux)",
|
|
33
|
+
path: join(home, ".config", "Claude", "claude_desktop_config.json"),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return candidates;
|
|
37
|
+
}
|
|
38
|
+
function extractServers(raw) {
|
|
39
|
+
if (!raw || typeof raw !== "object")
|
|
40
|
+
return {};
|
|
41
|
+
const obj = raw;
|
|
42
|
+
const servers = obj.mcpServers ?? obj.servers;
|
|
43
|
+
if (!servers || typeof servers !== "object")
|
|
44
|
+
return {};
|
|
45
|
+
return servers;
|
|
46
|
+
}
|
|
47
|
+
/** Thrown only for an explicitly-passed --config path, so failures are never confused with "nothing found in usual locations". */
|
|
48
|
+
export class ExplicitConfigError extends Error {
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Scans known MCP config locations (Claude Desktop, Claude Code, Cursor, VS Code)
|
|
52
|
+
* and returns every config file that actually exists and declares servers.
|
|
53
|
+
*
|
|
54
|
+
* If `explicitPath` is given, a missing file, unreadable/invalid JSON, or a
|
|
55
|
+
* valid file with zero servers all throw ExplicitConfigError with a specific
|
|
56
|
+
* reason โ silently falling through to "no configs found" would be misleading
|
|
57
|
+
* when the user pointed us at a file directly.
|
|
58
|
+
*/
|
|
59
|
+
export function discoverConfigFiles(explicitPath) {
|
|
60
|
+
if (explicitPath) {
|
|
61
|
+
if (!existsSync(explicitPath)) {
|
|
62
|
+
throw new ExplicitConfigError(`No file found at "${explicitPath}".`);
|
|
63
|
+
}
|
|
64
|
+
let parsed;
|
|
65
|
+
try {
|
|
66
|
+
const content = readFileSync(explicitPath, "utf-8");
|
|
67
|
+
parsed = JSON.parse(content);
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
71
|
+
throw new ExplicitConfigError(`Could not read/parse "${explicitPath}" as JSON: ${reason}`);
|
|
72
|
+
}
|
|
73
|
+
const servers = extractServers(parsed);
|
|
74
|
+
if (Object.keys(servers).length === 0) {
|
|
75
|
+
throw new ExplicitConfigError(`"${explicitPath}" is valid JSON but has no "mcpServers" (or "servers") entries.`);
|
|
76
|
+
}
|
|
77
|
+
return [{ source: "Custom path", path: explicitPath, servers }];
|
|
78
|
+
}
|
|
79
|
+
const found = [];
|
|
80
|
+
for (const candidate of candidatePaths()) {
|
|
81
|
+
if (!existsSync(candidate.path))
|
|
82
|
+
continue;
|
|
83
|
+
try {
|
|
84
|
+
const content = readFileSync(candidate.path, "utf-8");
|
|
85
|
+
const parsed = JSON.parse(content);
|
|
86
|
+
const servers = extractServers(parsed);
|
|
87
|
+
if (Object.keys(servers).length > 0) {
|
|
88
|
+
found.push({ source: candidate.source, path: candidate.path, servers });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// Skip unreadable/invalid JSON in auto-discovered files rather than crashing the whole scan โ
|
|
93
|
+
// here it's reasonable to stay silent since we're speculatively probing several locations.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return found;
|
|
97
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rough tool-count-derived token estimates for well-known MCP servers, based on
|
|
3
|
+
* publicly documented tool counts (each tool definition costs roughly 150-400
|
|
4
|
+
* tokens of context depending on schema complexity). These are estimates, not
|
|
5
|
+
* measurements โ use `--precise` in a future version to connect and measure directly.
|
|
6
|
+
*/
|
|
7
|
+
const KNOWN_SERVER_TOKEN_COSTS = {
|
|
8
|
+
"@modelcontextprotocol/server-github": 2400,
|
|
9
|
+
"@modelcontextprotocol/server-filesystem": 900,
|
|
10
|
+
"@modelcontextprotocol/server-slack": 1200,
|
|
11
|
+
"@modelcontextprotocol/server-postgres": 800,
|
|
12
|
+
"@modelcontextprotocol/server-sqlite": 700,
|
|
13
|
+
"@modelcontextprotocol/server-puppeteer": 1400,
|
|
14
|
+
"@modelcontextprotocol/server-brave-search": 500,
|
|
15
|
+
"@modelcontextprotocol/server-memory": 400,
|
|
16
|
+
"@modelcontextprotocol/server-everything": 2000,
|
|
17
|
+
"@modelcontextprotocol/server-fetch": 350,
|
|
18
|
+
"@modelcontextprotocol/server-sequential-thinking": 300,
|
|
19
|
+
"@modelcontextprotocol/server-google-maps": 900,
|
|
20
|
+
"@modelcontextprotocol/server-everart": 500,
|
|
21
|
+
"@playwright/mcp": 3200,
|
|
22
|
+
"firecrawl-mcp": 1600,
|
|
23
|
+
"@notionhq/notion-mcp-server": 2800,
|
|
24
|
+
"figma-developer-mcp": 1800,
|
|
25
|
+
"@upstash/context7-mcp": 600,
|
|
26
|
+
"@sentry/mcp-server": 1100,
|
|
27
|
+
"mcp-server-linear": 1300,
|
|
28
|
+
"@modelcontextprotocol/server-redis": 600,
|
|
29
|
+
"mongodb-mcp-server": 1500,
|
|
30
|
+
"@supabase/mcp-server-supabase": 1400,
|
|
31
|
+
"@cloudflare/mcp-server-cloudflare": 1900,
|
|
32
|
+
"stripe-mcp": 1700,
|
|
33
|
+
"@asana/mcp-server": 1200,
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Category keywords are matched as whole tokens against the server/package name,
|
|
37
|
+
* split on non-alphanumeric characters โ NOT as loose substrings. Substring
|
|
38
|
+
* matching previously caused false positives like "digital-ocean" -> vcs (via
|
|
39
|
+
* "git" inside "digital"), "research-helper" -> search-web (via "search" inside
|
|
40
|
+
* "research"), and "example-mcp" -> search-web (via "exa" inside "example").
|
|
41
|
+
*/
|
|
42
|
+
const CATEGORY_KEYWORDS = [
|
|
43
|
+
[["github", "gitlab", "bitbucket", "git"], "vcs"],
|
|
44
|
+
[["filesystem", "fs"], "filesystem"],
|
|
45
|
+
[["playwright", "puppeteer", "browserbase", "browser"], "browser-automation"],
|
|
46
|
+
[["postgres", "postgresql", "sqlite", "mysql", "mongo", "mongodb", "supabase", "dynamodb", "neon", "redis"], "database"],
|
|
47
|
+
[["brave", "search", "exa", "perplexity", "firecrawl"], "search-web"],
|
|
48
|
+
[["notion", "confluence", "obsidian", "docs", "context7", "drive"], "docs-knowledge"],
|
|
49
|
+
[["linear", "jira", "asana", "monday", "trello"], "project-management"],
|
|
50
|
+
[["slack", "discord", "teams", "email", "gmail"], "communication"],
|
|
51
|
+
[["figma", "framer", "webflow", "design"], "design"],
|
|
52
|
+
[["stripe", "payment", "payments"], "payments"],
|
|
53
|
+
[["aws", "cloudflare", "azure", "gcp", "terraform"], "cloud-infra"],
|
|
54
|
+
[["datadog", "sentry", "last9", "observability", "logs"], "observability"],
|
|
55
|
+
];
|
|
56
|
+
function tokenize(s) {
|
|
57
|
+
return new Set(s
|
|
58
|
+
.toLowerCase()
|
|
59
|
+
.split(/[^a-z0-9]+/)
|
|
60
|
+
.filter(Boolean));
|
|
61
|
+
}
|
|
62
|
+
export function estimateTokens(packageName, kind) {
|
|
63
|
+
if (packageName && KNOWN_SERVER_TOKEN_COSTS[packageName] !== undefined) {
|
|
64
|
+
return { tokens: KNOWN_SERVER_TOKEN_COSTS[packageName], confidence: "known" };
|
|
65
|
+
}
|
|
66
|
+
// Heuristic fallback by server "shape"
|
|
67
|
+
if (kind === "remote-http")
|
|
68
|
+
return { tokens: 1500, confidence: "heuristic" };
|
|
69
|
+
if (kind === "local-binary")
|
|
70
|
+
return { tokens: 1000, confidence: "heuristic" };
|
|
71
|
+
return { tokens: 800, confidence: "heuristic" };
|
|
72
|
+
}
|
|
73
|
+
export function categorize(name, packageName) {
|
|
74
|
+
const tokens = tokenize(`${name} ${packageName ?? ""}`);
|
|
75
|
+
for (const [keywords, category] of CATEGORY_KEYWORDS) {
|
|
76
|
+
if (keywords.some((k) => tokens.has(k)))
|
|
77
|
+
return category;
|
|
78
|
+
}
|
|
79
|
+
return "other";
|
|
80
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { auditConfigFiles } from "./auditor.js";
|
|
4
|
+
import { discoverConfigFiles, ExplicitConfigError } from "./configLocator.js";
|
|
5
|
+
import { printBanner, printDiscoveredFiles, printNoConfigsFound, printReport, } from "./report.js";
|
|
6
|
+
import chalk from "chalk";
|
|
7
|
+
const program = new Command();
|
|
8
|
+
program
|
|
9
|
+
.name("mcp-tollbooth")
|
|
10
|
+
.description("Measure the context-window toll of your MCP servers and flag duplicates. (Trust/staleness checks included as a secondary signal.)")
|
|
11
|
+
.version("0.1.0")
|
|
12
|
+
.option("-c, --config <path>", "path to a specific MCP config file to audit")
|
|
13
|
+
.option("--json", "output raw JSON instead of a formatted report")
|
|
14
|
+
.action(async (opts) => {
|
|
15
|
+
let configFiles;
|
|
16
|
+
try {
|
|
17
|
+
configFiles = discoverConfigFiles(opts.config);
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
if (err instanceof ExplicitConfigError) {
|
|
21
|
+
if (opts.json) {
|
|
22
|
+
console.log(JSON.stringify({ error: err.message }, null, 2));
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
if (!opts.json)
|
|
26
|
+
printBanner();
|
|
27
|
+
console.log(chalk.red(`โ ${err.message}`));
|
|
28
|
+
}
|
|
29
|
+
process.exitCode = 1;
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
if (!opts.json)
|
|
35
|
+
printBanner();
|
|
36
|
+
if (configFiles.length === 0) {
|
|
37
|
+
if (opts.json) {
|
|
38
|
+
console.log(JSON.stringify({ servers: [], message: "no config files found" }, null, 2));
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
printNoConfigsFound();
|
|
42
|
+
}
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (!opts.json)
|
|
46
|
+
printDiscoveredFiles(configFiles);
|
|
47
|
+
const report = await auditConfigFiles(configFiles);
|
|
48
|
+
if (opts.json) {
|
|
49
|
+
console.log(JSON.stringify(report, null, 2));
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
printReport(report);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
program.parseAsync(process.argv);
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
const NPM_REGISTRY = "https://registry.npmjs.org";
|
|
2
|
+
const GITHUB_API = "https://api.github.com";
|
|
3
|
+
/** Figures out roughly what kind of server this is, and pulls out an npm package name if present. */
|
|
4
|
+
export function classifyServer(cfg) {
|
|
5
|
+
if (cfg.url) {
|
|
6
|
+
return { kind: "remote-http", rawCommandLine: `${cfg.transport ?? "http"} ${cfg.url}` };
|
|
7
|
+
}
|
|
8
|
+
const command = cfg.command ?? "";
|
|
9
|
+
const args = cfg.args ?? [];
|
|
10
|
+
const rawCommandLine = [command, ...args].join(" ").trim();
|
|
11
|
+
if (command === "npx") {
|
|
12
|
+
// npx -y <package> [...rest] โ first non-flag arg after -y is the package
|
|
13
|
+
const pkg = args.find((a) => !a.startsWith("-"));
|
|
14
|
+
if (pkg) {
|
|
15
|
+
return { kind: "npx-package", packageName: stripVersion(pkg), rawCommandLine };
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
if (command === "uvx" || command === "pipx") {
|
|
19
|
+
const pkg = args.find((a) => !a.startsWith("-"));
|
|
20
|
+
if (pkg) {
|
|
21
|
+
return { kind: "npx-package", packageName: stripVersion(pkg), rawCommandLine };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (command) {
|
|
25
|
+
return { kind: "local-binary", rawCommandLine };
|
|
26
|
+
}
|
|
27
|
+
return { kind: "unknown", rawCommandLine: rawCommandLine || "(no command)" };
|
|
28
|
+
}
|
|
29
|
+
/** Strips a trailing "@version" pin from an npx-style package spec, respecting scoped packages (@scope/name@version). */
|
|
30
|
+
function stripVersion(pkg) {
|
|
31
|
+
const searchFrom = pkg.startsWith("@") ? 1 : 0;
|
|
32
|
+
const atIndex = pkg.indexOf("@", searchFrom);
|
|
33
|
+
return atIndex === -1 ? pkg : pkg.slice(0, atIndex);
|
|
34
|
+
}
|
|
35
|
+
function parseGithubUrl(url) {
|
|
36
|
+
if (!url)
|
|
37
|
+
return null;
|
|
38
|
+
const cleaned = url
|
|
39
|
+
.replace(/^git\+/, "")
|
|
40
|
+
.replace(/\.git$/, "")
|
|
41
|
+
.replace(/^git:\/\//, "https://");
|
|
42
|
+
const match = cleaned.match(/github\.com[/:]([^/]+)\/([^/]+?)(?:\/|$)/i);
|
|
43
|
+
if (!match)
|
|
44
|
+
return null;
|
|
45
|
+
return { owner: match[1], repo: match[2] };
|
|
46
|
+
}
|
|
47
|
+
export async function fetchNpmPackageInfo(packageName) {
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetch(`${NPM_REGISTRY}/${encodeURIComponent(packageName)}`);
|
|
50
|
+
if (!res.ok)
|
|
51
|
+
return null;
|
|
52
|
+
const data = await res.json();
|
|
53
|
+
const latestVersion = data["dist-tags"]?.latest;
|
|
54
|
+
const versionData = latestVersion ? data.versions?.[latestVersion] : undefined;
|
|
55
|
+
const repository = versionData?.repository ?? data.repository;
|
|
56
|
+
const repoUrl = typeof repository === "string" ? repository : repository?.url;
|
|
57
|
+
return {
|
|
58
|
+
name: packageName,
|
|
59
|
+
version: latestVersion,
|
|
60
|
+
repositoryUrl: repoUrl,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export async function fetchGithubRepoInfo(repoUrl) {
|
|
68
|
+
const parsed = parseGithubUrl(repoUrl);
|
|
69
|
+
if (!parsed)
|
|
70
|
+
return { status: "not-found" };
|
|
71
|
+
try {
|
|
72
|
+
const headers = {
|
|
73
|
+
Accept: "application/vnd.github+json",
|
|
74
|
+
"User-Agent": "mcp-tollbooth",
|
|
75
|
+
};
|
|
76
|
+
if (process.env.GITHUB_TOKEN) {
|
|
77
|
+
headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
|
|
78
|
+
}
|
|
79
|
+
const res = await fetch(`${GITHUB_API}/repos/${parsed.owner}/${parsed.repo}`, { headers });
|
|
80
|
+
if (res.status === 404)
|
|
81
|
+
return { status: "not-found" };
|
|
82
|
+
if (res.status === 403 || res.status === 429) {
|
|
83
|
+
// GitHub distinguishes rate-limit from auth-scope 403s via this header.
|
|
84
|
+
const remaining = res.headers.get("x-ratelimit-remaining");
|
|
85
|
+
if (remaining === "0" || res.status === 429)
|
|
86
|
+
return { status: "rate-limited" };
|
|
87
|
+
return { status: "error" };
|
|
88
|
+
}
|
|
89
|
+
if (!res.ok)
|
|
90
|
+
return { status: "error" };
|
|
91
|
+
const data = await res.json();
|
|
92
|
+
const pushedAt = data.pushed_at;
|
|
93
|
+
const daysSinceLastPush = Math.floor((Date.now() - new Date(pushedAt).getTime()) / (1000 * 60 * 60 * 24));
|
|
94
|
+
return {
|
|
95
|
+
status: "found",
|
|
96
|
+
info: {
|
|
97
|
+
owner: parsed.owner,
|
|
98
|
+
repo: parsed.repo,
|
|
99
|
+
stars: data.stargazers_count ?? 0,
|
|
100
|
+
openIssues: data.open_issues_count ?? 0,
|
|
101
|
+
archived: Boolean(data.archived),
|
|
102
|
+
hasLicense: Boolean(data.license),
|
|
103
|
+
daysSinceLastPush,
|
|
104
|
+
pushedAt,
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return { status: "error" };
|
|
110
|
+
}
|
|
111
|
+
}
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import Table from "cli-table3";
|
|
3
|
+
function scoreColor(score) {
|
|
4
|
+
if (score >= 75)
|
|
5
|
+
return chalk.green;
|
|
6
|
+
if (score >= 50)
|
|
7
|
+
return chalk.yellow;
|
|
8
|
+
return chalk.red;
|
|
9
|
+
}
|
|
10
|
+
function scoreLabel(score) {
|
|
11
|
+
if (score >= 75)
|
|
12
|
+
return "Healthy";
|
|
13
|
+
if (score >= 50)
|
|
14
|
+
return "Caution";
|
|
15
|
+
return "Risky";
|
|
16
|
+
}
|
|
17
|
+
export function printBanner() {
|
|
18
|
+
console.log(chalk.bold.cyan("\n MCP Tollbooth") + chalk.dim(" โ what your MCP servers are costing your context window\n"));
|
|
19
|
+
}
|
|
20
|
+
export function printNoConfigsFound() {
|
|
21
|
+
console.log(chalk.yellow("No MCP config files with servers were found in the usual locations."));
|
|
22
|
+
console.log(chalk.dim("Checked Claude Desktop, Claude Code (.mcp.json / ~/.claude.json), Cursor and VS Code configs."));
|
|
23
|
+
console.log(chalk.dim("Pass --config <path> to point at a specific file.\n"));
|
|
24
|
+
}
|
|
25
|
+
export function printDiscoveredFiles(files) {
|
|
26
|
+
console.log(chalk.bold("Scanned config files:"));
|
|
27
|
+
for (const f of files) {
|
|
28
|
+
console.log(` ${chalk.dim("โข")} ${f.source} ${chalk.dim(`(${f.path})`)} โ ${Object.keys(f.servers).length} server(s)`);
|
|
29
|
+
}
|
|
30
|
+
console.log("");
|
|
31
|
+
}
|
|
32
|
+
export function printReport(report) {
|
|
33
|
+
const table = new Table({
|
|
34
|
+
head: [
|
|
35
|
+
chalk.bold("Server"),
|
|
36
|
+
chalk.bold("Category"),
|
|
37
|
+
chalk.bold("Est. Tokens"),
|
|
38
|
+
chalk.bold("Trust*"),
|
|
39
|
+
chalk.bold("Last Push"),
|
|
40
|
+
chalk.bold("Stars"),
|
|
41
|
+
],
|
|
42
|
+
style: { head: [], border: [] },
|
|
43
|
+
});
|
|
44
|
+
// Lead with token cost โ that's the number every server silently taxes you on,
|
|
45
|
+
// whether or not it turns out to be well-maintained.
|
|
46
|
+
const sorted = [...report.servers].sort((a, b) => b.estimatedTokens - a.estimatedTokens);
|
|
47
|
+
for (const s of sorted) {
|
|
48
|
+
const color = scoreColor(s.trustScore);
|
|
49
|
+
const lastPush = s.githubInfo
|
|
50
|
+
? `${s.githubInfo.daysSinceLastPush}d ago`
|
|
51
|
+
: chalk.dim("unknown");
|
|
52
|
+
const stars = s.githubInfo ? s.githubInfo.stars.toLocaleString() : chalk.dim("โ");
|
|
53
|
+
const tokenStr = s.tokenConfidence === "known"
|
|
54
|
+
? chalk.bold(`~${s.estimatedTokens.toLocaleString()}`)
|
|
55
|
+
: chalk.dim(`~${s.estimatedTokens.toLocaleString()}*`);
|
|
56
|
+
table.push([
|
|
57
|
+
s.serverName,
|
|
58
|
+
chalk.dim(s.category),
|
|
59
|
+
tokenStr,
|
|
60
|
+
color(`${s.trustScore} ${scoreLabel(s.trustScore)}`),
|
|
61
|
+
lastPush,
|
|
62
|
+
stars,
|
|
63
|
+
]);
|
|
64
|
+
}
|
|
65
|
+
console.log(table.toString());
|
|
66
|
+
console.log(chalk.dim("\n* Trust is a rough heuristic (repo activity, stars, license) โ not a security scan. Token estimates marked with a trailing * are heuristic, not measured live (see README).\n"));
|
|
67
|
+
if (report.overlapGroups.length > 0) {
|
|
68
|
+
console.log(chalk.bold("Overlap detected โ you're paying twice for the same job:"));
|
|
69
|
+
for (const group of report.overlapGroups) {
|
|
70
|
+
console.log(` ${chalk.dim("โข")} ${chalk.yellow(group.category)}: ${group.serverNames.join(", ")} โ do you need all of these?`);
|
|
71
|
+
}
|
|
72
|
+
console.log("");
|
|
73
|
+
}
|
|
74
|
+
const lowTrust = sorted.filter((s) => s.trustScore < 50 && s.trustNotes.length > 0);
|
|
75
|
+
if (lowTrust.length > 0) {
|
|
76
|
+
console.log(chalk.bold("Worth a second look:\n"));
|
|
77
|
+
for (const s of lowTrust) {
|
|
78
|
+
const color = scoreColor(s.trustScore);
|
|
79
|
+
console.log(color.bold(`${s.serverName}`) + chalk.dim(` (${s.configSource})`));
|
|
80
|
+
for (const note of s.trustNotes) {
|
|
81
|
+
console.log(` ${chalk.dim("-")} ${note}`);
|
|
82
|
+
}
|
|
83
|
+
console.log("");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
for (const warning of report.warnings) {
|
|
87
|
+
console.log(chalk.yellow("โ ") + warning);
|
|
88
|
+
}
|
|
89
|
+
const overallColor = scoreColor(report.overallScore);
|
|
90
|
+
console.log("\n" +
|
|
91
|
+
chalk.bold("Total context toll: ") +
|
|
92
|
+
chalk.bold(`~${report.totalEstimatedTokens.toLocaleString()} tokens`) +
|
|
93
|
+
chalk.dim(` across ${report.servers.length} server(s)`) +
|
|
94
|
+
chalk.dim(` ยท avg trust ${overallColor(`${report.overallScore}/100`)}`));
|
|
95
|
+
console.log("");
|
|
96
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mcp-tollbooth",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "How much context are your MCP servers actually costing you? Token budget + duplicate-server detection for Claude Desktop, Claude Code, Cursor and VS Code.",
|
|
5
|
+
"keywords": ["mcp", "model-context-protocol", "claude", "claude-code", "cursor", "ai-agents", "context-window", "token-budget", "cli"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"author": "Ereb-Blade",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/Ereb-Blade/mcp-tollbooth.git"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/Ereb-Blade/mcp-tollbooth#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/Ereb-Blade/mcp-tollbooth/issues"
|
|
16
|
+
},
|
|
17
|
+
"bin": {
|
|
18
|
+
"mcp-tollbooth": "dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist"
|
|
22
|
+
],
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsc -p tsconfig.json && node -e \"try{require('fs').chmodSync('dist/index.js',0o755)}catch(e){}\"",
|
|
28
|
+
"start": "node dist/index.js",
|
|
29
|
+
"dev": "tsx src/index.ts"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"chalk": "^5.3.0",
|
|
33
|
+
"cli-table3": "^0.6.5",
|
|
34
|
+
"commander": "^12.1.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/node": "^20.14.9",
|
|
38
|
+
"tsx": "^4.16.2",
|
|
39
|
+
"typescript": "^5.5.3"
|
|
40
|
+
}
|
|
41
|
+
}
|