@oh-my-pi/pi-coding-agent 13.12.8 → 13.12.9

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/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [13.12.9] - 2026-03-17
6
+ ### Added
7
+
8
+ - Added `/session delete` command to delete current session with confirmation and return to session selector
9
+ - Added session deletion in session selector via Delete key with confirmation dialog
10
+
11
+ ### Changed
12
+
13
+ - Changed session deletion callback to return a boolean indicating success, allowing callers to distinguish between failed deletions and upstream cancellations
14
+
15
+ ### Fixed
16
+
17
+ - Fixed OAuth redirect URI validation to preserve exact configured values without adding trailing slashes
18
+ - Fixed session deletion error handling to display error messages in the session selector UI instead of silently failing
19
+ - Added `oauth.redirectUri`, `oauth.clientSecret`, and `oauth.callbackPath` support for MCP server OAuth config so providers can use exact registered redirect URIs while preserving local callback listener settings ([#445](https://github.com/can1357/oh-my-pi/issues/445))
20
+
5
21
  ## [13.12.8] - 2026-03-16
6
22
 
7
23
  ### Breaking Changes
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "13.12.8",
4
+ "version": "13.12.9",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -41,12 +41,12 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@mozilla/readability": "^0.6",
44
- "@oh-my-pi/omp-stats": "13.12.8",
45
- "@oh-my-pi/pi-agent-core": "13.12.8",
46
- "@oh-my-pi/pi-ai": "13.12.8",
47
- "@oh-my-pi/pi-natives": "13.12.8",
48
- "@oh-my-pi/pi-tui": "13.12.8",
49
- "@oh-my-pi/pi-utils": "13.12.8",
44
+ "@oh-my-pi/omp-stats": "13.12.9",
45
+ "@oh-my-pi/pi-agent-core": "13.12.9",
46
+ "@oh-my-pi/pi-ai": "13.12.9",
47
+ "@oh-my-pi/pi-natives": "13.12.9",
48
+ "@oh-my-pi/pi-tui": "13.12.9",
49
+ "@oh-my-pi/pi-utils": "13.12.9",
50
50
  "@sinclair/typebox": "^0.34",
51
51
  "@xterm/headless": "^6.0",
52
52
  "ajv": "^8.18",
@@ -31,11 +31,17 @@ export interface MCPServer {
31
31
  auth?: {
32
32
  type: "oauth" | "apikey";
33
33
  credentialId?: string;
34
+ tokenUrl?: string;
35
+ clientId?: string;
36
+ clientSecret?: string;
34
37
  };
35
- /** OAuth configuration (clientId, callbackPort) for servers requiring explicit client credentials */
38
+ /** OAuth configuration (clientId, clientSecret, redirectUri, callbackPort, callbackPath) for servers requiring explicit client credentials */
36
39
  oauth?: {
37
40
  clientId?: string;
41
+ clientSecret?: string;
42
+ redirectUri?: string;
38
43
  callbackPort?: number;
44
+ callbackPath?: string;
39
45
  };
40
46
  /** Transport type */
41
47
  transport?: "stdio" | "sse" | "http";
@@ -1,42 +1,52 @@
1
- /**
2
- * TUI session selector for --resume flag
3
- */
4
1
  import { ProcessTerminal, TUI } from "@oh-my-pi/pi-tui";
5
2
  import { SessionSelectorComponent } from "../modes/components/session-selector";
6
3
  import type { SessionInfo } from "../session/session-manager";
4
+ import { FileSessionStorage } from "../session/session-storage";
7
5
 
8
6
  /** Show TUI session selector and return selected session path or null if cancelled */
9
7
  export async function selectSession(sessions: SessionInfo[]): Promise<string | null> {
10
8
  const { promise, resolve } = Promise.withResolvers<string | null>();
11
9
  const ui = new TUI(new ProcessTerminal());
12
10
  let resolved = false;
13
- const selector = new SessionSelectorComponent(
14
- sessions,
15
- (path: string) => {
16
- if (!resolved) {
17
- resolved = true;
18
- ui.stop();
19
- resolve(path);
20
- }
21
- },
22
- () => {
23
- if (!resolved) {
24
- resolved = true;
25
- ui.stop();
26
- resolve(null);
27
- }
28
- },
29
- () => {
30
- if (!resolved) {
31
- resolved = true;
32
- ui.stop();
33
- process.exit(0);
34
- }
35
- },
36
- );
11
+ const storage = new FileSessionStorage();
37
12
 
13
+ const showSelector = () => {
14
+ const selector = new SessionSelectorComponent(
15
+ sessions,
16
+ (path: string) => {
17
+ if (!resolved) {
18
+ resolved = true;
19
+ ui.stop();
20
+ resolve(path);
21
+ }
22
+ },
23
+ () => {
24
+ if (!resolved) {
25
+ resolved = true;
26
+ ui.stop();
27
+ resolve(null);
28
+ }
29
+ },
30
+ () => {
31
+ if (!resolved) {
32
+ resolved = true;
33
+ ui.stop();
34
+ process.exit(0);
35
+ }
36
+ },
37
+ async (session: SessionInfo) => {
38
+ // Delete handler - SessionList will show confirmation internally
39
+ await storage.deleteSessionWithArtifacts(session.path);
40
+ return true;
41
+ },
42
+ );
43
+ return selector;
44
+ };
45
+
46
+ const selector = showSelector();
47
+ selector.setOnRequestRender(() => ui.requestRender());
38
48
  ui.addChild(selector);
39
- ui.setFocus(selector.getSessionList());
49
+ ui.setFocus(selector);
40
50
  ui.start();
41
51
  return promise;
42
52
  }
@@ -160,8 +160,24 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
160
160
  env: serverConfig.env as Record<string, string> | undefined,
161
161
  url: serverConfig.url as string | undefined,
162
162
  headers: serverConfig.headers as Record<string, string> | undefined,
163
- auth: serverConfig.auth as { type: "oauth" | "apikey"; credentialId?: string } | undefined,
164
- oauth: serverConfig.oauth as { clientId?: string; callbackPort?: number } | undefined,
163
+ auth: serverConfig.auth as
164
+ | {
165
+ type: "oauth" | "apikey";
166
+ credentialId?: string;
167
+ tokenUrl?: string;
168
+ clientId?: string;
169
+ clientSecret?: string;
170
+ }
171
+ | undefined,
172
+ oauth: serverConfig.oauth as
173
+ | {
174
+ clientId?: string;
175
+ clientSecret?: string;
176
+ redirectUri?: string;
177
+ callbackPort?: number;
178
+ callbackPath?: string;
179
+ }
180
+ | undefined,
165
181
  transport: serverConfig.type as "stdio" | "sse" | "http" | undefined,
166
182
  _source: createSourceMeta(PROVIDER_ID, path, level),
167
183
  });
@@ -34,9 +34,18 @@ interface MCPConfigFile {
34
34
  auth?: {
35
35
  type: "oauth" | "apikey";
36
36
  credentialId?: string;
37
+ tokenUrl?: string;
38
+ clientId?: string;
39
+ clientSecret?: string;
37
40
  };
38
41
  type?: "stdio" | "sse" | "http";
39
- oauth?: { clientId?: string; callbackPort?: number };
42
+ oauth?: {
43
+ clientId?: string;
44
+ clientSecret?: string;
45
+ redirectUri?: string;
46
+ callbackPort?: number;
47
+ callbackPath?: string;
48
+ };
40
49
  }
41
50
  >;
42
51
  }
@@ -93,7 +102,8 @@ function transformMCPConfig(config: MCPConfigFile, source: SourceMeta): MCPServe
93
102
  if (server.env) server.env = expandEnvVarsDeep(server.env);
94
103
  if (server.url) server.url = expandEnvVarsDeep(server.url);
95
104
  if (server.headers) server.headers = expandEnvVarsDeep(server.headers);
96
-
105
+ if (server.auth) server.auth = expandEnvVarsDeep(server.auth);
106
+ if (server.oauth) server.oauth = expandEnvVarsDeep(server.oauth);
97
107
  servers.push(server);
98
108
  }
99
109
  }
@@ -6,11 +6,97 @@
6
6
  */
7
7
 
8
8
  import type { OAuthController, OAuthCredentials } from "@oh-my-pi/pi-ai";
9
+ import type { OAuthCallbackFlowOptions } from "@oh-my-pi/pi-ai/utils/oauth/callback-server";
9
10
  import { OAuthCallbackFlow } from "@oh-my-pi/pi-ai/utils/oauth/callback-server";
10
11
 
11
12
  const DEFAULT_PORT = 3000;
12
13
  const CALLBACK_PATH = "/callback";
13
14
 
15
+ function isLoopbackHostname(hostname: string): boolean {
16
+ return hostname === "localhost" || hostname === "127.0.0.1";
17
+ }
18
+
19
+ function resolveRedirectUri(redirectUri: string | undefined): string | undefined {
20
+ const configured = redirectUri;
21
+ const trimmed = configured?.trim();
22
+ if (!trimmed) return undefined;
23
+ if (trimmed !== configured) {
24
+ throw new Error("OAuth redirect URI must not include surrounding whitespace");
25
+ }
26
+
27
+ const parsed = new URL(configured);
28
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
29
+ throw new Error("OAuth redirect URI must use http or https");
30
+ }
31
+ return configured;
32
+ }
33
+
34
+ function parseRedirectUri(redirectUri: string | undefined): URL | undefined {
35
+ return redirectUri ? new URL(redirectUri) : undefined;
36
+ }
37
+
38
+ function getUriPort(uri: URL): number {
39
+ if (uri.port !== "") return Number(uri.port);
40
+ return uri.protocol === "https:" ? 443 : 80;
41
+ }
42
+
43
+ function validateRedirectConfig(config: MCPOAuthConfig, redirectUri: string | undefined): void {
44
+ const parsed = parseRedirectUri(redirectUri);
45
+ if (!parsed || parsed.protocol !== "https:" || !isLoopbackHostname(parsed.hostname)) {
46
+ return;
47
+ }
48
+
49
+ if (config.callbackPort === undefined) {
50
+ throw new Error(
51
+ "HTTPS loopback redirect URIs require oauth.callbackPort to point at the local HTTP callback listener behind your TLS terminator",
52
+ );
53
+ }
54
+
55
+ if (config.callbackPort === getUriPort(parsed)) {
56
+ throw new Error(
57
+ "HTTPS loopback redirect URIs cannot reuse the same local port; terminate TLS separately and forward to oauth.callbackPort",
58
+ );
59
+ }
60
+ }
61
+
62
+ function resolveCallbackPort(callbackPort: number | undefined, redirectUri: string | undefined): number {
63
+ if (callbackPort !== undefined) return callbackPort;
64
+
65
+ const parsed = parseRedirectUri(redirectUri);
66
+ if (!parsed || parsed.protocol !== "http:" || !isLoopbackHostname(parsed.hostname)) {
67
+ return DEFAULT_PORT;
68
+ }
69
+
70
+ const port = getUriPort(parsed);
71
+ return Number.isFinite(port) && port > 0 ? port : DEFAULT_PORT;
72
+ }
73
+
74
+ function resolveCallbackPath(callbackPath: string | undefined, redirectUri: string | undefined): string {
75
+ const trimmed = callbackPath?.trim();
76
+ if (trimmed) return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
77
+
78
+ const parsed = parseRedirectUri(redirectUri);
79
+ if (parsed?.pathname) return parsed.pathname;
80
+ return CALLBACK_PATH;
81
+ }
82
+
83
+ function resolveCallbackHostname(redirectUri: string | undefined): string | undefined {
84
+ const parsed = parseRedirectUri(redirectUri);
85
+ if (!parsed || !isLoopbackHostname(parsed.hostname)) return undefined;
86
+ return parsed.hostname;
87
+ }
88
+
89
+ function resolveCallbackOptions(config: MCPOAuthConfig): OAuthCallbackFlowOptions {
90
+ const redirectUri = resolveRedirectUri(config.redirectUri);
91
+ validateRedirectConfig(config, redirectUri);
92
+ return {
93
+ preferredPort: resolveCallbackPort(config.callbackPort, redirectUri),
94
+ callbackPath: resolveCallbackPath(config.callbackPath, redirectUri),
95
+ callbackHostname: resolveCallbackHostname(redirectUri),
96
+ redirectUri,
97
+ };
98
+ }
99
+
14
100
  export interface MCPOAuthConfig {
15
101
  /** Authorization endpoint URL */
16
102
  authorizationUrl: string;
@@ -22,8 +108,12 @@ export interface MCPOAuthConfig {
22
108
  clientSecret?: string;
23
109
  /** OAuth scopes (space-separated) */
24
110
  scopes?: string;
111
+ /** Exact redirect URI to advertise to the provider */
112
+ redirectUri?: string;
25
113
  /** Custom callback port (default: 3000) */
26
114
  callbackPort?: number;
115
+ /** Custom callback path (default: /callback or redirectUri pathname) */
116
+ callbackPath?: string;
27
117
  }
28
118
 
29
119
  /**
@@ -39,7 +129,7 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
39
129
  private config: MCPOAuthConfig,
40
130
  ctrl: OAuthController,
41
131
  ) {
42
- super(ctrl, config.callbackPort ?? DEFAULT_PORT, CALLBACK_PATH);
132
+ super(ctrl, resolveCallbackOptions(config));
43
133
  this.#resolvedClientId = this.#resolveClientId(config);
44
134
  }
45
135
 
package/src/mcp/types.ts CHANGED
@@ -68,7 +68,10 @@ interface MCPServerConfigBase {
68
68
  /** OAuth configuration for servers requiring explicit client credentials */
69
69
  oauth?: {
70
70
  clientId?: string;
71
+ clientSecret?: string;
72
+ redirectUri?: string;
71
73
  callbackPort?: number;
74
+ callbackPath?: string;
72
75
  };
73
76
  }
74
77
 
@@ -4,6 +4,7 @@ import {
4
4
  Input,
5
5
  matchesKey,
6
6
  padding,
7
+ replaceTabs,
7
8
  Spacer,
8
9
  Text,
9
10
  truncateToWidth,
@@ -13,6 +14,7 @@ import { theme } from "../../modes/theme/theme";
13
14
  import type { SessionInfo } from "../../session/session-manager";
14
15
  import { fuzzyFilter } from "../../utils/fuzzy";
15
16
  import { DynamicBorder } from "./dynamic-border";
17
+ import { HookSelectorComponent } from "./hook-selector";
16
18
 
17
19
  /**
18
20
  * Custom session list component with multi-line items and search
@@ -26,6 +28,8 @@ class SessionList implements Component {
26
28
  onExit: () => void = () => {};
27
29
  #maxVisible: number = 5; // Max sessions visible (each session is 3 lines: msg + metadata + blank)
28
30
 
31
+ onDeleteRequest?: (session: SessionInfo) => void;
32
+
29
33
  constructor(
30
34
  private readonly allSessions: SessionInfo[],
31
35
  private readonly showCwd = false,
@@ -59,6 +63,18 @@ class SessionList implements Component {
59
63
  this.#selectedIndex = Math.min(this.#selectedIndex, Math.max(0, this.#filteredSessions.length - 1));
60
64
  }
61
65
 
66
+ removeSession(sessionPath: string): void {
67
+ const index = this.allSessions.findIndex(s => s.path === sessionPath);
68
+ if (index === -1) return;
69
+ this.allSessions.splice(index, 1);
70
+ // Re-filter to update filteredSessions
71
+ this.#filterSessions(this.#searchInput.getValue());
72
+ // Adjust selectedIndex if we deleted the last item or beyond
73
+ if (this.#selectedIndex >= this.#filteredSessions.length) {
74
+ this.#selectedIndex = Math.max(0, this.#filteredSessions.length - 1);
75
+ }
76
+ }
77
+
62
78
  invalidate(): void {
63
79
  // No cached state to invalidate currently
64
80
  }
@@ -157,78 +173,105 @@ class SessionList implements Component {
157
173
  lines.push(scrollInfo);
158
174
  }
159
175
 
176
+ // Add keybinding hint
177
+ lines.push("");
178
+ lines.push(theme.fg("muted", " [Del to delete, Enter to select, Esc to cancel]"));
179
+
160
180
  return lines;
161
181
  }
162
182
 
163
183
  handleInput(keyData: string): void {
184
+ // Delete key - request delete confirmation from parent
185
+ if (matchesKey(keyData, "delete")) {
186
+ const selected = this.#filteredSessions[this.#selectedIndex];
187
+ if (selected && this.onDeleteRequest) {
188
+ this.onDeleteRequest(selected);
189
+ }
190
+ return;
191
+ }
192
+
164
193
  // Up arrow
165
194
  if (matchesKey(keyData, "up")) {
166
195
  this.#selectedIndex = Math.max(0, this.#selectedIndex - 1);
196
+ return;
167
197
  }
168
198
  // Down arrow
169
- else if (matchesKey(keyData, "down")) {
199
+ if (matchesKey(keyData, "down")) {
170
200
  this.#selectedIndex = Math.min(this.#filteredSessions.length - 1, this.#selectedIndex + 1);
201
+ return;
171
202
  }
172
203
  // Page up - jump up by maxVisible items
173
- else if (matchesKey(keyData, "pageUp")) {
204
+ if (matchesKey(keyData, "pageUp")) {
174
205
  this.#selectedIndex = Math.max(0, this.#selectedIndex - this.#maxVisible);
206
+ return;
175
207
  }
176
208
  // Page down - jump down by maxVisible items
177
- else if (matchesKey(keyData, "pageDown")) {
209
+ if (matchesKey(keyData, "pageDown")) {
178
210
  this.#selectedIndex = Math.min(this.#filteredSessions.length - 1, this.#selectedIndex + this.#maxVisible);
211
+ return;
179
212
  }
180
213
  // Enter
181
- else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
214
+ if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
182
215
  const selected = this.#filteredSessions[this.#selectedIndex];
183
216
  if (selected && this.onSelect) {
184
217
  this.onSelect(selected.path);
185
218
  }
219
+ return;
186
220
  }
187
221
  // Escape - cancel
188
- else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
222
+ if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
189
223
  if (this.onCancel) {
190
224
  this.onCancel();
191
225
  }
226
+ return;
192
227
  }
193
228
  // Ctrl+C - exit
194
- else if (matchesKey(keyData, "ctrl+c")) {
229
+ if (matchesKey(keyData, "ctrl+c")) {
195
230
  this.onExit();
231
+ return;
196
232
  }
197
233
  // Pass everything else to search input
198
- else {
199
- this.#searchInput.handleInput(keyData);
200
- this.#filterSessions(this.#searchInput.getValue());
201
- }
234
+ this.#searchInput.handleInput(keyData);
235
+ this.#filterSessions(this.#searchInput.getValue());
202
236
  }
203
237
  }
204
238
 
205
239
  /**
206
- * Component that renders a session selector
240
+ * Component that renders a session selector with optional confirmation dialog
207
241
  */
208
242
  export class SessionSelectorComponent extends Container {
209
243
  #sessionList: SessionList;
244
+ #confirmationDialog: HookSelectorComponent | null = null;
245
+ #messageContainer: Container;
246
+ #onDelete?: (session: SessionInfo) => Promise<boolean>;
247
+ #onRequestRender?: () => void;
210
248
 
211
249
  constructor(
212
250
  sessions: SessionInfo[],
213
251
  onSelect: (sessionPath: string) => void,
214
252
  onCancel: () => void,
215
253
  onExit: () => void,
254
+ onDelete?: (session: SessionInfo) => Promise<boolean>,
216
255
  ) {
217
256
  super();
218
257
 
258
+ this.#messageContainer = new Container();
259
+ this.#onDelete = onDelete;
219
260
  // Add header
220
261
  this.addChild(new Spacer(1));
221
262
  this.addChild(new Text(theme.bold("Resume Session"), 1, 0));
222
263
  this.addChild(new Spacer(1));
223
264
  this.addChild(new DynamicBorder());
224
265
  this.addChild(new Spacer(1));
225
-
266
+ this.addChild(this.#messageContainer);
226
267
  // Create session list
227
268
  this.#sessionList = new SessionList(sessions);
228
269
  this.#sessionList.onSelect = onSelect;
229
270
  this.#sessionList.onCancel = onCancel;
230
271
  this.#sessionList.onExit = onExit;
231
-
272
+ this.#sessionList.onDeleteRequest = (session: SessionInfo) => {
273
+ this.#showDeleteConfirmation(session);
274
+ };
232
275
  this.addChild(this.#sessionList);
233
276
 
234
277
  // Add bottom border
@@ -236,6 +279,63 @@ export class SessionSelectorComponent extends Container {
236
279
  this.addChild(new DynamicBorder());
237
280
  }
238
281
 
282
+ setOnRequestRender(callback: () => void): void {
283
+ this.#onRequestRender = callback;
284
+ }
285
+
286
+ #clearError(): void {
287
+ this.#messageContainer.clear();
288
+ }
289
+
290
+ #showError(message: string): void {
291
+ this.#messageContainer.clear();
292
+ this.#messageContainer.addChild(new Text(theme.fg("error", `Error: ${replaceTabs(message)}`), 1, 0));
293
+ this.#messageContainer.addChild(new Spacer(1));
294
+ }
295
+
296
+ #showDeleteConfirmation(session: SessionInfo): void {
297
+ const displayName = session.title || session.firstMessage.slice(0, 40) || session.id;
298
+ this.#confirmationDialog = new HookSelectorComponent(
299
+ `Delete session?\n${displayName}`,
300
+ ["Yes", "No"],
301
+ async (option: string) => {
302
+ if (option === "Yes" && this.#onDelete) {
303
+ this.#clearError();
304
+ try {
305
+ const deleted = await this.#onDelete(session);
306
+ if (deleted) {
307
+ this.#sessionList.removeSession(session.path);
308
+ }
309
+ } catch (err) {
310
+ this.#showError(err instanceof Error ? err.message : String(err));
311
+ }
312
+ }
313
+ // Close confirmation dialog
314
+ this.removeChild(this.#confirmationDialog!);
315
+ this.#confirmationDialog = null;
316
+ // Request rerender
317
+ this.#onRequestRender?.();
318
+ },
319
+ () => {
320
+ // Cancel - close confirmation dialog
321
+ this.removeChild(this.#confirmationDialog!);
322
+ this.#confirmationDialog = null;
323
+ // Request rerender
324
+ this.#onRequestRender?.();
325
+ },
326
+ );
327
+ // Show confirmation dialog
328
+ this.addChild(this.#confirmationDialog);
329
+ }
330
+
331
+ handleInput(keyData: string): void {
332
+ if (this.#confirmationDialog) {
333
+ this.#confirmationDialog.handleInput(keyData);
334
+ } else {
335
+ this.#sessionList.handleInput(keyData);
336
+ }
337
+ }
338
+
239
339
  getSessionList(): SessionList {
240
340
  return this.#sessionList;
241
341
  }
@@ -32,7 +32,7 @@ import {
32
32
  searchSmitheryRegistry,
33
33
  toConfigName,
34
34
  } from "../../mcp/smithery-registry";
35
- import type { MCPServerConfig, MCPServerConnection } from "../../mcp/types";
35
+ import type { MCPAuthConfig, MCPServerConfig, MCPServerConnection } from "../../mcp/types";
36
36
  import type { OAuthCredential } from "../../session/auth-storage";
37
37
  import { shortenPath } from "../../tools/render-utils";
38
38
  import { openPath } from "../../utils/open";
@@ -400,13 +400,16 @@ export class MCPCommandController {
400
400
  }
401
401
 
402
402
  try {
403
+ const oauthClientSecret = finalConfig.oauth?.clientSecret ?? "";
403
404
  const credentialId = await this.#handleOAuthFlow(
404
405
  oauth.authorizationUrl,
405
406
  oauth.tokenUrl,
406
407
  oauth.clientId ?? finalConfig.oauth?.clientId ?? "",
407
- "",
408
+ oauthClientSecret,
408
409
  oauth.scopes ?? "",
409
410
  finalConfig.oauth?.callbackPort,
411
+ finalConfig.oauth?.callbackPath,
412
+ finalConfig.oauth?.redirectUri,
410
413
  );
411
414
  finalConfig = {
412
415
  ...finalConfig,
@@ -415,7 +418,7 @@ export class MCPCommandController {
415
418
  credentialId,
416
419
  tokenUrl: oauth.tokenUrl,
417
420
  clientId: oauth.clientId ?? finalConfig.oauth?.clientId,
418
- clientSecret: undefined,
421
+ clientSecret: finalConfig.oauth?.clientSecret,
419
422
  },
420
423
  };
421
424
  } catch (oauthError) {
@@ -478,6 +481,8 @@ export class MCPCommandController {
478
481
  clientSecret: string,
479
482
  scopes: string,
480
483
  callbackPort?: number,
484
+ callbackPath?: string,
485
+ redirectUri?: string,
481
486
  ): Promise<string> {
482
487
  const authStorage = this.ctx.session.modelRegistry.authStorage;
483
488
  let parsedAuthUrl: URL;
@@ -493,6 +498,7 @@ export class MCPCommandController {
493
498
  }
494
499
 
495
500
  const resolvedClientId = clientId.trim() || parsedAuthUrl.searchParams.get("client_id") || undefined;
501
+ const resolvedClientSecret = clientSecret.trim() || undefined;
496
502
 
497
503
  try {
498
504
  // Create OAuth flow
@@ -501,9 +507,11 @@ export class MCPCommandController {
501
507
  authorizationUrl: authUrl,
502
508
  tokenUrl: tokenUrl,
503
509
  clientId: resolvedClientId,
504
- clientSecret: clientSecret || undefined,
510
+ clientSecret: resolvedClientSecret,
505
511
  scopes: scopes || undefined,
512
+ redirectUri,
506
513
  callbackPort,
514
+ callbackPath,
507
515
  },
508
516
  {
509
517
  onAuth: (info: { url: string; instructions?: string }) => {
@@ -653,7 +661,7 @@ export class MCPCommandController {
653
661
  }
654
662
 
655
663
  #stripOAuthAuth(config: MCPServerConfig): MCPServerConfig {
656
- const next = { ...config } as MCPServerConfig & { auth?: { type: "oauth" | "apikey"; credentialId?: string } };
664
+ const next = { ...config } as MCPServerConfig & { auth?: MCPAuthConfig };
657
665
  delete next.auth;
658
666
  return next;
659
667
  }
@@ -1261,9 +1269,7 @@ export class MCPCommandController {
1261
1269
  return;
1262
1270
  }
1263
1271
 
1264
- const currentAuth = (
1265
- found.config as MCPServerConfig & { auth?: { type: "oauth" | "apikey"; credentialId?: string } }
1266
- ).auth;
1272
+ const currentAuth = (found.config as MCPServerConfig & { auth?: MCPAuthConfig }).auth;
1267
1273
  if (currentAuth?.type === "oauth") {
1268
1274
  await this.#removeManagedOAuthCredential(currentAuth.credentialId);
1269
1275
  }
@@ -1298,17 +1304,14 @@ export class MCPCommandController {
1298
1304
  return;
1299
1305
  }
1300
1306
 
1301
- const currentAuth = (
1302
- found.config as MCPServerConfig & {
1303
- auth?: { type: "oauth" | "apikey"; credentialId?: string; clientSecret?: string };
1304
- }
1305
- ).auth;
1307
+ const currentAuth = (found.config as MCPServerConfig & { auth?: MCPAuthConfig }).auth;
1306
1308
  if (currentAuth?.type === "oauth") {
1307
1309
  await this.#removeManagedOAuthCredential(currentAuth.credentialId);
1308
1310
  }
1309
1311
 
1310
1312
  const baseConfig = this.#stripOAuthAuth(found.config);
1311
1313
  const oauth = await this.#resolveOAuthEndpointsFromServer(baseConfig);
1314
+ const oauthClientSecret = found.config.oauth?.clientSecret ?? currentAuth?.clientSecret ?? "";
1312
1315
 
1313
1316
  this.#showMessage(["", theme.fg("muted", `Reauthorizing "${name}"...`), ""].join("\n"));
1314
1317
 
@@ -1316,9 +1319,11 @@ export class MCPCommandController {
1316
1319
  oauth.authorizationUrl,
1317
1320
  oauth.tokenUrl,
1318
1321
  oauth.clientId ?? found.config.oauth?.clientId ?? "",
1319
- "",
1322
+ oauthClientSecret,
1320
1323
  oauth.scopes ?? "",
1321
1324
  found.config.oauth?.callbackPort,
1325
+ found.config.oauth?.callbackPath,
1326
+ found.config.oauth?.redirectUri,
1322
1327
  );
1323
1328
 
1324
1329
  const updated: MCPServerConfig = {
@@ -1328,7 +1333,7 @@ export class MCPCommandController {
1328
1333
  credentialId,
1329
1334
  tokenUrl: oauth.tokenUrl,
1330
1335
  clientId: oauth.clientId ?? found.config.oauth?.clientId,
1331
- clientSecret: currentAuth?.clientSecret,
1336
+ clientSecret: oauthClientSecret || undefined,
1332
1337
  },
1333
1338
  };
1334
1339
  await updateMCPServer(found.filePath, name, updated);