@oh-my-pi/pi-coding-agent 13.5.8 → 13.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -1
- package/package.json +7 -7
- package/src/cli/args.ts +7 -0
- package/src/cli/stats-cli.ts +5 -0
- package/src/config/model-registry.ts +99 -9
- package/src/config/settings-schema.ts +22 -2
- package/src/extensibility/extensions/types.ts +2 -0
- package/src/internal-urls/docs-index.generated.ts +2 -2
- package/src/internal-urls/index.ts +2 -1
- package/src/internal-urls/mcp-protocol.ts +156 -0
- package/src/internal-urls/router.ts +1 -1
- package/src/internal-urls/types.ts +3 -3
- package/src/mcp/client.ts +235 -2
- package/src/mcp/index.ts +1 -1
- package/src/mcp/manager.ts +399 -5
- package/src/mcp/oauth-flow.ts +26 -1
- package/src/mcp/smithery-auth.ts +104 -0
- package/src/mcp/smithery-connect.ts +145 -0
- package/src/mcp/smithery-registry.ts +455 -0
- package/src/mcp/types.ts +140 -0
- package/src/modes/components/footer.ts +10 -4
- package/src/modes/components/settings-defs.ts +15 -1
- package/src/modes/components/status-line/git-utils.ts +42 -0
- package/src/modes/components/status-line/presets.ts +6 -6
- package/src/modes/components/status-line/segments.ts +27 -4
- package/src/modes/components/status-line/types.ts +2 -0
- package/src/modes/components/status-line-segment-editor.ts +1 -0
- package/src/modes/components/status-line.ts +109 -5
- package/src/modes/controllers/command-controller.ts +12 -2
- package/src/modes/controllers/extension-ui-controller.ts +12 -21
- package/src/modes/controllers/mcp-command-controller.ts +577 -14
- package/src/modes/controllers/selector-controller.ts +5 -0
- package/src/modes/theme/theme.ts +6 -0
- package/src/prompts/tools/hashline.md +4 -3
- package/src/sdk.ts +115 -3
- package/src/session/agent-session.ts +19 -4
- package/src/session/session-manager.ts +17 -5
- package/src/slash-commands/builtin-registry.ts +10 -0
- package/src/task/executor.ts +37 -3
- package/src/task/index.ts +37 -5
- package/src/task/isolation-backend.ts +72 -0
- package/src/task/render.ts +6 -1
- package/src/task/types.ts +1 -0
- package/src/task/worktree.ts +67 -5
- package/src/tools/index.ts +1 -1
- package/src/tools/path-utils.ts +2 -1
- package/src/tools/read.ts +3 -7
- package/src/utils/open.ts +1 -1
package/src/mcp/manager.ts
CHANGED
|
@@ -10,12 +10,36 @@ import type { SourceMeta } from "../capability/types";
|
|
|
10
10
|
import { resolveConfigValue } from "../config/resolve-config-value";
|
|
11
11
|
import type { CustomTool } from "../extensibility/custom-tools/types";
|
|
12
12
|
import type { AuthStorage } from "../session/auth-storage";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
connectToServer,
|
|
15
|
+
disconnectServer,
|
|
16
|
+
getPrompt,
|
|
17
|
+
listPrompts,
|
|
18
|
+
listResources,
|
|
19
|
+
listResourceTemplates,
|
|
20
|
+
listTools,
|
|
21
|
+
readResource,
|
|
22
|
+
serverSupportsPrompts,
|
|
23
|
+
serverSupportsResources,
|
|
24
|
+
subscribeToResources,
|
|
25
|
+
unsubscribeFromResources,
|
|
26
|
+
} from "./client";
|
|
14
27
|
import { loadAllMCPConfigs, validateServerConfig } from "./config";
|
|
15
28
|
import type { MCPToolDetails } from "./tool-bridge";
|
|
16
29
|
import { DeferredMCPTool, MCPTool } from "./tool-bridge";
|
|
17
30
|
import type { MCPToolCache } from "./tool-cache";
|
|
18
|
-
import type {
|
|
31
|
+
import type {
|
|
32
|
+
MCPGetPromptResult,
|
|
33
|
+
MCPPrompt,
|
|
34
|
+
MCPRequestOptions,
|
|
35
|
+
MCPResource,
|
|
36
|
+
MCPResourceReadResult,
|
|
37
|
+
MCPResourceTemplate,
|
|
38
|
+
MCPServerConfig,
|
|
39
|
+
MCPServerConnection,
|
|
40
|
+
MCPToolDefinition,
|
|
41
|
+
} from "./types";
|
|
42
|
+
import { MCPNotificationMethods } from "./types";
|
|
19
43
|
|
|
20
44
|
type ToolLoadResult = {
|
|
21
45
|
connection: MCPServerConnection;
|
|
@@ -50,6 +74,15 @@ function delay(ms: number): Promise<void> {
|
|
|
50
74
|
return Bun.sleep(ms);
|
|
51
75
|
}
|
|
52
76
|
|
|
77
|
+
export function resolveSubscriptionPostAction(
|
|
78
|
+
notificationsEnabled: boolean,
|
|
79
|
+
currentEpoch: number,
|
|
80
|
+
subscriptionEpoch: number,
|
|
81
|
+
): "rollback" | "ignore" | "apply" {
|
|
82
|
+
if (!notificationsEnabled) return "rollback";
|
|
83
|
+
if (currentEpoch !== subscriptionEpoch) return "ignore";
|
|
84
|
+
return "apply";
|
|
85
|
+
}
|
|
53
86
|
/** Result of loading MCP tools */
|
|
54
87
|
export interface MCPLoadResult {
|
|
55
88
|
/** Loaded tools as CustomTool instances */
|
|
@@ -86,12 +119,108 @@ export class MCPManager {
|
|
|
86
119
|
#pendingToolLoads = new Map<string, Promise<ToolLoadResult>>();
|
|
87
120
|
#sources = new Map<string, SourceMeta>();
|
|
88
121
|
#authStorage: AuthStorage | null = null;
|
|
122
|
+
#onNotification?: (serverName: string, method: string, params: unknown) => void;
|
|
123
|
+
#onToolsChanged?: (tools: CustomTool<TSchema, MCPToolDetails>[]) => void;
|
|
124
|
+
#onResourcesChanged?: (serverName: string, uri: string) => void;
|
|
125
|
+
#onPromptsChanged?: (serverName: string) => void;
|
|
126
|
+
#notificationsEnabled = false;
|
|
127
|
+
#notificationsEpoch = 0;
|
|
128
|
+
#subscribedResources = new Map<string, Set<string>>();
|
|
129
|
+
#pendingResourceRefresh = new Map<string, Promise<void>>();
|
|
89
130
|
|
|
90
131
|
constructor(
|
|
91
132
|
private cwd: string,
|
|
92
133
|
private toolCache: MCPToolCache | null = null,
|
|
93
134
|
) {}
|
|
94
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Set a callback to receive all server notifications.
|
|
138
|
+
*/
|
|
139
|
+
setOnNotification(handler: (serverName: string, method: string, params: unknown) => void): void {
|
|
140
|
+
this.#onNotification = handler;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Set a callback to fire when any server's tools change.
|
|
145
|
+
*/
|
|
146
|
+
setOnToolsChanged(handler: (tools: CustomTool<TSchema, MCPToolDetails>[]) => void): void {
|
|
147
|
+
this.#onToolsChanged = handler;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Set a callback to fire when any server's resources change.
|
|
152
|
+
*/
|
|
153
|
+
setOnResourcesChanged(handler: (serverName: string, uri: string) => void): void {
|
|
154
|
+
this.#onResourcesChanged = handler;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Set a callback to fire when any server's prompts change.
|
|
159
|
+
*/
|
|
160
|
+
setOnPromptsChanged(handler: (serverName: string) => void): void {
|
|
161
|
+
this.#onPromptsChanged = handler;
|
|
162
|
+
// Fire immediately for servers that already have prompts loaded
|
|
163
|
+
for (const [name, connection] of this.#connections) {
|
|
164
|
+
if (connection.prompts?.length) {
|
|
165
|
+
handler(name);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
setNotificationsEnabled(enabled: boolean): void {
|
|
171
|
+
const wasEnabled = this.#notificationsEnabled;
|
|
172
|
+
this.#notificationsEnabled = enabled;
|
|
173
|
+
if (enabled === wasEnabled) return;
|
|
174
|
+
|
|
175
|
+
this.#notificationsEpoch += 1;
|
|
176
|
+
const notificationEpoch = this.#notificationsEpoch;
|
|
177
|
+
|
|
178
|
+
if (enabled) {
|
|
179
|
+
// Subscribe to all connected servers that support it
|
|
180
|
+
for (const [name, connection] of this.#connections) {
|
|
181
|
+
if (connection.capabilities.resources?.subscribe && connection.resources) {
|
|
182
|
+
const uris = connection.resources.map(r => r.uri);
|
|
183
|
+
void subscribeToResources(connection, uris)
|
|
184
|
+
.then(() => {
|
|
185
|
+
const action = resolveSubscriptionPostAction(
|
|
186
|
+
this.#notificationsEnabled,
|
|
187
|
+
this.#notificationsEpoch,
|
|
188
|
+
notificationEpoch,
|
|
189
|
+
);
|
|
190
|
+
if (action === "rollback") {
|
|
191
|
+
void unsubscribeFromResources(connection, uris).catch(error => {
|
|
192
|
+
logger.debug("Failed to rollback stale MCP resource subscription", {
|
|
193
|
+
path: `mcp:${name}`,
|
|
194
|
+
error,
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (action === "ignore") {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
this.#subscribedResources.set(name, new Set(uris));
|
|
203
|
+
})
|
|
204
|
+
.catch(error => {
|
|
205
|
+
logger.debug("Failed to subscribe to MCP resources", { path: `mcp:${name}`, error });
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Unsubscribe from all servers
|
|
213
|
+
for (const [name, connection] of this.#connections) {
|
|
214
|
+
const uris = this.#subscribedResources.get(name);
|
|
215
|
+
if (uris && uris.size > 0) {
|
|
216
|
+
void unsubscribeFromResources(connection, Array.from(uris)).catch(error => {
|
|
217
|
+
logger.debug("Failed to unsubscribe MCP resources", { path: `mcp:${name}`, error });
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
this.#subscribedResources.clear();
|
|
222
|
+
}
|
|
223
|
+
|
|
95
224
|
/**
|
|
96
225
|
* Set the auth storage for resolving OAuth credentials.
|
|
97
226
|
*/
|
|
@@ -169,7 +298,11 @@ export class MCPManager {
|
|
|
169
298
|
// Resolve auth config before connecting, but do so per-server in parallel.
|
|
170
299
|
const connectionPromise = (async () => {
|
|
171
300
|
const resolvedConfig = await this.#resolveAuthConfig(config);
|
|
172
|
-
return connectToServer(name, resolvedConfig
|
|
301
|
+
return connectToServer(name, resolvedConfig, {
|
|
302
|
+
onNotification: (method, params) => {
|
|
303
|
+
this.#handleServerNotification(name, method, params);
|
|
304
|
+
},
|
|
305
|
+
});
|
|
173
306
|
})().then(
|
|
174
307
|
connection => {
|
|
175
308
|
// Store original config (without resolved tokens) to keep
|
|
@@ -203,12 +336,64 @@ export class MCPManager {
|
|
|
203
336
|
connectionTasks.push({ name, config, tracked, toolsPromise });
|
|
204
337
|
|
|
205
338
|
void toolsPromise
|
|
206
|
-
.then(({ connection, serverTools }) => {
|
|
339
|
+
.then(async ({ connection, serverTools }) => {
|
|
207
340
|
if (this.#pendingToolLoads.get(name) !== toolsPromise) return;
|
|
208
341
|
this.#pendingToolLoads.delete(name);
|
|
209
342
|
const customTools = MCPTool.fromTools(connection, serverTools);
|
|
210
343
|
this.#replaceServerTools(name, customTools);
|
|
344
|
+
this.#onToolsChanged?.(this.#tools);
|
|
211
345
|
void this.toolCache?.set(name, config, serverTools);
|
|
346
|
+
|
|
347
|
+
// Load resources and create resource tool (best-effort)
|
|
348
|
+
if (serverSupportsResources(connection.capabilities)) {
|
|
349
|
+
try {
|
|
350
|
+
const [resources] = await Promise.all([
|
|
351
|
+
listResources(connection),
|
|
352
|
+
listResourceTemplates(connection),
|
|
353
|
+
]);
|
|
354
|
+
|
|
355
|
+
if (this.#notificationsEnabled && connection.capabilities.resources?.subscribe) {
|
|
356
|
+
const uris = resources.map(r => r.uri);
|
|
357
|
+
const notificationEpoch = this.#notificationsEpoch;
|
|
358
|
+
void subscribeToResources(connection, uris)
|
|
359
|
+
.then(() => {
|
|
360
|
+
const action = resolveSubscriptionPostAction(
|
|
361
|
+
this.#notificationsEnabled,
|
|
362
|
+
this.#notificationsEpoch,
|
|
363
|
+
notificationEpoch,
|
|
364
|
+
);
|
|
365
|
+
if (action === "rollback") {
|
|
366
|
+
void unsubscribeFromResources(connection, uris).catch(error => {
|
|
367
|
+
logger.debug("Failed to rollback stale MCP resource subscription", {
|
|
368
|
+
path: `mcp:${name}`,
|
|
369
|
+
error,
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (action === "ignore") {
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
this.#subscribedResources.set(name, new Set(uris));
|
|
378
|
+
})
|
|
379
|
+
.catch(error => {
|
|
380
|
+
logger.debug("Failed to subscribe to MCP resources", { path: `mcp:${name}`, error });
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
} catch (error) {
|
|
384
|
+
logger.debug("Failed to load MCP resources", { path: `mcp:${name}`, error });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Load prompts (best-effort)
|
|
389
|
+
if (serverSupportsPrompts(connection.capabilities)) {
|
|
390
|
+
try {
|
|
391
|
+
await listPrompts(connection);
|
|
392
|
+
this.#onPromptsChanged?.(name);
|
|
393
|
+
} catch (error) {
|
|
394
|
+
logger.debug("Failed to load MCP prompts", { path: `mcp:${name}`, error });
|
|
395
|
+
}
|
|
396
|
+
}
|
|
212
397
|
})
|
|
213
398
|
.catch(error => {
|
|
214
399
|
if (this.#pendingToolLoads.get(name) !== toolsPromise) return;
|
|
@@ -291,6 +476,49 @@ export class MCPManager {
|
|
|
291
476
|
this.#tools.push(...tools);
|
|
292
477
|
}
|
|
293
478
|
|
|
479
|
+
#triggerNotificationRefresh(serverName: string, kind: "tools" | "resources" | "prompts"): void {
|
|
480
|
+
const refresh = (() => {
|
|
481
|
+
switch (kind) {
|
|
482
|
+
case "tools":
|
|
483
|
+
return this.refreshServerTools(serverName);
|
|
484
|
+
case "resources":
|
|
485
|
+
return this.refreshServerResources(serverName);
|
|
486
|
+
case "prompts":
|
|
487
|
+
return this.refreshServerPrompts(serverName);
|
|
488
|
+
}
|
|
489
|
+
})();
|
|
490
|
+
void refresh.catch(error => {
|
|
491
|
+
logger.debug("Failed MCP notification refresh", { path: `mcp:${serverName}`, kind, error });
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
#handleServerNotification(serverName: string, method: string, params: unknown): void {
|
|
495
|
+
logger.debug("MCP notification received", { path: `mcp:${serverName}`, method });
|
|
496
|
+
|
|
497
|
+
switch (method) {
|
|
498
|
+
case MCPNotificationMethods.TOOLS_LIST_CHANGED:
|
|
499
|
+
this.#triggerNotificationRefresh(serverName, "tools");
|
|
500
|
+
break;
|
|
501
|
+
case MCPNotificationMethods.RESOURCES_LIST_CHANGED:
|
|
502
|
+
this.#triggerNotificationRefresh(serverName, "resources");
|
|
503
|
+
break;
|
|
504
|
+
case MCPNotificationMethods.RESOURCES_UPDATED: {
|
|
505
|
+
const uri = (params as { uri?: string })?.uri;
|
|
506
|
+
const subscribed = this.#subscribedResources.get(serverName);
|
|
507
|
+
if (uri && subscribed?.has(uri)) {
|
|
508
|
+
this.#onResourcesChanged?.(serverName, uri);
|
|
509
|
+
}
|
|
510
|
+
break;
|
|
511
|
+
}
|
|
512
|
+
case MCPNotificationMethods.PROMPTS_LIST_CHANGED:
|
|
513
|
+
this.#triggerNotificationRefresh(serverName, "prompts");
|
|
514
|
+
break;
|
|
515
|
+
default:
|
|
516
|
+
break;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
this.#onNotification?.(serverName, method, params);
|
|
520
|
+
}
|
|
521
|
+
|
|
294
522
|
/**
|
|
295
523
|
* Get all loaded tools.
|
|
296
524
|
*/
|
|
@@ -364,13 +592,25 @@ export class MCPManager {
|
|
|
364
592
|
this.#sources.delete(name);
|
|
365
593
|
|
|
366
594
|
const connection = this.#connections.get(name);
|
|
595
|
+
|
|
596
|
+
const subscribedUris = this.#subscribedResources.get(name);
|
|
597
|
+
if (subscribedUris && subscribedUris.size > 0 && connection) {
|
|
598
|
+
void unsubscribeFromResources(connection, Array.from(subscribedUris)).catch(() => {});
|
|
599
|
+
}
|
|
600
|
+
this.#subscribedResources.delete(name);
|
|
601
|
+
|
|
367
602
|
if (connection) {
|
|
368
603
|
await disconnectServer(connection);
|
|
369
604
|
this.#connections.delete(name);
|
|
370
605
|
}
|
|
371
606
|
|
|
372
|
-
// Remove tools from this server
|
|
607
|
+
// Remove tools from this server and notify consumers
|
|
608
|
+
const hadTools = this.#tools.some(t => t.name.startsWith(`mcp_${name}_`));
|
|
373
609
|
this.#tools = this.#tools.filter(t => !t.name.startsWith(`mcp_${name}_`));
|
|
610
|
+
if (hadTools) this.#onToolsChanged?.(this.#tools);
|
|
611
|
+
|
|
612
|
+
// Notify prompt consumers so stale commands are cleared
|
|
613
|
+
if (connection?.prompts?.length) this.#onPromptsChanged?.(name);
|
|
374
614
|
}
|
|
375
615
|
|
|
376
616
|
/**
|
|
@@ -385,6 +625,7 @@ export class MCPManager {
|
|
|
385
625
|
this.#sources.clear();
|
|
386
626
|
this.#connections.clear();
|
|
387
627
|
this.#tools = [];
|
|
628
|
+
this.#subscribedResources.clear();
|
|
388
629
|
}
|
|
389
630
|
|
|
390
631
|
/**
|
|
@@ -404,6 +645,7 @@ export class MCPManager {
|
|
|
404
645
|
|
|
405
646
|
// Replace tools from this server
|
|
406
647
|
this.#replaceServerTools(name, customTools);
|
|
648
|
+
this.#onToolsChanged?.(this.#tools);
|
|
407
649
|
}
|
|
408
650
|
|
|
409
651
|
/**
|
|
@@ -414,6 +656,158 @@ export class MCPManager {
|
|
|
414
656
|
await Promise.allSettled(promises);
|
|
415
657
|
}
|
|
416
658
|
|
|
659
|
+
/**
|
|
660
|
+
* Refresh resources from a specific server.
|
|
661
|
+
*/
|
|
662
|
+
async refreshServerResources(name: string): Promise<void> {
|
|
663
|
+
const existing = this.#pendingResourceRefresh.get(name);
|
|
664
|
+
if (existing) return existing;
|
|
665
|
+
|
|
666
|
+
const doRefresh = async (): Promise<void> => {
|
|
667
|
+
const connection = this.#connections.get(name);
|
|
668
|
+
if (!connection || !serverSupportsResources(connection.capabilities)) return;
|
|
669
|
+
|
|
670
|
+
// Clear cached resources
|
|
671
|
+
connection.resources = undefined;
|
|
672
|
+
connection.resourceTemplates = undefined;
|
|
673
|
+
|
|
674
|
+
// Reload
|
|
675
|
+
const [resources] = await Promise.all([listResources(connection), listResourceTemplates(connection)]);
|
|
676
|
+
if (this.#notificationsEnabled && connection.capabilities.resources?.subscribe) {
|
|
677
|
+
const newUris = new Set(resources.map(r => r.uri));
|
|
678
|
+
const oldUris = this.#subscribedResources.get(name);
|
|
679
|
+
const notificationEpoch = this.#notificationsEpoch;
|
|
680
|
+
|
|
681
|
+
// Unsubscribe URIs that were removed
|
|
682
|
+
if (oldUris) {
|
|
683
|
+
const removed = [...oldUris].filter(uri => !newUris.has(uri));
|
|
684
|
+
if (removed.length > 0) {
|
|
685
|
+
try {
|
|
686
|
+
await unsubscribeFromResources(connection, removed);
|
|
687
|
+
} catch (error) {
|
|
688
|
+
logger.debug("Failed to unsubscribe stale MCP resources", { path: `mcp:${name}`, error });
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Subscribe to the current set and update tracking atomically
|
|
694
|
+
try {
|
|
695
|
+
const allUris = [...newUris];
|
|
696
|
+
await subscribeToResources(connection, allUris);
|
|
697
|
+
const action = resolveSubscriptionPostAction(
|
|
698
|
+
this.#notificationsEnabled,
|
|
699
|
+
this.#notificationsEpoch,
|
|
700
|
+
notificationEpoch,
|
|
701
|
+
);
|
|
702
|
+
if (action === "rollback") {
|
|
703
|
+
await unsubscribeFromResources(connection, allUris).catch(error => {
|
|
704
|
+
logger.debug("Failed to rollback stale MCP resource subscription", { path: `mcp:${name}`, error });
|
|
705
|
+
});
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
if (action === "ignore") {
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
this.#subscribedResources.set(name, newUris);
|
|
712
|
+
} catch (error) {
|
|
713
|
+
logger.debug("Failed to re-subscribe to MCP resources", { path: `mcp:${name}`, error });
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
const promise = doRefresh().finally(() => {
|
|
719
|
+
if (this.#pendingResourceRefresh.get(name) === promise) {
|
|
720
|
+
this.#pendingResourceRefresh.delete(name);
|
|
721
|
+
}
|
|
722
|
+
});
|
|
723
|
+
this.#pendingResourceRefresh.set(name, promise);
|
|
724
|
+
return promise;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Refresh prompts from a specific server.
|
|
729
|
+
*/
|
|
730
|
+
async refreshServerPrompts(name: string): Promise<void> {
|
|
731
|
+
const connection = this.#connections.get(name);
|
|
732
|
+
if (!connection || !serverSupportsPrompts(connection.capabilities)) return;
|
|
733
|
+
|
|
734
|
+
connection.prompts = undefined;
|
|
735
|
+
await listPrompts(connection);
|
|
736
|
+
|
|
737
|
+
this.#onPromptsChanged?.(name);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Get resources and templates for a specific server.
|
|
742
|
+
*/
|
|
743
|
+
getServerResources(name: string): { resources: MCPResource[]; templates: MCPResourceTemplate[] } | undefined {
|
|
744
|
+
const connection = this.#connections.get(name);
|
|
745
|
+
if (!connection) return undefined;
|
|
746
|
+
return {
|
|
747
|
+
resources: connection.resources ?? [],
|
|
748
|
+
templates: connection.resourceTemplates ?? [],
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Read a specific resource from a server.
|
|
754
|
+
*/
|
|
755
|
+
async readServerResource(
|
|
756
|
+
name: string,
|
|
757
|
+
uri: string,
|
|
758
|
+
options?: MCPRequestOptions,
|
|
759
|
+
): Promise<MCPResourceReadResult | undefined> {
|
|
760
|
+
const connection = this.#connections.get(name);
|
|
761
|
+
if (!connection) return undefined;
|
|
762
|
+
return readResource(connection, uri, options);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Get prompts for a specific server.
|
|
767
|
+
*/
|
|
768
|
+
getServerPrompts(name: string): MCPPrompt[] | undefined {
|
|
769
|
+
const connection = this.#connections.get(name);
|
|
770
|
+
if (!connection) return undefined;
|
|
771
|
+
return connection.prompts ?? [];
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Get a specific prompt from a server.
|
|
776
|
+
*/
|
|
777
|
+
async executePrompt(
|
|
778
|
+
name: string,
|
|
779
|
+
promptName: string,
|
|
780
|
+
args?: Record<string, string>,
|
|
781
|
+
options?: MCPRequestOptions,
|
|
782
|
+
): Promise<MCPGetPromptResult | undefined> {
|
|
783
|
+
const connection = this.#connections.get(name);
|
|
784
|
+
if (!connection) return undefined;
|
|
785
|
+
return getPrompt(connection, promptName, args, options);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Get all server instructions (for system prompt injection).
|
|
790
|
+
*/
|
|
791
|
+
getServerInstructions(): Map<string, string> {
|
|
792
|
+
const instructions = new Map<string, string>();
|
|
793
|
+
for (const [name, connection] of this.#connections) {
|
|
794
|
+
if (connection.instructions) {
|
|
795
|
+
instructions.set(name, connection.instructions);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
return instructions;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Get notification state for display.
|
|
803
|
+
*/
|
|
804
|
+
getNotificationState(): { enabled: boolean; subscriptions: Map<string, ReadonlySet<string>> } {
|
|
805
|
+
return {
|
|
806
|
+
enabled: this.#notificationsEnabled,
|
|
807
|
+
subscriptions: this.#subscribedResources as Map<string, ReadonlySet<string>>,
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
|
|
417
811
|
/**
|
|
418
812
|
* Resolve OAuth credentials and shell commands in config.
|
|
419
813
|
*/
|
package/src/mcp/oauth-flow.ts
CHANGED
|
@@ -54,7 +54,8 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
|
|
|
54
54
|
if (!params.get("response_type")) {
|
|
55
55
|
params.set("response_type", "code");
|
|
56
56
|
}
|
|
57
|
-
|
|
57
|
+
const existingClientId = params.get("client_id")?.trim();
|
|
58
|
+
if (this.#resolvedClientId && !existingClientId) {
|
|
58
59
|
params.set("client_id", this.#resolvedClientId);
|
|
59
60
|
}
|
|
60
61
|
if (this.config.scopes && !params.get("scope")) {
|
|
@@ -72,6 +73,10 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
|
|
|
72
73
|
// Store code verifier for token exchange
|
|
73
74
|
this.#codeVerifier = codeVerifier;
|
|
74
75
|
|
|
76
|
+
if (!params.get("client_id")) {
|
|
77
|
+
await this.#assertClientIdNotRequired(authUrl.toString());
|
|
78
|
+
}
|
|
79
|
+
|
|
75
80
|
return { url: authUrl.toString() };
|
|
76
81
|
}
|
|
77
82
|
|
|
@@ -228,4 +233,24 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
|
|
|
228
233
|
|
|
229
234
|
return null;
|
|
230
235
|
}
|
|
236
|
+
|
|
237
|
+
async #assertClientIdNotRequired(authorizationUrl: string): Promise<void> {
|
|
238
|
+
try {
|
|
239
|
+
const response = await fetch(authorizationUrl, {
|
|
240
|
+
method: "GET",
|
|
241
|
+
redirect: "manual",
|
|
242
|
+
headers: { Accept: "text/plain,text/html,application/json" },
|
|
243
|
+
});
|
|
244
|
+
if (response.status < 400) return;
|
|
245
|
+
const body = await response.text();
|
|
246
|
+
if (/client[_-]?id/i.test(body) && /(required|missing|invalid)/i.test(body)) {
|
|
247
|
+
throw new Error("OAuth provider requires client_id");
|
|
248
|
+
}
|
|
249
|
+
} catch (error) {
|
|
250
|
+
if (error instanceof Error && /client[_-]?id/i.test(error.message)) {
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
// Ignore network/probe failures to avoid blocking flows that still work.
|
|
254
|
+
}
|
|
255
|
+
}
|
|
231
256
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { isEnoent, logger } from "@oh-my-pi/pi-utils";
|
|
4
|
+
import { getAgentDir } from "@oh-my-pi/pi-utils/dirs";
|
|
5
|
+
|
|
6
|
+
const SMITHERY_AUTH_FILENAME = "smithery.json";
|
|
7
|
+
const SMITHERY_URL = process.env.SMITHERY_URL || "https://smithery.ai";
|
|
8
|
+
|
|
9
|
+
type SmitheryCliAuthSession = {
|
|
10
|
+
sessionId: string;
|
|
11
|
+
authUrl: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type SmitheryCliPollResponse = {
|
|
15
|
+
status: "pending" | "success" | "error";
|
|
16
|
+
apiKey?: string;
|
|
17
|
+
message?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type SmitheryAuthPayload = {
|
|
21
|
+
apiKey?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function getSmitheryAuthPath(): string {
|
|
25
|
+
return path.join(getAgentDir(), SMITHERY_AUTH_FILENAME);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeApiKey(value: string | undefined): string | undefined {
|
|
29
|
+
if (!value) return undefined;
|
|
30
|
+
const trimmed = value.trim();
|
|
31
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getSmitheryLoginUrl(): string {
|
|
35
|
+
return SMITHERY_URL;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function createSmitheryCliAuthSession(): Promise<SmitheryCliAuthSession> {
|
|
39
|
+
const response = await fetch(`${SMITHERY_URL}/api/auth/cli/session`, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
});
|
|
42
|
+
if (!response.ok) {
|
|
43
|
+
throw new Error(`Failed to create Smithery auth session: ${response.status} ${response.statusText}`);
|
|
44
|
+
}
|
|
45
|
+
return (await response.json()) as SmitheryCliAuthSession;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function pollSmitheryCliAuthSession(
|
|
49
|
+
sessionId: string,
|
|
50
|
+
signal?: AbortSignal,
|
|
51
|
+
): Promise<SmitheryCliPollResponse> {
|
|
52
|
+
const response = await fetch(`${SMITHERY_URL}/api/auth/cli/poll/${sessionId}`, {
|
|
53
|
+
signal,
|
|
54
|
+
});
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
if (response.status === 404 || response.status === 410) {
|
|
57
|
+
throw new Error("Smithery login session expired. Please try again.");
|
|
58
|
+
}
|
|
59
|
+
throw new Error(`Smithery auth polling failed: ${response.status} ${response.statusText}`);
|
|
60
|
+
}
|
|
61
|
+
return (await response.json()) as SmitheryCliPollResponse;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function getSmitheryApiKey(): Promise<string | undefined> {
|
|
65
|
+
const envKey = normalizeApiKey(process.env.SMITHERY_API_KEY);
|
|
66
|
+
if (envKey) return envKey;
|
|
67
|
+
|
|
68
|
+
const authPath = getSmitheryAuthPath();
|
|
69
|
+
try {
|
|
70
|
+
const payload = (await Bun.file(authPath).json()) as SmitheryAuthPayload;
|
|
71
|
+
return normalizeApiKey(payload.apiKey);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (isEnoent(error)) return undefined;
|
|
74
|
+
logger.warn("Failed to read Smithery auth file, treating as missing", { path: authPath, error });
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function saveSmitheryApiKey(apiKey: string): Promise<void> {
|
|
80
|
+
const normalized = normalizeApiKey(apiKey);
|
|
81
|
+
if (!normalized) {
|
|
82
|
+
throw new Error("Smithery API key cannot be empty.");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const authPath = getSmitheryAuthPath();
|
|
86
|
+
const payload: SmitheryAuthPayload = { apiKey: normalized };
|
|
87
|
+
await Bun.write(authPath, `${JSON.stringify(payload, null, 2)}\n`);
|
|
88
|
+
try {
|
|
89
|
+
await fs.chmod(authPath, 0o600);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
logger.warn("Could not set restrictive permissions on Smithery auth file", { path: authPath, error });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function clearSmitheryApiKey(): Promise<boolean> {
|
|
96
|
+
const authPath = getSmitheryAuthPath();
|
|
97
|
+
try {
|
|
98
|
+
await fs.rm(authPath);
|
|
99
|
+
return true;
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (isEnoent(error)) return false;
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
}
|