@evantahler/mcpx 0.15.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/.claude/settings.local.json +18 -0
- package/.claude/skills/mcpx.md +165 -0
- package/.claude/worktrees/elastic-jennings/.claude/settings.local.json +18 -0
- package/.claude/worktrees/elastic-jennings/.claude/skills/mcpcli.md +93 -0
- package/.claude/worktrees/elastic-jennings/.github/workflows/auto-release.yml +117 -0
- package/.claude/worktrees/elastic-jennings/.github/workflows/ci.yml +18 -0
- package/.claude/worktrees/elastic-jennings/.prettierignore +4 -0
- package/.claude/worktrees/elastic-jennings/.prettierrc +7 -0
- package/.claude/worktrees/elastic-jennings/CLAUDE.md +19 -0
- package/.claude/worktrees/elastic-jennings/LICENSE +21 -0
- package/.claude/worktrees/elastic-jennings/README.md +487 -0
- package/.claude/worktrees/elastic-jennings/bun.lock +381 -0
- package/.claude/worktrees/elastic-jennings/install.sh +55 -0
- package/.claude/worktrees/elastic-jennings/package.json +56 -0
- package/.claude/worktrees/elastic-jennings/src/cli.ts +39 -0
- package/.claude/worktrees/elastic-jennings/src/client/http.ts +100 -0
- package/.claude/worktrees/elastic-jennings/src/client/manager.ts +266 -0
- package/.claude/worktrees/elastic-jennings/src/client/oauth.ts +299 -0
- package/.claude/worktrees/elastic-jennings/src/client/stdio.ts +12 -0
- package/.claude/worktrees/elastic-jennings/src/commands/add.ts +155 -0
- package/.claude/worktrees/elastic-jennings/src/commands/auth.ts +114 -0
- package/.claude/worktrees/elastic-jennings/src/commands/exec.ts +91 -0
- package/.claude/worktrees/elastic-jennings/src/commands/index.ts +62 -0
- package/.claude/worktrees/elastic-jennings/src/commands/info.ts +38 -0
- package/.claude/worktrees/elastic-jennings/src/commands/list.ts +30 -0
- package/.claude/worktrees/elastic-jennings/src/commands/remove.ts +67 -0
- package/.claude/worktrees/elastic-jennings/src/commands/search.ts +45 -0
- package/.claude/worktrees/elastic-jennings/src/commands/skill.ts +70 -0
- package/.claude/worktrees/elastic-jennings/src/config/env.ts +41 -0
- package/.claude/worktrees/elastic-jennings/src/config/loader.ts +156 -0
- package/.claude/worktrees/elastic-jennings/src/config/schemas.ts +137 -0
- package/.claude/worktrees/elastic-jennings/src/context.ts +53 -0
- package/.claude/worktrees/elastic-jennings/src/output/formatter.ts +316 -0
- package/.claude/worktrees/elastic-jennings/src/output/logger.ts +114 -0
- package/.claude/worktrees/elastic-jennings/src/search/index.ts +69 -0
- package/.claude/worktrees/elastic-jennings/src/search/indexer.ts +92 -0
- package/.claude/worktrees/elastic-jennings/src/search/keyword.ts +86 -0
- package/.claude/worktrees/elastic-jennings/src/search/semantic.ts +75 -0
- package/.claude/worktrees/elastic-jennings/src/search/staleness.ts +8 -0
- package/.claude/worktrees/elastic-jennings/src/validation/schema.ts +77 -0
- package/.claude/worktrees/elastic-jennings/test/cli.test.ts +51 -0
- package/.claude/worktrees/elastic-jennings/test/client/manager.test.ts +249 -0
- package/.claude/worktrees/elastic-jennings/test/client/oauth.test.ts +328 -0
- package/.claude/worktrees/elastic-jennings/test/commands/add-remove.test.ts +253 -0
- package/.claude/worktrees/elastic-jennings/test/commands/exec.test.ts +105 -0
- package/.claude/worktrees/elastic-jennings/test/commands/info.test.ts +48 -0
- package/.claude/worktrees/elastic-jennings/test/commands/list.test.ts +39 -0
- package/.claude/worktrees/elastic-jennings/test/commands/skill.test.ts +98 -0
- package/.claude/worktrees/elastic-jennings/test/config/env.test.ts +61 -0
- package/.claude/worktrees/elastic-jennings/test/config/loader.test.ts +139 -0
- package/.claude/worktrees/elastic-jennings/test/fixtures/.keep +0 -0
- package/.claude/worktrees/elastic-jennings/test/fixtures/auth.json +10 -0
- package/.claude/worktrees/elastic-jennings/test/fixtures/mock-config/.keep +0 -0
- package/.claude/worktrees/elastic-jennings/test/fixtures/mock-config/servers.json +8 -0
- package/.claude/worktrees/elastic-jennings/test/fixtures/mock-server.ts +113 -0
- package/.claude/worktrees/elastic-jennings/test/fixtures/search.json +15 -0
- package/.claude/worktrees/elastic-jennings/test/fixtures/servers.json +18 -0
- package/.claude/worktrees/elastic-jennings/test/integration/stdio-server.test.ts +149 -0
- package/.claude/worktrees/elastic-jennings/test/output/formatter.test.ts +54 -0
- package/.claude/worktrees/elastic-jennings/test/output/logger.test.ts +89 -0
- package/.claude/worktrees/elastic-jennings/test/search/indexer.test.ts +32 -0
- package/.claude/worktrees/elastic-jennings/test/search/keyword.test.ts +80 -0
- package/.claude/worktrees/elastic-jennings/test/search/semantic.test.ts +32 -0
- package/.claude/worktrees/elastic-jennings/test/validation/schema.test.ts +113 -0
- package/.claude/worktrees/elastic-jennings/tsconfig.json +29 -0
- package/.cursor/rules/mcpx.mdc +165 -0
- package/LICENSE +21 -0
- package/README.md +627 -0
- package/package.json +58 -0
- package/src/cli.ts +72 -0
- package/src/client/browser.ts +24 -0
- package/src/client/debug-fetch.ts +81 -0
- package/src/client/elicitation.ts +368 -0
- package/src/client/http.ts +25 -0
- package/src/client/manager.ts +566 -0
- package/src/client/oauth.ts +314 -0
- package/src/client/sse.ts +17 -0
- package/src/client/stdio.ts +12 -0
- package/src/client/trace.ts +184 -0
- package/src/commands/add.ts +179 -0
- package/src/commands/auth.ts +114 -0
- package/src/commands/exec.ts +156 -0
- package/src/commands/index.ts +62 -0
- package/src/commands/info.ts +63 -0
- package/src/commands/list.ts +64 -0
- package/src/commands/ping.ts +69 -0
- package/src/commands/prompt.ts +60 -0
- package/src/commands/remove.ts +67 -0
- package/src/commands/resource.ts +46 -0
- package/src/commands/search.ts +49 -0
- package/src/commands/servers.ts +66 -0
- package/src/commands/skill.ts +112 -0
- package/src/commands/task.ts +82 -0
- package/src/config/env.ts +41 -0
- package/src/config/loader.ts +156 -0
- package/src/config/schemas.ts +152 -0
- package/src/context.ts +62 -0
- package/src/lib/input.ts +36 -0
- package/src/output/formatter.ts +884 -0
- package/src/output/logger.ts +173 -0
- package/src/search/index.ts +69 -0
- package/src/search/indexer.ts +92 -0
- package/src/search/keyword.ts +86 -0
- package/src/search/semantic.ts +75 -0
- package/src/search/staleness.ts +8 -0
- package/src/validation/schema.ts +103 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const ENV_VAR_PATTERN = /\$\{([^}]+)\}/g;
|
|
2
|
+
|
|
3
|
+
/** Whether to throw on missing env vars (default: true) */
|
|
4
|
+
function isStrictEnv(): boolean {
|
|
5
|
+
return process.env.MCP_STRICT_ENV !== "false";
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Replace ${VAR_NAME} in a string with the corresponding env var value */
|
|
9
|
+
export function interpolateEnvString(value: string): string {
|
|
10
|
+
return value.replace(ENV_VAR_PATTERN, (_match, varName: string) => {
|
|
11
|
+
const envValue = process.env[varName];
|
|
12
|
+
if (envValue === undefined) {
|
|
13
|
+
if (isStrictEnv()) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
`Environment variable "${varName}" is not set (set MCP_STRICT_ENV=false to warn instead)`,
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
console.warn(`Warning: environment variable "${varName}" is not set`);
|
|
19
|
+
return "";
|
|
20
|
+
}
|
|
21
|
+
return envValue;
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Recursively interpolate env vars in all string values of an object */
|
|
26
|
+
export function interpolateEnv<T>(obj: T): T {
|
|
27
|
+
if (typeof obj === "string") {
|
|
28
|
+
return interpolateEnvString(obj) as T;
|
|
29
|
+
}
|
|
30
|
+
if (Array.isArray(obj)) {
|
|
31
|
+
return obj.map((item) => interpolateEnv(item)) as T;
|
|
32
|
+
}
|
|
33
|
+
if (typeof obj === "object" && obj !== null) {
|
|
34
|
+
const result: Record<string, unknown> = {};
|
|
35
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
36
|
+
result[key] = interpolateEnv(value);
|
|
37
|
+
}
|
|
38
|
+
return result as T;
|
|
39
|
+
}
|
|
40
|
+
return obj;
|
|
41
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { join, resolve } from "path";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { interpolateEnv } from "./env.ts";
|
|
4
|
+
import {
|
|
5
|
+
type Config,
|
|
6
|
+
type ServersFile,
|
|
7
|
+
type AuthFile,
|
|
8
|
+
type SearchIndex,
|
|
9
|
+
validateServersFile,
|
|
10
|
+
validateAuthFile,
|
|
11
|
+
validateSearchIndex,
|
|
12
|
+
} from "./schemas.ts";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_CONFIG_DIR = join(homedir(), ".mcpcli");
|
|
15
|
+
|
|
16
|
+
const EMPTY_SERVERS: ServersFile = { mcpServers: {} };
|
|
17
|
+
const EMPTY_AUTH: AuthFile = {};
|
|
18
|
+
const EMPTY_SEARCH_INDEX: SearchIndex = {
|
|
19
|
+
version: 1,
|
|
20
|
+
indexed_at: "",
|
|
21
|
+
embedding_model: "claude",
|
|
22
|
+
tools: [],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Read and parse a JSON file, returning undefined if it doesn't exist */
|
|
26
|
+
async function readJsonFile(path: string): Promise<unknown | undefined> {
|
|
27
|
+
const file = Bun.file(path);
|
|
28
|
+
if (!(await file.exists())) return undefined;
|
|
29
|
+
const text = await file.text();
|
|
30
|
+
return JSON.parse(text);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Resolve the config directory from options, env, cwd, or default */
|
|
34
|
+
function resolveConfigDir(configFlag?: string): string {
|
|
35
|
+
// 1. -c / --config flag
|
|
36
|
+
if (configFlag) return resolve(configFlag);
|
|
37
|
+
|
|
38
|
+
// 2. MCP_CONFIG_PATH env var
|
|
39
|
+
const envPath = process.env.MCP_CONFIG_PATH;
|
|
40
|
+
if (envPath) return resolve(envPath);
|
|
41
|
+
|
|
42
|
+
// 3. ./servers.json exists in cwd → use cwd
|
|
43
|
+
// (checked at load time, not here — we return the candidate dir)
|
|
44
|
+
|
|
45
|
+
// 4. Default ~/.mcpcli/
|
|
46
|
+
return DEFAULT_CONFIG_DIR;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Check if servers.json exists in the given directory */
|
|
50
|
+
async function hasServersFile(dir: string): Promise<boolean> {
|
|
51
|
+
return Bun.file(join(dir, "servers.json")).exists();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface LoadConfigOptions {
|
|
55
|
+
configFlag?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Load and validate all config files */
|
|
59
|
+
export async function loadConfig(options: LoadConfigOptions = {}): Promise<Config> {
|
|
60
|
+
// Resolve config directory
|
|
61
|
+
let configDir = resolveConfigDir(options.configFlag);
|
|
62
|
+
|
|
63
|
+
// If the resolved dir doesn't have servers.json, check cwd
|
|
64
|
+
if (!(await hasServersFile(configDir))) {
|
|
65
|
+
const cwd = process.cwd();
|
|
66
|
+
if (await hasServersFile(cwd)) {
|
|
67
|
+
configDir = cwd;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Ensure config directory exists
|
|
72
|
+
await Bun.write(join(configDir, ".keep"), "").catch(() => {});
|
|
73
|
+
// Remove the .keep file, it was just to ensure the dir
|
|
74
|
+
Bun.file(join(configDir, ".keep"))
|
|
75
|
+
.exists()
|
|
76
|
+
.then((exists) => {
|
|
77
|
+
if (exists) Bun.write(join(configDir, ".keep"), "");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Load servers.json
|
|
81
|
+
const serversPath = join(configDir, "servers.json");
|
|
82
|
+
const rawServers = await readJsonFile(serversPath);
|
|
83
|
+
let servers: ServersFile;
|
|
84
|
+
if (rawServers === undefined) {
|
|
85
|
+
servers = EMPTY_SERVERS;
|
|
86
|
+
} else {
|
|
87
|
+
servers = validateServersFile(rawServers);
|
|
88
|
+
// Interpolate env vars in server configs
|
|
89
|
+
servers = {
|
|
90
|
+
mcpServers: Object.fromEntries(
|
|
91
|
+
Object.entries(servers.mcpServers).map(([name, config]) => [name, interpolateEnv(config)]),
|
|
92
|
+
),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Load auth.json
|
|
97
|
+
const authPath = join(configDir, "auth.json");
|
|
98
|
+
const rawAuth = await readJsonFile(authPath);
|
|
99
|
+
const auth: AuthFile = rawAuth !== undefined ? validateAuthFile(rawAuth) : EMPTY_AUTH;
|
|
100
|
+
|
|
101
|
+
// Load search.json
|
|
102
|
+
const searchPath = join(configDir, "search.json");
|
|
103
|
+
const rawSearch = await readJsonFile(searchPath);
|
|
104
|
+
const searchIndex: SearchIndex =
|
|
105
|
+
rawSearch !== undefined ? validateSearchIndex(rawSearch) : EMPTY_SEARCH_INDEX;
|
|
106
|
+
|
|
107
|
+
return { configDir, servers, auth, searchIndex };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Save auth.json to the config directory */
|
|
111
|
+
export async function saveAuth(configDir: string, auth: AuthFile): Promise<void> {
|
|
112
|
+
await Bun.write(join(configDir, "auth.json"), JSON.stringify(auth, null, 2) + "\n");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Load search.json from the config directory */
|
|
116
|
+
export async function loadSearchIndex(configDir: string): Promise<SearchIndex> {
|
|
117
|
+
const raw = await readJsonFile(join(configDir, "search.json"));
|
|
118
|
+
return raw !== undefined ? validateSearchIndex(raw) : { ...EMPTY_SEARCH_INDEX };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Save search.json to the config directory */
|
|
122
|
+
export async function saveSearchIndex(configDir: string, index: SearchIndex): Promise<void> {
|
|
123
|
+
await Bun.write(join(configDir, "search.json"), JSON.stringify(index, null, 2) + "\n");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Save servers.json to the config directory */
|
|
127
|
+
export async function saveServers(configDir: string, servers: ServersFile): Promise<void> {
|
|
128
|
+
await Bun.write(join(configDir, "servers.json"), JSON.stringify(servers, null, 2) + "\n");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Load servers.json without env interpolation (preserves ${VAR} placeholders) */
|
|
132
|
+
export async function loadRawServers(
|
|
133
|
+
configFlag?: string,
|
|
134
|
+
): Promise<{ configDir: string; servers: ServersFile }> {
|
|
135
|
+
let configDir = resolveConfigDir(configFlag);
|
|
136
|
+
|
|
137
|
+
if (!(await hasServersFile(configDir))) {
|
|
138
|
+
const cwd = process.cwd();
|
|
139
|
+
if (await hasServersFile(cwd)) {
|
|
140
|
+
configDir = cwd;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const serversPath = join(configDir, "servers.json");
|
|
145
|
+
const raw = await readJsonFile(serversPath);
|
|
146
|
+
const servers = raw !== undefined ? validateServersFile(raw) : EMPTY_SERVERS;
|
|
147
|
+
|
|
148
|
+
return { configDir, servers };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Load auth.json without loading the full config */
|
|
152
|
+
export async function loadRawAuth(configDir: string): Promise<AuthFile> {
|
|
153
|
+
const authPath = join(configDir, "auth.json");
|
|
154
|
+
const raw = await readJsonFile(authPath);
|
|
155
|
+
return raw !== undefined ? validateAuthFile(raw) : EMPTY_AUTH;
|
|
156
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
2
|
+
import type {
|
|
3
|
+
OAuthTokens,
|
|
4
|
+
OAuthClientInformation,
|
|
5
|
+
OAuthClientInformationMixed,
|
|
6
|
+
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
7
|
+
|
|
8
|
+
// Re-export SDK types we use throughout the codebase
|
|
9
|
+
export type { Tool, OAuthTokens, OAuthClientInformation, OAuthClientInformationMixed };
|
|
10
|
+
|
|
11
|
+
// --- Server config (our format, not MCP spec) ---
|
|
12
|
+
|
|
13
|
+
/** Stdio MCP server config */
|
|
14
|
+
export interface StdioServerConfig {
|
|
15
|
+
command: string;
|
|
16
|
+
args?: string[];
|
|
17
|
+
env?: Record<string, string>;
|
|
18
|
+
cwd?: string;
|
|
19
|
+
allowedTools?: string[];
|
|
20
|
+
disabledTools?: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** HTTP MCP server config */
|
|
24
|
+
export interface HttpServerConfig {
|
|
25
|
+
url: string;
|
|
26
|
+
headers?: Record<string, string>;
|
|
27
|
+
allowedTools?: string[];
|
|
28
|
+
disabledTools?: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type ServerConfig = StdioServerConfig | HttpServerConfig;
|
|
32
|
+
|
|
33
|
+
export function isStdioServer(config: ServerConfig): config is StdioServerConfig {
|
|
34
|
+
return "command" in config;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isHttpServer(config: ServerConfig): config is HttpServerConfig {
|
|
38
|
+
return "url" in config;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Top-level servers.json shape */
|
|
42
|
+
export interface ServersFile {
|
|
43
|
+
mcpServers: Record<string, ServerConfig>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// --- Auth storage (wraps SDK's OAuthTokens with our persistence fields) ---
|
|
47
|
+
|
|
48
|
+
/** Per-server auth entry stored in auth.json */
|
|
49
|
+
export interface AuthEntry {
|
|
50
|
+
tokens: OAuthTokens;
|
|
51
|
+
expires_at?: string;
|
|
52
|
+
client_info?: OAuthClientInformationMixed;
|
|
53
|
+
complete?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Top-level auth.json shape */
|
|
57
|
+
export type AuthFile = Record<string, AuthEntry>;
|
|
58
|
+
|
|
59
|
+
// --- Search index (entirely our format) ---
|
|
60
|
+
|
|
61
|
+
/** A single tool entry in the search index */
|
|
62
|
+
export interface IndexedTool {
|
|
63
|
+
server: string;
|
|
64
|
+
tool: string;
|
|
65
|
+
description: string;
|
|
66
|
+
input_schema?: Tool["inputSchema"];
|
|
67
|
+
scenarios: string[];
|
|
68
|
+
keywords: string[];
|
|
69
|
+
embedding: number[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Top-level search.json shape */
|
|
73
|
+
export interface SearchIndex {
|
|
74
|
+
version: number;
|
|
75
|
+
indexed_at: string;
|
|
76
|
+
embedding_model: string;
|
|
77
|
+
tools: IndexedTool[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --- Combined config ---
|
|
81
|
+
|
|
82
|
+
/** Validated config returned by loadConfig */
|
|
83
|
+
export interface Config {
|
|
84
|
+
configDir: string;
|
|
85
|
+
servers: ServersFile;
|
|
86
|
+
auth: AuthFile;
|
|
87
|
+
searchIndex: SearchIndex;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// --- Validation ---
|
|
91
|
+
|
|
92
|
+
/** Validate that a parsed object looks like a valid servers.json */
|
|
93
|
+
export function validateServersFile(data: unknown): ServersFile {
|
|
94
|
+
if (typeof data !== "object" || data === null) {
|
|
95
|
+
throw new Error("servers.json must be a JSON object");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const obj = data as Record<string, unknown>;
|
|
99
|
+
if (typeof obj.mcpServers !== "object" || obj.mcpServers === null) {
|
|
100
|
+
throw new Error('servers.json must have a "mcpServers" object');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const servers = obj.mcpServers as Record<string, unknown>;
|
|
104
|
+
for (const [name, config] of Object.entries(servers)) {
|
|
105
|
+
if (typeof config !== "object" || config === null) {
|
|
106
|
+
throw new Error(`Server "${name}" must be an object`);
|
|
107
|
+
}
|
|
108
|
+
const c = config as Record<string, unknown>;
|
|
109
|
+
const hasCommand = typeof c.command === "string";
|
|
110
|
+
const hasUrl = typeof c.url === "string";
|
|
111
|
+
if (!hasCommand && !hasUrl) {
|
|
112
|
+
throw new Error(`Server "${name}" must have either "command" (stdio) or "url" (http)`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return data as ServersFile;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Validate auth.json — lenient, just check shape */
|
|
120
|
+
export function validateAuthFile(data: unknown): AuthFile {
|
|
121
|
+
if (typeof data !== "object" || data === null) {
|
|
122
|
+
throw new Error("auth.json must be a JSON object");
|
|
123
|
+
}
|
|
124
|
+
return data as AuthFile;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Validate search.json — lenient, just check shape */
|
|
128
|
+
export function validateSearchIndex(data: unknown): SearchIndex {
|
|
129
|
+
if (typeof data !== "object" || data === null) {
|
|
130
|
+
throw new Error("search.json must be a JSON object");
|
|
131
|
+
}
|
|
132
|
+
const obj = data as Record<string, unknown>;
|
|
133
|
+
if (!Array.isArray(obj.tools)) {
|
|
134
|
+
throw new Error('search.json must have a "tools" array');
|
|
135
|
+
}
|
|
136
|
+
return data as SearchIndex;
|
|
137
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { loadConfig, type LoadConfigOptions } from "./config/loader.ts";
|
|
3
|
+
import { ServerManager } from "./client/manager.ts";
|
|
4
|
+
import type { Config } from "./config/schemas.ts";
|
|
5
|
+
import type { FormatOptions } from "./output/formatter.ts";
|
|
6
|
+
import { logger } from "./output/logger.ts";
|
|
7
|
+
|
|
8
|
+
export interface AppContext {
|
|
9
|
+
config: Config;
|
|
10
|
+
manager: ServerManager;
|
|
11
|
+
formatOptions: FormatOptions;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Build the app context from the root commander program options */
|
|
15
|
+
export async function getContext(program: Command): Promise<AppContext> {
|
|
16
|
+
const opts = program.opts();
|
|
17
|
+
|
|
18
|
+
const config = await loadConfig({
|
|
19
|
+
configFlag: opts.config as string | undefined,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const verbose = !!(
|
|
23
|
+
(opts.verbose as boolean | undefined) ||
|
|
24
|
+
process.env.MCP_DEBUG === "1" ||
|
|
25
|
+
process.env.MCP_DEBUG === "true"
|
|
26
|
+
);
|
|
27
|
+
const showSecrets = !!(opts.showSecrets as boolean | undefined);
|
|
28
|
+
const concurrency = Number(process.env.MCP_CONCURRENCY ?? 5);
|
|
29
|
+
const timeout = Number(process.env.MCP_TIMEOUT ?? 1800) * 1000;
|
|
30
|
+
const maxRetries = Number(process.env.MCP_MAX_RETRIES ?? 3);
|
|
31
|
+
|
|
32
|
+
const manager = new ServerManager({
|
|
33
|
+
servers: config.servers,
|
|
34
|
+
configDir: config.configDir,
|
|
35
|
+
auth: config.auth,
|
|
36
|
+
concurrency,
|
|
37
|
+
verbose,
|
|
38
|
+
showSecrets,
|
|
39
|
+
timeout,
|
|
40
|
+
maxRetries,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const formatOptions: FormatOptions = {
|
|
44
|
+
json: opts.json as boolean | undefined,
|
|
45
|
+
withDescriptions: opts.withDescriptions as boolean | undefined,
|
|
46
|
+
verbose,
|
|
47
|
+
showSecrets,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
logger.configure(formatOptions);
|
|
51
|
+
|
|
52
|
+
return { config, manager, formatOptions };
|
|
53
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { bold, cyan, dim, green, red, yellow } from "ansis";
|
|
2
|
+
import type { Tool } from "../config/schemas.ts";
|
|
3
|
+
import type { ToolWithServer } from "../client/manager.ts";
|
|
4
|
+
import type { ValidationError } from "../validation/schema.ts";
|
|
5
|
+
import type { SearchResult } from "../search/index.ts";
|
|
6
|
+
|
|
7
|
+
export interface FormatOptions {
|
|
8
|
+
json?: boolean;
|
|
9
|
+
withDescriptions?: boolean;
|
|
10
|
+
verbose?: boolean;
|
|
11
|
+
showSecrets?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Check if stdout is a TTY (interactive terminal) */
|
|
15
|
+
export function isInteractive(options: FormatOptions): boolean {
|
|
16
|
+
if (options.json) return false;
|
|
17
|
+
return process.stdout.isTTY ?? false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Format a list of tools with server names */
|
|
21
|
+
export function formatToolList(tools: ToolWithServer[], options: FormatOptions): string {
|
|
22
|
+
if (!isInteractive(options)) {
|
|
23
|
+
if (options.withDescriptions) {
|
|
24
|
+
return JSON.stringify(
|
|
25
|
+
tools.map((t) => ({
|
|
26
|
+
server: t.server,
|
|
27
|
+
tool: t.tool.name,
|
|
28
|
+
description: t.tool.description ?? "",
|
|
29
|
+
})),
|
|
30
|
+
null,
|
|
31
|
+
2,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
return JSON.stringify(
|
|
35
|
+
tools.map((t) => ({ server: t.server, tool: t.tool.name })),
|
|
36
|
+
null,
|
|
37
|
+
2,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (tools.length === 0) {
|
|
42
|
+
return dim("No tools found");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Calculate column widths
|
|
46
|
+
const maxServer = Math.max(...tools.map((t) => t.server.length));
|
|
47
|
+
const maxTool = Math.max(...tools.map((t) => t.tool.name.length));
|
|
48
|
+
|
|
49
|
+
return tools
|
|
50
|
+
.map((t) => {
|
|
51
|
+
const server = cyan(t.server.padEnd(maxServer));
|
|
52
|
+
const tool = bold(t.tool.name.padEnd(maxTool));
|
|
53
|
+
if (options.withDescriptions && t.tool.description) {
|
|
54
|
+
return `${server} ${tool} ${dim(t.tool.description)}`;
|
|
55
|
+
}
|
|
56
|
+
return `${server} ${tool}`;
|
|
57
|
+
})
|
|
58
|
+
.join("\n");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Format tools for a single server */
|
|
62
|
+
export function formatServerTools(
|
|
63
|
+
serverName: string,
|
|
64
|
+
tools: Tool[],
|
|
65
|
+
options: FormatOptions,
|
|
66
|
+
): string {
|
|
67
|
+
if (!isInteractive(options)) {
|
|
68
|
+
return JSON.stringify(
|
|
69
|
+
{
|
|
70
|
+
server: serverName,
|
|
71
|
+
tools: tools.map((t) => ({ name: t.name, description: t.description ?? "" })),
|
|
72
|
+
},
|
|
73
|
+
null,
|
|
74
|
+
2,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (tools.length === 0) {
|
|
79
|
+
return dim(`No tools found for ${serverName}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const header = cyan.bold(serverName);
|
|
83
|
+
const maxName = Math.max(...tools.map((t) => t.name.length));
|
|
84
|
+
|
|
85
|
+
const lines = tools.map((t) => {
|
|
86
|
+
const name = ` ${bold(t.name.padEnd(maxName))}`;
|
|
87
|
+
if (t.description) {
|
|
88
|
+
return `${name} ${dim(t.description)}`;
|
|
89
|
+
}
|
|
90
|
+
return name;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return [header, ...lines].join("\n");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Format a tool schema */
|
|
97
|
+
export function formatToolSchema(serverName: string, tool: Tool, options: FormatOptions): string {
|
|
98
|
+
if (!isInteractive(options)) {
|
|
99
|
+
return JSON.stringify(
|
|
100
|
+
{
|
|
101
|
+
server: serverName,
|
|
102
|
+
tool: tool.name,
|
|
103
|
+
description: tool.description ?? "",
|
|
104
|
+
inputSchema: tool.inputSchema,
|
|
105
|
+
},
|
|
106
|
+
null,
|
|
107
|
+
2,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const lines: string[] = [];
|
|
112
|
+
lines.push(`${cyan(serverName)}/${bold(tool.name)}`);
|
|
113
|
+
|
|
114
|
+
if (tool.description) {
|
|
115
|
+
lines.push(dim(tool.description));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
lines.push("");
|
|
119
|
+
lines.push(bold("Input Schema:"));
|
|
120
|
+
lines.push(formatSchema(tool.inputSchema, 2));
|
|
121
|
+
|
|
122
|
+
return lines.join("\n");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Format a JSON schema as a readable parameter list */
|
|
126
|
+
function formatSchema(schema: Tool["inputSchema"], indent: number): string {
|
|
127
|
+
const pad = " ".repeat(indent);
|
|
128
|
+
const properties = schema.properties ?? {};
|
|
129
|
+
const required = new Set(schema.required ?? []);
|
|
130
|
+
|
|
131
|
+
if (Object.keys(properties).length === 0) {
|
|
132
|
+
return `${pad}${dim("(no parameters)")}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return Object.entries(properties)
|
|
136
|
+
.map(([name, prop]) => {
|
|
137
|
+
const p = prop as Record<string, unknown>;
|
|
138
|
+
const type = (p.type as string) ?? "any";
|
|
139
|
+
const req = required.has(name) ? red("*") : "";
|
|
140
|
+
const desc = p.description ? ` ${dim(String(p.description))}` : "";
|
|
141
|
+
return `${pad}${green(name)}${req} ${dim(`(${type})`)}${desc}`;
|
|
142
|
+
})
|
|
143
|
+
.join("\n");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Format detailed tool help with example payload */
|
|
147
|
+
export function formatToolHelp(serverName: string, tool: Tool, options: FormatOptions): string {
|
|
148
|
+
if (!isInteractive(options)) {
|
|
149
|
+
return JSON.stringify(
|
|
150
|
+
{
|
|
151
|
+
server: serverName,
|
|
152
|
+
tool: tool.name,
|
|
153
|
+
description: tool.description ?? "",
|
|
154
|
+
inputSchema: tool.inputSchema,
|
|
155
|
+
example: generateExample(tool.inputSchema),
|
|
156
|
+
},
|
|
157
|
+
null,
|
|
158
|
+
2,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const lines: string[] = [];
|
|
163
|
+
lines.push(`${cyan(serverName)}/${bold(tool.name)}`);
|
|
164
|
+
|
|
165
|
+
if (tool.description) {
|
|
166
|
+
lines.push(dim(tool.description));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
lines.push("");
|
|
170
|
+
lines.push(bold("Parameters:"));
|
|
171
|
+
lines.push(formatSchema(tool.inputSchema, 2));
|
|
172
|
+
|
|
173
|
+
const example = generateExample(tool.inputSchema);
|
|
174
|
+
lines.push("");
|
|
175
|
+
lines.push(bold("Example:"));
|
|
176
|
+
lines.push(dim(` mcpcli call ${serverName} ${tool.name} '${JSON.stringify(example)}'`));
|
|
177
|
+
|
|
178
|
+
return lines.join("\n");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Generate an example payload from a JSON schema */
|
|
182
|
+
function generateExample(schema: Tool["inputSchema"]): Record<string, unknown> {
|
|
183
|
+
const properties = schema.properties ?? {};
|
|
184
|
+
const required = new Set(schema.required ?? []);
|
|
185
|
+
const example: Record<string, unknown> = {};
|
|
186
|
+
|
|
187
|
+
for (const [name, prop] of Object.entries(properties)) {
|
|
188
|
+
const p = prop as Record<string, unknown>;
|
|
189
|
+
// Include required fields and first few optional fields
|
|
190
|
+
if (required.has(name) || Object.keys(example).length < 3) {
|
|
191
|
+
example[name] = exampleValue(name, p);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return example;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function exampleValue(name: string, prop: Record<string, unknown>): unknown {
|
|
199
|
+
// Use enum first choice if available
|
|
200
|
+
if (Array.isArray(prop.enum) && prop.enum.length > 0) {
|
|
201
|
+
return prop.enum[0];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Use default if provided
|
|
205
|
+
if (prop.default !== undefined) {
|
|
206
|
+
return prop.default;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const type = prop.type as string | undefined;
|
|
210
|
+
switch (type) {
|
|
211
|
+
case "string":
|
|
212
|
+
return `<${name}>`;
|
|
213
|
+
case "number":
|
|
214
|
+
case "integer":
|
|
215
|
+
return 0;
|
|
216
|
+
case "boolean":
|
|
217
|
+
return true;
|
|
218
|
+
case "array":
|
|
219
|
+
return [];
|
|
220
|
+
case "object":
|
|
221
|
+
return {};
|
|
222
|
+
default:
|
|
223
|
+
return `<${name}>`;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Format a tool call result */
|
|
228
|
+
export function formatCallResult(result: unknown, _options: FormatOptions): string {
|
|
229
|
+
return JSON.stringify(parseNestedJson(result), null, 2);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Recursively parse JSON strings inside MCP content blocks */
|
|
233
|
+
function parseNestedJson(value: unknown): unknown {
|
|
234
|
+
if (typeof value === "string") {
|
|
235
|
+
try {
|
|
236
|
+
return parseNestedJson(JSON.parse(value));
|
|
237
|
+
} catch {
|
|
238
|
+
return value;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (Array.isArray(value)) {
|
|
242
|
+
return value.map(parseNestedJson);
|
|
243
|
+
}
|
|
244
|
+
if (typeof value === "object" && value !== null) {
|
|
245
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, parseNestedJson(v)]));
|
|
246
|
+
}
|
|
247
|
+
return value;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Format validation errors for tool input */
|
|
251
|
+
export function formatValidationErrors(
|
|
252
|
+
serverName: string,
|
|
253
|
+
toolName: string,
|
|
254
|
+
errors: ValidationError[],
|
|
255
|
+
options: FormatOptions,
|
|
256
|
+
): string {
|
|
257
|
+
if (!isInteractive(options)) {
|
|
258
|
+
return JSON.stringify({
|
|
259
|
+
error: "validation",
|
|
260
|
+
server: serverName,
|
|
261
|
+
tool: toolName,
|
|
262
|
+
details: errors,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const header = `${red("error:")} invalid arguments for ${cyan(serverName)}/${bold(toolName)}`;
|
|
267
|
+
const details = errors.map((e) => ` ${yellow(e.path)}: ${e.message}`).join("\n");
|
|
268
|
+
return `${header}\n${details}`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Format search results */
|
|
272
|
+
export function formatSearchResults(results: SearchResult[], options: FormatOptions): string {
|
|
273
|
+
if (!isInteractive(options)) {
|
|
274
|
+
return JSON.stringify(results, null, 2);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (results.length === 0) {
|
|
278
|
+
return dim("No matching tools found");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const maxServer = Math.max(...results.map((r) => r.server.length));
|
|
282
|
+
const maxTool = Math.max(...results.map((r) => r.tool.length));
|
|
283
|
+
|
|
284
|
+
// First line of description only for the main row
|
|
285
|
+
const firstLine = (s: string) => s.split("\n")[0] ?? "";
|
|
286
|
+
|
|
287
|
+
return results
|
|
288
|
+
.map((r) => {
|
|
289
|
+
const server = cyan(r.server.padEnd(maxServer));
|
|
290
|
+
const tool = bold(r.tool.padEnd(maxTool));
|
|
291
|
+
const score = yellow(r.score.toFixed(2).padStart(5));
|
|
292
|
+
const summary = firstLine(r.description);
|
|
293
|
+
const line = `${server} ${tool} ${score} ${dim(summary)}`;
|
|
294
|
+
|
|
295
|
+
// Show remaining description lines indented below
|
|
296
|
+
const descLines = r.description.split("\n").slice(1);
|
|
297
|
+
const extra = descLines.filter((l) => l.trim()).length > 0;
|
|
298
|
+
if (!extra) return line;
|
|
299
|
+
|
|
300
|
+
const indent = " ".repeat(maxServer + maxTool + 12);
|
|
301
|
+
const rest = descLines
|
|
302
|
+
.filter((l) => l.trim())
|
|
303
|
+
.map((l) => `${indent}${dim(l.trim())}`)
|
|
304
|
+
.join("\n");
|
|
305
|
+
return `${line}\n${rest}`;
|
|
306
|
+
})
|
|
307
|
+
.join("\n");
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Format an error message */
|
|
311
|
+
export function formatError(message: string, options: FormatOptions): string {
|
|
312
|
+
if (!isInteractive(options)) {
|
|
313
|
+
return JSON.stringify({ error: message });
|
|
314
|
+
}
|
|
315
|
+
return `${red("error:")} ${message}`;
|
|
316
|
+
}
|