@oh-my-pi/pi-coding-agent 15.13.0 → 15.13.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/CHANGELOG.md +1656 -613
- package/dist/cli.js +12765 -12731
- package/dist/types/autolearn/managed-skills.d.ts +1 -1
- package/dist/types/capability/mcp.d.ts +2 -1
- package/dist/types/cli/args.d.ts +2 -0
- package/dist/types/cli/flag-tables.d.ts +126 -0
- package/dist/types/cli/profile-alias.d.ts +29 -0
- package/dist/types/cli/profile-bootstrap.d.ts +55 -0
- package/dist/types/commands/launch.d.ts +6 -0
- package/dist/types/config/model-roles.d.ts +3 -2
- package/dist/types/config/settings-schema.d.ts +2 -0
- package/dist/types/edit/file-snapshot-store.d.ts +14 -0
- package/dist/types/extensibility/extensions/runner.d.ts +11 -0
- package/dist/types/mcp/manager.d.ts +5 -1
- package/dist/types/mcp/oauth-credentials.d.ts +17 -0
- package/dist/types/mcp/oauth-flow.d.ts +41 -0
- package/dist/types/mcp/types.d.ts +2 -0
- package/dist/types/modes/components/background-tan-message.d.ts +9 -0
- package/dist/types/modes/components/mcp-add-wizard.d.ts +9 -5
- package/dist/types/modes/interactive-mode.d.ts +4 -0
- package/dist/types/modes/types.d.ts +3 -0
- package/dist/types/sdk.d.ts +1 -1
- package/dist/types/session/messages.d.ts +8 -0
- package/dist/types/session/session-manager.d.ts +6 -0
- package/dist/types/tools/builtin-names.d.ts +2 -0
- package/dist/types/tools/index.d.ts +3 -2
- package/dist/types/utils/external-editor.d.ts +11 -1
- package/package.json +12 -12
- package/src/autolearn/managed-skills.ts +3 -5
- package/src/capability/mcp.ts +2 -1
- package/src/cli/args.ts +61 -103
- package/src/cli/completion-gen.ts +2 -2
- package/src/cli/flag-tables.ts +270 -0
- package/src/cli/profile-alias.ts +338 -0
- package/src/cli/profile-bootstrap.ts +243 -0
- package/src/cli.ts +83 -16
- package/src/commands/launch.ts +7 -0
- package/src/config/mcp-schema.json +4 -0
- package/src/config/model-roles.ts +17 -4
- package/src/config/settings-schema.ts +2 -0
- package/src/discovery/builtin.ts +15 -9
- package/src/discovery/helpers.ts +25 -0
- package/src/discovery/mcp-json.ts +1 -0
- package/src/discovery/omp-extension-roots.ts +2 -2
- package/src/edit/file-snapshot-store.ts +43 -0
- package/src/eval/__tests__/agent-bridge.test.ts +3 -2
- package/src/eval/__tests__/helpers-local-roots.test.ts +1 -1
- package/src/eval/js/shared/runtime.ts +54 -0
- package/src/extensibility/extensions/runner.ts +25 -2
- package/src/goals/runtime.ts +4 -1
- package/src/internal-urls/docs-index.generated.ts +6 -6
- package/src/mcp/manager.ts +108 -71
- package/src/mcp/oauth-credentials.ts +104 -0
- package/src/mcp/oauth-flow.ts +67 -0
- package/src/mcp/types.ts +2 -0
- package/src/modes/components/agent-hub.ts +6 -0
- package/src/modes/components/background-tan-message.ts +36 -0
- package/src/modes/components/mcp-add-wizard.ts +17 -10
- package/src/modes/components/model-selector.ts +50 -6
- package/src/modes/components/tool-execution.ts +12 -0
- package/src/modes/controllers/input-controller.ts +21 -10
- package/src/modes/controllers/mcp-command-controller.ts +184 -112
- package/src/modes/controllers/tan-command-controller.ts +27 -11
- package/src/modes/interactive-mode.ts +6 -0
- package/src/modes/types.ts +3 -0
- package/src/modes/utils/ui-helpers.ts +6 -0
- package/src/prompts/bench.md +9 -4
- package/src/sdk.ts +6 -5
- package/src/session/agent-session.ts +30 -1
- package/src/session/messages.ts +9 -0
- package/src/session/session-manager.ts +7 -2
- package/src/tiny/text.ts +5 -1
- package/src/tools/ast-grep.ts +5 -1
- package/src/tools/builtin-names.ts +35 -0
- package/src/tools/index.ts +3 -2
- package/src/tools/read.ts +9 -0
- package/src/tools/search.ts +5 -1
- package/src/tts/tts-worker.ts +13 -5
- package/src/utils/external-editor.ts +15 -2
- package/src/utils/title-generator.ts +1 -1
- package/src/workspace-tree.ts +46 -6
- package/dist/types/utils/tools-manager.test.d.ts +0 -1
- package/src/utils/tools-manager.test.ts +0 -25
package/src/mcp/manager.ts
CHANGED
|
@@ -27,7 +27,12 @@ import {
|
|
|
27
27
|
unsubscribeFromResources,
|
|
28
28
|
} from "./client";
|
|
29
29
|
import { loadAllMCPConfigs, validateServerConfig } from "./config";
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
lookupMcpOAuthCredential,
|
|
32
|
+
type MCPOAuthCredentialLookup,
|
|
33
|
+
selectMcpOAuthRefreshMaterial,
|
|
34
|
+
} from "./oauth-credentials";
|
|
35
|
+
import { type MCPStoredOAuthCredential, refreshMCPOAuthToken } from "./oauth-flow";
|
|
31
36
|
import type { MCPToolDetails } from "./tool-bridge";
|
|
32
37
|
import { DeferredMCPTool, MCPTool } from "./tool-bridge";
|
|
33
38
|
import type { MCPToolCache } from "./tool-cache";
|
|
@@ -400,9 +405,15 @@ export class MCPManager {
|
|
|
400
405
|
}
|
|
401
406
|
|
|
402
407
|
// Wire auth refresh for HTTP transports so 401s trigger token refresh.
|
|
403
|
-
|
|
408
|
+
// Gate on a resolvable managed credential, not on the auth block:
|
|
409
|
+
// definition-only configs (url-keyed fallback) get Bearer injection
|
|
410
|
+
// too and need the same mid-session refresh hook.
|
|
411
|
+
if (
|
|
412
|
+
connection.transport instanceof HttpTransport &&
|
|
413
|
+
lookupMcpOAuthCredential(this.#authStorage, config)
|
|
414
|
+
) {
|
|
404
415
|
connection.transport.onAuthError = async () => {
|
|
405
|
-
const refreshed = await this.#resolveAuthConfig(config, true);
|
|
416
|
+
const refreshed = await this.#resolveAuthConfig(config, { forceRefresh: true });
|
|
406
417
|
if (refreshed.type === "http" || refreshed.type === "sse") {
|
|
407
418
|
return refreshed.headers ?? null;
|
|
408
419
|
}
|
|
@@ -673,9 +684,11 @@ export class MCPManager {
|
|
|
673
684
|
|
|
674
685
|
/**
|
|
675
686
|
* Resolve auth and shell-command substitutions in config before connecting.
|
|
687
|
+
* Pass `oauth: false` to skip OAuth credential injection (used by reauth's
|
|
688
|
+
* unauthenticated probe, which must observe the server's bare 401).
|
|
676
689
|
*/
|
|
677
|
-
async prepareConfig(config: MCPServerConfig): Promise<MCPServerConfig> {
|
|
678
|
-
return this.#resolveAuthConfig(config);
|
|
690
|
+
async prepareConfig(config: MCPServerConfig, options?: { oauth?: boolean }): Promise<MCPServerConfig> {
|
|
691
|
+
return this.#resolveAuthConfig(config, options);
|
|
679
692
|
}
|
|
680
693
|
|
|
681
694
|
/**
|
|
@@ -925,9 +938,10 @@ export class MCPManager {
|
|
|
925
938
|
this.#connections.set(name, connection);
|
|
926
939
|
|
|
927
940
|
// Wire auth refresh for HTTP transports, and reconnect for any transport.
|
|
928
|
-
|
|
941
|
+
// Same gate as connectServers: any resolvable managed credential.
|
|
942
|
+
if (connection.transport instanceof HttpTransport && lookupMcpOAuthCredential(this.#authStorage, config)) {
|
|
929
943
|
connection.transport.onAuthError = async () => {
|
|
930
|
-
const refreshed = await this.#resolveAuthConfig(config, true);
|
|
944
|
+
const refreshed = await this.#resolveAuthConfig(config, { forceRefresh: true });
|
|
931
945
|
if (refreshed.type === "http" || refreshed.type === "sse") {
|
|
932
946
|
return refreshed.headers ?? null;
|
|
933
947
|
}
|
|
@@ -1169,78 +1183,101 @@ export class MCPManager {
|
|
|
1169
1183
|
|
|
1170
1184
|
/**
|
|
1171
1185
|
* Resolve OAuth credentials and shell commands in config.
|
|
1186
|
+
* `oauth: false` skips credential injection (reauth's unauthenticated probe);
|
|
1187
|
+
* `forceRefresh` bypasses the expiry buffer (401/403 auth-error hook).
|
|
1172
1188
|
*/
|
|
1173
|
-
async #resolveAuthConfig(
|
|
1189
|
+
async #resolveAuthConfig(
|
|
1190
|
+
config: MCPServerConfig,
|
|
1191
|
+
opts?: { forceRefresh?: boolean; oauth?: boolean },
|
|
1192
|
+
): Promise<MCPServerConfig> {
|
|
1174
1193
|
let resolved: MCPServerConfig = { ...config };
|
|
1175
1194
|
|
|
1176
1195
|
const auth = config.auth;
|
|
1177
|
-
|
|
1178
|
-
|
|
1196
|
+
const lookup: MCPOAuthCredentialLookup | undefined =
|
|
1197
|
+
opts?.oauth !== false ? lookupMcpOAuthCredential(this.#authStorage, config) : undefined;
|
|
1198
|
+
if (lookup && this.#authStorage) {
|
|
1199
|
+
const { credentialId } = lookup;
|
|
1179
1200
|
try {
|
|
1180
|
-
let credential =
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1201
|
+
let credential: MCPStoredOAuthCredential | undefined = lookup.credential;
|
|
1202
|
+
// Refresh material comes from ONE source: the credential's embedded
|
|
1203
|
+
// fields (written atomically with the tokens they minted — tokenUrl
|
|
1204
|
+
// always present) or, for legacy rows that predate embedding, the
|
|
1205
|
+
// config auth block. Never mix the two: a shared file's auth block
|
|
1206
|
+
// can belong to another profile, whose client the grant is NOT
|
|
1207
|
+
// bound to.
|
|
1208
|
+
const material = selectMcpOAuthRefreshMaterial(credential, auth);
|
|
1209
|
+
const tokenUrl = material?.tokenUrl;
|
|
1210
|
+
const clientId = material?.clientId;
|
|
1211
|
+
const clientSecret = material?.clientSecret;
|
|
1212
|
+
const resource =
|
|
1213
|
+
material?.resource ?? (config.type === "http" || config.type === "sse" ? config.url : undefined);
|
|
1214
|
+
// Proactive refresh: 5-minute buffer before expiry
|
|
1215
|
+
// Force refresh: on 401/403 auth errors (revoked tokens, clock skew, missing expires)
|
|
1216
|
+
const REFRESH_BUFFER_MS = 5 * 60_000;
|
|
1217
|
+
const shouldRefresh =
|
|
1218
|
+
opts?.forceRefresh || (credential.expires && Date.now() >= credential.expires - REFRESH_BUFFER_MS);
|
|
1219
|
+
if (shouldRefresh && credential.refresh && tokenUrl) {
|
|
1220
|
+
try {
|
|
1221
|
+
const refreshed = await refreshMCPOAuthToken(
|
|
1222
|
+
tokenUrl,
|
|
1223
|
+
credential.refresh,
|
|
1224
|
+
clientId,
|
|
1225
|
+
clientSecret,
|
|
1226
|
+
resource,
|
|
1227
|
+
);
|
|
1228
|
+
// Spread the old credential first so embedded refresh material survives rotation.
|
|
1229
|
+
const refreshedCredential: MCPStoredOAuthCredential = {
|
|
1230
|
+
...credential,
|
|
1231
|
+
...refreshed,
|
|
1232
|
+
tokenUrl,
|
|
1233
|
+
clientId,
|
|
1234
|
+
clientSecret,
|
|
1235
|
+
resource,
|
|
1236
|
+
};
|
|
1237
|
+
await this.#authStorage.set(credentialId, refreshedCredential);
|
|
1238
|
+
credential = refreshedCredential;
|
|
1239
|
+
} catch (refreshError) {
|
|
1240
|
+
const errorMsg = refreshError instanceof Error ? refreshError.message : String(refreshError);
|
|
1241
|
+
if (isDefinitiveOAuthFailure(errorMsg)) {
|
|
1242
|
+
// `invalid_grant` / `invalid_token` / 401 from the token endpoint means
|
|
1243
|
+
// the server has retired this credential — keeping the stale access
|
|
1244
|
+
// token would just re-fail with 401 on every MCP request and leave a
|
|
1245
|
+
// poisoned row in agent.db that survives restarts. Drop it now so the
|
|
1246
|
+
// next connect attempt surfaces a clean "needs reauth" failure and
|
|
1247
|
+
// the user can recover with `/mcp reauth <server>` (or `/mcp unauth`
|
|
1248
|
+
// to forget the server entirely).
|
|
1249
|
+
logger.warn("MCP OAuth refresh failed definitively; cleared credential", {
|
|
1250
|
+
credentialId,
|
|
1251
|
+
error: errorMsg,
|
|
1252
|
+
});
|
|
1253
|
+
await this.#authStorage.remove(credentialId);
|
|
1254
|
+
credential = undefined;
|
|
1255
|
+
} else {
|
|
1256
|
+
logger.warn("MCP OAuth refresh failed, using existing token", {
|
|
1257
|
+
credentialId,
|
|
1258
|
+
error: refreshError,
|
|
1259
|
+
});
|
|
1223
1260
|
}
|
|
1224
1261
|
}
|
|
1262
|
+
}
|
|
1225
1263
|
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
}
|
|
1264
|
+
if (credential) {
|
|
1265
|
+
if (resolved.type === "http" || resolved.type === "sse") {
|
|
1266
|
+
resolved = {
|
|
1267
|
+
...resolved,
|
|
1268
|
+
headers: {
|
|
1269
|
+
...resolved.headers,
|
|
1270
|
+
Authorization: `Bearer ${credential.access}`,
|
|
1271
|
+
},
|
|
1272
|
+
};
|
|
1273
|
+
} else {
|
|
1274
|
+
resolved = {
|
|
1275
|
+
...resolved,
|
|
1276
|
+
env: {
|
|
1277
|
+
...resolved.env,
|
|
1278
|
+
OAUTH_ACCESS_TOKEN: credential.access,
|
|
1279
|
+
},
|
|
1280
|
+
};
|
|
1244
1281
|
}
|
|
1245
1282
|
}
|
|
1246
1283
|
} catch (error) {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { getActiveProfile } from "@oh-my-pi/pi-utils/dirs";
|
|
2
|
+
import { expandEnvVarsDeep } from "../discovery/helpers";
|
|
3
|
+
import type { AuthStorage } from "../session/auth-storage";
|
|
4
|
+
import {
|
|
5
|
+
isManagedMCPOAuthCredentialId,
|
|
6
|
+
type MCPStoredOAuthCredential,
|
|
7
|
+
mcpOAuthCredentialId,
|
|
8
|
+
mcpOAuthCredentialProfile,
|
|
9
|
+
} from "./oauth-flow";
|
|
10
|
+
import type { MCPAuthConfig, MCPServerConfig } from "./types";
|
|
11
|
+
|
|
12
|
+
export interface MCPOAuthCredentialLookup {
|
|
13
|
+
credentialId: string;
|
|
14
|
+
credential: MCPStoredOAuthCredential;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type MCPOAuthRefreshMaterial = MCPStoredOAuthCredential | MCPAuthConfig | undefined;
|
|
18
|
+
|
|
19
|
+
export function mcpOAuthCredentialIdsForServerUrl(serverUrl: string | undefined): string[] {
|
|
20
|
+
if (!serverUrl) return [];
|
|
21
|
+
const ids: string[] = [];
|
|
22
|
+
for (const url of [expandEnvVarsDeep(serverUrl), serverUrl]) {
|
|
23
|
+
const id = mcpOAuthCredentialId(url);
|
|
24
|
+
if (!ids.includes(id)) ids.push(id);
|
|
25
|
+
}
|
|
26
|
+
return ids;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function hasMcpAuthorizationHeader(config: MCPServerConfig): boolean {
|
|
30
|
+
if (config.type !== "http" && config.type !== "sse") return false;
|
|
31
|
+
return Object.keys(config.headers ?? {}).some(header => header.toLowerCase() === "authorization");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function lookupMcpOAuthCredentialForServer(
|
|
35
|
+
authStorage: AuthStorage | null | undefined,
|
|
36
|
+
auth: MCPAuthConfig | undefined,
|
|
37
|
+
serverUrl: string | undefined,
|
|
38
|
+
options: { allowUrlKeyedFallback?: boolean } = {},
|
|
39
|
+
): MCPOAuthCredentialLookup | undefined {
|
|
40
|
+
if (!authStorage) return undefined;
|
|
41
|
+
if (auth && auth.type !== "oauth") return undefined;
|
|
42
|
+
const urlKeyedCredentialIds = mcpOAuthCredentialIdsForServerUrl(serverUrl);
|
|
43
|
+
if (
|
|
44
|
+
auth?.credentialId &&
|
|
45
|
+
(!auth.credentialId.startsWith("mcp_oauth:profile:") || urlKeyedCredentialIds.includes(auth.credentialId))
|
|
46
|
+
) {
|
|
47
|
+
const credential = authStorage.get(auth.credentialId);
|
|
48
|
+
if (credential?.type === "oauth") {
|
|
49
|
+
return { credentialId: auth.credentialId, credential };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (options.allowUrlKeyedFallback === false) return undefined;
|
|
53
|
+
for (const credentialId of urlKeyedCredentialIds) {
|
|
54
|
+
const credential = authStorage.get(credentialId);
|
|
55
|
+
if (credential?.type === "oauth") {
|
|
56
|
+
return { credentialId, credential };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function lookupMcpOAuthCredential(
|
|
63
|
+
authStorage: AuthStorage | null | undefined,
|
|
64
|
+
config: MCPServerConfig,
|
|
65
|
+
): MCPOAuthCredentialLookup | undefined {
|
|
66
|
+
const auth = config.auth;
|
|
67
|
+
if (config.type !== "http" && config.type !== "sse") {
|
|
68
|
+
return lookupMcpOAuthCredentialForServer(authStorage, auth, undefined);
|
|
69
|
+
}
|
|
70
|
+
if (hasMcpAuthorizationHeader(config)) {
|
|
71
|
+
return lookupMcpOAuthCredentialForServer(authStorage, auth, config.url, { allowUrlKeyedFallback: false });
|
|
72
|
+
}
|
|
73
|
+
return lookupMcpOAuthCredentialForServer(authStorage, auth, config.url);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function selectMcpOAuthRefreshMaterial(
|
|
77
|
+
credential: MCPStoredOAuthCredential,
|
|
78
|
+
auth: MCPAuthConfig | undefined,
|
|
79
|
+
): MCPOAuthRefreshMaterial {
|
|
80
|
+
return credential.tokenUrl ? credential : auth;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function removeManagedMcpOAuthCredential(
|
|
84
|
+
authStorage: AuthStorage,
|
|
85
|
+
credentialId: string | undefined,
|
|
86
|
+
): Promise<boolean> {
|
|
87
|
+
if (!isManagedMCPOAuthCredentialId(credentialId)) return false;
|
|
88
|
+
const scopedProfile = mcpOAuthCredentialProfile(credentialId);
|
|
89
|
+
if (scopedProfile !== undefined && scopedProfile !== (getActiveProfile() ?? "default")) return false;
|
|
90
|
+
if (authStorage.get(credentialId)?.type !== "oauth") return false;
|
|
91
|
+
await authStorage.remove(credentialId);
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function removeManagedMcpOAuthCredentials(
|
|
96
|
+
authStorage: AuthStorage,
|
|
97
|
+
credentialIds: readonly (string | undefined)[],
|
|
98
|
+
): Promise<boolean> {
|
|
99
|
+
let removed = false;
|
|
100
|
+
for (const credentialId of credentialIds) {
|
|
101
|
+
removed = (await removeManagedMcpOAuthCredential(authStorage, credentialId)) || removed;
|
|
102
|
+
}
|
|
103
|
+
return removed;
|
|
104
|
+
}
|
package/src/mcp/oauth-flow.ts
CHANGED
|
@@ -9,6 +9,60 @@ import type { OAuthCallbackFlowOptions } from "@oh-my-pi/pi-ai/oauth/callback-se
|
|
|
9
9
|
import { OAuthCallbackFlow } from "@oh-my-pi/pi-ai/oauth/callback-server";
|
|
10
10
|
import type { OAuthController, OAuthCredentials } from "@oh-my-pi/pi-ai/oauth/types";
|
|
11
11
|
import type { FetchImpl } from "@oh-my-pi/pi-ai/types";
|
|
12
|
+
import { getActiveProfile } from "@oh-my-pi/pi-utils/dirs";
|
|
13
|
+
import type { OAuthCredential } from "../session/auth-storage";
|
|
14
|
+
|
|
15
|
+
/** Credential-id prefix for OMP-managed MCP OAuth credentials keyed by profile and server URL. */
|
|
16
|
+
const MCP_OAUTH_URL_CREDENTIAL_PREFIX = "mcp_oauth:";
|
|
17
|
+
|
|
18
|
+
/** Credential-id prefix for profile-scoped MCP OAuth credentials (`mcp_oauth:profile:<profile>:<serverUrl>`). */
|
|
19
|
+
const MCP_OAUTH_PROFILE_CREDENTIAL_PREFIX = `${MCP_OAUTH_URL_CREDENTIAL_PREFIX}profile:`;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Deterministic credential id for an MCP server URL scoped to an OMP profile.
|
|
23
|
+
*
|
|
24
|
+
* Local profile stores are already separate, but auth-broker storage shares one
|
|
25
|
+
* provider namespace across profiles. Including the profile in the provider key
|
|
26
|
+
* keeps a shared project `mcp.json` definition from making profile B overwrite
|
|
27
|
+
* or read profile A's OAuth row for the same server URL. The URL is used
|
|
28
|
+
* verbatim (query string included) because it can carry tenant selectors such
|
|
29
|
+
* as `?project_ref=`.
|
|
30
|
+
*/
|
|
31
|
+
export function mcpOAuthCredentialId(serverUrl: string, profile: string | undefined = getActiveProfile()): string {
|
|
32
|
+
return `${MCP_OAUTH_PROFILE_CREDENTIAL_PREFIX}${profile ?? "default"}:${serverUrl}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Whether a credential id was minted by OMP's MCP OAuth flows (either era). */
|
|
36
|
+
export function isManagedMCPOAuthCredentialId(credentialId: string | undefined): credentialId is string {
|
|
37
|
+
return (
|
|
38
|
+
!!credentialId &&
|
|
39
|
+
(credentialId.startsWith("mcp_oauth_") || credentialId.startsWith(MCP_OAUTH_URL_CREDENTIAL_PREFIX))
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Profile segment of a profile-scoped `mcp_oauth:profile:<profile>:<serverUrl>`
|
|
45
|
+
* credential id, or `undefined` for legacy non-profile-scoped managed ids
|
|
46
|
+
* (`mcp_oauth:<url>`, `mcp_oauth_<rand>`). The server URL itself contains `:`
|
|
47
|
+
* and `/`, so only the segment between the prefix and the FIRST subsequent `:`
|
|
48
|
+
* is the profile; everything after it is the URL.
|
|
49
|
+
*/
|
|
50
|
+
export function mcpOAuthCredentialProfile(credentialId: string): string | undefined {
|
|
51
|
+
if (!credentialId.startsWith(MCP_OAUTH_PROFILE_CREDENTIAL_PREFIX)) return undefined;
|
|
52
|
+
const separator = credentialId.indexOf(":", MCP_OAUTH_PROFILE_CREDENTIAL_PREFIX.length);
|
|
53
|
+
return separator === -1 ? undefined : credentialId.slice(MCP_OAUTH_PROFILE_CREDENTIAL_PREFIX.length, separator);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Stored MCP OAuth credential. Refresh material is embedded so token refresh
|
|
58
|
+
* works without any `auth` block persisted in (possibly shared) config files.
|
|
59
|
+
*/
|
|
60
|
+
export interface MCPStoredOAuthCredential extends OAuthCredential {
|
|
61
|
+
tokenUrl?: string;
|
|
62
|
+
clientId?: string;
|
|
63
|
+
clientSecret?: string;
|
|
64
|
+
resource?: string;
|
|
65
|
+
}
|
|
12
66
|
|
|
13
67
|
const DEFAULT_PORT = 3000;
|
|
14
68
|
const CALLBACK_PATH = "/callback";
|
|
@@ -126,6 +180,15 @@ export interface MCPOAuthConfig {
|
|
|
126
180
|
clientSecret?: string;
|
|
127
181
|
/** OAuth scopes (space-separated) */
|
|
128
182
|
scopes?: string;
|
|
183
|
+
/**
|
|
184
|
+
* `prompt` parameter for the authorization request. Defaults to `"consent"`
|
|
185
|
+
* so the provider always shows its authorize screen instead of silently
|
|
186
|
+
* re-approving the browser's current session — without it, reauthorizing to
|
|
187
|
+
* switch accounts/workspaces is impossible once a session cookie exists
|
|
188
|
+
* (RFC 6749 §3.1 requires servers to ignore the param when unsupported).
|
|
189
|
+
* Set to `""` to omit the parameter entirely.
|
|
190
|
+
*/
|
|
191
|
+
prompt?: string;
|
|
129
192
|
/** Exact redirect URI to advertise to the provider */
|
|
130
193
|
redirectUri?: string;
|
|
131
194
|
/** Custom callback port (default: 3000) */
|
|
@@ -202,6 +265,10 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
|
|
|
202
265
|
if (this.config.scopes && !params.get("scope")) {
|
|
203
266
|
params.set("scope", this.config.scopes);
|
|
204
267
|
}
|
|
268
|
+
const prompt = this.config.prompt ?? "consent";
|
|
269
|
+
if (prompt && !params.get("prompt")) {
|
|
270
|
+
params.set("prompt", prompt);
|
|
271
|
+
}
|
|
205
272
|
const existingResource = params.get("resource")?.trim();
|
|
206
273
|
if (existingResource) {
|
|
207
274
|
this.#resource = resolveResourceUri(existingResource);
|
package/src/mcp/types.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { AgentLifecycleManager } from "../../registry/agent-lifecycle";
|
|
|
28
28
|
import { type AgentRef, AgentRegistry, type AgentStatus, MAIN_AGENT_ID } from "../../registry/agent-registry";
|
|
29
29
|
import type { AgentSession } from "../../session/agent-session";
|
|
30
30
|
import {
|
|
31
|
+
BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE,
|
|
31
32
|
type CustomMessage,
|
|
32
33
|
isSilentAbort,
|
|
33
34
|
LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE,
|
|
@@ -45,6 +46,7 @@ import type { ObservableSession, SessionObserverRegistry } from "../session-obse
|
|
|
45
46
|
import { getEditorTheme, theme } from "../theme/theme";
|
|
46
47
|
import { matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers";
|
|
47
48
|
import { AssistantMessageComponent } from "./assistant-message";
|
|
49
|
+
import { createBackgroundTanDispatchBlock } from "./background-tan-message";
|
|
48
50
|
import { BashExecutionComponent } from "./bash-execution";
|
|
49
51
|
import { BranchSummaryMessageComponent } from "./branch-summary-message";
|
|
50
52
|
import { CollabPromptMessageComponent } from "./collab-prompt-message";
|
|
@@ -1215,6 +1217,10 @@ export class AgentHubOverlayComponent extends Container {
|
|
|
1215
1217
|
this.#chatLog.addChild(card);
|
|
1216
1218
|
return;
|
|
1217
1219
|
}
|
|
1220
|
+
if (message.customType === BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE) {
|
|
1221
|
+
this.#chatLog.addChild(createBackgroundTanDispatchBlock(message as CustomMessage<unknown>));
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1218
1224
|
const handoffComponent = createHandoffSummaryMessageComponent(
|
|
1219
1225
|
message as CustomMessage<unknown>,
|
|
1220
1226
|
this.#chatExpanded,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Text } from "@oh-my-pi/pi-tui";
|
|
2
|
+
import type { BackgroundTanDispatchDetails, CustomMessage } from "../../session/messages";
|
|
3
|
+
import { replaceTabs } from "../../tools/render-utils";
|
|
4
|
+
import { theme } from "../theme/theme";
|
|
5
|
+
import { TranscriptBlock } from "./transcript-container";
|
|
6
|
+
|
|
7
|
+
const TAN_WORK_PREVIEW_LENGTH = 56;
|
|
8
|
+
|
|
9
|
+
function previewWork(work: string): string {
|
|
10
|
+
const singleLine = replaceTabs(work).trim().replace(/\s+/g, " ");
|
|
11
|
+
if (singleLine.length <= TAN_WORK_PREVIEW_LENGTH) return singleLine;
|
|
12
|
+
return `${singleLine.slice(0, TAN_WORK_PREVIEW_LENGTH - 1)}…`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Single-line transcript pill for a `/tan` background-dispatch breadcrumb,
|
|
17
|
+
* styled as a sibling of the "Background job completed" line. The full
|
|
18
|
+
* system-notice content (the persisted `content`) is for the model only — the
|
|
19
|
+
* user sees one compact line, not the raw `<system-notice>` block.
|
|
20
|
+
*/
|
|
21
|
+
export function createBackgroundTanDispatchBlock(message: CustomMessage<unknown>): TranscriptBlock {
|
|
22
|
+
const details = (message as CustomMessage<Partial<BackgroundTanDispatchDetails>>).details;
|
|
23
|
+
const jobId = details?.jobId ?? "unknown";
|
|
24
|
+
const work = details?.work ? previewWork(details.work) : undefined;
|
|
25
|
+
const line = [
|
|
26
|
+
theme.fg("muted", `${theme.icon.output} Tangent dispatched`),
|
|
27
|
+
theme.fg("dim", "[task]"),
|
|
28
|
+
theme.fg("accent", jobId),
|
|
29
|
+
work ? theme.fg("dim", `${theme.format.dash} ${work}`) : undefined,
|
|
30
|
+
]
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.join(" ");
|
|
33
|
+
const block = new TranscriptBlock();
|
|
34
|
+
block.addChild(new Text(line, 1, 0));
|
|
35
|
+
return block;
|
|
36
|
+
}
|
|
@@ -49,14 +49,19 @@ type WizardStep =
|
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
51
|
* Result of the wizard's OAuth callback. `credentialId` is mandatory;
|
|
52
|
-
* `clientId
|
|
53
|
-
*
|
|
54
|
-
*
|
|
52
|
+
* `clientId` is populated when the OAuth provider performed dynamic client
|
|
53
|
+
* registration (or when the caller pre-supplied it) so the wizard can fold it
|
|
54
|
+
* into the final `mcp.json` entry. Refresh material (including any DCR client
|
|
55
|
+
* secret) is embedded in the stored credential, never written to config files.
|
|
55
56
|
*/
|
|
56
57
|
export interface MCPAddWizardOAuthResult {
|
|
57
58
|
credentialId: string;
|
|
58
59
|
clientId?: string;
|
|
59
|
-
|
|
60
|
+
resource?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface MCPAddWizardOAuthOptions {
|
|
64
|
+
serverUrl?: string;
|
|
60
65
|
resource?: string;
|
|
61
66
|
}
|
|
62
67
|
|
|
@@ -125,7 +130,7 @@ export class MCPAddWizard extends Container {
|
|
|
125
130
|
clientId: string,
|
|
126
131
|
clientSecret: string,
|
|
127
132
|
scopes: string,
|
|
128
|
-
|
|
133
|
+
options?: MCPAddWizardOAuthOptions,
|
|
129
134
|
) => Promise<MCPAddWizardOAuthResult>)
|
|
130
135
|
| null = null;
|
|
131
136
|
#onTestConnectionCallback: ((config: MCPServerConfig) => Promise<void>) | null = null;
|
|
@@ -140,7 +145,7 @@ export class MCPAddWizard extends Container {
|
|
|
140
145
|
clientId: string,
|
|
141
146
|
clientSecret: string,
|
|
142
147
|
scopes: string,
|
|
143
|
-
|
|
148
|
+
options?: MCPAddWizardOAuthOptions,
|
|
144
149
|
) => Promise<MCPAddWizardOAuthResult>,
|
|
145
150
|
onTestConnection?: (config: MCPServerConfig) => Promise<void>,
|
|
146
151
|
onRender?: () => void,
|
|
@@ -1157,14 +1162,16 @@ export class MCPAddWizard extends Container {
|
|
|
1157
1162
|
this.#state.oauthClientId,
|
|
1158
1163
|
this.#state.oauthClientSecret,
|
|
1159
1164
|
this.#state.oauthScopes,
|
|
1160
|
-
|
|
1165
|
+
{
|
|
1166
|
+
serverUrl: this.#state.url || undefined,
|
|
1167
|
+
resource: oauthResource || undefined,
|
|
1168
|
+
},
|
|
1161
1169
|
);
|
|
1162
1170
|
|
|
1163
|
-
// Store credential ID + any dynamically-registered client
|
|
1164
|
-
//
|
|
1171
|
+
// Store credential ID + any dynamically-registered client id. DCR client
|
|
1172
|
+
// secrets stay embedded in the stored credential, never in mcp.json.
|
|
1165
1173
|
this.#state.oauthCredentialId = oauthResult.credentialId;
|
|
1166
1174
|
if (oauthResult.clientId) this.#state.oauthClientId = oauthResult.clientId;
|
|
1167
|
-
if (oauthResult.clientSecret) this.#state.oauthClientSecret = oauthResult.clientSecret;
|
|
1168
1175
|
this.#state.oauthResource = oauthResult.resource ?? oauthResource;
|
|
1169
1176
|
|
|
1170
1177
|
// Show success message
|
|
@@ -936,7 +936,8 @@ export class ModelSelectorComponent extends Container {
|
|
|
936
936
|
// Build role badges. Solid badges are configured; outlined badges are auto-selected defaults.
|
|
937
937
|
const roleBadgeTokens: string[] = [];
|
|
938
938
|
for (const role of MODEL_ROLE_IDS) {
|
|
939
|
-
const { tag, color } = getRoleInfo(role, this.#settings);
|
|
939
|
+
const { tag, color, hidden } = getRoleInfo(role, this.#settings);
|
|
940
|
+
if (hidden) continue;
|
|
940
941
|
const assigned = this.#roles[role];
|
|
941
942
|
if (!tag || !assigned || !modelsAreEqual(assigned.model, item.model)) continue;
|
|
942
943
|
|
|
@@ -1053,6 +1054,10 @@ export class ModelSelectorComponent extends Container {
|
|
|
1053
1054
|
this.#menuStep = "role";
|
|
1054
1055
|
this.#menuSelectedRole = null;
|
|
1055
1056
|
this.#menuSelectedIndex = 0;
|
|
1057
|
+
// Collapse the model list while the action/thinking menu is open so the
|
|
1058
|
+
// menu owns the full viewport instead of stacking below a now-irrelevant
|
|
1059
|
+
// (and often off-screen) list.
|
|
1060
|
+
this.#listContainer.clear();
|
|
1056
1061
|
this.#updateMenu();
|
|
1057
1062
|
}
|
|
1058
1063
|
|
|
@@ -1061,6 +1066,8 @@ export class ModelSelectorComponent extends Container {
|
|
|
1061
1066
|
this.#menuStep = "role";
|
|
1062
1067
|
this.#menuSelectedRole = null;
|
|
1063
1068
|
this.#menuContainer.clear();
|
|
1069
|
+
// Restore the model list that #openMenu collapsed.
|
|
1070
|
+
this.#updateList();
|
|
1064
1071
|
}
|
|
1065
1072
|
|
|
1066
1073
|
#updateMenu(): void {
|
|
@@ -1088,11 +1095,21 @@ export class ModelSelectorComponent extends Container {
|
|
|
1088
1095
|
? ` Thinking for: ${selectedRoleName} (${selectedItem.id})`
|
|
1089
1096
|
: ` Action for: ${selectedItem.id}`;
|
|
1090
1097
|
const hintText = showingThinking ? " Enter: confirm Esc: back" : " Enter: continue Esc: cancel";
|
|
1091
|
-
|
|
1098
|
+
// Window the option list so a long action/thinking menu scrolls inside the
|
|
1099
|
+
// viewport instead of running off the bottom of the screen.
|
|
1100
|
+
const maxVisible = this.#getMenuVisibleCount(optionLines.length);
|
|
1101
|
+
const needsScroll = optionLines.length > maxVisible;
|
|
1102
|
+
const startIndex = needsScroll
|
|
1103
|
+
? Math.max(0, Math.min(this.#menuSelectedIndex - Math.floor(maxVisible / 2), optionLines.length - maxVisible))
|
|
1104
|
+
: 0;
|
|
1105
|
+
const endIndex = needsScroll ? startIndex + maxVisible : optionLines.length;
|
|
1106
|
+
const contentWidth = Math.max(
|
|
1092
1107
|
visibleWidth(headerText),
|
|
1093
1108
|
visibleWidth(hintText),
|
|
1094
1109
|
...optionLines.map(line => visibleWidth(line)),
|
|
1095
1110
|
);
|
|
1111
|
+
// Reserve one column for the scrollbar when the list overflows.
|
|
1112
|
+
const menuWidth = contentWidth + (needsScroll ? 1 : 0);
|
|
1096
1113
|
|
|
1097
1114
|
this.#menuContainer.addChild(new Spacer(1));
|
|
1098
1115
|
this.#menuContainer.addChild(new Text(theme.fg("border", theme.boxSharp.horizontal.repeat(menuWidth)), 0, 0));
|
|
@@ -1109,12 +1126,28 @@ export class ModelSelectorComponent extends Container {
|
|
|
1109
1126
|
}
|
|
1110
1127
|
this.#menuContainer.addChild(new Spacer(1));
|
|
1111
1128
|
|
|
1112
|
-
|
|
1129
|
+
const visibleRows: string[] = [];
|
|
1130
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
1113
1131
|
const lineText = optionLines[i];
|
|
1114
|
-
if (
|
|
1132
|
+
if (lineText === undefined) continue;
|
|
1115
1133
|
const isSelected = i === this.#menuSelectedIndex;
|
|
1116
|
-
|
|
1117
|
-
|
|
1134
|
+
visibleRows.push(isSelected ? theme.fg("accent", lineText) : theme.fg("muted", lineText));
|
|
1135
|
+
}
|
|
1136
|
+
if (needsScroll) {
|
|
1137
|
+
const sv = new ScrollView(visibleRows, {
|
|
1138
|
+
height: visibleRows.length,
|
|
1139
|
+
scrollbar: "auto",
|
|
1140
|
+
totalRows: optionLines.length,
|
|
1141
|
+
theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
|
|
1142
|
+
});
|
|
1143
|
+
sv.setScrollOffset(startIndex);
|
|
1144
|
+
for (const row of sv.render(menuWidth)) {
|
|
1145
|
+
this.#menuContainer.addChild(new Text(row, 0, 0));
|
|
1146
|
+
}
|
|
1147
|
+
} else {
|
|
1148
|
+
for (const row of visibleRows) {
|
|
1149
|
+
this.#menuContainer.addChild(new Text(row, 0, 0));
|
|
1150
|
+
}
|
|
1118
1151
|
}
|
|
1119
1152
|
|
|
1120
1153
|
this.#menuContainer.addChild(new Spacer(1));
|
|
@@ -1122,6 +1155,17 @@ export class ModelSelectorComponent extends Container {
|
|
|
1122
1155
|
this.#menuContainer.addChild(new Text(theme.fg("border", theme.boxSharp.horizontal.repeat(menuWidth)), 0, 0));
|
|
1123
1156
|
}
|
|
1124
1157
|
|
|
1158
|
+
#getMenuVisibleCount(optionCount: number): number {
|
|
1159
|
+
// Rows the selector chrome and the menu's own header/hint/borders/spacers
|
|
1160
|
+
// consume, leaving the remainder of the viewport for the scrollable option
|
|
1161
|
+
// window. Without a known terminal height (e.g. tests) show every option.
|
|
1162
|
+
const MENU_CHROME_ROWS = 19;
|
|
1163
|
+
const MIN_VISIBLE_OPTIONS = 4;
|
|
1164
|
+
const terminalRows = this.#tui.terminal?.rows ?? 0;
|
|
1165
|
+
if (!Number.isFinite(terminalRows) || terminalRows <= 0) return optionCount;
|
|
1166
|
+
return Math.max(MIN_VISIBLE_OPTIONS, Math.min(optionCount, terminalRows - MENU_CHROME_ROWS));
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1125
1169
|
handleInput(keyData: string): void {
|
|
1126
1170
|
if (this.#isMenuOpen) {
|
|
1127
1171
|
this.#handleMenuInput(keyData);
|