mcp-compress-router 1.0.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 +7 -0
- package/README.md +51 -0
- package/build/catalog.js +54 -0
- package/build/cli/add-command.js +107 -0
- package/build/cli/config-io.js +213 -0
- package/build/cli/config-path-note.js +21 -0
- package/build/cli/get-command.js +49 -0
- package/build/cli/index.js +6 -0
- package/build/cli/list-command.js +79 -0
- package/build/cli/login-command.js +219 -0
- package/build/cli/logout-command.js +34 -0
- package/build/cli/remove-command.js +26 -0
- package/build/config.js +80 -0
- package/build/discovery.js +39 -0
- package/build/index.js +241 -0
- package/build/services/auth-status.js +113 -0
- package/build/services/catalog.js +54 -0
- package/build/services/config.js +273 -0
- package/build/services/discovery.js +104 -0
- package/build/services/index.js +6 -0
- package/build/services/invoker.js +72 -0
- package/build/services/oauth.js +145 -0
- package/build/text-format.js +24 -0
- package/build/tools/get-tool-schema.js +61 -0
- package/build/tools/index.js +2 -0
- package/build/tools/invoke-tool.js +92 -0
- package/build/types.js +1 -0
- package/build/utils/expand-env.js +47 -0
- package/build/utils/index.js +7 -0
- package/build/utils/logger.js +78 -0
- package/build/utils/open-browser.js +49 -0
- package/build/utils/parse-jsonc.js +24 -0
- package/build/utils/text-format.js +24 -0
- package/build/utils/types.js +1 -0
- package/build/utils/validate-arguments.js +156 -0
- package/package.json +71 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright 2026 Andrey Meshkov
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# MCP Compressing Router
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
Compress all connected MCP into a single router MCP and save up to 99% on
|
|
5
|
+
tokens.
|
|
6
|
+
</p>
|
|
7
|
+
|
|
8
|
+
<p align="center">
|
|
9
|
+
<img src="docs/assets/mcp-compress-router.png"
|
|
10
|
+
alt="MCP Compress Router" width="600"/>
|
|
11
|
+
</p>
|
|
12
|
+
|
|
13
|
+
## The Problem
|
|
14
|
+
|
|
15
|
+
When you have multiple MCPs every request to the LLM will include ALL their
|
|
16
|
+
tools and descriptions, which can quickly eat up your token limit and increase
|
|
17
|
+
costs.
|
|
18
|
+
|
|
19
|
+
Check out this [example](docs/assets/tools.json) to understand how
|
|
20
|
+
quickly and how large it can get. This example represents just 3 popular MCP
|
|
21
|
+
servers: Notion MCP, Github MCP and Pylance MCP.
|
|
22
|
+
|
|
23
|
+
The overhead that is created is about **26K tokens**, but let's check how much
|
|
24
|
+
it actually costs you in USD. I will use Opus API pricing for calculation and
|
|
25
|
+
I'll assume that on average you have a 50-turn coding session (pretty
|
|
26
|
+
reasonable these days).
|
|
27
|
+
|
|
28
|
+
- Input: `26K tokens * $5 / 1M = $0.13`
|
|
29
|
+
- Cache write (caching is not free): `26K tokens * $6.25 / 1M = $0.1625`
|
|
30
|
+
- Cache read (49 turns): `26K tokens * 49 * $0.50 / 1M = $0.637`
|
|
31
|
+
|
|
32
|
+
So the total overhead on an average coding session is about **$0.9275**.
|
|
33
|
+
And that's just for 3 MCPs, imagine if you had more!
|
|
34
|
+
|
|
35
|
+
## The Solution
|
|
36
|
+
|
|
37
|
+
Instead of sending all the tools and descriptions every time, you can use a
|
|
38
|
+
single router MCP that compresses all the connected MCPs into one with just
|
|
39
|
+
two tools: `get_tool_schema`, `invoke_tool`.
|
|
40
|
+
|
|
41
|
+
`get_tool_schema` in the description only has a list of MCP servers, optional
|
|
42
|
+
descriptions (you can write them yourself), and a list of tool names for each
|
|
43
|
+
MCP server. [Here is an example](docs/assets/tools-compressed.json) of how the
|
|
44
|
+
compressed version looks like, and it takes about 900 tokens.
|
|
45
|
+
|
|
46
|
+
If we repeat our exercise with the compressed version, the total overhead on
|
|
47
|
+
an average coding session will be about **$0.032175** so we saved about
|
|
48
|
+
**96.5%** on costs!
|
|
49
|
+
|
|
50
|
+
This is just a basic example with just 3 MCP servers, the more MCP servers you
|
|
51
|
+
have, the more you save.
|
package/build/catalog.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the immutable tool catalog from discovered server data.
|
|
3
|
+
*
|
|
4
|
+
* @param discovered - Results from parallel discovery.
|
|
5
|
+
* @returns An immutable ToolCatalog.
|
|
6
|
+
*/
|
|
7
|
+
export function buildCatalog(discovered) {
|
|
8
|
+
const toolMap = new Map();
|
|
9
|
+
const servers = discovered.map((ds) => {
|
|
10
|
+
for (const tool of ds.tools) {
|
|
11
|
+
const key = `${ds.name}::${tool.name}`;
|
|
12
|
+
toolMap.set(key, tool);
|
|
13
|
+
}
|
|
14
|
+
return {
|
|
15
|
+
name: ds.name,
|
|
16
|
+
description: ds.description,
|
|
17
|
+
tools: ds.tools,
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
return { servers, toolMap };
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Looks up tool schemas in the catalog by server and tool names.
|
|
24
|
+
*
|
|
25
|
+
* @param catalog - The tool catalog.
|
|
26
|
+
* @param serverName - The server to look up.
|
|
27
|
+
* @param toolNames - The tool names to retrieve.
|
|
28
|
+
* @returns The matching tool descriptors.
|
|
29
|
+
* @throws If the server or any tool is not found.
|
|
30
|
+
*/
|
|
31
|
+
export function lookupTools(catalog, serverName, toolNames) {
|
|
32
|
+
const server = catalog.servers.find((s) => s.name === serverName);
|
|
33
|
+
if (!server) {
|
|
34
|
+
const available = catalog.servers.map((s) => s.name).join(', ');
|
|
35
|
+
throw new Error(`Server "${serverName}" not found. Available servers: ${available}`);
|
|
36
|
+
}
|
|
37
|
+
const results = [];
|
|
38
|
+
const missing = [];
|
|
39
|
+
for (const toolName of toolNames) {
|
|
40
|
+
const key = `${serverName}::${toolName}`;
|
|
41
|
+
const tool = catalog.toolMap.get(key);
|
|
42
|
+
if (!tool) {
|
|
43
|
+
missing.push(toolName);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
results.push(tool);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (missing.length > 0) {
|
|
50
|
+
const valid = server.tools.map((t) => t.name).join(', ');
|
|
51
|
+
throw new Error(`Tool(s) not found on server "${serverName}": ${missing.join(', ')}. Valid tools: ${valid}`);
|
|
52
|
+
}
|
|
53
|
+
return results;
|
|
54
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { ensureConfigDir, readConfigFile, writeConfigFile, readCredentials, writeCredentials, } from './config-io.js';
|
|
2
|
+
/**
|
|
3
|
+
* Handles the `add <name> <commandOrUrl> [args...]` subcommand.
|
|
4
|
+
*
|
|
5
|
+
* - If commandOrUrl starts with http:// or https://, auto-detects as HTTP.
|
|
6
|
+
* - Otherwise treats it as a stdio command.
|
|
7
|
+
* - Writes the entry to the mcpServers object and saves the config file.
|
|
8
|
+
*
|
|
9
|
+
* @param configPath - Absolute path to the mcp.json file.
|
|
10
|
+
* @param opts - Parsed CLI options.
|
|
11
|
+
* @returns Human-readable confirmation message.
|
|
12
|
+
* @throws If the server name already exists.
|
|
13
|
+
*/
|
|
14
|
+
export async function handleAdd(configPath, opts) {
|
|
15
|
+
await ensureConfigDir(configPath);
|
|
16
|
+
const servers = await readConfigFile(configPath);
|
|
17
|
+
if (opts.name in servers) {
|
|
18
|
+
throw new Error(`Server "${opts.name}" already exists. Use "remove ${opts.name}" first to replace it.`);
|
|
19
|
+
}
|
|
20
|
+
// Auto-detect HTTP from URL pattern
|
|
21
|
+
const isUrl = opts.commandOrUrl.startsWith('http://') || opts.commandOrUrl.startsWith('https://');
|
|
22
|
+
const type = isUrl ? 'http' : opts.transport;
|
|
23
|
+
const entry = { type };
|
|
24
|
+
if (type === 'http') {
|
|
25
|
+
entry.url = opts.commandOrUrl;
|
|
26
|
+
if (opts.headers && Object.keys(opts.headers).length > 0) {
|
|
27
|
+
entry.headers = opts.headers;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
entry.command = opts.commandOrUrl;
|
|
32
|
+
if (opts.rest && opts.rest.length > 0) {
|
|
33
|
+
entry.args = opts.rest;
|
|
34
|
+
}
|
|
35
|
+
if (opts.env && Object.keys(opts.env).length > 0) {
|
|
36
|
+
entry.env = opts.env;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
servers[opts.name] = entry;
|
|
40
|
+
await writeConfigFile(configPath, servers);
|
|
41
|
+
let result = `Added server "${opts.name}" (${type}).`;
|
|
42
|
+
// For HTTP servers, proactively check whether the server advertises
|
|
43
|
+
// OAuth metadata. If it does, start the login flow automatically so
|
|
44
|
+
// the user is not left with an unauthenticated server.
|
|
45
|
+
if (type === 'http') {
|
|
46
|
+
try {
|
|
47
|
+
const loginResult = await tryAutoLogin(configPath, opts.name, opts.commandOrUrl);
|
|
48
|
+
if (loginResult) {
|
|
49
|
+
result += `\n${loginResult}`;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// Best-effort — don't block add on auto-login failure
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Probes the server for OAuth metadata and, if found, runs the login
|
|
60
|
+
* flow automatically. The probed auth requirement is cached in
|
|
61
|
+
* `credentials.json` regardless of the login outcome so the `list`
|
|
62
|
+
* command can show auth status without re-probing.
|
|
63
|
+
*
|
|
64
|
+
* @param configPath - Absolute path to the mcp.json file.
|
|
65
|
+
* @param name - Server name just added.
|
|
66
|
+
* @param url - Server URL to probe for OAuth metadata.
|
|
67
|
+
* @returns The login confirmation message, or undefined if the server
|
|
68
|
+
* does not advertise OAuth (or the probe failed).
|
|
69
|
+
*/
|
|
70
|
+
async function tryAutoLogin(configPath, name, url) {
|
|
71
|
+
const { discoverAuthorizationServerMetadata } = await import('@modelcontextprotocol/sdk/client/auth.js');
|
|
72
|
+
let requirement;
|
|
73
|
+
let hasOAuth;
|
|
74
|
+
try {
|
|
75
|
+
const metadata = await discoverAuthorizationServerMetadata(new URL(url));
|
|
76
|
+
hasOAuth = metadata !== undefined;
|
|
77
|
+
requirement = hasOAuth ? 'oauth' : 'none';
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// Probe failed — cache 'unknown' and don't block the add.
|
|
81
|
+
await persistAuthRequirement(configPath, name, 'unknown');
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
// Cache the requirement regardless of the login outcome.
|
|
85
|
+
await persistAuthRequirement(configPath, name, requirement);
|
|
86
|
+
if (!hasOAuth) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
const { handleLogin } = await import('./login-command.js');
|
|
90
|
+
return handleLogin(configPath, name);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Caches the probed auth requirement for a server in credentials.json,
|
|
94
|
+
* preserving any previously stored tokens or client registration.
|
|
95
|
+
*
|
|
96
|
+
* @param configPath - Absolute path to the mcp.json file.
|
|
97
|
+
* @param name - Server name.
|
|
98
|
+
* @param requirement - The probed auth requirement to cache.
|
|
99
|
+
*/
|
|
100
|
+
async function persistAuthRequirement(configPath, name, requirement) {
|
|
101
|
+
const existing = await readCredentials(configPath);
|
|
102
|
+
await writeCredentials(configPath, name, {
|
|
103
|
+
...existing[name],
|
|
104
|
+
authRequirement: requirement,
|
|
105
|
+
checkedAt: new Date().toISOString(),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { parseJsonc } from '../utils/index.js';
|
|
4
|
+
/**
|
|
5
|
+
* Type guard for Node.js system errors that carry a `code` property.
|
|
6
|
+
*
|
|
7
|
+
* @param err - The error to inspect.
|
|
8
|
+
* @returns True if the error has a string `code` property (e.g., 'ENOENT').
|
|
9
|
+
*/
|
|
10
|
+
function isNodeError(err) {
|
|
11
|
+
return err instanceof Error && typeof err.code === 'string';
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Derives the credentials.json path from the config path.
|
|
15
|
+
*
|
|
16
|
+
* @param configPath - Absolute path to mcp.json.
|
|
17
|
+
* @returns Absolute path to credentials.json in the same directory.
|
|
18
|
+
*/
|
|
19
|
+
function getCredentialsPath(configPath) {
|
|
20
|
+
return path.join(path.dirname(configPath), 'credentials.json');
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Ensures the parent directory and config file exist.
|
|
24
|
+
* If the file does not exist, creates it with an empty mcpServers object.
|
|
25
|
+
* Idempotent — does nothing if the file already exists.
|
|
26
|
+
*
|
|
27
|
+
* @param configPath - Absolute path to the mcp.json file.
|
|
28
|
+
*/
|
|
29
|
+
export async function ensureConfigDir(configPath) {
|
|
30
|
+
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
31
|
+
try {
|
|
32
|
+
await fs.access(configPath);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
await fs.writeFile(configPath, JSON.stringify({ mcpServers: {} }, null, 2) + '\n');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Reads the mcpServers object from the config file.
|
|
40
|
+
*
|
|
41
|
+
* @param configPath - Absolute path to the mcp.json file.
|
|
42
|
+
* @returns The raw mcpServers object.
|
|
43
|
+
* @throws If the file is missing, invalid JSON, or missing mcpServers key.
|
|
44
|
+
*/
|
|
45
|
+
export async function readConfigFile(configPath) {
|
|
46
|
+
let raw;
|
|
47
|
+
try {
|
|
48
|
+
raw = await fs.readFile(configPath, 'utf-8');
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
throw new Error(`Config file not found: ${configPath}`);
|
|
52
|
+
}
|
|
53
|
+
let parsed;
|
|
54
|
+
try {
|
|
55
|
+
parsed = parseJsonc(raw, configPath);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
throw new Error(`Failed to parse config file: ${configPath}`);
|
|
59
|
+
}
|
|
60
|
+
if (typeof parsed !== 'object' || parsed === null || !('mcpServers' in parsed)) {
|
|
61
|
+
throw new Error(`Config file must contain an mcpServers object`);
|
|
62
|
+
}
|
|
63
|
+
return parsed.mcpServers;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Writes the mcpServers object to the config file.
|
|
67
|
+
* Preserves non-credential top-level keys other than mcpServers.
|
|
68
|
+
* Silently drops any legacy "credentials" key (credentials are now
|
|
69
|
+
* stored in credentials.json).
|
|
70
|
+
*
|
|
71
|
+
* @param configPath - Absolute path to the mcp.json file.
|
|
72
|
+
* @param mcpServers - The mcpServers object to write.
|
|
73
|
+
*/
|
|
74
|
+
export async function writeConfigFile(configPath, mcpServers) {
|
|
75
|
+
// Preserve any existing top-level keys except credentials
|
|
76
|
+
// (credentials are stored in credentials.json)
|
|
77
|
+
let existing = { mcpServers: {} };
|
|
78
|
+
try {
|
|
79
|
+
const raw = await fs.readFile(configPath, 'utf-8');
|
|
80
|
+
existing = parseJsonc(raw, configPath);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// File doesn't exist or is invalid — start fresh
|
|
84
|
+
}
|
|
85
|
+
// Drop legacy credentials key that may exist from before the
|
|
86
|
+
// credentials.json separation
|
|
87
|
+
delete existing.credentials;
|
|
88
|
+
existing.mcpServers = mcpServers;
|
|
89
|
+
await fs.writeFile(configPath, JSON.stringify(existing, null, 2) + '\n');
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Reads the credentials object from credentials.json.
|
|
93
|
+
*
|
|
94
|
+
* @param configPath - Absolute path to the mcp.json file.
|
|
95
|
+
* @returns The credentials store, or empty object if the file does not exist.
|
|
96
|
+
* @throws If the file exists but cannot be read (permission denied) or
|
|
97
|
+
* contains invalid JSON.
|
|
98
|
+
*/
|
|
99
|
+
export async function readCredentials(configPath) {
|
|
100
|
+
const credPath = getCredentialsPath(configPath);
|
|
101
|
+
let raw;
|
|
102
|
+
try {
|
|
103
|
+
raw = await fs.readFile(credPath, 'utf-8');
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
if (isNodeError(err) && err.code === 'ENOENT') {
|
|
107
|
+
return {};
|
|
108
|
+
}
|
|
109
|
+
throw new Error(`Failed to read credentials file: ${credPath}`, { cause: err });
|
|
110
|
+
}
|
|
111
|
+
let parsed;
|
|
112
|
+
try {
|
|
113
|
+
parsed = JSON.parse(raw);
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
throw new Error(`Credentials file contains invalid JSON: ${credPath}`, { cause: err });
|
|
117
|
+
}
|
|
118
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
119
|
+
throw new Error(`Credentials file must contain a JSON object: ${credPath}`);
|
|
120
|
+
}
|
|
121
|
+
return parsed;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Writes (or overwrites) credentials for a single server.
|
|
125
|
+
* Preserves existing mcpServers and credentials for other servers.
|
|
126
|
+
*
|
|
127
|
+
* @param configPath - Absolute path to the mcp.json file.
|
|
128
|
+
* @param name - Server name.
|
|
129
|
+
* @param credentials - The credentials to store.
|
|
130
|
+
*/
|
|
131
|
+
export async function writeCredentials(configPath, name, credentials, logger) {
|
|
132
|
+
const credPath = getCredentialsPath(configPath);
|
|
133
|
+
// Read existing store (or start fresh)
|
|
134
|
+
let store = {};
|
|
135
|
+
try {
|
|
136
|
+
const raw = await fs.readFile(credPath, 'utf-8');
|
|
137
|
+
const parsed = JSON.parse(raw);
|
|
138
|
+
if (typeof parsed === 'object' && parsed !== null) {
|
|
139
|
+
store = parsed;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
if (isNodeError(err) && err.code === 'ENOENT') {
|
|
144
|
+
// File does not exist — will be created below
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
throw new Error(`Failed to read credentials file for writing: ${credPath}`, { cause: err });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// Merge in the new/updated entry
|
|
151
|
+
store[name] = credentials;
|
|
152
|
+
// Determine if this is a first-time creation
|
|
153
|
+
let isNewFile = false;
|
|
154
|
+
try {
|
|
155
|
+
await fs.access(credPath);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
isNewFile = true;
|
|
159
|
+
}
|
|
160
|
+
// Write the store
|
|
161
|
+
await fs.writeFile(credPath, JSON.stringify(store, null, 2) + '\n');
|
|
162
|
+
// On first creation, set restrictive permissions (owner read/write only)
|
|
163
|
+
if (isNewFile) {
|
|
164
|
+
try {
|
|
165
|
+
await fs.chmod(credPath, 0o600);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
// chmod is a no-op on Windows; if it somehow fails on Unix, log a warning
|
|
169
|
+
if (logger) {
|
|
170
|
+
logger.info(`Warning: Failed to set restrictive permissions on new credentials file: ${credPath}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// On Windows, verify chmod did something; if not, log a warning
|
|
174
|
+
if (process.platform === 'win32' && logger) {
|
|
175
|
+
logger.info(`Warning: File permissions cannot be restricted on Windows. ` +
|
|
176
|
+
`Credentials stored in: ${credPath}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Removes credentials for a server from the config file.
|
|
182
|
+
* No-op if the server has no stored credentials.
|
|
183
|
+
* Deletes the credentials file when the last entry is removed.
|
|
184
|
+
*
|
|
185
|
+
* @param configPath - Absolute path to the mcp.json file.
|
|
186
|
+
* @param name - Server name.
|
|
187
|
+
*/
|
|
188
|
+
export async function removeCredentials(configPath, name) {
|
|
189
|
+
const credPath = getCredentialsPath(configPath);
|
|
190
|
+
let store = {};
|
|
191
|
+
try {
|
|
192
|
+
const raw = await fs.readFile(credPath, 'utf-8');
|
|
193
|
+
const parsed = JSON.parse(raw);
|
|
194
|
+
if (typeof parsed === 'object' && parsed !== null) {
|
|
195
|
+
store = parsed;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
if (isNodeError(err) && err.code === 'ENOENT') {
|
|
200
|
+
// File does not exist — nothing to remove
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
throw new Error(`Failed to read credentials file for removal: ${credPath}`, { cause: err });
|
|
204
|
+
}
|
|
205
|
+
delete store[name];
|
|
206
|
+
if (Object.keys(store).length === 0) {
|
|
207
|
+
// No remaining entries — delete the file entirely
|
|
208
|
+
await fs.unlink(credPath);
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
await fs.writeFile(credPath, JSON.stringify(store, null, 2) + '\n');
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Appends the resolved config file path to a command's output when no
|
|
3
|
+
* explicit `-c`/`--config` flag was provided, so the user can see which
|
|
4
|
+
* default configuration file was operated on.
|
|
5
|
+
*
|
|
6
|
+
* When an explicit path was given via `-c`, the output is returned
|
|
7
|
+
* unchanged.
|
|
8
|
+
*
|
|
9
|
+
* @param result - The original command output (may be empty or undefined).
|
|
10
|
+
* @param explicitConfig - The raw `-c` value, or undefined when omitted.
|
|
11
|
+
* @param resolvedPath - The config file path that was actually used.
|
|
12
|
+
* @returns The output followed by a default-path note when `-c` was
|
|
13
|
+
* omitted; otherwise the original output unchanged.
|
|
14
|
+
*/
|
|
15
|
+
export function appendConfigPathNote(result, explicitConfig, resolvedPath) {
|
|
16
|
+
if (explicitConfig !== undefined) {
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
const note = `Using config: ${resolvedPath}`;
|
|
20
|
+
return result ? `${result}\n${note}` : note;
|
|
21
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { ensureConfigDir, readConfigFile } from './config-io.js';
|
|
2
|
+
/**
|
|
3
|
+
* Handles the `get <name>` subcommand: prints details for one server.
|
|
4
|
+
*
|
|
5
|
+
* @param configPath - Absolute path to the mcp.json file.
|
|
6
|
+
* @param name - Server name to look up.
|
|
7
|
+
* @returns Human-readable output to print to stdout.
|
|
8
|
+
* @throws If the server name is not found.
|
|
9
|
+
*/
|
|
10
|
+
export async function handleGet(configPath, name) {
|
|
11
|
+
await ensureConfigDir(configPath);
|
|
12
|
+
const servers = await readConfigFile(configPath);
|
|
13
|
+
const entry = servers[name];
|
|
14
|
+
if (!entry) {
|
|
15
|
+
const available = Object.keys(servers);
|
|
16
|
+
const hint = available.length > 0
|
|
17
|
+
? ` Available servers: ${available.join(', ')}`
|
|
18
|
+
: ' No servers configured.';
|
|
19
|
+
throw new Error(`Server "${name}" not found.${hint}`);
|
|
20
|
+
}
|
|
21
|
+
const lines = [];
|
|
22
|
+
lines.push(`Name: ${name}`);
|
|
23
|
+
lines.push(`Type: ${entry.type}`);
|
|
24
|
+
if (entry.description) {
|
|
25
|
+
lines.push(`Description: ${entry.description}`);
|
|
26
|
+
}
|
|
27
|
+
if (entry.command) {
|
|
28
|
+
lines.push(`Command: ${entry.command}`);
|
|
29
|
+
}
|
|
30
|
+
if (entry.args && entry.args.length > 0) {
|
|
31
|
+
lines.push(`Args: ${entry.args.join(' ')}`);
|
|
32
|
+
}
|
|
33
|
+
if (entry.env && Object.keys(entry.env).length > 0) {
|
|
34
|
+
lines.push('Environment:');
|
|
35
|
+
for (const [k, v] of Object.entries(entry.env)) {
|
|
36
|
+
lines.push(` ${k}=${v}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (entry.url) {
|
|
40
|
+
lines.push(`URL: ${entry.url}`);
|
|
41
|
+
}
|
|
42
|
+
if (entry.headers && Object.keys(entry.headers).length > 0) {
|
|
43
|
+
lines.push('Headers:');
|
|
44
|
+
for (const [k, v] of Object.entries(entry.headers)) {
|
|
45
|
+
lines.push(` ${k}: ${v}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return lines.join('\n');
|
|
49
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { handleAdd } from './add-command.js';
|
|
2
|
+
export { handleRemove } from './remove-command.js';
|
|
3
|
+
export { handleGet } from './get-command.js';
|
|
4
|
+
export { handleList } from './list-command.js';
|
|
5
|
+
export { handleLogin } from './login-command.js';
|
|
6
|
+
export { handleLogout } from './logout-command.js';
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { ensureConfigDir, readConfigFile, readCredentials } from './config-io.js';
|
|
2
|
+
import { computeAuthStatus } from '../services/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* Builds the display string for the command (stdio) or URL (http)
|
|
5
|
+
* column of a server row.
|
|
6
|
+
*
|
|
7
|
+
* @param server - Typed downstream server config.
|
|
8
|
+
* @returns The command plus args, or the URL.
|
|
9
|
+
*/
|
|
10
|
+
function buildCommandOrUrl(server) {
|
|
11
|
+
if (server.type === 'stdio') {
|
|
12
|
+
const parts = [];
|
|
13
|
+
if (server.command) {
|
|
14
|
+
parts.push(server.command);
|
|
15
|
+
}
|
|
16
|
+
if (server.args?.length) {
|
|
17
|
+
parts.push(...server.args);
|
|
18
|
+
}
|
|
19
|
+
return parts.join(' ');
|
|
20
|
+
}
|
|
21
|
+
return server.url ?? '';
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Renders the list header and server rows as a fixed-width table. The
|
|
25
|
+
* final (Auth) column is left unpadded so lines never carry trailing
|
|
26
|
+
* whitespace.
|
|
27
|
+
*
|
|
28
|
+
* @param configPath - Absolute path to the mcp.json file.
|
|
29
|
+
* @param rows - Prepared server rows.
|
|
30
|
+
* @returns The formatted table as a single string.
|
|
31
|
+
*/
|
|
32
|
+
function formatList(configPath, rows) {
|
|
33
|
+
const header = `Configuration was loaded from ${configPath}`;
|
|
34
|
+
if (rows.length === 0) {
|
|
35
|
+
return header;
|
|
36
|
+
}
|
|
37
|
+
const nameWidth = Math.max('Name'.length, ...rows.map((r) => r.name.length));
|
|
38
|
+
const typeWidth = Math.max('Type'.length, ...rows.map((r) => r.type.length));
|
|
39
|
+
const commandWidth = Math.max('CommandOrUrl'.length, ...rows.map((r) => r.commandOrUrl.length));
|
|
40
|
+
const pad = (val, width) => val.padEnd(width);
|
|
41
|
+
const columns = (name, type, command, auth) => `${pad(name, nameWidth)} ${pad(type, typeWidth)} ${pad(command, commandWidth)} ${auth}`;
|
|
42
|
+
return [
|
|
43
|
+
header,
|
|
44
|
+
'',
|
|
45
|
+
columns('Name', 'Type', 'CommandOrUrl', 'Auth'),
|
|
46
|
+
...rows.map((r) => columns(r.name, r.type, r.commandOrUrl, r.auth)),
|
|
47
|
+
].join('\n');
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Handles the `list` subcommand: prints the configuration file that was
|
|
51
|
+
* loaded followed by a table of every configured server and its auth
|
|
52
|
+
* status. Reads only local files (`mcp.json` and `credentials.json`) —
|
|
53
|
+
* no network access.
|
|
54
|
+
*
|
|
55
|
+
* @param configPath - Absolute path to the mcp.json file.
|
|
56
|
+
* @returns Human-readable output to print to stdout.
|
|
57
|
+
*/
|
|
58
|
+
export async function handleList(configPath) {
|
|
59
|
+
await ensureConfigDir(configPath);
|
|
60
|
+
const servers = await readConfigFile(configPath);
|
|
61
|
+
const credentials = await readCredentials(configPath);
|
|
62
|
+
const rows = Object.entries(servers).map(([name, entry]) => {
|
|
63
|
+
const typed = {
|
|
64
|
+
name,
|
|
65
|
+
type: entry.type,
|
|
66
|
+
command: entry.command,
|
|
67
|
+
args: entry.args,
|
|
68
|
+
url: entry.url,
|
|
69
|
+
headers: entry.headers,
|
|
70
|
+
};
|
|
71
|
+
return {
|
|
72
|
+
name,
|
|
73
|
+
type: entry.type,
|
|
74
|
+
commandOrUrl: buildCommandOrUrl(typed),
|
|
75
|
+
auth: computeAuthStatus(typed, credentials[name]),
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
return formatList(configPath, rows);
|
|
79
|
+
}
|