@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.
Files changed (106) hide show
  1. package/.claude/settings.local.json +18 -0
  2. package/.claude/skills/mcpx.md +165 -0
  3. package/.claude/worktrees/elastic-jennings/.claude/settings.local.json +18 -0
  4. package/.claude/worktrees/elastic-jennings/.claude/skills/mcpcli.md +93 -0
  5. package/.claude/worktrees/elastic-jennings/.github/workflows/auto-release.yml +117 -0
  6. package/.claude/worktrees/elastic-jennings/.github/workflows/ci.yml +18 -0
  7. package/.claude/worktrees/elastic-jennings/.prettierignore +4 -0
  8. package/.claude/worktrees/elastic-jennings/.prettierrc +7 -0
  9. package/.claude/worktrees/elastic-jennings/CLAUDE.md +19 -0
  10. package/.claude/worktrees/elastic-jennings/LICENSE +21 -0
  11. package/.claude/worktrees/elastic-jennings/README.md +487 -0
  12. package/.claude/worktrees/elastic-jennings/bun.lock +381 -0
  13. package/.claude/worktrees/elastic-jennings/install.sh +55 -0
  14. package/.claude/worktrees/elastic-jennings/package.json +56 -0
  15. package/.claude/worktrees/elastic-jennings/src/cli.ts +39 -0
  16. package/.claude/worktrees/elastic-jennings/src/client/http.ts +100 -0
  17. package/.claude/worktrees/elastic-jennings/src/client/manager.ts +266 -0
  18. package/.claude/worktrees/elastic-jennings/src/client/oauth.ts +299 -0
  19. package/.claude/worktrees/elastic-jennings/src/client/stdio.ts +12 -0
  20. package/.claude/worktrees/elastic-jennings/src/commands/add.ts +155 -0
  21. package/.claude/worktrees/elastic-jennings/src/commands/auth.ts +114 -0
  22. package/.claude/worktrees/elastic-jennings/src/commands/exec.ts +91 -0
  23. package/.claude/worktrees/elastic-jennings/src/commands/index.ts +62 -0
  24. package/.claude/worktrees/elastic-jennings/src/commands/info.ts +38 -0
  25. package/.claude/worktrees/elastic-jennings/src/commands/list.ts +30 -0
  26. package/.claude/worktrees/elastic-jennings/src/commands/remove.ts +67 -0
  27. package/.claude/worktrees/elastic-jennings/src/commands/search.ts +45 -0
  28. package/.claude/worktrees/elastic-jennings/src/commands/skill.ts +70 -0
  29. package/.claude/worktrees/elastic-jennings/src/config/env.ts +41 -0
  30. package/.claude/worktrees/elastic-jennings/src/config/loader.ts +156 -0
  31. package/.claude/worktrees/elastic-jennings/src/config/schemas.ts +137 -0
  32. package/.claude/worktrees/elastic-jennings/src/context.ts +53 -0
  33. package/.claude/worktrees/elastic-jennings/src/output/formatter.ts +316 -0
  34. package/.claude/worktrees/elastic-jennings/src/output/logger.ts +114 -0
  35. package/.claude/worktrees/elastic-jennings/src/search/index.ts +69 -0
  36. package/.claude/worktrees/elastic-jennings/src/search/indexer.ts +92 -0
  37. package/.claude/worktrees/elastic-jennings/src/search/keyword.ts +86 -0
  38. package/.claude/worktrees/elastic-jennings/src/search/semantic.ts +75 -0
  39. package/.claude/worktrees/elastic-jennings/src/search/staleness.ts +8 -0
  40. package/.claude/worktrees/elastic-jennings/src/validation/schema.ts +77 -0
  41. package/.claude/worktrees/elastic-jennings/test/cli.test.ts +51 -0
  42. package/.claude/worktrees/elastic-jennings/test/client/manager.test.ts +249 -0
  43. package/.claude/worktrees/elastic-jennings/test/client/oauth.test.ts +328 -0
  44. package/.claude/worktrees/elastic-jennings/test/commands/add-remove.test.ts +253 -0
  45. package/.claude/worktrees/elastic-jennings/test/commands/exec.test.ts +105 -0
  46. package/.claude/worktrees/elastic-jennings/test/commands/info.test.ts +48 -0
  47. package/.claude/worktrees/elastic-jennings/test/commands/list.test.ts +39 -0
  48. package/.claude/worktrees/elastic-jennings/test/commands/skill.test.ts +98 -0
  49. package/.claude/worktrees/elastic-jennings/test/config/env.test.ts +61 -0
  50. package/.claude/worktrees/elastic-jennings/test/config/loader.test.ts +139 -0
  51. package/.claude/worktrees/elastic-jennings/test/fixtures/.keep +0 -0
  52. package/.claude/worktrees/elastic-jennings/test/fixtures/auth.json +10 -0
  53. package/.claude/worktrees/elastic-jennings/test/fixtures/mock-config/.keep +0 -0
  54. package/.claude/worktrees/elastic-jennings/test/fixtures/mock-config/servers.json +8 -0
  55. package/.claude/worktrees/elastic-jennings/test/fixtures/mock-server.ts +113 -0
  56. package/.claude/worktrees/elastic-jennings/test/fixtures/search.json +15 -0
  57. package/.claude/worktrees/elastic-jennings/test/fixtures/servers.json +18 -0
  58. package/.claude/worktrees/elastic-jennings/test/integration/stdio-server.test.ts +149 -0
  59. package/.claude/worktrees/elastic-jennings/test/output/formatter.test.ts +54 -0
  60. package/.claude/worktrees/elastic-jennings/test/output/logger.test.ts +89 -0
  61. package/.claude/worktrees/elastic-jennings/test/search/indexer.test.ts +32 -0
  62. package/.claude/worktrees/elastic-jennings/test/search/keyword.test.ts +80 -0
  63. package/.claude/worktrees/elastic-jennings/test/search/semantic.test.ts +32 -0
  64. package/.claude/worktrees/elastic-jennings/test/validation/schema.test.ts +113 -0
  65. package/.claude/worktrees/elastic-jennings/tsconfig.json +29 -0
  66. package/.cursor/rules/mcpx.mdc +165 -0
  67. package/LICENSE +21 -0
  68. package/README.md +627 -0
  69. package/package.json +58 -0
  70. package/src/cli.ts +72 -0
  71. package/src/client/browser.ts +24 -0
  72. package/src/client/debug-fetch.ts +81 -0
  73. package/src/client/elicitation.ts +368 -0
  74. package/src/client/http.ts +25 -0
  75. package/src/client/manager.ts +566 -0
  76. package/src/client/oauth.ts +314 -0
  77. package/src/client/sse.ts +17 -0
  78. package/src/client/stdio.ts +12 -0
  79. package/src/client/trace.ts +184 -0
  80. package/src/commands/add.ts +179 -0
  81. package/src/commands/auth.ts +114 -0
  82. package/src/commands/exec.ts +156 -0
  83. package/src/commands/index.ts +62 -0
  84. package/src/commands/info.ts +63 -0
  85. package/src/commands/list.ts +64 -0
  86. package/src/commands/ping.ts +69 -0
  87. package/src/commands/prompt.ts +60 -0
  88. package/src/commands/remove.ts +67 -0
  89. package/src/commands/resource.ts +46 -0
  90. package/src/commands/search.ts +49 -0
  91. package/src/commands/servers.ts +66 -0
  92. package/src/commands/skill.ts +112 -0
  93. package/src/commands/task.ts +82 -0
  94. package/src/config/env.ts +41 -0
  95. package/src/config/loader.ts +156 -0
  96. package/src/config/schemas.ts +152 -0
  97. package/src/context.ts +62 -0
  98. package/src/lib/input.ts +36 -0
  99. package/src/output/formatter.ts +884 -0
  100. package/src/output/logger.ts +173 -0
  101. package/src/search/index.ts +69 -0
  102. package/src/search/indexer.ts +92 -0
  103. package/src/search/keyword.ts +86 -0
  104. package/src/search/semantic.ts +75 -0
  105. package/src/search/staleness.ts +8 -0
  106. package/src/validation/schema.ts +103 -0
@@ -0,0 +1,100 @@
1
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
2
+ import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
3
+ import { dim } from "ansis";
4
+ import type { HttpServerConfig } from "../config/schemas.ts";
5
+ import { logger } from "../output/logger.ts";
6
+
7
+ type FetchLike = (url: string | URL, init?: RequestInit) => Promise<Response>;
8
+
9
+ export function createHttpTransport(
10
+ config: HttpServerConfig,
11
+ authProvider?: OAuthClientProvider,
12
+ verbose = false,
13
+ showSecrets = false,
14
+ ): StreamableHTTPClientTransport {
15
+ const requestInit: RequestInit = {};
16
+ if (config.headers) {
17
+ requestInit.headers = config.headers;
18
+ }
19
+
20
+ return new StreamableHTTPClientTransport(new URL(config.url), {
21
+ authProvider,
22
+ requestInit: Object.keys(requestInit).length > 0 ? requestInit : undefined,
23
+ fetch: verbose ? createDebugFetch(showSecrets) : undefined,
24
+ });
25
+ }
26
+
27
+ function createDebugFetch(showSecrets: boolean): FetchLike {
28
+ const isTTY = process.stderr.isTTY ?? false;
29
+ const fmt = (s: string) => (isTTY ? dim(s) : s);
30
+
31
+ return async (url, init) => {
32
+ const start = performance.now();
33
+
34
+ // Request
35
+ log(fmt(`> ${init?.method ?? "GET"} ${url}`));
36
+ logHeaders(">", init?.headers, fmt, showSecrets);
37
+ log(fmt(">"));
38
+ if (init?.body) {
39
+ logBody(String(init.body), fmt);
40
+ }
41
+
42
+ const response = await fetch(url, init);
43
+ const elapsed = Math.round(performance.now() - start);
44
+
45
+ // Response
46
+ log(fmt(`< ${response.status} ${response.statusText} (${elapsed}ms)`));
47
+ logHeaders("<", response.headers, fmt, showSecrets);
48
+ log(fmt("<"));
49
+
50
+ return response;
51
+ };
52
+ }
53
+
54
+ function log(line: string) {
55
+ logger.writeRaw(line + "\n");
56
+ }
57
+
58
+ function logHeaders(
59
+ prefix: string,
60
+ headers: HeadersInit | Headers | undefined,
61
+ fmt: (s: string) => string,
62
+ showSecrets: boolean,
63
+ ) {
64
+ if (!headers) return;
65
+
66
+ const format = (key: string, value: string) =>
67
+ fmt(`${prefix} ${key}: ${showSecrets ? value : maskSensitive(key, value)}`);
68
+
69
+ if (headers instanceof Headers) {
70
+ headers.forEach((value, key) => log(format(key, value)));
71
+ } else if (Array.isArray(headers)) {
72
+ for (const [key, value] of headers) {
73
+ log(format(key, value));
74
+ }
75
+ } else {
76
+ for (const [key, value] of Object.entries(headers)) {
77
+ log(format(key, value));
78
+ }
79
+ }
80
+ }
81
+
82
+ function logBody(body: string, fmt: (s: string) => string) {
83
+ try {
84
+ const formatted = JSON.stringify(JSON.parse(body), null, 2);
85
+ for (const line of formatted.split("\n")) {
86
+ log(fmt(line));
87
+ }
88
+ } catch {
89
+ log(fmt(body));
90
+ }
91
+ }
92
+
93
+ function maskSensitive(key: string, value: string): string {
94
+ const lower = key.toLowerCase();
95
+ if (lower === "authorization" || lower === "cookie" || lower === "set-cookie") {
96
+ if (value.length <= 12) return value;
97
+ return value.slice(0, 12) + "...";
98
+ }
99
+ return value;
100
+ }
@@ -0,0 +1,266 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
3
+ import picomatch from "picomatch";
4
+ import type { Tool, ServerConfig, ServersFile, AuthFile } from "../config/schemas.ts";
5
+ import { isStdioServer, isHttpServer } from "../config/schemas.ts";
6
+ import { createStdioTransport } from "./stdio.ts";
7
+ import { createHttpTransport } from "./http.ts";
8
+ import { McpOAuthProvider } from "./oauth.ts";
9
+
10
+ export interface ToolWithServer {
11
+ server: string;
12
+ tool: Tool;
13
+ }
14
+
15
+ export interface ServerError {
16
+ server: string;
17
+ message: string;
18
+ }
19
+
20
+ export interface ServerManagerOptions {
21
+ servers: ServersFile;
22
+ configDir: string;
23
+ auth: AuthFile;
24
+ concurrency?: number;
25
+ verbose?: boolean;
26
+ showSecrets?: boolean;
27
+ timeout?: number; // ms, default 1_800_000 (30 min)
28
+ maxRetries?: number; // default 3
29
+ }
30
+
31
+ export class ServerManager {
32
+ private clients = new Map<string, Client>();
33
+ private transports = new Map<string, Transport>();
34
+ private oauthProviders = new Map<string, McpOAuthProvider>();
35
+ private servers: ServersFile;
36
+ private configDir: string;
37
+ private auth: AuthFile;
38
+ private concurrency: number;
39
+ private verbose: boolean;
40
+ private showSecrets: boolean;
41
+ private timeout: number;
42
+ private maxRetries: number;
43
+
44
+ constructor(opts: ServerManagerOptions) {
45
+ this.servers = opts.servers;
46
+ this.configDir = opts.configDir;
47
+ this.auth = opts.auth;
48
+ this.concurrency = opts.concurrency ?? 5;
49
+ this.verbose = opts.verbose ?? false;
50
+ this.showSecrets = opts.showSecrets ?? false;
51
+ this.timeout = opts.timeout ?? 1_800_000;
52
+ this.maxRetries = opts.maxRetries ?? 3;
53
+ }
54
+
55
+ /** Get or create a connected client for a server */
56
+ async getClient(serverName: string): Promise<Client> {
57
+ const existing = this.clients.get(serverName);
58
+ if (existing) return existing;
59
+
60
+ const config = this.servers.mcpServers[serverName];
61
+ if (!config) {
62
+ throw new Error(`Unknown server: "${serverName}"`);
63
+ }
64
+
65
+ // Auto-refresh expired OAuth tokens before connecting to HTTP servers
66
+ if (isHttpServer(config)) {
67
+ const provider = this.getOrCreateOAuthProvider(serverName);
68
+ if (!provider.isComplete()) {
69
+ throw new Error(`Not authenticated with "${serverName}". Run: mcpcli auth ${serverName}`);
70
+ }
71
+ try {
72
+ await provider.refreshIfNeeded(config.url);
73
+ } catch {
74
+ // If refresh fails, continue — the transport will send the existing token
75
+ }
76
+ }
77
+
78
+ const transport = this.createTransport(serverName, config);
79
+ this.transports.set(serverName, transport);
80
+
81
+ const client = new Client({ name: "mcpcli", version: "0.1.0" });
82
+ await this.withTimeout(client.connect(transport), `connect(${serverName})`);
83
+ this.clients.set(serverName, client);
84
+
85
+ return client;
86
+ }
87
+
88
+ private getOrCreateOAuthProvider(serverName: string): McpOAuthProvider {
89
+ let provider = this.oauthProviders.get(serverName);
90
+ if (!provider) {
91
+ provider = new McpOAuthProvider({
92
+ serverName,
93
+ configDir: this.configDir,
94
+ auth: this.auth,
95
+ });
96
+ this.oauthProviders.set(serverName, provider);
97
+ }
98
+ return provider;
99
+ }
100
+
101
+ private createTransport(serverName: string, config: ServerConfig): Transport {
102
+ if (isStdioServer(config)) {
103
+ return createStdioTransport(config);
104
+ }
105
+ if (isHttpServer(config)) {
106
+ // Only pass the OAuth provider if the server already has tokens.
107
+ // Without tokens, passing the provider causes the SDK transport to
108
+ // auto-trigger the browser OAuth flow on 401, which fails because
109
+ // there's no callback server running. Users must run `mcpcli auth <server>` first.
110
+ const provider = this.getOrCreateOAuthProvider(serverName);
111
+ return createHttpTransport(
112
+ config,
113
+ provider.isComplete() ? provider : undefined,
114
+ this.verbose,
115
+ this.showSecrets,
116
+ );
117
+ }
118
+ throw new Error("Invalid server config");
119
+ }
120
+
121
+ /** Race a promise against a timeout */
122
+ private withTimeout<T>(promise: Promise<T>, label: string): Promise<T> {
123
+ if (this.timeout <= 0) return promise;
124
+ let timer: ReturnType<typeof setTimeout>;
125
+ return Promise.race([
126
+ promise.finally(() => clearTimeout(timer)),
127
+ new Promise<never>((_, reject) => {
128
+ timer = setTimeout(
129
+ () => reject(new Error(`${label}: timed out after ${this.timeout / 1000}s`)),
130
+ this.timeout,
131
+ );
132
+ timer.unref();
133
+ }),
134
+ ]);
135
+ }
136
+
137
+ /** Retry a function up to maxRetries times, clearing cached client between attempts */
138
+ private async withRetry<T>(fn: () => Promise<T>, label: string, serverName?: string): Promise<T> {
139
+ let lastError: Error | undefined;
140
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
141
+ try {
142
+ return await fn();
143
+ } catch (err) {
144
+ lastError = err instanceof Error ? err : new Error(String(err));
145
+ if (attempt < this.maxRetries && serverName) {
146
+ // Clear cached client so next attempt reconnects fresh
147
+ try {
148
+ await this.clients.get(serverName)?.close();
149
+ } catch {
150
+ // ignore close errors
151
+ }
152
+ this.clients.delete(serverName);
153
+ this.transports.delete(serverName);
154
+ }
155
+ }
156
+ }
157
+ throw lastError;
158
+ }
159
+
160
+ /** List tools for a single server, applying allowedTools/disabledTools filters */
161
+ async listTools(serverName: string): Promise<Tool[]> {
162
+ return this.withRetry(
163
+ async () => {
164
+ const client = await this.getClient(serverName);
165
+ const result = await this.withTimeout(client.listTools(), `listTools(${serverName})`);
166
+ const config = this.servers.mcpServers[serverName]!;
167
+ return filterTools(result.tools, config.allowedTools, config.disabledTools);
168
+ },
169
+ `listTools(${serverName})`,
170
+ serverName,
171
+ );
172
+ }
173
+
174
+ /** List tools across all configured servers */
175
+ async getAllTools(): Promise<{ tools: ToolWithServer[]; errors: ServerError[] }> {
176
+ const serverNames = Object.keys(this.servers.mcpServers);
177
+ const tools: ToolWithServer[] = [];
178
+ const errors: ServerError[] = [];
179
+
180
+ // Process in batches of `concurrency`
181
+ for (let i = 0; i < serverNames.length; i += this.concurrency) {
182
+ const batch = serverNames.slice(i, i + this.concurrency);
183
+ const batchResults = await Promise.allSettled(
184
+ batch.map(async (name) => {
185
+ const serverTools = await this.listTools(name);
186
+ return serverTools.map((tool) => ({ server: name, tool }));
187
+ }),
188
+ );
189
+
190
+ for (let j = 0; j < batchResults.length; j++) {
191
+ const result = batchResults[j]!;
192
+ if (result.status === "fulfilled") {
193
+ tools.push(...result.value);
194
+ } else {
195
+ const name = batch[j]!;
196
+ const message =
197
+ result.reason instanceof Error ? result.reason.message : String(result.reason);
198
+ errors.push({ server: name, message });
199
+ }
200
+ }
201
+ }
202
+
203
+ return { tools, errors };
204
+ }
205
+
206
+ /** Call a tool on a specific server */
207
+ async callTool(
208
+ serverName: string,
209
+ toolName: string,
210
+ args: Record<string, unknown> = {},
211
+ ): Promise<unknown> {
212
+ return this.withRetry(
213
+ async () => {
214
+ const client = await this.getClient(serverName);
215
+ return this.withTimeout(
216
+ client.callTool({ name: toolName, arguments: args }),
217
+ `callTool(${serverName}/${toolName})`,
218
+ );
219
+ },
220
+ `callTool(${serverName}/${toolName})`,
221
+ serverName,
222
+ );
223
+ }
224
+
225
+ /** Get the schema for a specific tool */
226
+ async getToolSchema(serverName: string, toolName: string): Promise<Tool | undefined> {
227
+ const tools = await this.listTools(serverName);
228
+ return tools.find((t) => t.name === toolName);
229
+ }
230
+
231
+ /** Get all server names */
232
+ getServerNames(): string[] {
233
+ return Object.keys(this.servers.mcpServers);
234
+ }
235
+
236
+ /** Disconnect all clients */
237
+ async close(): Promise<void> {
238
+ const closePromises = [...this.clients.entries()].map(async ([name, client]) => {
239
+ try {
240
+ await client.close();
241
+ } catch {
242
+ // Ignore close errors
243
+ }
244
+ this.clients.delete(name);
245
+ this.transports.delete(name);
246
+ });
247
+ await Promise.allSettled(closePromises);
248
+ }
249
+ }
250
+
251
+ /** Apply allowedTools/disabledTools glob filters to a tool list */
252
+ function filterTools(tools: Tool[], allowedTools?: string[], disabledTools?: string[]): Tool[] {
253
+ let filtered = tools;
254
+
255
+ if (allowedTools && allowedTools.length > 0) {
256
+ const isAllowed = picomatch(allowedTools);
257
+ filtered = filtered.filter((t) => isAllowed(t.name));
258
+ }
259
+
260
+ if (disabledTools && disabledTools.length > 0) {
261
+ const isDisabled = picomatch(disabledTools);
262
+ filtered = filtered.filter((t) => !isDisabled(t.name));
263
+ }
264
+
265
+ return filtered;
266
+ }
@@ -0,0 +1,299 @@
1
+ import { exec } from "child_process";
2
+ import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
3
+ import {
4
+ auth,
5
+ discoverOAuthServerInfo,
6
+ refreshAuthorization,
7
+ } from "@modelcontextprotocol/sdk/client/auth.js";
8
+ import type {
9
+ OAuthClientMetadata,
10
+ OAuthClientInformationMixed,
11
+ OAuthTokens,
12
+ } from "@modelcontextprotocol/sdk/shared/auth.js";
13
+ import type { AuthFile } from "../config/schemas.ts";
14
+ import { saveAuth } from "../config/loader.ts";
15
+ import type { FormatOptions } from "../output/formatter.ts";
16
+ import { logger } from "../output/logger.ts";
17
+
18
+ export class McpOAuthProvider implements OAuthClientProvider {
19
+ private serverName: string;
20
+ private configDir: string;
21
+ private auth: AuthFile;
22
+ private _codeVerifier?: string;
23
+ private _callbackPort = 0;
24
+
25
+ constructor(opts: { serverName: string; configDir: string; auth: AuthFile }) {
26
+ this.serverName = opts.serverName;
27
+ this.configDir = opts.configDir;
28
+ this.auth = opts.auth;
29
+ }
30
+
31
+ get redirectUrl(): string {
32
+ return `http://127.0.0.1:${this._callbackPort}/callback`;
33
+ }
34
+
35
+ get clientMetadata(): OAuthClientMetadata {
36
+ return {
37
+ redirect_uris: [`http://127.0.0.1:${this._callbackPort}/callback`],
38
+ grant_types: ["authorization_code", "refresh_token"],
39
+ response_types: ["code"],
40
+ client_name: "mcpcli",
41
+ token_endpoint_auth_method: "none",
42
+ };
43
+ }
44
+
45
+ clientInformation(): OAuthClientInformationMixed | undefined {
46
+ const entry = this.auth[this.serverName];
47
+ // During an active auth flow, return client_info even if incomplete.
48
+ // For normal usage (transport), the manager checks isComplete() separately.
49
+ return entry?.client_info;
50
+ }
51
+
52
+ async saveClientInformation(info: OAuthClientInformationMixed): Promise<void> {
53
+ if (!this.auth[this.serverName]) {
54
+ this.auth[this.serverName] = { tokens: {} as OAuthTokens };
55
+ }
56
+ this.auth[this.serverName]!.client_info = info;
57
+ await saveAuth(this.configDir, this.auth);
58
+ }
59
+
60
+ tokens(): OAuthTokens | undefined {
61
+ return this.auth[this.serverName]?.tokens;
62
+ }
63
+
64
+ async saveTokens(tokens: OAuthTokens): Promise<void> {
65
+ if (!this.auth[this.serverName]) {
66
+ this.auth[this.serverName] = { tokens };
67
+ } else {
68
+ this.auth[this.serverName]!.tokens = tokens;
69
+ }
70
+
71
+ // Compute expires_at from expires_in
72
+ if (tokens.expires_in) {
73
+ const expiresAt = new Date(Date.now() + tokens.expires_in * 1000);
74
+ this.auth[this.serverName]!.expires_at = expiresAt.toISOString();
75
+ }
76
+
77
+ // Mark auth as complete — tokens have been successfully obtained
78
+ this.auth[this.serverName]!.complete = true;
79
+
80
+ await saveAuth(this.configDir, this.auth);
81
+ }
82
+
83
+ async redirectToAuthorization(url: URL): Promise<void> {
84
+ const urlStr = url.toString();
85
+
86
+ logger.info(urlStr);
87
+
88
+ const cmd =
89
+ process.platform === "darwin"
90
+ ? `open "${urlStr}"`
91
+ : process.platform === "win32"
92
+ ? `start "${urlStr}"`
93
+ : `xdg-open "${urlStr}"`;
94
+
95
+ return new Promise((resolve, reject) => {
96
+ exec(cmd, (err) => (err ? reject(err) : resolve()));
97
+ });
98
+ }
99
+
100
+ async saveCodeVerifier(v: string): Promise<void> {
101
+ this._codeVerifier = v;
102
+ }
103
+
104
+ codeVerifier(): string {
105
+ if (!this._codeVerifier) {
106
+ throw new Error("Code verifier not set");
107
+ }
108
+ return this._codeVerifier;
109
+ }
110
+
111
+ async invalidateCredentials(
112
+ scope: "all" | "client" | "tokens" | "verifier" | "discovery",
113
+ ): Promise<void> {
114
+ const entry = this.auth[this.serverName];
115
+ if (!entry) return;
116
+
117
+ switch (scope) {
118
+ case "all":
119
+ delete this.auth[this.serverName];
120
+ break;
121
+ case "client":
122
+ delete entry.client_info;
123
+ break;
124
+ case "tokens":
125
+ delete this.auth[this.serverName];
126
+ // Re-create entry without tokens but keep client_info
127
+ if (entry.client_info) {
128
+ this.auth[this.serverName] = {
129
+ tokens: {} as OAuthTokens,
130
+ client_info: entry.client_info,
131
+ };
132
+ }
133
+ break;
134
+ case "verifier":
135
+ this._codeVerifier = undefined;
136
+ return; // No need to persist
137
+ case "discovery":
138
+ return; // Nothing to clear locally
139
+ }
140
+
141
+ await saveAuth(this.configDir, this.auth);
142
+ }
143
+
144
+ /** Whether the auth flow completed successfully (tokens were obtained) */
145
+ isComplete(): boolean {
146
+ return !!this.auth[this.serverName]?.complete;
147
+ }
148
+
149
+ /** Clear any incomplete auth state from a previously cancelled flow */
150
+ async clearIncomplete(): Promise<void> {
151
+ const entry = this.auth[this.serverName];
152
+ if (entry && !entry.complete) {
153
+ delete this.auth[this.serverName];
154
+ await saveAuth(this.configDir, this.auth);
155
+ }
156
+ }
157
+
158
+ setCallbackPort(port: number): void {
159
+ this._callbackPort = port;
160
+ }
161
+
162
+ isExpired(): boolean {
163
+ const entry = this.auth[this.serverName];
164
+ if (!entry?.expires_at) return false;
165
+ return new Date(entry.expires_at) <= new Date();
166
+ }
167
+
168
+ hasRefreshToken(): boolean {
169
+ const tokens = this.auth[this.serverName]?.tokens;
170
+ return !!tokens?.refresh_token;
171
+ }
172
+
173
+ async refreshIfNeeded(serverUrl: string): Promise<void> {
174
+ if (!this.isExpired()) return;
175
+
176
+ if (!this.hasRefreshToken()) {
177
+ throw new Error(
178
+ `Token expired for "${this.serverName}" and no refresh token available. Run: mcpcli auth ${this.serverName}`,
179
+ );
180
+ }
181
+
182
+ const clientInfo = this.clientInformation();
183
+ if (!clientInfo) {
184
+ throw new Error(
185
+ `No client information for "${this.serverName}". Run: mcpcli auth ${this.serverName}`,
186
+ );
187
+ }
188
+
189
+ const tokens = await refreshAuthorization(serverUrl, {
190
+ clientInformation: clientInfo,
191
+ refreshToken: this.auth[this.serverName]!.tokens.refresh_token!,
192
+ });
193
+
194
+ await this.saveTokens(tokens);
195
+
196
+ logger.info(`Token refreshed for "${this.serverName}"`);
197
+ }
198
+ }
199
+
200
+ /** Start a local callback server to receive the OAuth authorization code */
201
+ export function startCallbackServer(): {
202
+ server: ReturnType<typeof Bun.serve>;
203
+ authCodePromise: Promise<string>;
204
+ } {
205
+ let resolveCode: (code: string) => void;
206
+ let rejectCode: (err: Error) => void;
207
+
208
+ const authCodePromise = new Promise<string>((resolve, reject) => {
209
+ resolveCode = resolve;
210
+ rejectCode = reject;
211
+ });
212
+
213
+ const server = Bun.serve({
214
+ port: 0,
215
+ fetch(req) {
216
+ const url = new URL(req.url);
217
+ if (url.pathname !== "/callback") {
218
+ return new Response("Not found", { status: 404 });
219
+ }
220
+
221
+ const error = url.searchParams.get("error");
222
+ if (error) {
223
+ const desc = url.searchParams.get("error_description") || error;
224
+ rejectCode!(new Error(`OAuth error: ${desc}`));
225
+ return new Response(
226
+ "<html><body><h1>Authentication Failed</h1><p>You can close this window.</p></body></html>",
227
+ { headers: { "Content-Type": "text/html" } },
228
+ );
229
+ }
230
+
231
+ const code = url.searchParams.get("code");
232
+ if (!code) {
233
+ rejectCode!(new Error("No authorization code received"));
234
+ return new Response(
235
+ "<html><body><h1>Error</h1><p>No authorization code received.</p></body></html>",
236
+ { headers: { "Content-Type": "text/html" } },
237
+ );
238
+ }
239
+
240
+ resolveCode!(code);
241
+ return new Response(
242
+ "<html><body><h1>Authenticated!</h1><p>You can close this window.</p></body></html>",
243
+ { headers: { "Content-Type": "text/html" } },
244
+ );
245
+ },
246
+ });
247
+
248
+ return { server, authCodePromise };
249
+ }
250
+
251
+ /** Probe for OAuth support and run the auth flow if the server supports it.
252
+ * Returns true if auth ran, false if server doesn't support OAuth (silent skip). */
253
+ export async function tryOAuthIfSupported(
254
+ serverName: string,
255
+ serverUrl: string,
256
+ configDir: string,
257
+ auth: AuthFile,
258
+ formatOptions: FormatOptions,
259
+ ): Promise<boolean> {
260
+ let oauthSupported: boolean;
261
+ try {
262
+ const info = await discoverOAuthServerInfo(serverUrl);
263
+ oauthSupported = info.authorizationServerMetadata !== undefined;
264
+ } catch {
265
+ return false;
266
+ }
267
+
268
+ if (!oauthSupported) return false;
269
+
270
+ const provider = new McpOAuthProvider({ serverName, configDir, auth });
271
+ const spinner = logger.startSpinner(`Authenticating with "${serverName}"…`, formatOptions);
272
+ try {
273
+ await runOAuthFlow(serverUrl, provider);
274
+ spinner.success(`Authenticated with "${serverName}"`);
275
+ return true;
276
+ } catch (err) {
277
+ spinner.error(`Authentication failed: ${err instanceof Error ? err.message : err}`);
278
+ throw err;
279
+ }
280
+ }
281
+
282
+ /** Run a full OAuth authorization flow for an HTTP MCP server */
283
+ export async function runOAuthFlow(serverUrl: string, provider: McpOAuthProvider): Promise<void> {
284
+ // Clear any leftover state from a previously cancelled auth flow
285
+ await provider.clearIncomplete();
286
+
287
+ const { server, authCodePromise } = startCallbackServer();
288
+ try {
289
+ provider.setCallbackPort(server.port);
290
+
291
+ const result = await auth(provider, { serverUrl });
292
+ if (result === "REDIRECT") {
293
+ const code = await authCodePromise;
294
+ await auth(provider, { serverUrl, authorizationCode: code });
295
+ }
296
+ } finally {
297
+ server.stop();
298
+ }
299
+ }
@@ -0,0 +1,12 @@
1
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
2
+ import type { StdioServerConfig } from "../config/schemas.ts";
3
+
4
+ export function createStdioTransport(config: StdioServerConfig): StdioClientTransport {
5
+ return new StdioClientTransport({
6
+ command: config.command,
7
+ args: config.args,
8
+ env: config.env ? { ...process.env, ...config.env } : undefined,
9
+ cwd: config.cwd,
10
+ stderr: "pipe",
11
+ });
12
+ }