@evantahler/mcpcli 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +432 -0
- package/package.json +56 -0
- package/skills/mcpcli.md +85 -0
- package/src/cli.ts +34 -0
- package/src/client/http.ts +99 -0
- package/src/client/manager.ts +204 -0
- package/src/client/oauth.ts +263 -0
- package/src/client/stdio.ts +12 -0
- package/src/commands/auth.ts +106 -0
- package/src/commands/call.ts +104 -0
- package/src/commands/index.ts +53 -0
- package/src/commands/info.ts +42 -0
- package/src/commands/list.ts +30 -0
- package/src/commands/search.ts +37 -0
- package/src/config/env.ts +41 -0
- package/src/config/loader.ts +118 -0
- package/src/config/schemas.ts +137 -0
- package/src/context.ts +41 -0
- package/src/output/formatter.ts +316 -0
- package/src/output/spinner.ts +39 -0
- package/src/search/index.ts +69 -0
- package/src/search/indexer.ts +91 -0
- package/src/search/keyword.ts +86 -0
- package/src/search/semantic.ts +75 -0
- package/src/validation/schema.ts +77 -0
|
@@ -0,0 +1,99 @@
|
|
|
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
|
+
|
|
6
|
+
type FetchLike = (url: string | URL, init?: RequestInit) => Promise<Response>;
|
|
7
|
+
|
|
8
|
+
export function createHttpTransport(
|
|
9
|
+
config: HttpServerConfig,
|
|
10
|
+
authProvider?: OAuthClientProvider,
|
|
11
|
+
verbose = false,
|
|
12
|
+
showSecrets = false,
|
|
13
|
+
): StreamableHTTPClientTransport {
|
|
14
|
+
const requestInit: RequestInit = {};
|
|
15
|
+
if (config.headers) {
|
|
16
|
+
requestInit.headers = config.headers;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return new StreamableHTTPClientTransport(new URL(config.url), {
|
|
20
|
+
authProvider,
|
|
21
|
+
requestInit: Object.keys(requestInit).length > 0 ? requestInit : undefined,
|
|
22
|
+
fetch: verbose ? createDebugFetch(showSecrets) : undefined,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function createDebugFetch(showSecrets: boolean): FetchLike {
|
|
27
|
+
const isTTY = process.stderr.isTTY ?? false;
|
|
28
|
+
const fmt = (s: string) => (isTTY ? dim(s) : s);
|
|
29
|
+
|
|
30
|
+
return async (url, init) => {
|
|
31
|
+
const start = performance.now();
|
|
32
|
+
|
|
33
|
+
// Request
|
|
34
|
+
log(fmt(`> ${init?.method ?? "GET"} ${url}`));
|
|
35
|
+
logHeaders(">", init?.headers, fmt, showSecrets);
|
|
36
|
+
log(fmt(">"));
|
|
37
|
+
if (init?.body) {
|
|
38
|
+
logBody(String(init.body), fmt);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const response = await fetch(url, init);
|
|
42
|
+
const elapsed = Math.round(performance.now() - start);
|
|
43
|
+
|
|
44
|
+
// Response
|
|
45
|
+
log(fmt(`< ${response.status} ${response.statusText} (${elapsed}ms)`));
|
|
46
|
+
logHeaders("<", response.headers, fmt, showSecrets);
|
|
47
|
+
log(fmt("<"));
|
|
48
|
+
|
|
49
|
+
return response;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function log(line: string) {
|
|
54
|
+
process.stderr.write(line + "\n");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function logHeaders(
|
|
58
|
+
prefix: string,
|
|
59
|
+
headers: HeadersInit | Headers | undefined,
|
|
60
|
+
fmt: (s: string) => string,
|
|
61
|
+
showSecrets: boolean,
|
|
62
|
+
) {
|
|
63
|
+
if (!headers) return;
|
|
64
|
+
|
|
65
|
+
const format = (key: string, value: string) =>
|
|
66
|
+
fmt(`${prefix} ${key}: ${showSecrets ? value : maskSensitive(key, value)}`);
|
|
67
|
+
|
|
68
|
+
if (headers instanceof Headers) {
|
|
69
|
+
headers.forEach((value, key) => log(format(key, value)));
|
|
70
|
+
} else if (Array.isArray(headers)) {
|
|
71
|
+
for (const [key, value] of headers) {
|
|
72
|
+
log(format(key, value));
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
76
|
+
log(format(key, value));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function logBody(body: string, fmt: (s: string) => string) {
|
|
82
|
+
try {
|
|
83
|
+
const formatted = JSON.stringify(JSON.parse(body), null, 2);
|
|
84
|
+
for (const line of formatted.split("\n")) {
|
|
85
|
+
log(fmt(line));
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
log(fmt(body));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function maskSensitive(key: string, value: string): string {
|
|
93
|
+
const lower = key.toLowerCase();
|
|
94
|
+
if (lower === "authorization" || lower === "cookie" || lower === "set-cookie") {
|
|
95
|
+
if (value.length <= 12) return value;
|
|
96
|
+
return value.slice(0, 12) + "...";
|
|
97
|
+
}
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
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 class ServerManager {
|
|
21
|
+
private clients = new Map<string, Client>();
|
|
22
|
+
private transports = new Map<string, Transport>();
|
|
23
|
+
private oauthProviders = new Map<string, McpOAuthProvider>();
|
|
24
|
+
private servers: ServersFile;
|
|
25
|
+
private configDir: string;
|
|
26
|
+
private auth: AuthFile;
|
|
27
|
+
private concurrency: number;
|
|
28
|
+
private verbose: boolean;
|
|
29
|
+
private showSecrets: boolean;
|
|
30
|
+
|
|
31
|
+
constructor(
|
|
32
|
+
servers: ServersFile,
|
|
33
|
+
configDir: string,
|
|
34
|
+
auth: AuthFile,
|
|
35
|
+
concurrency = 5,
|
|
36
|
+
verbose = false,
|
|
37
|
+
showSecrets = false,
|
|
38
|
+
) {
|
|
39
|
+
this.servers = servers;
|
|
40
|
+
this.configDir = configDir;
|
|
41
|
+
this.auth = auth;
|
|
42
|
+
this.concurrency = concurrency;
|
|
43
|
+
this.verbose = verbose;
|
|
44
|
+
this.showSecrets = showSecrets;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Get or create a connected client for a server */
|
|
48
|
+
async getClient(serverName: string): Promise<Client> {
|
|
49
|
+
const existing = this.clients.get(serverName);
|
|
50
|
+
if (existing) return existing;
|
|
51
|
+
|
|
52
|
+
const config = this.servers.mcpServers[serverName];
|
|
53
|
+
if (!config) {
|
|
54
|
+
throw new Error(`Unknown server: "${serverName}"`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Auto-refresh expired OAuth tokens before connecting to HTTP servers
|
|
58
|
+
if (isHttpServer(config)) {
|
|
59
|
+
const provider = this.getOrCreateOAuthProvider(serverName);
|
|
60
|
+
if (!provider.isComplete()) {
|
|
61
|
+
throw new Error(`Not authenticated with "${serverName}". Run: mcpcli auth ${serverName}`);
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
await provider.refreshIfNeeded(config.url);
|
|
65
|
+
} catch {
|
|
66
|
+
// If refresh fails, continue — the transport will send the existing token
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const transport = this.createTransport(serverName, config);
|
|
71
|
+
this.transports.set(serverName, transport);
|
|
72
|
+
|
|
73
|
+
const client = new Client({ name: "mcpcli", version: "0.1.0" });
|
|
74
|
+
await client.connect(transport);
|
|
75
|
+
this.clients.set(serverName, client);
|
|
76
|
+
|
|
77
|
+
return client;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private getOrCreateOAuthProvider(serverName: string): McpOAuthProvider {
|
|
81
|
+
let provider = this.oauthProviders.get(serverName);
|
|
82
|
+
if (!provider) {
|
|
83
|
+
provider = new McpOAuthProvider({
|
|
84
|
+
serverName,
|
|
85
|
+
configDir: this.configDir,
|
|
86
|
+
auth: this.auth,
|
|
87
|
+
});
|
|
88
|
+
this.oauthProviders.set(serverName, provider);
|
|
89
|
+
}
|
|
90
|
+
return provider;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private createTransport(serverName: string, config: ServerConfig): Transport {
|
|
94
|
+
if (isStdioServer(config)) {
|
|
95
|
+
return createStdioTransport(config);
|
|
96
|
+
}
|
|
97
|
+
if (isHttpServer(config)) {
|
|
98
|
+
// Only pass the OAuth provider if the server already has tokens.
|
|
99
|
+
// Without tokens, passing the provider causes the SDK transport to
|
|
100
|
+
// auto-trigger the browser OAuth flow on 401, which fails because
|
|
101
|
+
// there's no callback server running. Users must run `mcpcli auth <server>` first.
|
|
102
|
+
const provider = this.getOrCreateOAuthProvider(serverName);
|
|
103
|
+
return createHttpTransport(
|
|
104
|
+
config,
|
|
105
|
+
provider.isComplete() ? provider : undefined,
|
|
106
|
+
this.verbose,
|
|
107
|
+
this.showSecrets,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
throw new Error("Invalid server config");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** List tools for a single server, applying allowedTools/disabledTools filters */
|
|
114
|
+
async listTools(serverName: string): Promise<Tool[]> {
|
|
115
|
+
const client = await this.getClient(serverName);
|
|
116
|
+
const result = await client.listTools();
|
|
117
|
+
const config = this.servers.mcpServers[serverName]!;
|
|
118
|
+
return filterTools(result.tools, config.allowedTools, config.disabledTools);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** List tools across all configured servers */
|
|
122
|
+
async getAllTools(): Promise<{ tools: ToolWithServer[]; errors: ServerError[] }> {
|
|
123
|
+
const serverNames = Object.keys(this.servers.mcpServers);
|
|
124
|
+
const tools: ToolWithServer[] = [];
|
|
125
|
+
const errors: ServerError[] = [];
|
|
126
|
+
|
|
127
|
+
// Process in batches of `concurrency`
|
|
128
|
+
for (let i = 0; i < serverNames.length; i += this.concurrency) {
|
|
129
|
+
const batch = serverNames.slice(i, i + this.concurrency);
|
|
130
|
+
const batchResults = await Promise.allSettled(
|
|
131
|
+
batch.map(async (name) => {
|
|
132
|
+
const serverTools = await this.listTools(name);
|
|
133
|
+
return serverTools.map((tool) => ({ server: name, tool }));
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
for (let j = 0; j < batchResults.length; j++) {
|
|
138
|
+
const result = batchResults[j]!;
|
|
139
|
+
if (result.status === "fulfilled") {
|
|
140
|
+
tools.push(...result.value);
|
|
141
|
+
} else {
|
|
142
|
+
const name = batch[j]!;
|
|
143
|
+
const message =
|
|
144
|
+
result.reason instanceof Error ? result.reason.message : String(result.reason);
|
|
145
|
+
errors.push({ server: name, message });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { tools, errors };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Call a tool on a specific server */
|
|
154
|
+
async callTool(
|
|
155
|
+
serverName: string,
|
|
156
|
+
toolName: string,
|
|
157
|
+
args: Record<string, unknown> = {},
|
|
158
|
+
): Promise<unknown> {
|
|
159
|
+
const client = await this.getClient(serverName);
|
|
160
|
+
return client.callTool({ name: toolName, arguments: args });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Get the schema for a specific tool */
|
|
164
|
+
async getToolSchema(serverName: string, toolName: string): Promise<Tool | undefined> {
|
|
165
|
+
const tools = await this.listTools(serverName);
|
|
166
|
+
return tools.find((t) => t.name === toolName);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Get all server names */
|
|
170
|
+
getServerNames(): string[] {
|
|
171
|
+
return Object.keys(this.servers.mcpServers);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Disconnect all clients */
|
|
175
|
+
async close(): Promise<void> {
|
|
176
|
+
const closePromises = [...this.clients.entries()].map(async ([name, client]) => {
|
|
177
|
+
try {
|
|
178
|
+
await client.close();
|
|
179
|
+
} catch {
|
|
180
|
+
// Ignore close errors
|
|
181
|
+
}
|
|
182
|
+
this.clients.delete(name);
|
|
183
|
+
this.transports.delete(name);
|
|
184
|
+
});
|
|
185
|
+
await Promise.allSettled(closePromises);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Apply allowedTools/disabledTools glob filters to a tool list */
|
|
190
|
+
function filterTools(tools: Tool[], allowedTools?: string[], disabledTools?: string[]): Tool[] {
|
|
191
|
+
let filtered = tools;
|
|
192
|
+
|
|
193
|
+
if (allowedTools && allowedTools.length > 0) {
|
|
194
|
+
const isAllowed = picomatch(allowedTools);
|
|
195
|
+
filtered = filtered.filter((t) => isAllowed(t.name));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (disabledTools && disabledTools.length > 0) {
|
|
199
|
+
const isDisabled = picomatch(disabledTools);
|
|
200
|
+
filtered = filtered.filter((t) => !isDisabled(t.name));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return filtered;
|
|
204
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { exec } from "child_process";
|
|
2
|
+
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
3
|
+
import { auth, refreshAuthorization } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
4
|
+
import type {
|
|
5
|
+
OAuthClientMetadata,
|
|
6
|
+
OAuthClientInformationMixed,
|
|
7
|
+
OAuthTokens,
|
|
8
|
+
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
9
|
+
import type { AuthFile } from "../config/schemas.ts";
|
|
10
|
+
import { saveAuth } from "../config/loader.ts";
|
|
11
|
+
|
|
12
|
+
export class McpOAuthProvider implements OAuthClientProvider {
|
|
13
|
+
private serverName: string;
|
|
14
|
+
private configDir: string;
|
|
15
|
+
private auth: AuthFile;
|
|
16
|
+
private _codeVerifier?: string;
|
|
17
|
+
private _callbackPort = 0;
|
|
18
|
+
|
|
19
|
+
constructor(opts: { serverName: string; configDir: string; auth: AuthFile }) {
|
|
20
|
+
this.serverName = opts.serverName;
|
|
21
|
+
this.configDir = opts.configDir;
|
|
22
|
+
this.auth = opts.auth;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get redirectUrl(): string {
|
|
26
|
+
return `http://127.0.0.1:${this._callbackPort}/callback`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get clientMetadata(): OAuthClientMetadata {
|
|
30
|
+
return {
|
|
31
|
+
redirect_uris: [`http://127.0.0.1:${this._callbackPort}/callback`],
|
|
32
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
33
|
+
response_types: ["code"],
|
|
34
|
+
client_name: "mcpcli",
|
|
35
|
+
token_endpoint_auth_method: "none",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
clientInformation(): OAuthClientInformationMixed | undefined {
|
|
40
|
+
const entry = this.auth[this.serverName];
|
|
41
|
+
// During an active auth flow, return client_info even if incomplete.
|
|
42
|
+
// For normal usage (transport), the manager checks isComplete() separately.
|
|
43
|
+
return entry?.client_info;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async saveClientInformation(info: OAuthClientInformationMixed): Promise<void> {
|
|
47
|
+
if (!this.auth[this.serverName]) {
|
|
48
|
+
this.auth[this.serverName] = { tokens: {} as OAuthTokens };
|
|
49
|
+
}
|
|
50
|
+
this.auth[this.serverName]!.client_info = info;
|
|
51
|
+
await saveAuth(this.configDir, this.auth);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
tokens(): OAuthTokens | undefined {
|
|
55
|
+
return this.auth[this.serverName]?.tokens;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
|
59
|
+
if (!this.auth[this.serverName]) {
|
|
60
|
+
this.auth[this.serverName] = { tokens };
|
|
61
|
+
} else {
|
|
62
|
+
this.auth[this.serverName]!.tokens = tokens;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Compute expires_at from expires_in
|
|
66
|
+
if (tokens.expires_in) {
|
|
67
|
+
const expiresAt = new Date(Date.now() + tokens.expires_in * 1000);
|
|
68
|
+
this.auth[this.serverName]!.expires_at = expiresAt.toISOString();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Mark auth as complete — tokens have been successfully obtained
|
|
72
|
+
this.auth[this.serverName]!.complete = true;
|
|
73
|
+
|
|
74
|
+
await saveAuth(this.configDir, this.auth);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async redirectToAuthorization(url: URL): Promise<void> {
|
|
78
|
+
const urlStr = url.toString();
|
|
79
|
+
|
|
80
|
+
if (process.stderr.isTTY) {
|
|
81
|
+
const { dim } = await import("ansis");
|
|
82
|
+
process.stderr.write(`${dim(urlStr)}\n`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const cmd =
|
|
86
|
+
process.platform === "darwin"
|
|
87
|
+
? `open "${urlStr}"`
|
|
88
|
+
: process.platform === "win32"
|
|
89
|
+
? `start "${urlStr}"`
|
|
90
|
+
: `xdg-open "${urlStr}"`;
|
|
91
|
+
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
exec(cmd, (err) => (err ? reject(err) : resolve()));
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async saveCodeVerifier(v: string): Promise<void> {
|
|
98
|
+
this._codeVerifier = v;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
codeVerifier(): string {
|
|
102
|
+
if (!this._codeVerifier) {
|
|
103
|
+
throw new Error("Code verifier not set");
|
|
104
|
+
}
|
|
105
|
+
return this._codeVerifier;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async invalidateCredentials(
|
|
109
|
+
scope: "all" | "client" | "tokens" | "verifier" | "discovery",
|
|
110
|
+
): Promise<void> {
|
|
111
|
+
const entry = this.auth[this.serverName];
|
|
112
|
+
if (!entry) return;
|
|
113
|
+
|
|
114
|
+
switch (scope) {
|
|
115
|
+
case "all":
|
|
116
|
+
delete this.auth[this.serverName];
|
|
117
|
+
break;
|
|
118
|
+
case "client":
|
|
119
|
+
delete entry.client_info;
|
|
120
|
+
break;
|
|
121
|
+
case "tokens":
|
|
122
|
+
delete this.auth[this.serverName];
|
|
123
|
+
// Re-create entry without tokens but keep client_info
|
|
124
|
+
if (entry.client_info) {
|
|
125
|
+
this.auth[this.serverName] = {
|
|
126
|
+
tokens: {} as OAuthTokens,
|
|
127
|
+
client_info: entry.client_info,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
break;
|
|
131
|
+
case "verifier":
|
|
132
|
+
this._codeVerifier = undefined;
|
|
133
|
+
return; // No need to persist
|
|
134
|
+
case "discovery":
|
|
135
|
+
return; // Nothing to clear locally
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
await saveAuth(this.configDir, this.auth);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Whether the auth flow completed successfully (tokens were obtained) */
|
|
142
|
+
isComplete(): boolean {
|
|
143
|
+
return !!this.auth[this.serverName]?.complete;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Clear any incomplete auth state from a previously cancelled flow */
|
|
147
|
+
async clearIncomplete(): Promise<void> {
|
|
148
|
+
const entry = this.auth[this.serverName];
|
|
149
|
+
if (entry && !entry.complete) {
|
|
150
|
+
delete this.auth[this.serverName];
|
|
151
|
+
await saveAuth(this.configDir, this.auth);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
setCallbackPort(port: number): void {
|
|
156
|
+
this._callbackPort = port;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
isExpired(): boolean {
|
|
160
|
+
const entry = this.auth[this.serverName];
|
|
161
|
+
if (!entry?.expires_at) return false;
|
|
162
|
+
return new Date(entry.expires_at) <= new Date();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
hasRefreshToken(): boolean {
|
|
166
|
+
const tokens = this.auth[this.serverName]?.tokens;
|
|
167
|
+
return !!tokens?.refresh_token;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async refreshIfNeeded(serverUrl: string): Promise<void> {
|
|
171
|
+
if (!this.isExpired()) return;
|
|
172
|
+
|
|
173
|
+
if (!this.hasRefreshToken()) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
`Token expired for "${this.serverName}" and no refresh token available. Run: mcpcli auth ${this.serverName}`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const clientInfo = this.clientInformation();
|
|
180
|
+
if (!clientInfo) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`No client information for "${this.serverName}". Run: mcpcli auth ${this.serverName}`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const tokens = await refreshAuthorization(serverUrl, {
|
|
187
|
+
clientInformation: clientInfo,
|
|
188
|
+
refreshToken: this.auth[this.serverName]!.tokens.refresh_token!,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
await this.saveTokens(tokens);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Start a local callback server to receive the OAuth authorization code */
|
|
196
|
+
export function startCallbackServer(): {
|
|
197
|
+
server: ReturnType<typeof Bun.serve>;
|
|
198
|
+
authCodePromise: Promise<string>;
|
|
199
|
+
} {
|
|
200
|
+
let resolveCode: (code: string) => void;
|
|
201
|
+
let rejectCode: (err: Error) => void;
|
|
202
|
+
|
|
203
|
+
const authCodePromise = new Promise<string>((resolve, reject) => {
|
|
204
|
+
resolveCode = resolve;
|
|
205
|
+
rejectCode = reject;
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const server = Bun.serve({
|
|
209
|
+
port: 0,
|
|
210
|
+
fetch(req) {
|
|
211
|
+
const url = new URL(req.url);
|
|
212
|
+
if (url.pathname !== "/callback") {
|
|
213
|
+
return new Response("Not found", { status: 404 });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const error = url.searchParams.get("error");
|
|
217
|
+
if (error) {
|
|
218
|
+
const desc = url.searchParams.get("error_description") || error;
|
|
219
|
+
rejectCode!(new Error(`OAuth error: ${desc}`));
|
|
220
|
+
return new Response(
|
|
221
|
+
"<html><body><h1>Authentication Failed</h1><p>You can close this window.</p></body></html>",
|
|
222
|
+
{ headers: { "Content-Type": "text/html" } },
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const code = url.searchParams.get("code");
|
|
227
|
+
if (!code) {
|
|
228
|
+
rejectCode!(new Error("No authorization code received"));
|
|
229
|
+
return new Response(
|
|
230
|
+
"<html><body><h1>Error</h1><p>No authorization code received.</p></body></html>",
|
|
231
|
+
{ headers: { "Content-Type": "text/html" } },
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
resolveCode!(code);
|
|
236
|
+
return new Response(
|
|
237
|
+
"<html><body><h1>Authenticated!</h1><p>You can close this window.</p></body></html>",
|
|
238
|
+
{ headers: { "Content-Type": "text/html" } },
|
|
239
|
+
);
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
return { server, authCodePromise };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Run a full OAuth authorization flow for an HTTP MCP server */
|
|
247
|
+
export async function runOAuthFlow(serverUrl: string, provider: McpOAuthProvider): Promise<void> {
|
|
248
|
+
// Clear any leftover state from a previously cancelled auth flow
|
|
249
|
+
await provider.clearIncomplete();
|
|
250
|
+
|
|
251
|
+
const { server, authCodePromise } = startCallbackServer();
|
|
252
|
+
try {
|
|
253
|
+
provider.setCallbackPort(server.port);
|
|
254
|
+
|
|
255
|
+
const result = await auth(provider, { serverUrl });
|
|
256
|
+
if (result === "REDIRECT") {
|
|
257
|
+
const code = await authCodePromise;
|
|
258
|
+
await auth(provider, { serverUrl, authorizationCode: code });
|
|
259
|
+
}
|
|
260
|
+
} finally {
|
|
261
|
+
server.stop();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
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 { startSpinner } from "../output/spinner.ts";
|
|
7
|
+
|
|
8
|
+
export function registerAuthCommand(program: Command) {
|
|
9
|
+
program
|
|
10
|
+
.command("auth <server>")
|
|
11
|
+
.description("authenticate with an HTTP MCP server")
|
|
12
|
+
.option("-s, --status", "check auth status and token TTL")
|
|
13
|
+
.option("-r, --refresh", "force token refresh")
|
|
14
|
+
.action(async (server: string, options: { status?: boolean; refresh?: boolean }) => {
|
|
15
|
+
const { config, formatOptions } = await getContext(program);
|
|
16
|
+
|
|
17
|
+
const serverConfig = config.servers.mcpServers[server];
|
|
18
|
+
if (!serverConfig) {
|
|
19
|
+
console.error(`Unknown server: "${server}"`);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
if (!isHttpServer(serverConfig)) {
|
|
23
|
+
console.error(
|
|
24
|
+
`Server "${server}" is not an HTTP server — OAuth only applies to HTTP servers`,
|
|
25
|
+
);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const provider = new McpOAuthProvider({
|
|
30
|
+
serverName: server,
|
|
31
|
+
configDir: config.configDir,
|
|
32
|
+
auth: config.auth,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (options.status) {
|
|
36
|
+
showStatus(server, provider);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (options.refresh) {
|
|
41
|
+
const spinner = startSpinner(`Refreshing token for "${server}"…`, formatOptions);
|
|
42
|
+
try {
|
|
43
|
+
await provider.refreshIfNeeded(serverConfig.url);
|
|
44
|
+
spinner.success(`Token refreshed for "${server}"`);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
spinner.error(`Refresh failed: ${err instanceof Error ? err.message : err}`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Default: full OAuth flow
|
|
53
|
+
const spinner = startSpinner(`Authenticating with "${server}"…`, formatOptions);
|
|
54
|
+
try {
|
|
55
|
+
await runOAuthFlow(serverConfig.url, provider);
|
|
56
|
+
spinner.success(`Authenticated with "${server}"`);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
spinner.error(`Authentication failed: ${err instanceof Error ? err.message : err}`);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function registerDeauthCommand(program: Command) {
|
|
65
|
+
program
|
|
66
|
+
.command("deauth <server>")
|
|
67
|
+
.description("remove stored authentication for a server")
|
|
68
|
+
.action(async (server: string) => {
|
|
69
|
+
const { config } = await getContext(program);
|
|
70
|
+
|
|
71
|
+
if (!config.auth[server]) {
|
|
72
|
+
console.log(`No auth stored for "${server}"`);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
delete config.auth[server];
|
|
77
|
+
await saveAuth(config.configDir, config.auth);
|
|
78
|
+
console.log(`Deauthenticated "${server}"`);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function showStatus(server: string, provider: McpOAuthProvider) {
|
|
83
|
+
if (!provider.isComplete()) {
|
|
84
|
+
console.log(`${server}: not authenticated`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const expired = provider.isExpired();
|
|
89
|
+
const hasRefresh = provider.hasRefreshToken();
|
|
90
|
+
const status = expired ? "expired" : "authenticated";
|
|
91
|
+
|
|
92
|
+
console.log(`${server}: ${status}`);
|
|
93
|
+
if (hasRefresh) {
|
|
94
|
+
console.log(" refresh token: present");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!expired) {
|
|
98
|
+
// Show TTL if we have expires_at from the auth entry
|
|
99
|
+
const entry = provider["auth"][server];
|
|
100
|
+
if (entry?.expires_at) {
|
|
101
|
+
const remaining = new Date(entry.expires_at).getTime() - Date.now();
|
|
102
|
+
const minutes = Math.round(remaining / 60000);
|
|
103
|
+
console.log(` expires in: ${minutes} minutes`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|