@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,179 @@
|
|
|
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, resolveResourceUrl } 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("--transport <type>", 'transport for HTTP servers: "sse" or "streamable-http"')
|
|
18
|
+
.option("--allowed-tools <tools>", "comma-separated list of allowed tools")
|
|
19
|
+
.option("--disabled-tools <tools>", "comma-separated list of disabled tools")
|
|
20
|
+
.option("-f, --force", "overwrite if server already exists")
|
|
21
|
+
.option("--no-auth", "skip automatic OAuth authentication after adding an HTTP server")
|
|
22
|
+
.option("--no-index", "skip rebuilding the search index after adding")
|
|
23
|
+
.action(
|
|
24
|
+
async (
|
|
25
|
+
name: string,
|
|
26
|
+
options: {
|
|
27
|
+
command?: string;
|
|
28
|
+
args?: string;
|
|
29
|
+
env?: string;
|
|
30
|
+
cwd?: string;
|
|
31
|
+
url?: string;
|
|
32
|
+
header?: string[];
|
|
33
|
+
transport?: string;
|
|
34
|
+
allowedTools?: string;
|
|
35
|
+
disabledTools?: string;
|
|
36
|
+
force?: boolean;
|
|
37
|
+
auth?: boolean;
|
|
38
|
+
index?: boolean;
|
|
39
|
+
},
|
|
40
|
+
) => {
|
|
41
|
+
const hasCommand = !!options.command;
|
|
42
|
+
const hasUrl = !!options.url;
|
|
43
|
+
|
|
44
|
+
if (!hasCommand && !hasUrl) {
|
|
45
|
+
console.error("Must specify --command (stdio) or --url (http)");
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
if (hasCommand && hasUrl) {
|
|
49
|
+
console.error("Cannot specify both --command and --url");
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const configFlag = program.opts().config;
|
|
54
|
+
const { configDir, servers } = await loadRawServers(configFlag);
|
|
55
|
+
|
|
56
|
+
if (servers.mcpServers[name] && !options.force) {
|
|
57
|
+
console.error(`Server "${name}" already exists (use --force to overwrite)`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let config: ServerConfig;
|
|
62
|
+
|
|
63
|
+
if (hasCommand) {
|
|
64
|
+
config = buildStdioConfig(options);
|
|
65
|
+
} else {
|
|
66
|
+
config = buildHttpConfig(options);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (hasUrl && options.transport) {
|
|
70
|
+
if (options.transport !== "sse" && options.transport !== "streamable-http") {
|
|
71
|
+
console.error('--transport must be "sse" or "streamable-http"');
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
(config as { transport: string }).transport = options.transport;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Common options
|
|
78
|
+
if (options.allowedTools) {
|
|
79
|
+
config.allowedTools = options.allowedTools.split(",").map((t) => t.trim());
|
|
80
|
+
}
|
|
81
|
+
if (options.disabledTools) {
|
|
82
|
+
config.disabledTools = options.disabledTools.split(",").map((t) => t.trim());
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// For HTTP servers, resolve the canonical resource URL before saving.
|
|
86
|
+
// Some servers (e.g. hf.co → huggingface.co) advertise a different canonical
|
|
87
|
+
// URL in their OAuth protected resource metadata, and the SDK enforces that the
|
|
88
|
+
// stored URL matches this canonical URL during the OAuth token flow.
|
|
89
|
+
let effectiveUrl = options.url!;
|
|
90
|
+
if (hasUrl && options.auth !== false) {
|
|
91
|
+
const canonical = await resolveResourceUrl(effectiveUrl);
|
|
92
|
+
if (canonical !== effectiveUrl) {
|
|
93
|
+
(config as { url: string }).url = canonical;
|
|
94
|
+
effectiveUrl = canonical;
|
|
95
|
+
console.log(`Resolved canonical URL: ${canonical}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
servers.mcpServers[name] = config;
|
|
100
|
+
await saveServers(configDir, servers);
|
|
101
|
+
console.log(`Added server "${name}" to ${configDir}/servers.json`);
|
|
102
|
+
|
|
103
|
+
// Auto-auth: probe for OAuth support and run the flow if supported
|
|
104
|
+
if (hasUrl && options.auth !== false) {
|
|
105
|
+
const auth = await loadRawAuth(configDir);
|
|
106
|
+
const formatOptions = {
|
|
107
|
+
json: !!program.opts().json,
|
|
108
|
+
verbose: !!program.opts().verbose,
|
|
109
|
+
showSecrets: false,
|
|
110
|
+
};
|
|
111
|
+
try {
|
|
112
|
+
await tryOAuthIfSupported(name, effectiveUrl, configDir, auth, formatOptions);
|
|
113
|
+
} catch {
|
|
114
|
+
console.error(`Warning: OAuth authentication failed. Run: mcpx auth ${name}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Commander treats --no-index as index=false (default true)
|
|
119
|
+
if (options.index !== false) {
|
|
120
|
+
await runIndex(program);
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function collect(value: string, previous: string[]): string[] {
|
|
127
|
+
return previous.concat([value]);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function buildStdioConfig(options: {
|
|
131
|
+
command?: string;
|
|
132
|
+
args?: string;
|
|
133
|
+
env?: string;
|
|
134
|
+
cwd?: string;
|
|
135
|
+
}): ServerConfig {
|
|
136
|
+
const config: Record<string, unknown> = { command: options.command! };
|
|
137
|
+
|
|
138
|
+
if (options.args) {
|
|
139
|
+
config.args = options.args.split(",").map((a) => a.trim());
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (options.env) {
|
|
143
|
+
const env: Record<string, string> = {};
|
|
144
|
+
for (const pair of options.env.split(",")) {
|
|
145
|
+
const eqIdx = pair.indexOf("=");
|
|
146
|
+
if (eqIdx === -1) {
|
|
147
|
+
console.error(`Invalid env format "${pair}", expected KEY=VAL`);
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
env[pair.slice(0, eqIdx).trim()] = pair.slice(eqIdx + 1).trim();
|
|
151
|
+
}
|
|
152
|
+
config.env = env;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (options.cwd) {
|
|
156
|
+
config.cwd = options.cwd;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return config as ServerConfig;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function buildHttpConfig(options: { url?: string; header?: string[] }): ServerConfig {
|
|
163
|
+
const config: Record<string, unknown> = { url: options.url! };
|
|
164
|
+
|
|
165
|
+
if (options.header && options.header.length > 0) {
|
|
166
|
+
const headers: Record<string, string> = {};
|
|
167
|
+
for (const h of options.header) {
|
|
168
|
+
const colonIdx = h.indexOf(":");
|
|
169
|
+
if (colonIdx === -1) {
|
|
170
|
+
console.error(`Invalid header format "${h}", expected Key:Value`);
|
|
171
|
+
process.exit(1);
|
|
172
|
+
}
|
|
173
|
+
headers[h.slice(0, colonIdx).trim()] = h.slice(colonIdx + 1).trim();
|
|
174
|
+
}
|
|
175
|
+
config.headers = headers;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return config as ServerConfig;
|
|
179
|
+
}
|
|
@@ -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,156 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { getContext } from "../context.ts";
|
|
3
|
+
import {
|
|
4
|
+
formatCallResult,
|
|
5
|
+
formatError,
|
|
6
|
+
formatServerTools,
|
|
7
|
+
formatTaskCreated,
|
|
8
|
+
formatValidationErrors,
|
|
9
|
+
} from "../output/formatter.ts";
|
|
10
|
+
import { logger } from "../output/logger.ts";
|
|
11
|
+
import { validateToolInput } from "../validation/schema.ts";
|
|
12
|
+
import { parseJsonArgs, readStdin } from "../lib/input.ts";
|
|
13
|
+
|
|
14
|
+
export function registerExecCommand(program: Command) {
|
|
15
|
+
program
|
|
16
|
+
.command("exec <server> [tool] [args]")
|
|
17
|
+
.description("execute a tool (omit tool name to list available tools)")
|
|
18
|
+
.option("-f, --file <path>", "read JSON args from a file")
|
|
19
|
+
.option("--no-wait", "return task handle immediately without waiting for completion")
|
|
20
|
+
.option("--ttl <ms>", "task TTL in milliseconds", "60000")
|
|
21
|
+
.action(
|
|
22
|
+
async (
|
|
23
|
+
server: string,
|
|
24
|
+
tool: string | undefined,
|
|
25
|
+
argsStr: string | undefined,
|
|
26
|
+
options: { file?: string; wait: boolean; ttl: string },
|
|
27
|
+
) => {
|
|
28
|
+
const { manager, formatOptions } = await getContext(program);
|
|
29
|
+
|
|
30
|
+
if (!tool) {
|
|
31
|
+
try {
|
|
32
|
+
const tools = await manager.listTools(server);
|
|
33
|
+
console.log(formatServerTools(server, tools, formatOptions));
|
|
34
|
+
} catch (err) {
|
|
35
|
+
console.error(formatError(String(err), formatOptions));
|
|
36
|
+
process.exit(1);
|
|
37
|
+
} finally {
|
|
38
|
+
await manager.close();
|
|
39
|
+
}
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
// Error if both --file and positional arg provided
|
|
44
|
+
if (options.file && argsStr) {
|
|
45
|
+
throw new Error("Cannot specify both --file and inline JSON args");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Parse args from: --file > positional arg > stdin > empty
|
|
49
|
+
let args: Record<string, unknown> = {};
|
|
50
|
+
|
|
51
|
+
if (options.file) {
|
|
52
|
+
const file = Bun.file(options.file);
|
|
53
|
+
if (!(await file.exists())) {
|
|
54
|
+
throw new Error(`File not found: ${options.file}`);
|
|
55
|
+
}
|
|
56
|
+
const content = await file.text();
|
|
57
|
+
args = parseJsonArgs(content);
|
|
58
|
+
} else if (argsStr) {
|
|
59
|
+
args = parseJsonArgs(argsStr);
|
|
60
|
+
} else if (!process.stdin.isTTY) {
|
|
61
|
+
// Read from stdin
|
|
62
|
+
const stdin = await readStdin();
|
|
63
|
+
if (stdin.trim()) {
|
|
64
|
+
args = parseJsonArgs(stdin);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Validate args against tool inputSchema before calling
|
|
69
|
+
const toolSchema = await manager.getToolSchema(server, tool);
|
|
70
|
+
if (toolSchema) {
|
|
71
|
+
const validation = validateToolInput(server, toolSchema, args);
|
|
72
|
+
if (!validation.valid) {
|
|
73
|
+
console.error(formatValidationErrors(server, tool, validation.errors, formatOptions));
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Check if tool supports task-augmented execution
|
|
79
|
+
const taskSupport = (toolSchema as Record<string, unknown> | undefined)?.execution as
|
|
80
|
+
| { taskSupport?: string }
|
|
81
|
+
| undefined;
|
|
82
|
+
const supportsTask = await manager.serverSupportsTask(server);
|
|
83
|
+
const useTask =
|
|
84
|
+
supportsTask &&
|
|
85
|
+
taskSupport?.taskSupport !== undefined &&
|
|
86
|
+
taskSupport.taskSupport !== "forbidden";
|
|
87
|
+
|
|
88
|
+
if (useTask) {
|
|
89
|
+
const abortController = new AbortController();
|
|
90
|
+
let currentTaskId: string | undefined;
|
|
91
|
+
|
|
92
|
+
// Graceful Ctrl+C: cancel the task before exiting
|
|
93
|
+
const sigintHandler = async () => {
|
|
94
|
+
abortController.abort();
|
|
95
|
+
if (currentTaskId) {
|
|
96
|
+
try {
|
|
97
|
+
await manager.cancelTask(server, currentTaskId);
|
|
98
|
+
} catch {
|
|
99
|
+
// best effort
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
await manager.close();
|
|
103
|
+
process.exit(130);
|
|
104
|
+
};
|
|
105
|
+
process.on("SIGINT", sigintHandler);
|
|
106
|
+
|
|
107
|
+
const spinner = logger.startSpinner(`Executing ${server}/${tool}...`, formatOptions);
|
|
108
|
+
try {
|
|
109
|
+
const stream = manager.callToolStream(server, tool, args, {
|
|
110
|
+
ttl: parseInt(options.ttl, 10),
|
|
111
|
+
signal: abortController.signal,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
for await (const message of stream) {
|
|
115
|
+
switch (message.type) {
|
|
116
|
+
case "taskCreated":
|
|
117
|
+
currentTaskId = message.task.taskId;
|
|
118
|
+
if (!options.wait) {
|
|
119
|
+
// --no-wait: output the task handle and exit
|
|
120
|
+
spinner.stop();
|
|
121
|
+
console.log(formatTaskCreated(message.task, formatOptions));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
spinner.update(`Task ${message.task.taskId} (${message.task.status})...`);
|
|
125
|
+
break;
|
|
126
|
+
case "taskStatus":
|
|
127
|
+
spinner.update(`Task ${message.task.taskId} (${message.task.status})...`);
|
|
128
|
+
break;
|
|
129
|
+
case "result":
|
|
130
|
+
spinner.stop();
|
|
131
|
+
console.log(formatCallResult(message.result, formatOptions));
|
|
132
|
+
return;
|
|
133
|
+
case "error":
|
|
134
|
+
spinner.error("Task failed");
|
|
135
|
+
throw message.error;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} finally {
|
|
139
|
+
process.removeListener("SIGINT", sigintHandler);
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
// Standard synchronous tool call
|
|
143
|
+
const spinner = logger.startSpinner(`Executing ${server}/${tool}...`, formatOptions);
|
|
144
|
+
const result = await manager.callTool(server, tool, args);
|
|
145
|
+
spinner.stop();
|
|
146
|
+
console.log(formatCallResult(result, formatOptions));
|
|
147
|
+
}
|
|
148
|
+
} catch (err) {
|
|
149
|
+
console.error(formatError(String(err), formatOptions));
|
|
150
|
+
process.exit(1);
|
|
151
|
+
} finally {
|
|
152
|
+
await manager.close();
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
);
|
|
156
|
+
}
|
|
@@ -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: mcpx 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 mcpx index to refresh)`));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
await manager.close();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
await runIndex(program);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import type { Tool, Resource, Prompt } from "../config/schemas.ts";
|
|
3
|
+
import { getContext } from "../context.ts";
|
|
4
|
+
import { formatServerOverview, formatToolSchema, formatError } from "../output/formatter.ts";
|
|
5
|
+
import { logger } from "../output/logger.ts";
|
|
6
|
+
|
|
7
|
+
export function registerInfoCommand(program: Command) {
|
|
8
|
+
program
|
|
9
|
+
.command("info <server> [tool]")
|
|
10
|
+
.description("show server overview, or schema for a specific tool")
|
|
11
|
+
.action(async (server: string, tool: string | undefined) => {
|
|
12
|
+
const { manager, formatOptions } = await getContext(program);
|
|
13
|
+
const target = tool ? `${server}/${tool}` : server;
|
|
14
|
+
const spinner = logger.startSpinner(`Connecting to ${target}...`, formatOptions);
|
|
15
|
+
try {
|
|
16
|
+
if (tool) {
|
|
17
|
+
const toolSchema = await manager.getToolSchema(server, tool);
|
|
18
|
+
spinner.stop();
|
|
19
|
+
if (!toolSchema) {
|
|
20
|
+
console.error(
|
|
21
|
+
formatError(`Tool "${tool}" not found on server "${server}"`, formatOptions),
|
|
22
|
+
);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
console.log(formatToolSchema(server, toolSchema, formatOptions));
|
|
26
|
+
} else {
|
|
27
|
+
// Get server info first to check capabilities
|
|
28
|
+
const serverInfo = await manager.getServerInfo(server);
|
|
29
|
+
const caps = serverInfo.capabilities as Record<string, unknown> | undefined;
|
|
30
|
+
|
|
31
|
+
// Only fetch what the server supports
|
|
32
|
+
const fetches: [Promise<Tool[]>, Promise<Resource[]>, Promise<Prompt[]>] = [
|
|
33
|
+
caps?.tools !== undefined ? manager.listTools(server) : Promise.resolve([]),
|
|
34
|
+
caps?.resources !== undefined ? manager.listResources(server) : Promise.resolve([]),
|
|
35
|
+
caps?.prompts !== undefined ? manager.listPrompts(server) : Promise.resolve([]),
|
|
36
|
+
];
|
|
37
|
+
const [tools, resources, prompts] = await Promise.all(fetches);
|
|
38
|
+
|
|
39
|
+
spinner.stop();
|
|
40
|
+
console.log(
|
|
41
|
+
formatServerOverview(
|
|
42
|
+
{
|
|
43
|
+
serverName: server,
|
|
44
|
+
version: serverInfo.version,
|
|
45
|
+
capabilities: caps,
|
|
46
|
+
instructions: serverInfo.instructions,
|
|
47
|
+
tools,
|
|
48
|
+
resourceCount: resources.length,
|
|
49
|
+
promptCount: prompts.length,
|
|
50
|
+
},
|
|
51
|
+
formatOptions,
|
|
52
|
+
),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
} catch (err) {
|
|
56
|
+
spinner.error(`Failed to connect to ${target}`);
|
|
57
|
+
console.error(formatError(String(err), formatOptions));
|
|
58
|
+
process.exit(1);
|
|
59
|
+
} finally {
|
|
60
|
+
await manager.close();
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { getContext } from "../context.ts";
|
|
3
|
+
import { formatUnifiedList, formatError } from "../output/formatter.ts";
|
|
4
|
+
import type { UnifiedItem } from "../output/formatter.ts";
|
|
5
|
+
import { logger } from "../output/logger.ts";
|
|
6
|
+
|
|
7
|
+
export function registerListCommand(program: Command) {
|
|
8
|
+
program.action(async () => {
|
|
9
|
+
const { manager, formatOptions } = await getContext(program);
|
|
10
|
+
const spinner = logger.startSpinner("Connecting to servers...", formatOptions);
|
|
11
|
+
try {
|
|
12
|
+
const [toolsResult, resourcesResult, promptsResult] = await Promise.all([
|
|
13
|
+
manager.getAllTools(),
|
|
14
|
+
manager.getAllResources(),
|
|
15
|
+
manager.getAllPrompts(),
|
|
16
|
+
]);
|
|
17
|
+
spinner.stop();
|
|
18
|
+
|
|
19
|
+
const items: UnifiedItem[] = [
|
|
20
|
+
...toolsResult.tools.map((t) => ({
|
|
21
|
+
server: t.server,
|
|
22
|
+
type: "tool" as const,
|
|
23
|
+
name: t.tool.name,
|
|
24
|
+
description: t.tool.description,
|
|
25
|
+
})),
|
|
26
|
+
...resourcesResult.resources.map((r) => ({
|
|
27
|
+
server: r.server,
|
|
28
|
+
type: "resource" as const,
|
|
29
|
+
name: r.resource.uri,
|
|
30
|
+
description: r.resource.description,
|
|
31
|
+
})),
|
|
32
|
+
...promptsResult.prompts.map((p) => ({
|
|
33
|
+
server: p.server,
|
|
34
|
+
type: "prompt" as const,
|
|
35
|
+
name: p.prompt.name,
|
|
36
|
+
description: p.prompt.description,
|
|
37
|
+
})),
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const typeOrder = { tool: 0, resource: 1, prompt: 2 };
|
|
41
|
+
items.sort((a, b) => {
|
|
42
|
+
if (a.server !== b.server) return a.server.localeCompare(b.server);
|
|
43
|
+
if (a.type !== b.type) return typeOrder[a.type] - typeOrder[b.type];
|
|
44
|
+
return a.name.localeCompare(b.name);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const errors = [...toolsResult.errors, ...resourcesResult.errors, ...promptsResult.errors];
|
|
48
|
+
if (errors.length > 0) {
|
|
49
|
+
for (const err of errors) {
|
|
50
|
+
console.error(`"${err.server}": ${err.message}`);
|
|
51
|
+
}
|
|
52
|
+
if (items.length > 0) console.log("");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
console.log(formatUnifiedList(items, formatOptions));
|
|
56
|
+
} catch (err) {
|
|
57
|
+
spinner.error("Failed to list servers");
|
|
58
|
+
console.error(formatError(String(err), formatOptions));
|
|
59
|
+
process.exit(1);
|
|
60
|
+
} finally {
|
|
61
|
+
await manager.close();
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|