@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.
@@ -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
+ }