@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,155 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import type { ServerConfig } from "../config/schemas.ts";
|
|
3
|
+
import { loadRawAuth, loadRawServers, saveServers } from "../config/loader.ts";
|
|
4
|
+
import { tryOAuthIfSupported } from "../client/oauth.ts";
|
|
5
|
+
import { runIndex } from "./index.ts";
|
|
6
|
+
|
|
7
|
+
export function registerAddCommand(program: Command) {
|
|
8
|
+
program
|
|
9
|
+
.command("add <name>")
|
|
10
|
+
.description("add an MCP server to your config")
|
|
11
|
+
.option("--command <cmd>", "command to run (stdio server)")
|
|
12
|
+
.option("--args <args>", "comma-separated arguments for the command")
|
|
13
|
+
.option("--env <vars>", "comma-separated KEY=VAL environment variables")
|
|
14
|
+
.option("--cwd <dir>", "working directory for the command")
|
|
15
|
+
.option("--url <url>", "server URL (HTTP server)")
|
|
16
|
+
.option("--header <h>", "header in Key:Value format (repeatable)", collect, [])
|
|
17
|
+
.option("--allowed-tools <tools>", "comma-separated list of allowed tools")
|
|
18
|
+
.option("--disabled-tools <tools>", "comma-separated list of disabled tools")
|
|
19
|
+
.option("-f, --force", "overwrite if server already exists")
|
|
20
|
+
.option("--no-auth", "skip automatic OAuth authentication after adding an HTTP server")
|
|
21
|
+
.option("--no-index", "skip rebuilding the search index after adding")
|
|
22
|
+
.action(
|
|
23
|
+
async (
|
|
24
|
+
name: string,
|
|
25
|
+
options: {
|
|
26
|
+
command?: string;
|
|
27
|
+
args?: string;
|
|
28
|
+
env?: string;
|
|
29
|
+
cwd?: string;
|
|
30
|
+
url?: string;
|
|
31
|
+
header?: string[];
|
|
32
|
+
allowedTools?: string;
|
|
33
|
+
disabledTools?: string;
|
|
34
|
+
force?: boolean;
|
|
35
|
+
auth?: boolean;
|
|
36
|
+
index?: boolean;
|
|
37
|
+
},
|
|
38
|
+
) => {
|
|
39
|
+
const hasCommand = !!options.command;
|
|
40
|
+
const hasUrl = !!options.url;
|
|
41
|
+
|
|
42
|
+
if (!hasCommand && !hasUrl) {
|
|
43
|
+
console.error("Must specify --command (stdio) or --url (http)");
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
if (hasCommand && hasUrl) {
|
|
47
|
+
console.error("Cannot specify both --command and --url");
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const configFlag = program.opts().config;
|
|
52
|
+
const { configDir, servers } = await loadRawServers(configFlag);
|
|
53
|
+
|
|
54
|
+
if (servers.mcpServers[name] && !options.force) {
|
|
55
|
+
console.error(`Server "${name}" already exists (use --force to overwrite)`);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let config: ServerConfig;
|
|
60
|
+
|
|
61
|
+
if (hasCommand) {
|
|
62
|
+
config = buildStdioConfig(options);
|
|
63
|
+
} else {
|
|
64
|
+
config = buildHttpConfig(options);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Common options
|
|
68
|
+
if (options.allowedTools) {
|
|
69
|
+
config.allowedTools = options.allowedTools.split(",").map((t) => t.trim());
|
|
70
|
+
}
|
|
71
|
+
if (options.disabledTools) {
|
|
72
|
+
config.disabledTools = options.disabledTools.split(",").map((t) => t.trim());
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
servers.mcpServers[name] = config;
|
|
76
|
+
await saveServers(configDir, servers);
|
|
77
|
+
console.log(`Added server "${name}" to ${configDir}/servers.json`);
|
|
78
|
+
|
|
79
|
+
// Auto-auth: probe for OAuth support and run the flow if supported
|
|
80
|
+
if (hasUrl && options.auth !== false) {
|
|
81
|
+
const auth = await loadRawAuth(configDir);
|
|
82
|
+
const formatOptions = {
|
|
83
|
+
json: !!program.opts().json,
|
|
84
|
+
verbose: !!program.opts().verbose,
|
|
85
|
+
showSecrets: false,
|
|
86
|
+
};
|
|
87
|
+
try {
|
|
88
|
+
await tryOAuthIfSupported(name, options.url!, configDir, auth, formatOptions);
|
|
89
|
+
} catch {
|
|
90
|
+
console.error(`Warning: OAuth authentication failed. Run: mcpcli auth ${name}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Commander treats --no-index as index=false (default true)
|
|
95
|
+
if (options.index !== false) {
|
|
96
|
+
await runIndex(program);
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function collect(value: string, previous: string[]): string[] {
|
|
103
|
+
return previous.concat([value]);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function buildStdioConfig(options: {
|
|
107
|
+
command?: string;
|
|
108
|
+
args?: string;
|
|
109
|
+
env?: string;
|
|
110
|
+
cwd?: string;
|
|
111
|
+
}): ServerConfig {
|
|
112
|
+
const config: Record<string, unknown> = { command: options.command! };
|
|
113
|
+
|
|
114
|
+
if (options.args) {
|
|
115
|
+
config.args = options.args.split(",").map((a) => a.trim());
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (options.env) {
|
|
119
|
+
const env: Record<string, string> = {};
|
|
120
|
+
for (const pair of options.env.split(",")) {
|
|
121
|
+
const eqIdx = pair.indexOf("=");
|
|
122
|
+
if (eqIdx === -1) {
|
|
123
|
+
console.error(`Invalid env format "${pair}", expected KEY=VAL`);
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
env[pair.slice(0, eqIdx).trim()] = pair.slice(eqIdx + 1).trim();
|
|
127
|
+
}
|
|
128
|
+
config.env = env;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (options.cwd) {
|
|
132
|
+
config.cwd = options.cwd;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return config as ServerConfig;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function buildHttpConfig(options: { url?: string; header?: string[] }): ServerConfig {
|
|
139
|
+
const config: Record<string, unknown> = { url: options.url! };
|
|
140
|
+
|
|
141
|
+
if (options.header && options.header.length > 0) {
|
|
142
|
+
const headers: Record<string, string> = {};
|
|
143
|
+
for (const h of options.header) {
|
|
144
|
+
const colonIdx = h.indexOf(":");
|
|
145
|
+
if (colonIdx === -1) {
|
|
146
|
+
console.error(`Invalid header format "${h}", expected Key:Value`);
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
headers[h.slice(0, colonIdx).trim()] = h.slice(colonIdx + 1).trim();
|
|
150
|
+
}
|
|
151
|
+
config.headers = headers;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return config as ServerConfig;
|
|
155
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { getContext } from "../context.ts";
|
|
3
|
+
import { isHttpServer } from "../config/schemas.ts";
|
|
4
|
+
import { saveAuth } from "../config/loader.ts";
|
|
5
|
+
import { McpOAuthProvider, runOAuthFlow } from "../client/oauth.ts";
|
|
6
|
+
import { logger } from "../output/logger.ts";
|
|
7
|
+
import { runIndex } from "./index.ts";
|
|
8
|
+
|
|
9
|
+
export function registerAuthCommand(program: Command) {
|
|
10
|
+
program
|
|
11
|
+
.command("auth <server>")
|
|
12
|
+
.description("authenticate with an HTTP MCP server")
|
|
13
|
+
.option("-s, --status", "check auth status and token TTL")
|
|
14
|
+
.option("-r, --refresh", "force token refresh")
|
|
15
|
+
.option("--no-index", "skip rebuilding the search index after auth")
|
|
16
|
+
.action(
|
|
17
|
+
async (server: string, options: { status?: boolean; refresh?: boolean; index?: boolean }) => {
|
|
18
|
+
const { config, formatOptions } = await getContext(program);
|
|
19
|
+
|
|
20
|
+
const serverConfig = config.servers.mcpServers[server];
|
|
21
|
+
if (!serverConfig) {
|
|
22
|
+
console.error(`Unknown server: "${server}"`);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
if (!isHttpServer(serverConfig)) {
|
|
26
|
+
console.error(
|
|
27
|
+
`Server "${server}" is not an HTTP server — OAuth only applies to HTTP servers`,
|
|
28
|
+
);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const provider = new McpOAuthProvider({
|
|
33
|
+
serverName: server,
|
|
34
|
+
configDir: config.configDir,
|
|
35
|
+
auth: config.auth,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
if (options.status) {
|
|
39
|
+
showStatus(server, provider);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (options.refresh) {
|
|
44
|
+
const spinner = logger.startSpinner(`Refreshing token for "${server}"…`, formatOptions);
|
|
45
|
+
try {
|
|
46
|
+
await provider.refreshIfNeeded(serverConfig.url);
|
|
47
|
+
spinner.success(`Token refreshed for "${server}"`);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
spinner.error(`Refresh failed: ${err instanceof Error ? err.message : err}`);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Default: full OAuth flow
|
|
56
|
+
const spinner = logger.startSpinner(`Authenticating with "${server}"…`, formatOptions);
|
|
57
|
+
try {
|
|
58
|
+
await runOAuthFlow(serverConfig.url, provider);
|
|
59
|
+
spinner.success(`Authenticated with "${server}"`);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
spinner.error(`Authentication failed: ${err instanceof Error ? err.message : err}`);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (options.index !== false) {
|
|
66
|
+
await runIndex(program);
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function registerDeauthCommand(program: Command) {
|
|
73
|
+
program
|
|
74
|
+
.command("deauth <server>")
|
|
75
|
+
.description("remove stored authentication for a server")
|
|
76
|
+
.action(async (server: string) => {
|
|
77
|
+
const { config } = await getContext(program);
|
|
78
|
+
|
|
79
|
+
if (!config.auth[server]) {
|
|
80
|
+
console.log(`No auth stored for "${server}"`);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
delete config.auth[server];
|
|
85
|
+
await saveAuth(config.configDir, config.auth);
|
|
86
|
+
console.log(`Deauthenticated "${server}"`);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function showStatus(server: string, provider: McpOAuthProvider) {
|
|
91
|
+
if (!provider.isComplete()) {
|
|
92
|
+
console.log(`${server}: not authenticated`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const expired = provider.isExpired();
|
|
97
|
+
const hasRefresh = provider.hasRefreshToken();
|
|
98
|
+
const status = expired ? "expired" : "authenticated";
|
|
99
|
+
|
|
100
|
+
console.log(`${server}: ${status}`);
|
|
101
|
+
if (hasRefresh) {
|
|
102
|
+
console.log(" refresh token: present");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!expired) {
|
|
106
|
+
// Show TTL if we have expires_at from the auth entry
|
|
107
|
+
const entry = provider["auth"][server];
|
|
108
|
+
if (entry?.expires_at) {
|
|
109
|
+
const remaining = new Date(entry.expires_at).getTime() - Date.now();
|
|
110
|
+
const minutes = Math.round(remaining / 60000);
|
|
111
|
+
console.log(` expires in: ${minutes} minutes`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { getContext } from "../context.ts";
|
|
3
|
+
import {
|
|
4
|
+
formatCallResult,
|
|
5
|
+
formatError,
|
|
6
|
+
formatServerTools,
|
|
7
|
+
formatValidationErrors,
|
|
8
|
+
} from "../output/formatter.ts";
|
|
9
|
+
import { logger } from "../output/logger.ts";
|
|
10
|
+
import { validateToolInput } from "../validation/schema.ts";
|
|
11
|
+
|
|
12
|
+
export function registerExecCommand(program: Command) {
|
|
13
|
+
program
|
|
14
|
+
.command("exec <server> [tool] [args]")
|
|
15
|
+
.description("execute a tool (omit tool name to list available tools)")
|
|
16
|
+
.action(async (server: string, tool: string | undefined, argsStr: string | undefined) => {
|
|
17
|
+
const { manager, formatOptions } = await getContext(program);
|
|
18
|
+
|
|
19
|
+
if (!tool) {
|
|
20
|
+
try {
|
|
21
|
+
const tools = await manager.listTools(server);
|
|
22
|
+
console.log(formatServerTools(server, tools, formatOptions));
|
|
23
|
+
} catch (err) {
|
|
24
|
+
console.error(formatError(String(err), formatOptions));
|
|
25
|
+
process.exit(1);
|
|
26
|
+
} finally {
|
|
27
|
+
await manager.close();
|
|
28
|
+
}
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
// Parse args from argument, stdin, or empty
|
|
33
|
+
let args: Record<string, unknown> = {};
|
|
34
|
+
|
|
35
|
+
if (argsStr) {
|
|
36
|
+
args = parseJsonArgs(argsStr);
|
|
37
|
+
} else if (!process.stdin.isTTY) {
|
|
38
|
+
// Read from stdin
|
|
39
|
+
const stdin = await readStdin();
|
|
40
|
+
if (stdin.trim()) {
|
|
41
|
+
args = parseJsonArgs(stdin);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Validate args against tool inputSchema before calling
|
|
46
|
+
const toolSchema = await manager.getToolSchema(server, tool);
|
|
47
|
+
if (toolSchema) {
|
|
48
|
+
const validation = validateToolInput(server, toolSchema, args);
|
|
49
|
+
if (!validation.valid) {
|
|
50
|
+
console.error(formatValidationErrors(server, tool, validation.errors, formatOptions));
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const spinner = logger.startSpinner(`Executing ${server}/${tool}...`, formatOptions);
|
|
56
|
+
const result = await manager.callTool(server, tool, args);
|
|
57
|
+
spinner.stop();
|
|
58
|
+
console.log(formatCallResult(result, formatOptions));
|
|
59
|
+
} catch (err) {
|
|
60
|
+
console.error(formatError(String(err), formatOptions));
|
|
61
|
+
process.exit(1);
|
|
62
|
+
} finally {
|
|
63
|
+
await manager.close();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseJsonArgs(str: string): Record<string, unknown> {
|
|
69
|
+
try {
|
|
70
|
+
const parsed = JSON.parse(str);
|
|
71
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
72
|
+
throw new Error("Tool arguments must be a JSON object");
|
|
73
|
+
}
|
|
74
|
+
return parsed as Record<string, unknown>;
|
|
75
|
+
} catch (err) {
|
|
76
|
+
if (err instanceof SyntaxError) {
|
|
77
|
+
throw new Error(`Invalid JSON: ${err.message}`);
|
|
78
|
+
}
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function readStdin(): Promise<string> {
|
|
84
|
+
const chunks: string[] = [];
|
|
85
|
+
const reader = process.stdin;
|
|
86
|
+
reader.setEncoding("utf-8");
|
|
87
|
+
for await (const chunk of reader) {
|
|
88
|
+
chunks.push(chunk as string);
|
|
89
|
+
}
|
|
90
|
+
return chunks.join("");
|
|
91
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { yellow } from "ansis";
|
|
3
|
+
import { getContext } from "../context.ts";
|
|
4
|
+
import { buildSearchIndex } from "../search/indexer.ts";
|
|
5
|
+
import { getStaleServers } from "../search/staleness.ts";
|
|
6
|
+
import { saveSearchIndex } from "../config/loader.ts";
|
|
7
|
+
import { formatError } from "../output/formatter.ts";
|
|
8
|
+
import { logger } from "../output/logger.ts";
|
|
9
|
+
|
|
10
|
+
/** Run the search index build. Reusable from other commands (e.g. add). */
|
|
11
|
+
export async function runIndex(program: Command): Promise<void> {
|
|
12
|
+
const { config, manager, formatOptions } = await getContext(program);
|
|
13
|
+
const spinner = logger.startSpinner("Connecting to servers...", formatOptions);
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const start = performance.now();
|
|
17
|
+
const index = await buildSearchIndex(manager, (progress) => {
|
|
18
|
+
spinner.update(`Indexing ${progress.current}/${progress.total}: ${progress.tool}`);
|
|
19
|
+
});
|
|
20
|
+
const elapsed = ((performance.now() - start) / 1000).toFixed(1);
|
|
21
|
+
|
|
22
|
+
await saveSearchIndex(config.configDir, index);
|
|
23
|
+
spinner.success(`Indexed ${index.tools.length} tools in ${elapsed}s`);
|
|
24
|
+
|
|
25
|
+
logger.info(`Saved to ${config.configDir}/search.json`);
|
|
26
|
+
} catch (err) {
|
|
27
|
+
spinner.error("Indexing failed");
|
|
28
|
+
console.error(formatError(String(err), formatOptions));
|
|
29
|
+
process.exit(1);
|
|
30
|
+
} finally {
|
|
31
|
+
await manager.close();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function registerIndexCommand(program: Command) {
|
|
36
|
+
program
|
|
37
|
+
.command("index")
|
|
38
|
+
.description("build the search index from all configured servers")
|
|
39
|
+
.option("-i, --status", "show index status")
|
|
40
|
+
.action(async (options: { status?: boolean }) => {
|
|
41
|
+
if (options.status) {
|
|
42
|
+
const { config, manager } = await getContext(program);
|
|
43
|
+
const idx = config.searchIndex;
|
|
44
|
+
if (idx.tools.length === 0) {
|
|
45
|
+
console.log("No search index. Run: mcpcli index");
|
|
46
|
+
} else {
|
|
47
|
+
console.log(`Tools: ${idx.tools.length}`);
|
|
48
|
+
console.log(`Model: ${idx.embedding_model}`);
|
|
49
|
+
console.log(`Indexed: ${idx.indexed_at}`);
|
|
50
|
+
|
|
51
|
+
const stale = getStaleServers(idx, config.servers);
|
|
52
|
+
if (stale.length > 0) {
|
|
53
|
+
console.log(yellow(`Stale: ${stale.join(", ")} (run mcpcli index to refresh)`));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
await manager.close();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
await runIndex(program);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { getContext } from "../context.ts";
|
|
3
|
+
import { formatServerTools, formatToolSchema, formatError } from "../output/formatter.ts";
|
|
4
|
+
import { logger } from "../output/logger.ts";
|
|
5
|
+
|
|
6
|
+
export function registerInfoCommand(program: Command) {
|
|
7
|
+
program
|
|
8
|
+
.command("info <server> [tool]")
|
|
9
|
+
.description("show tools for a server, or schema for a specific tool")
|
|
10
|
+
.action(async (server: string, tool: string | undefined) => {
|
|
11
|
+
const { manager, formatOptions } = await getContext(program);
|
|
12
|
+
const target = tool ? `${server}/${tool}` : server;
|
|
13
|
+
const spinner = logger.startSpinner(`Connecting to ${target}...`, formatOptions);
|
|
14
|
+
try {
|
|
15
|
+
if (tool) {
|
|
16
|
+
const toolSchema = await manager.getToolSchema(server, tool);
|
|
17
|
+
spinner.stop();
|
|
18
|
+
if (!toolSchema) {
|
|
19
|
+
console.error(
|
|
20
|
+
formatError(`Tool "${tool}" not found on server "${server}"`, formatOptions),
|
|
21
|
+
);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
console.log(formatToolSchema(server, toolSchema, formatOptions));
|
|
25
|
+
} else {
|
|
26
|
+
const tools = await manager.listTools(server);
|
|
27
|
+
spinner.stop();
|
|
28
|
+
console.log(formatServerTools(server, tools, formatOptions));
|
|
29
|
+
}
|
|
30
|
+
} catch (err) {
|
|
31
|
+
spinner.error(`Failed to connect to ${target}`);
|
|
32
|
+
console.error(formatError(String(err), formatOptions));
|
|
33
|
+
process.exit(1);
|
|
34
|
+
} finally {
|
|
35
|
+
await manager.close();
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { getContext } from "../context.ts";
|
|
3
|
+
import { formatToolList, formatError } from "../output/formatter.ts";
|
|
4
|
+
import { logger } from "../output/logger.ts";
|
|
5
|
+
|
|
6
|
+
export function registerListCommand(program: Command) {
|
|
7
|
+
program.action(async () => {
|
|
8
|
+
const { manager, formatOptions } = await getContext(program);
|
|
9
|
+
const spinner = logger.startSpinner("Connecting to servers...", formatOptions);
|
|
10
|
+
try {
|
|
11
|
+
const { tools, errors } = await manager.getAllTools();
|
|
12
|
+
spinner.stop();
|
|
13
|
+
|
|
14
|
+
if (errors.length > 0) {
|
|
15
|
+
for (const err of errors) {
|
|
16
|
+
console.error(`"${err.server}": ${err.message}`);
|
|
17
|
+
}
|
|
18
|
+
if (tools.length > 0) console.log("");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
console.log(formatToolList(tools, formatOptions));
|
|
22
|
+
} catch (err) {
|
|
23
|
+
spinner.error("Failed to list tools");
|
|
24
|
+
console.error(formatError(String(err), formatOptions));
|
|
25
|
+
process.exit(1);
|
|
26
|
+
} finally {
|
|
27
|
+
await manager.close();
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import {
|
|
3
|
+
loadRawServers,
|
|
4
|
+
loadRawAuth,
|
|
5
|
+
loadSearchIndex,
|
|
6
|
+
saveServers,
|
|
7
|
+
saveAuth,
|
|
8
|
+
saveSearchIndex,
|
|
9
|
+
} from "../config/loader.ts";
|
|
10
|
+
|
|
11
|
+
export function registerRemoveCommand(program: Command) {
|
|
12
|
+
program
|
|
13
|
+
.command("remove <name>")
|
|
14
|
+
.description("remove an MCP server from your config")
|
|
15
|
+
.option("--keep-auth", "keep stored authentication credentials")
|
|
16
|
+
.option("--dry-run", "show what would be removed without changing files")
|
|
17
|
+
.action(async (name: string, options: { keepAuth?: boolean; dryRun?: boolean }) => {
|
|
18
|
+
const configFlag = program.opts().config;
|
|
19
|
+
const { configDir, servers } = await loadRawServers(configFlag);
|
|
20
|
+
|
|
21
|
+
if (!servers.mcpServers[name]) {
|
|
22
|
+
console.error(`Unknown server: "${name}"`);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (options.dryRun) {
|
|
27
|
+
console.log(`Would remove server "${name}" from ${configDir}/servers.json`);
|
|
28
|
+
if (!options.keepAuth) {
|
|
29
|
+
const auth = await loadRawAuth(configDir);
|
|
30
|
+
if (auth[name]) {
|
|
31
|
+
console.log(`Would remove auth for "${name}" from ${configDir}/auth.json`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const searchIndex = await loadSearchIndex(configDir);
|
|
35
|
+
const indexedCount = searchIndex.tools.filter((t) => t.server === name).length;
|
|
36
|
+
if (indexedCount > 0) {
|
|
37
|
+
console.log(
|
|
38
|
+
`Would remove ${indexedCount} tool(s) for "${name}" from ${configDir}/search.json`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
delete servers.mcpServers[name];
|
|
45
|
+
await saveServers(configDir, servers);
|
|
46
|
+
console.log(`Removed server "${name}" from ${configDir}/servers.json`);
|
|
47
|
+
|
|
48
|
+
if (!options.keepAuth) {
|
|
49
|
+
const auth = await loadRawAuth(configDir);
|
|
50
|
+
if (auth[name]) {
|
|
51
|
+
delete auth[name];
|
|
52
|
+
await saveAuth(configDir, auth);
|
|
53
|
+
console.log(`Removed auth for "${name}" from ${configDir}/auth.json`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Remove tools for this server from the search index
|
|
58
|
+
const searchIndex = await loadSearchIndex(configDir);
|
|
59
|
+
const before = searchIndex.tools.length;
|
|
60
|
+
searchIndex.tools = searchIndex.tools.filter((t) => t.server !== name);
|
|
61
|
+
const removed = before - searchIndex.tools.length;
|
|
62
|
+
if (removed > 0) {
|
|
63
|
+
await saveSearchIndex(configDir, searchIndex);
|
|
64
|
+
console.log(`Removed ${removed} tool(s) for "${name}" from ${configDir}/search.json`);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { getContext } from "../context.ts";
|
|
3
|
+
import { search } from "../search/index.ts";
|
|
4
|
+
import { getStaleServers } from "../search/staleness.ts";
|
|
5
|
+
import { formatError, formatSearchResults } from "../output/formatter.ts";
|
|
6
|
+
import { logger } from "../output/logger.ts";
|
|
7
|
+
|
|
8
|
+
export function registerSearchCommand(program: Command) {
|
|
9
|
+
program
|
|
10
|
+
.command("search <terms...>")
|
|
11
|
+
.description("search tools by keyword and/or semantic similarity")
|
|
12
|
+
.option("-k, --keyword", "keyword/glob search only")
|
|
13
|
+
.option("-q, --query", "semantic search only")
|
|
14
|
+
.action(async (terms: string[], options: { keyword?: boolean; query?: boolean }) => {
|
|
15
|
+
const query = terms.join(" ");
|
|
16
|
+
const { config, formatOptions } = await getContext(program);
|
|
17
|
+
|
|
18
|
+
if (config.searchIndex.tools.length === 0) {
|
|
19
|
+
console.error(formatError("No search index found. Run: mcpcli index", formatOptions));
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const stale = getStaleServers(config.searchIndex, config.servers);
|
|
24
|
+
if (stale.length > 0) {
|
|
25
|
+
logger.warn(
|
|
26
|
+
`Warning: index has tools for removed servers: ${stale.join(", ")}. Run: mcpcli index`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const spinner = logger.startSpinner("Searching...", formatOptions);
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const results = await search(query, config.searchIndex, {
|
|
34
|
+
keywordOnly: options.keyword,
|
|
35
|
+
semanticOnly: options.query,
|
|
36
|
+
});
|
|
37
|
+
spinner.stop();
|
|
38
|
+
console.log(formatSearchResults(results, formatOptions));
|
|
39
|
+
} catch (err) {
|
|
40
|
+
spinner.stop();
|
|
41
|
+
console.error(formatError(String(err), formatOptions));
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { resolve, dirname, join } from "path";
|
|
3
|
+
import { readFile, mkdir, writeFile, access } from "fs/promises";
|
|
4
|
+
import { homedir } from "os";
|
|
5
|
+
|
|
6
|
+
export function registerSkillCommand(program: Command) {
|
|
7
|
+
const skill = program.command("skill").description("manage mcpcli skills");
|
|
8
|
+
|
|
9
|
+
skill
|
|
10
|
+
.command("install")
|
|
11
|
+
.description("install the mcpcli skill for an AI agent")
|
|
12
|
+
.requiredOption("--claude", "install for Claude Code")
|
|
13
|
+
.option("--global", "install to ~/.claude/skills/")
|
|
14
|
+
.option("--project", "install to ./.claude/skills/ (default)")
|
|
15
|
+
.option("-f, --force", "overwrite if file already exists")
|
|
16
|
+
.action(
|
|
17
|
+
async (options: {
|
|
18
|
+
claude?: boolean;
|
|
19
|
+
global?: boolean;
|
|
20
|
+
project?: boolean;
|
|
21
|
+
force?: boolean;
|
|
22
|
+
}) => {
|
|
23
|
+
// Resolve the bundled skill file
|
|
24
|
+
const skillSource = resolve(dirname(Bun.main), "..", ".claude", "skills", "mcpcli.md");
|
|
25
|
+
|
|
26
|
+
let content: string;
|
|
27
|
+
try {
|
|
28
|
+
content = await readFile(skillSource, "utf-8");
|
|
29
|
+
} catch {
|
|
30
|
+
console.error(`Could not read skill file: ${skillSource}`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Determine targets — default to project if neither flag is set
|
|
35
|
+
const targets: { label: string; dir: string }[] = [];
|
|
36
|
+
|
|
37
|
+
if (options.global) {
|
|
38
|
+
targets.push({
|
|
39
|
+
label: "global",
|
|
40
|
+
dir: join(homedir(), ".claude", "skills"),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
if (options.project || !options.global) {
|
|
44
|
+
targets.push({
|
|
45
|
+
label: "project",
|
|
46
|
+
dir: resolve(".claude", "skills"),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
for (const target of targets) {
|
|
51
|
+
const dest = join(target.dir, "mcpcli.md");
|
|
52
|
+
|
|
53
|
+
// Check if file already exists
|
|
54
|
+
if (!options.force) {
|
|
55
|
+
try {
|
|
56
|
+
await access(dest);
|
|
57
|
+
console.error(`${dest} already exists (use --force to overwrite)`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
} catch {
|
|
60
|
+
// File doesn't exist — good
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
await mkdir(target.dir, { recursive: true });
|
|
65
|
+
await writeFile(dest, content, "utf-8");
|
|
66
|
+
console.log(`Installed mcpcli skill (${target.label}): ${dest}`);
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
}
|