@oh-my-tool/cli 0.2.0 → 0.3.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.
Files changed (46) hide show
  1. package/README.md +7 -2
  2. package/assets/skills/oh-my-tool/SKILL.md +11 -3
  3. package/bin/ohmytool.cjs +0 -0
  4. package/package.json +12 -10
  5. package/src/cli/commands/describe.ts +23 -22
  6. package/src/cli/commands/extension.ts +24 -24
  7. package/src/cli/commands/index.ts +7 -6
  8. package/src/cli/commands/integrate.ts +64 -64
  9. package/src/cli/commands/mcp.ts +86 -0
  10. package/src/cli/commands/run.ts +8 -7
  11. package/src/cli/commands/search.ts +14 -13
  12. package/src/cli/commands/secret.ts +68 -68
  13. package/src/cli/context.ts +25 -2
  14. package/src/cli/index.ts +296 -272
  15. package/src/cli/parseArgs.ts +62 -44
  16. package/src/config/config.ts +155 -63
  17. package/src/core/executor.ts +89 -89
  18. package/src/core/registry.ts +31 -31
  19. package/src/core/result.ts +14 -14
  20. package/src/extension/discovery.ts +61 -61
  21. package/src/extension/install.ts +23 -23
  22. package/src/extension/loader.ts +32 -32
  23. package/src/extension/manifest.ts +114 -114
  24. package/src/integration/adapters.ts +98 -98
  25. package/src/integration/index.ts +4 -4
  26. package/src/integration/manager.ts +375 -375
  27. package/src/integration/skill.ts +84 -84
  28. package/src/integration/types.ts +55 -55
  29. package/src/policy/policy.ts +136 -136
  30. package/src/runtime/errors.ts +7 -2
  31. package/src/runtime/executor.ts +6 -1
  32. package/src/runtime/provider.ts +1 -0
  33. package/src/runtime/providers/mcp/normalize.ts +36 -0
  34. package/src/runtime/providers/mcp/oauth-callback.ts +91 -0
  35. package/src/runtime/providers/mcp/oauth-provider.ts +348 -0
  36. package/src/runtime/providers/mcp/oauth-store.ts +106 -0
  37. package/src/runtime/providers/mcp/provider.ts +99 -0
  38. package/src/runtime/providers/mcp/safe-errors.ts +63 -0
  39. package/src/runtime/providers/mcp/session.ts +117 -0
  40. package/src/runtime/providers/mcp/transport.ts +140 -0
  41. package/src/runtime/result.ts +1 -1
  42. package/src/runtime/runtime.ts +38 -12
  43. package/src/runtime/schema.ts +14 -4
  44. package/src/search/search.ts +78 -78
  45. package/src/secrets/secrets.ts +45 -45
  46. package/src/version.ts +1 -1
@@ -0,0 +1,91 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+ import { RuntimeError } from "../../errors";
3
+
4
+ export const DEFAULT_OAUTH_CALLBACK_TIMEOUT_MS = 300_000;
5
+
6
+ export interface OAuthCallback {
7
+ readonly redirectUrl: URL;
8
+ waitForResult(expectedState: string): Promise<URLSearchParams>;
9
+ close(): Promise<void>;
10
+ }
11
+
12
+ const SUCCESS_PAGE = "<!doctype html><html><body><h1>Authorization complete</h1><p>You can close this window.</p></body></html>";
13
+ const ERROR_PAGE = "<!doctype html><html><body><h1>Authorization failed</h1><p>Return to Oh My Tool for details.</p></body></html>";
14
+ const RESPONSE_HEADERS = { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" };
15
+
16
+ function sameState(actual: string | null, expected: string): boolean {
17
+ if (actual === null) return false;
18
+ const actualBytes = Buffer.from(actual, "base64url");
19
+ const expectedBytes = Buffer.from(expected, "base64url");
20
+ return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes);
21
+ }
22
+
23
+ export async function createOAuthCallback(
24
+ port: number,
25
+ timeoutMs = DEFAULT_OAUTH_CALLBACK_TIMEOUT_MS,
26
+ ): Promise<OAuthCallback> {
27
+ let expectedState: string | undefined;
28
+ let result: PromiseWithResolvers<URLSearchParams> | undefined;
29
+ let timeout: ReturnType<typeof setTimeout> | undefined;
30
+ let settled = false;
31
+ let closed = false;
32
+
33
+ const server = Bun.serve({
34
+ hostname: "127.0.0.1",
35
+ port,
36
+ fetch(rawRequest) {
37
+ const request = rawRequest as unknown as { url: string; method: string };
38
+ const url = new URL(request.url);
39
+ if (url.pathname !== "/oauth/callback") return new Response("Not found", { status: 404 });
40
+ if (request.method !== "GET") return new Response("Method not allowed", { status: 405 });
41
+ if (settled || result === undefined || expectedState === undefined) return new Response("Conflict", { status: 409 });
42
+
43
+ settled = true;
44
+ if (timeout !== undefined) clearTimeout(timeout);
45
+ if (!sameState(url.searchParams.get("state"), expectedState)) {
46
+ result.reject(new RuntimeError("MCP_OAUTH_STATE_MISMATCH", "OAuth callback state did not match"));
47
+ return new Response(ERROR_PAGE, { status: 400, headers: RESPONSE_HEADERS });
48
+ }
49
+ const oauthError = url.searchParams.get("error");
50
+ if (oauthError !== null) {
51
+ const code = oauthError === "access_denied" ? "MCP_OAUTH_ACCESS_DENIED" : "MCP_OAUTH_AUTHORIZATION_FAILED";
52
+ result.reject(new RuntimeError(code, "OAuth authorization was not completed"));
53
+ return new Response(ERROR_PAGE, { status: 400, headers: RESPONSE_HEADERS });
54
+ }
55
+ if (url.searchParams.get("code") === null) {
56
+ result.reject(new RuntimeError("MCP_OAUTH_AUTHORIZATION_FAILED", "OAuth authorization was not completed"));
57
+ return new Response(ERROR_PAGE, { status: 400, headers: RESPONSE_HEADERS });
58
+ }
59
+ result.resolve(new URLSearchParams(url.searchParams));
60
+ return new Response(SUCCESS_PAGE, { status: 200, headers: RESPONSE_HEADERS });
61
+ },
62
+ });
63
+
64
+ return {
65
+ redirectUrl: new URL(`http://127.0.0.1:${server.port}/oauth/callback`),
66
+ waitForResult(state) {
67
+ if (result !== undefined) return result.promise;
68
+ if (closed) {
69
+ return Promise.reject(new RuntimeError("MCP_OAUTH_CALLBACK_CLOSED", "OAuth callback listener is closed"));
70
+ }
71
+ expectedState = state;
72
+ result = Promise.withResolvers<URLSearchParams>();
73
+ timeout = setTimeout(() => {
74
+ if (settled) return;
75
+ settled = true;
76
+ result?.reject(new RuntimeError("MCP_OAUTH_TIMEOUT", "OAuth callback timed out"));
77
+ }, timeoutMs);
78
+ return result.promise;
79
+ },
80
+ async close() {
81
+ if (closed) return;
82
+ closed = true;
83
+ if (timeout !== undefined) clearTimeout(timeout);
84
+ if (!settled && result !== undefined) {
85
+ settled = true;
86
+ result.reject(new RuntimeError("MCP_OAUTH_CALLBACK_CLOSED", "OAuth callback listener is closed"));
87
+ }
88
+ await server.stop(true);
89
+ },
90
+ };
91
+ }
@@ -0,0 +1,348 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import open from "open";
3
+ import {
4
+ Client,
5
+ UnauthorizedError,
6
+ type OAuthClientInformationContext,
7
+ type OAuthClientMetadata,
8
+ type OAuthClientProvider,
9
+ type OAuthDiscoveryState,
10
+ type StoredOAuthClientInformation,
11
+ type StoredOAuthTokens,
12
+ } from "@modelcontextprotocol/client";
13
+ import type {
14
+ McpHttpServerConfig,
15
+ } from "../../../config/config";
16
+ import { VERSION } from "../../../version";
17
+ import type { SecretStore } from "@oh-my-tool/sdk";
18
+ import { RuntimeError } from "../../errors";
19
+ import {
20
+ createMcpTransport,
21
+ type McpTransport,
22
+ McpTransportSetupError,
23
+ type OAuthMcpServerConfig,
24
+ } from "./transport";
25
+ import { configuredMcpValues, normalizeMcpError } from "./safe-errors";
26
+ import { createMcpOAuthStore, type SecretMcpOAuthStore } from "./oauth-store";
27
+ import {
28
+ createOAuthCallback,
29
+ DEFAULT_OAUTH_CALLBACK_TIMEOUT_MS,
30
+ type OAuthCallback,
31
+ } from "./oauth-callback";
32
+
33
+ export interface McpOAuthProviderOptions {
34
+ readonly redirectUrl?: URL;
35
+ readonly interactive?: boolean;
36
+ }
37
+
38
+ export interface McpOAuthClientProvider extends OAuthClientProvider {
39
+ readonly redirectUrl: URL;
40
+ readonly secretValues: readonly string[];
41
+ authorizationUrl(): URL | undefined;
42
+ authorizationState(): string | undefined;
43
+ clearVerifier(): Promise<void>;
44
+ }
45
+
46
+ export interface InteractiveOAuthDeps {
47
+ readonly openBrowser: (url: string) => Promise<unknown>;
48
+ readonly createCallback: (port: number) => Promise<OAuthCallback>;
49
+ readonly callbackTimeoutMs: number;
50
+ readonly createClient: (info: { name: string; version: string }) => InteractiveOAuthClient;
51
+ readonly createTransport: typeof createMcpTransport;
52
+ }
53
+
54
+ export interface InteractiveOAuthClient {
55
+ connect(transport: McpTransport): Promise<void>;
56
+ close(): Promise<void>;
57
+ }
58
+
59
+ function oauthAuthRequired(serverId: string): RuntimeError {
60
+ return new RuntimeError(
61
+ "MCP_AUTH_REQUIRED",
62
+ `MCP server '${serverId}' requires user authorization; run 'ohmytool mcp auth ${serverId}'`,
63
+ );
64
+ }
65
+
66
+ class PersistentMcpOAuthProvider implements McpOAuthClientProvider {
67
+ readonly clientMetadata: OAuthClientMetadata;
68
+ private pendingAuthorizationUrl: URL | undefined;
69
+ private pendingState: string | undefined;
70
+
71
+ constructor(
72
+ private readonly serverId: string,
73
+ config: OAuthMcpServerConfig,
74
+ private readonly store: SecretMcpOAuthStore,
75
+ readonly redirectUrl: URL,
76
+ private readonly preRegisteredClient: StoredOAuthClientInformation | undefined,
77
+ readonly secretValues: readonly string[],
78
+ private readonly interactive: boolean,
79
+ ) {
80
+ this.clientMetadata = {
81
+ redirect_uris: [redirectUrl.toString()],
82
+ grant_types: ["authorization_code", "refresh_token"],
83
+ response_types: ["code"],
84
+ token_endpoint_auth_method: config.auth.tokenEndpointAuthMethod,
85
+ client_name: "Oh My Tool",
86
+ ...(config.auth.scopes.length === 0 ? {} : { scope: config.auth.scopes.join(" ") }),
87
+ };
88
+ }
89
+
90
+ state(): string {
91
+ this.pendingState = randomBytes(32).toString("base64url");
92
+ return this.pendingState;
93
+ }
94
+
95
+ async clientInformation(_ctx?: OAuthClientInformationContext): Promise<StoredOAuthClientInformation | undefined> {
96
+ if (this.preRegisteredClient !== undefined) return Promise.resolve(this.preRegisteredClient);
97
+ const stored = await this.store.clientInformation();
98
+ if (!this.interactive && stored === undefined) throw oauthAuthRequired(this.serverId);
99
+ return stored;
100
+ }
101
+
102
+ saveClientInformation(info: StoredOAuthClientInformation, ctx?: OAuthClientInformationContext): Promise<void> {
103
+ return this.store.saveClientInformation(ctx === undefined ? info : { ...info, issuer: ctx.issuer });
104
+ }
105
+
106
+ tokens(_ctx?: OAuthClientInformationContext): Promise<StoredOAuthTokens | undefined> {
107
+ return this.store.tokens();
108
+ }
109
+
110
+ saveTokens(tokens: StoredOAuthTokens, ctx?: OAuthClientInformationContext): Promise<void> {
111
+ return this.store.saveTokens(ctx === undefined ? tokens : { ...tokens, issuer: ctx.issuer });
112
+ }
113
+
114
+ async redirectToAuthorization(url: URL): Promise<void> {
115
+ if (!this.interactive) {
116
+ await this.store.clearVerifier();
117
+ throw oauthAuthRequired(this.serverId);
118
+ }
119
+ this.pendingAuthorizationUrl = new URL(url);
120
+ }
121
+
122
+ authorizationUrl(): URL | undefined {
123
+ return this.pendingAuthorizationUrl === undefined ? undefined : new URL(this.pendingAuthorizationUrl);
124
+ }
125
+
126
+ authorizationState(): string | undefined {
127
+ return this.pendingState;
128
+ }
129
+
130
+ saveCodeVerifier(value: string): Promise<void> {
131
+ return this.store.saveCodeVerifier(value);
132
+ }
133
+
134
+ async codeVerifier(): Promise<string> {
135
+ const value = await this.store.codeVerifier();
136
+ if (value === undefined) {
137
+ throw new RuntimeError(
138
+ "MCP_OAUTH_VERIFIER_MISSING",
139
+ `OAuth PKCE verifier for MCP server '${this.serverId}' is missing`,
140
+ );
141
+ }
142
+ return value;
143
+ }
144
+
145
+ saveDiscoveryState(value: OAuthDiscoveryState): Promise<void> {
146
+ return this.store.saveDiscoveryState(value);
147
+ }
148
+
149
+ discoveryState(): Promise<OAuthDiscoveryState | undefined> {
150
+ return this.store.discoveryState();
151
+ }
152
+
153
+ invalidateCredentials(scope: "all" | "client" | "tokens" | "verifier" | "discovery"): Promise<void> {
154
+ return scope === "all" ? this.store.clearAll() : this.store.clear(scope);
155
+ }
156
+
157
+ clearVerifier(): Promise<void> {
158
+ return this.store.clearVerifier();
159
+ }
160
+ }
161
+
162
+ function defaultRedirectUrl(config: OAuthMcpServerConfig): URL {
163
+ return new URL(`http://127.0.0.1:${config.auth.callbackPort}/oauth/callback`);
164
+ }
165
+
166
+ export async function createMcpOAuthProvider(
167
+ serverId: string,
168
+ config: OAuthMcpServerConfig,
169
+ secrets: SecretStore,
170
+ options: McpOAuthProviderOptions = {},
171
+ ): Promise<McpOAuthClientProvider> {
172
+ const interactive = options.interactive ?? false;
173
+ const store = createMcpOAuthStore(serverId, secrets);
174
+ if (!interactive && await store.tokens() === undefined) throw oauthAuthRequired(serverId);
175
+ let preRegisteredClient: StoredOAuthClientInformation | undefined;
176
+ const secretValues: string[] = [];
177
+ if (config.auth.clientId !== undefined) {
178
+ let clientSecret: string | undefined;
179
+ if (config.auth.clientSecretSecret !== undefined) {
180
+ clientSecret = await secrets.get(config.auth.clientSecretSecret);
181
+ if (clientSecret === undefined) {
182
+ throw new RuntimeError(
183
+ "MCP_SECRET_NOT_FOUND",
184
+ `MCP server '${serverId}' requires missing secret '${config.auth.clientSecretSecret}'`,
185
+ );
186
+ }
187
+ secretValues.push(clientSecret);
188
+ }
189
+ preRegisteredClient = {
190
+ client_id: config.auth.clientId,
191
+ client_secret: clientSecret,
192
+ };
193
+ }
194
+ return new PersistentMcpOAuthProvider(
195
+ serverId,
196
+ config,
197
+ store,
198
+ options.redirectUrl ?? defaultRedirectUrl(config),
199
+ preRegisteredClient,
200
+ secretValues,
201
+ interactive,
202
+ );
203
+ }
204
+
205
+ function oauthConfig(serverId: string, config: McpHttpServerConfig): OAuthMcpServerConfig {
206
+ if (!config.enabled || config.transport !== "streamable-http" || config.auth.type !== "oauth") {
207
+ throw new RuntimeError("MCP_OAUTH_NOT_CONFIGURED", `MCP server '${serverId}' is not an enabled OAuth Streamable HTTP server`);
208
+ }
209
+ return config as OAuthMcpServerConfig;
210
+ }
211
+
212
+ function safeAuthorizationUrl(url: URL): boolean {
213
+ return url.protocol === "https:" || (
214
+ url.protocol === "http:" && (url.hostname === "127.0.0.1" || url.hostname === "::1" || url.hostname === "[::1]" || url.hostname === "localhost")
215
+ );
216
+ }
217
+
218
+ async function waitForCallback(callback: OAuthCallback, state: string, timeoutMs: number): Promise<URLSearchParams> {
219
+ let timeout: ReturnType<typeof setTimeout> | undefined;
220
+ try {
221
+ return await Promise.race([
222
+ callback.waitForResult(state),
223
+ new Promise<never>((_, reject) => {
224
+ timeout = setTimeout(() => reject(new RuntimeError("MCP_OAUTH_TIMEOUT", "OAuth callback timed out")), timeoutMs);
225
+ }),
226
+ ]);
227
+ } finally {
228
+ if (timeout !== undefined) clearTimeout(timeout);
229
+ }
230
+ }
231
+
232
+ async function closeClient(client: InteractiveOAuthClient | undefined): Promise<void> {
233
+ if (client === undefined) return;
234
+ try {
235
+ await client.close();
236
+ } catch {
237
+ // Cleanup must not mask the authorization result or its original error.
238
+ }
239
+ }
240
+
241
+ async function oauthCredentialValues(provider: McpOAuthClientProvider | undefined): Promise<string[]> {
242
+ if (provider === undefined) return [];
243
+ const values = [...provider.secretValues];
244
+ try {
245
+ const storedTokens = await provider.tokens();
246
+ if (storedTokens !== undefined) {
247
+ for (const [key, value] of Object.entries(storedTokens)) {
248
+ if ((key === "access_token" || key === "refresh_token" || key === "id_token") && typeof value === "string") {
249
+ values.push(value);
250
+ }
251
+ }
252
+ }
253
+ } catch {
254
+ // Preserve the original failure; malformed stored credentials already use a secret-free error.
255
+ }
256
+ try {
257
+ const storedClient = await provider.clientInformation();
258
+ if (typeof storedClient?.client_secret === "string") values.push(storedClient.client_secret);
259
+ } catch {
260
+ // Preserve the original failure.
261
+ }
262
+ return values;
263
+ }
264
+
265
+ type FinishableTransport = McpTransport & { finishAuth(callbackParams: URLSearchParams): Promise<void> };
266
+
267
+ export async function authorizeMcpServer(
268
+ serverId: string,
269
+ config: McpHttpServerConfig,
270
+ secrets: SecretStore,
271
+ deps: Partial<InteractiveOAuthDeps> = {},
272
+ ): Promise<{ serverId: string; authorized: true }> {
273
+ const validated = oauthConfig(serverId, config);
274
+ const secretValues = configuredMcpValues(validated);
275
+ const callbackTimeoutMs = deps.callbackTimeoutMs ?? DEFAULT_OAUTH_CALLBACK_TIMEOUT_MS;
276
+ const openBrowser = deps.openBrowser ?? (async (url: string) => open(url));
277
+ const createClient = deps.createClient ?? ((info) => new Client(info));
278
+ const createTransport = deps.createTransport ?? createMcpTransport;
279
+ let callback: OAuthCallback | undefined;
280
+ let provider: McpOAuthClientProvider | undefined;
281
+ let firstClient: InteractiveOAuthClient | undefined;
282
+ let secondClient: InteractiveOAuthClient | undefined;
283
+ try {
284
+ callback = deps.createCallback === undefined
285
+ ? await createOAuthCallback(validated.auth.callbackPort, callbackTimeoutMs)
286
+ : await deps.createCallback(validated.auth.callbackPort);
287
+ const activeProvider = await createMcpOAuthProvider(serverId, validated, secrets, {
288
+ redirectUrl: callback.redirectUrl,
289
+ interactive: true,
290
+ });
291
+ provider = activeProvider;
292
+ firstClient = createClient({ name: "oh-my-tool", version: VERSION });
293
+ const firstConnection = await createTransport(serverId, validated, secrets, async () => activeProvider);
294
+ secretValues.push(...firstConnection.secretValues);
295
+ try {
296
+ await firstClient.connect(firstConnection.transport);
297
+ return { serverId, authorized: true };
298
+ } catch (cause) {
299
+ if (!(cause instanceof UnauthorizedError)) throw cause;
300
+ }
301
+
302
+ const authorizationUrl = activeProvider.authorizationUrl();
303
+ const state = activeProvider.authorizationState();
304
+ if (authorizationUrl === undefined || state === undefined) {
305
+ throw new RuntimeError("MCP_OAUTH_AUTHORIZATION_FAILED", `MCP server '${serverId}' did not provide an authorization URL`);
306
+ }
307
+ if (!safeAuthorizationUrl(authorizationUrl)) {
308
+ throw new RuntimeError("MCP_OAUTH_AUTHORIZATION_URL_UNSAFE", `MCP server '${serverId}' returned an unsafe authorization URL`);
309
+ }
310
+ try {
311
+ await openBrowser(authorizationUrl.toString());
312
+ } catch {
313
+ console.error(authorizationUrl.toString());
314
+ }
315
+ const callbackParams = await waitForCallback(callback, state, callbackTimeoutMs);
316
+ await (firstConnection.transport as FinishableTransport).finishAuth(callbackParams);
317
+ await closeClient(firstClient);
318
+ firstClient = undefined;
319
+
320
+ secondClient = createClient({ name: "oh-my-tool", version: VERSION });
321
+ const secondConnection = await createTransport(serverId, validated, secrets, async () => activeProvider);
322
+ secretValues.push(...secondConnection.secretValues);
323
+ await secondClient.connect(secondConnection.transport);
324
+ return { serverId, authorized: true };
325
+ } catch (cause) {
326
+ secretValues.push(...await oauthCredentialValues(provider));
327
+ if (cause instanceof McpTransportSetupError) {
328
+ secretValues.push(...cause.secretValues);
329
+ throw normalizeMcpError(serverId, cause.cause, secretValues);
330
+ }
331
+ throw normalizeMcpError(serverId, cause, secretValues);
332
+ } finally {
333
+ await closeClient(firstClient);
334
+ await closeClient(secondClient);
335
+ await callback?.close();
336
+ await provider?.clearVerifier();
337
+ }
338
+ }
339
+
340
+ export async function logoutMcpServer(
341
+ serverId: string,
342
+ config: McpHttpServerConfig,
343
+ secrets: SecretStore,
344
+ ): Promise<{ serverId: string; loggedOut: true }> {
345
+ oauthConfig(serverId, config);
346
+ await createMcpOAuthStore(serverId, secrets).clearAll();
347
+ return { serverId, loggedOut: true };
348
+ }
@@ -0,0 +1,106 @@
1
+ import type {
2
+ OAuthDiscoveryState,
3
+ StoredOAuthClientInformation,
4
+ StoredOAuthTokens,
5
+ } from "@modelcontextprotocol/client";
6
+ import type { SecretStore } from "@oh-my-tool/sdk";
7
+ import { RuntimeError } from "../../errors";
8
+
9
+ export interface McpOAuthStore {
10
+ tokens(): Promise<StoredOAuthTokens | undefined>;
11
+ saveTokens(tokens: StoredOAuthTokens): Promise<void>;
12
+ clientInformation(): Promise<StoredOAuthClientInformation | undefined>;
13
+ saveClientInformation(info: StoredOAuthClientInformation): Promise<void>;
14
+ codeVerifier(): Promise<string | undefined>;
15
+ saveCodeVerifier(value: string): Promise<void>;
16
+ discoveryState(): Promise<OAuthDiscoveryState | undefined>;
17
+ saveDiscoveryState(value: OAuthDiscoveryState): Promise<void>;
18
+ clearVerifier(): Promise<void>;
19
+ clearAll(): Promise<void>;
20
+ }
21
+
22
+ type CredentialScope = "tokens" | "client" | "verifier" | "discovery";
23
+
24
+ function credentialNames(serverId: string): Record<CredentialScope, string> {
25
+ const prefix = `mcp:${serverId}:oauth`;
26
+ return {
27
+ tokens: `${prefix}:tokens`,
28
+ client: `${prefix}:client`,
29
+ verifier: `${prefix}:verifier`,
30
+ discovery: `${prefix}:discovery`,
31
+ };
32
+ }
33
+
34
+ export class SecretMcpOAuthStore implements McpOAuthStore {
35
+ private readonly names: Record<CredentialScope, string>;
36
+
37
+ constructor(
38
+ private readonly serverId: string,
39
+ private readonly secrets: SecretStore,
40
+ ) {
41
+ this.names = credentialNames(serverId);
42
+ }
43
+
44
+ private async object<T extends object>(scope: CredentialScope): Promise<T | undefined> {
45
+ const stored = await this.secrets.get(this.names[scope]);
46
+ if (stored === undefined) return undefined;
47
+ try {
48
+ const parsed: unknown = JSON.parse(stored);
49
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not an object");
50
+ return parsed as T;
51
+ } catch {
52
+ throw new RuntimeError(
53
+ "MCP_OAUTH_CREDENTIALS_INVALID",
54
+ `Stored OAuth ${scope} credentials for MCP server '${this.serverId}' are invalid`,
55
+ );
56
+ }
57
+ }
58
+
59
+ tokens(): Promise<StoredOAuthTokens | undefined> {
60
+ return this.object<StoredOAuthTokens>("tokens");
61
+ }
62
+
63
+ saveTokens(tokens: StoredOAuthTokens): Promise<void> {
64
+ return this.secrets.set(this.names.tokens, JSON.stringify(tokens));
65
+ }
66
+
67
+ clientInformation(): Promise<StoredOAuthClientInformation | undefined> {
68
+ return this.object<StoredOAuthClientInformation>("client");
69
+ }
70
+
71
+ saveClientInformation(info: StoredOAuthClientInformation): Promise<void> {
72
+ return this.secrets.set(this.names.client, JSON.stringify(info));
73
+ }
74
+
75
+ codeVerifier(): Promise<string | undefined> {
76
+ return this.secrets.get(this.names.verifier);
77
+ }
78
+
79
+ saveCodeVerifier(value: string): Promise<void> {
80
+ return this.secrets.set(this.names.verifier, value);
81
+ }
82
+
83
+ discoveryState(): Promise<OAuthDiscoveryState | undefined> {
84
+ return this.object<OAuthDiscoveryState>("discovery");
85
+ }
86
+
87
+ saveDiscoveryState(value: OAuthDiscoveryState): Promise<void> {
88
+ return this.secrets.set(this.names.discovery, JSON.stringify(value));
89
+ }
90
+
91
+ clear(scope: CredentialScope): Promise<void> {
92
+ return this.secrets.delete(this.names[scope]);
93
+ }
94
+
95
+ clearVerifier(): Promise<void> {
96
+ return this.clear("verifier");
97
+ }
98
+
99
+ async clearAll(): Promise<void> {
100
+ await Promise.all(Object.values(this.names).map((name) => this.secrets.delete(name)));
101
+ }
102
+ }
103
+
104
+ export function createMcpOAuthStore(serverId: string, secrets: SecretStore): SecretMcpOAuthStore {
105
+ return new SecretMcpOAuthStore(serverId, secrets);
106
+ }
@@ -0,0 +1,99 @@
1
+ import type { CallToolResult } from "@modelcontextprotocol/client";
2
+ import type { McpEnabledServerConfig } from "../../../config/config";
3
+ import type { SecretStore } from "@oh-my-tool/sdk";
4
+ import type { ExecutionContext, ToolDescriptor, ToolProvider } from "../../provider";
5
+ import type { ToolResult } from "../../result";
6
+ import { RuntimeError } from "../../errors";
7
+ import { createMcpSession, type McpSession, type McpSessionFactory } from "./session";
8
+ import { normalizeMcpTool } from "./normalize";
9
+
10
+ export interface McpProviderOptions {
11
+ readonly serverId: string;
12
+ readonly config: McpEnabledServerConfig;
13
+ readonly secrets: SecretStore;
14
+ readonly createSession?: McpSessionFactory;
15
+ }
16
+
17
+ export class McpProvider implements ToolProvider {
18
+ readonly id: string;
19
+ readonly kind = "mcp";
20
+ private session?: McpSession;
21
+ private descriptors?: readonly ToolDescriptor[];
22
+ private readonly routes = new Map<string, string>();
23
+ private closePromise?: Promise<void>;
24
+
25
+ constructor(private readonly options: McpProviderOptions) {
26
+ this.id = `mcp:${options.serverId}`;
27
+ }
28
+
29
+ async listTools(): Promise<readonly ToolDescriptor[]> {
30
+ if (this.descriptors) return this.descriptors;
31
+ if (this.closePromise) await this.closePromise;
32
+ const createSession = this.options.createSession ?? createMcpSession;
33
+ const session = await createSession(this.options.serverId, this.options.config, this.options.secrets);
34
+ try {
35
+ const normalized: ToolDescriptor[] = [];
36
+ const routes = new Map<string, string>();
37
+ const seenCursors = new Set<string>();
38
+ let cursor: string | undefined;
39
+ do {
40
+ const page = await session.listTools(cursor);
41
+ for (const tool of page.tools) {
42
+ const item = normalizeMcpTool(this.options.serverId, this.options.config.namespace, this.id, tool);
43
+ if (routes.has(item.descriptor.id)) {
44
+ throw new RuntimeError("MCP_DUPLICATE_TOOL_ID", `duplicate MCP tool '${item.descriptor.id}'`);
45
+ }
46
+ routes.set(item.descriptor.id, item.remoteName);
47
+ normalized.push(item.descriptor);
48
+ }
49
+ if (page.nextCursor !== undefined) {
50
+ if (seenCursors.has(page.nextCursor) || page.nextCursor === cursor) {
51
+ throw new RuntimeError("MCP_PAGINATION_LOOP", `MCP server '${this.options.serverId}' returned a pagination loop`);
52
+ }
53
+ seenCursors.add(page.nextCursor);
54
+ }
55
+ cursor = page.nextCursor;
56
+ } while (cursor !== undefined);
57
+ this.session = session;
58
+ this.routes.clear();
59
+ for (const [id, remote] of routes) this.routes.set(id, remote);
60
+ this.descriptors = Object.freeze(normalized);
61
+ return this.descriptors;
62
+ } catch (error) {
63
+ try { await session.close(); } catch { /* preserve discovery failure */ }
64
+ throw error;
65
+ }
66
+ }
67
+
68
+ async execute(toolId: string, input: unknown, _context: ExecutionContext): Promise<ToolResult> {
69
+ if (!this.descriptors || !this.session) await this.listTools();
70
+ const remoteName = this.routes.get(toolId);
71
+ if (!remoteName || !this.session) {
72
+ throw new RuntimeError("MCP_TOOL_NOT_FOUND", `MCP tool '${toolId}' was not discovered`);
73
+ }
74
+ if (input === null || typeof input !== "object" || Array.isArray(input)) {
75
+ throw new RuntimeError("INVALID_INPUT", `input for MCP tool '${toolId}' must be an object`);
76
+ }
77
+ const result = await this.session.callTool(remoteName, input as Record<string, unknown>);
78
+ if (result.isError) {
79
+ const details: Record<string, unknown> = { content: result.content };
80
+ if (result.structuredContent !== undefined) details.structuredContent = result.structuredContent;
81
+ throw new RuntimeError("MCP_TOOL_ERROR", `MCP tool '${toolId}' reported an error`, details);
82
+ }
83
+ const data: Record<string, unknown> = { content: result.content };
84
+ if (result.structuredContent !== undefined) data.structuredContent = result.structuredContent;
85
+ return { data, meta: { mcpServer: this.options.serverId, remoteTool: remoteName } };
86
+ }
87
+
88
+ close(): Promise<void> {
89
+ if (this.closePromise) return this.closePromise;
90
+ this.closePromise = (async () => {
91
+ const session = this.session;
92
+ this.session = undefined;
93
+ this.descriptors = undefined;
94
+ this.routes.clear();
95
+ if (session) await session.close();
96
+ })();
97
+ return this.closePromise;
98
+ }
99
+ }