From 3dbc71e73ddb2873e0b27b888889c6f793ca179a Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 23 Sep 2026 01:16:33 +0800 Subject: [PATCH 01/29] fix(mcp): preserve distinct tool identities during projection Generated-by: Codex --- .../runtime/src/__tests__/mcp-tools.test.ts | 45 ++++++++++++++++++- packages/runtime/src/mcp-tools.ts | 10 ++++- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/packages/runtime/src/__tests__/mcp-tools.test.ts b/packages/runtime/src/__tests__/mcp-tools.test.ts index 51945e033a..33e31d0e36 100644 --- a/packages/runtime/src/__tests__/mcp-tools.test.ts +++ b/packages/runtime/src/__tests__/mcp-tools.test.ts @@ -66,7 +66,7 @@ test('buildMcpTools projects discovery, abort, and rich model output', async () const tools = buildMcpTools(provider); assert.deepEqual( tools.map((tool) => tool.name), - ['mcp__read_server__read_item', 'mcp__write__mutate-item'], + [mcpProxyToolName('read server', 'read.item'), 'mcp__write__mutate-item'], ); assert.equal(tools[0]?.categoryHint, 'network_send'); assert.equal(tools[1]?.categoryHint, 'network_send'); @@ -444,6 +444,47 @@ test('a trusted composition can preserve provider-owned activity semantics', () assert.equal(tool?.activityKind, 'computer'); }); +test('distinct MCP identities remain callable after name normalization', async () => { + const identities = [ + ['server', 'get.item'], + ['server', 'get_item'], + ['server', '读取'], + ['server', '写入'], + ['a__b', 'c'], + ['a', 'b__c'], + ]; + const calls: McpToolBinding[] = []; + const tools = buildMcpTools( + fakeProvider( + identities.map(([server, name], index) => + boundTool(descriptor(server!, name!), binding(String(index))), + ), + async (value) => { + calls.push(value); + return { content: [] }; + }, + ), + ); + assert.equal(new Set(tools.map((tool) => tool.name)).size, identities.length); + for (const tool of tools) { + await tool.impl( + {}, + { + sessionId: 's', + turnId: 't', + cwd: '/tmp', + toolCallId: tool.name, + abortSignal: new AbortController().signal, + emitOutput() {}, + }, + ); + } + assert.deepEqual( + calls, + identities.map((_, index) => binding(String(index))), + ); +}); + test('mcpProxyToolName is stable, provider-safe, and bounded to 64 chars', () => { const first = mcpProxyToolName('服 务/'.repeat(20), 'tool.with punctuation '.repeat(20)); const second = mcpProxyToolName('服 务/'.repeat(20), 'tool.with punctuation '.repeat(20)); @@ -468,7 +509,7 @@ test('buildMcpToolsWithIdentities pairs each proxy tool with its source identity assert.deepEqual( identified.map(({ tool, serverId, toolName }) => [tool.name, serverId, toolName]), [ - ['mcp__read_server__read_item', 'read server', 'read.item'], + [mcpProxyToolName('read server', 'read.item'), 'read server', 'read.item'], ['mcp__write__mutate-item', 'write', 'mutate-item'], ], ); diff --git a/packages/runtime/src/mcp-tools.ts b/packages/runtime/src/mcp-tools.ts index 06ab9ec4d8..f72b6fd48d 100644 --- a/packages/runtime/src/mcp-tools.ts +++ b/packages/runtime/src/mcp-tools.ts @@ -231,7 +231,15 @@ function mcpToolDescription(descriptor: McpToolDescriptor): string { export function mcpProxyToolName(serverId: string, toolName: string): string { const raw = `mcp__${sanitizeNamePart(serverId)}__${sanitizeNamePart(toolName)}`; - if (raw.length <= MAX_PROVIDER_TOOL_NAME) return raw; + // Preserve existing simple names; hash identities whose spelling or separators are ambiguous. + if ( + raw.length <= MAX_PROVIDER_TOOL_NAME && + sanitizeNamePart(serverId) === serverId && + sanitizeNamePart(toolName) === toolName && + !serverId.includes('__') && + !toolName.includes('__') + ) + return raw; const hash = createHash('sha256') .update(`${serverId}\0${toolName}`) .digest('hex') From b15cf47df65ea185e769e6c3ee91b0a2a7a87cef Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 23 Sep 2026 01:26:04 +0800 Subject: [PATCH 02/29] fix(mcp): bind OAuth credentials and callbacks to their issuer Generated-by: Codex --- .../__tests__/mcp-oauth-controller.test.ts | 18 ++++ .../main/__tests__/mcp-secret-guard.test.ts | 9 ++ apps/desktop/src/main/mcp-oauth-controller.ts | 68 ++++----------- apps/desktop/src/main/mcp-secret-guard.ts | 3 +- .../mcp-credential-retirement.test.ts | 22 +++++ packages/core/src/mcp.ts | 9 +- packages/mcp/src/__tests__/oauth.test.ts | 82 ++++++++++++++++++- packages/mcp/src/index.ts | 19 ++++- packages/mcp/src/oauth.ts | 63 ++++++++++++-- .../src/__tests__/mcp-config-store.test.ts | 8 +- packages/storage/src/mcp-config-store.ts | 16 ++++ 11 files changed, 249 insertions(+), 68 deletions(-) diff --git a/apps/desktop/src/main/__tests__/mcp-oauth-controller.test.ts b/apps/desktop/src/main/__tests__/mcp-oauth-controller.test.ts index 2562083e2a..3d933ecf39 100644 --- a/apps/desktop/src/main/__tests__/mcp-oauth-controller.test.ts +++ b/apps/desktop/src/main/__tests__/mcp-oauth-controller.test.ts @@ -83,6 +83,24 @@ test('controller login drives browser round-trip to a connected server', async ( assert.equal(after.state, 'needs-auth'); }); +test('an OAuth error callback is issuer-validated before its error is accepted', async () => { + const fixture = await createOAuthFixture(); + const manager = new McpClientManager({ oauthStorage: createMemoryMcpOAuthStorage() }); + cleanups.push(() => manager.close()); + await manager.sync({ version: MCP_CONFIG_VERSION, mcpServers: { remote: { url: fixture.mcpUrl, transport: 'streamable-http' } } }); + const controller = createMcpOAuthController({ manager, openExternal: async (value) => { + const authorization = new URL(value); + const callback = new URL(authorization.searchParams.get('redirect_uri')!); + callback.searchParams.set('state', authorization.searchParams.get('state')!); + callback.searchParams.set('iss', 'https://unrelated.example'); + callback.searchParams.set('error', 'access_denied'); + const response = await fetch(callback); + assert.doesNotMatch(await response.text(), /access_denied/); + } }); + await assert.rejects(controller.login('remote'), /issuer/iu); + assert.equal(await manager.pendingAuthorization('remote'), undefined); +}); + test('resumeLogin rebinds the persisted callback port and completes the round', async () => { const fixture = await createOAuthFixture(); const storage = createMemoryMcpOAuthStorage(); diff --git a/apps/desktop/src/main/__tests__/mcp-secret-guard.test.ts b/apps/desktop/src/main/__tests__/mcp-secret-guard.test.ts index 02fe319de6..5db3cd840c 100644 --- a/apps/desktop/src/main/__tests__/mcp-secret-guard.test.ts +++ b/apps/desktop/src/main/__tests__/mcp-secret-guard.test.ts @@ -325,6 +325,15 @@ describe('MCP secret redaction', () => { assert.throws(() => restoreMcpConfigSecrets(incoming, previous), McpSecretRestoreError); }); + it('rejects a sentinel when the OAuth issuer changed', () => { + const previous = withSecret('real-secret'); + const incoming = redactMcpConfigSecrets(previous); + const server = incoming.mcpServers.notion; + assert.ok(server && 'url' in server && server.oauth); + server.oauth.issuer = 'https://other.example'; + assert.throws(() => restoreMcpConfigSecrets(incoming, previous), McpSecretRestoreError); + }); + it('rejects a sentinel that has no previous value instead of persisting or dropping it', () => { const incoming = withSecret(mcpSecretMarker('oauth')); assert.throws( diff --git a/apps/desktop/src/main/mcp-oauth-controller.ts b/apps/desktop/src/main/mcp-oauth-controller.ts index 35405bb0c8..06e4764ed3 100644 --- a/apps/desktop/src/main/mcp-oauth-controller.ts +++ b/apps/desktop/src/main/mcp-oauth-controller.ts @@ -32,7 +32,7 @@ import { randomBytes } from 'node:crypto'; import { createServer, type Server, type ServerResponse } from 'node:http'; import { isLoopbackHost, type McpServerStatus } from '@maka/core/mcp'; -import type { McpAuthorizationStart } from '@maka/mcp'; +import type { McpAuthorizationCallback, McpAuthorizationStart } from '@maka/mcp'; const CALLBACK_PATH = '/callback'; const DEFAULT_LOGIN_TIMEOUT_MS = 5 * 60_000; @@ -49,7 +49,7 @@ export interface McpOAuthLoginManager { ): Promise; finishAuthorization( serverId: string, - callback: { code: string; iss?: string; state?: string }, + callback: McpAuthorizationCallback, options?: { signal?: AbortSignal }, ): Promise; clearAuthorization( @@ -141,7 +141,7 @@ export function createMcpOAuthController(deps: McpOAuthControllerDeps): McpOAuth }); }); const copy = deps.copy ?? { - successTitle: 'Login complete', + successTitle: 'Login response received', successBody: 'You can close this tab and return to Maka.', failureTitle: 'Login failed', }; @@ -212,7 +212,7 @@ export function createMcpOAuthController(deps: McpOAuthControllerDeps): McpOAuth // The shell launch rides the same deadline: a hung `openExternal` // must not hold the listener and the active guard past it. await deadline.race(deps.openExternal(authorizationUrl.toString())); - const payload = await deadline.race(callback.authorizationCode); + const payload = await deadline.race(callback.authorizationResponse); return await deadline.race( deps.manager.finishAuthorization( serverId, @@ -273,7 +273,7 @@ export function createMcpOAuthController(deps: McpOAuthControllerDeps): McpOAuth return undefined; } try { - const payload = await deadline.race(callback.authorizationCode); + const payload = await deadline.race(callback.authorizationResponse); await deadline.race(Promise.resolve(deps.ensureReady?.())); return await deadline.race( deps.manager.finishAuthorization( @@ -328,18 +328,9 @@ export function createMcpOAuthController(deps: McpOAuthControllerDeps): McpOAuth }; } -/** What the loopback listener hands back after verifying the state: the - * full protocol payload the SDK still needs to validate — the code AND the - * RFC 9207 `iss` parameter. Truncating to a bare code here would silently - * disable the SDK's authorization-server mix-up defense. */ -export interface McpAuthorizationCallbackPayload { - code: string; - iss?: string; -} - interface CallbackListener { redirectUrl: string; - authorizationCode: Promise; + authorizationResponse: Promise; close(): void; } @@ -391,15 +382,15 @@ function startCallbackListener(input: { copy: { successTitle: string; successBody: string; failureTitle: string }; }): Promise { return new Promise((resolveListener, rejectListener) => { - let settleCode!: (payload: McpAuthorizationCallbackPayload) => void; + let settleCode!: (payload: McpAuthorizationCallback) => void; let failCode!: (error: Error) => void; - const authorizationCode = new Promise((resolve, reject) => { + const authorizationResponse = new Promise((resolve, reject) => { settleCode = resolve; failCode = reject; }); // The 'authorized' short-circuit never awaits this promise, and close() // rejects it — mark it handled so that path can't crash the process. - authorizationCode.catch(() => {}); + authorizationResponse.catch(() => {}); let expectedHost: string | undefined; const server: Server = createServer((request, response) => { @@ -431,23 +422,16 @@ function startCallbackListener(input: { return; } const error = url.searchParams.get('error'); - if (error) { - // Fixed local copy only: `error_description` is the authorization - // server's arbitrary prose, and rendering it on a page the user - // reads as Maka's is a phishing surface even HTML-escaped. The - // sanitized code is the one server-controlled token shown. - respond(response, 200, input.copy.failureTitle, sanitizeOAuthErrorCode(error)); - failCode(new Error(`Authorization failed: ${sanitizeOAuthErrorCode(error)}`)); - return; - } const code = url.searchParams.get('code'); - if (!code) { + if ((!code && !error) || (code && error)) { respond(response, 400, input.copy.failureTitle, 'Invalid callback.'); return; } respond(response, 200, input.copy.successTitle, input.copy.successBody); const iss = url.searchParams.get('iss'); - settleCode({ code, ...(iss !== null ? { iss } : {}) }); + // The manager validates the issuer before accepting either result. + // Never display remote error text on this unauthenticated callback page. + settleCode({ ...(error ? { error } : { code: code! }), ...(iss !== null ? { iss } : {}) }); }); server.on('error', (error) => { rejectListener(error); @@ -461,7 +445,7 @@ function startCallbackListener(input: { expectedHost = `127.0.0.1:${address.port}`; resolveListener({ redirectUrl: `http://127.0.0.1:${address.port}${CALLBACK_PATH}`, - authorizationCode, + authorizationResponse, close: () => { failCode(new Error('Login cancelled')); server.close(); @@ -474,30 +458,6 @@ function startCallbackListener(input: { }); } -/** The registered OAuth error codes this flow can encounter (RFC 6749 §4.1.2.1 - * and §5.2, plus the OIDC interaction codes). A strict allowlist, not a shape - * check: the parameter is attacker-writable, and anything that merely LOOKS - * like a code (`opaqueSecret123`) must not tunnel through to the renderer. */ -const OAUTH_ERROR_CODES = new Set([ - 'invalid_request', - 'unauthorized_client', - 'access_denied', - 'unsupported_response_type', - 'invalid_scope', - 'server_error', - 'temporarily_unavailable', - 'invalid_client', - 'invalid_grant', - 'unsupported_grant_type', - 'interaction_required', - 'login_required', - 'consent_required', -]); - -function sanitizeOAuthErrorCode(value: string): string { - return OAUTH_ERROR_CODES.has(value) ? value : 'unknown_error'; -} - function requireStatus(manager: McpOAuthLoginManager, serverId: string): McpServerStatus { const status = manager.status(serverId); if (!status) throw new Error(`Unknown MCP server: ${serverId}`); diff --git a/apps/desktop/src/main/mcp-secret-guard.ts b/apps/desktop/src/main/mcp-secret-guard.ts index 471d9c6488..a91a14b8bc 100644 --- a/apps/desktop/src/main/mcp-secret-guard.ts +++ b/apps/desktop/src/main/mcp-secret-guard.ts @@ -308,7 +308,8 @@ function restoreRemote( if (next.oauth?.clientSecret !== undefined) { if (next.oauth.clientSecret === mcpSecretMarker('oauth')) { const priorSecret = - sameEndpoint && priorRemote.oauth?.clientId === next.oauth.clientId + sameEndpoint && priorRemote.oauth?.clientId === next.oauth.clientId && + priorRemote.oauth?.issuer === next.oauth.issuer ? priorRemote.oauth?.clientSecret : undefined; if (priorSecret === undefined) { diff --git a/packages/core/src/__tests__/mcp-credential-retirement.test.ts b/packages/core/src/__tests__/mcp-credential-retirement.test.ts index 3d3afb29b0..ccc7458718 100644 --- a/packages/core/src/__tests__/mcp-credential-retirement.test.ts +++ b/packages/core/src/__tests__/mcp-credential-retirement.test.ts @@ -48,4 +48,26 @@ describe('MCP credential retirement', () => { ); assert.equal(mcpConfigChangeRetiresCredentials(stdio, { command: 'different-server' }), false); }); + + it('retires credentials when a static OAuth registration changes', () => { + const before = { + ...remote, + oauth: { issuer: 'https://issuer.example', clientId: 'client', clientSecret: 'secret' }, + }; + for (const oauth of [ + undefined, + { ...before.oauth, issuer: 'https://other.example' }, + { ...before.oauth, clientId: 'other' }, + { ...before.oauth, clientSecret: 'replacement' }, + ]) { + assert.equal(mcpConfigChangeRetiresCredentials(before, { ...remote, oauth }), true); + } + assert.equal( + mcpConfigChangeRetiresCredentials(before, { + ...before, + oauth: { ...before.oauth, scopes: ['read'] }, + }), + false, + ); + }); }); diff --git a/packages/core/src/mcp.ts b/packages/core/src/mcp.ts index 2cc941ed73..3960e7d0b3 100644 --- a/packages/core/src/mcp.ts +++ b/packages/core/src/mcp.ts @@ -49,6 +49,8 @@ export interface McpRemoteServerConfig { * `callbackPort`, because its redirect URI was registered with a fixed port. */ export interface McpOAuthConfig { + /** Authorization server that owns the pre-registered client credentials. */ + issuer?: string; clientId?: string; clientSecret?: string; scopes?: string[]; @@ -354,7 +356,12 @@ export function mcpConfigChangeRetiresCredentials( const previousStdio = isMcpStdioConfig(previous); const nextStdio = isMcpStdioConfig(next); if (previousStdio || nextStdio) return previousStdio !== nextStdio; - return previous.url !== next.url; + return ( + previous.url !== next.url || + previous.oauth?.issuer !== next.oauth?.issuer || + previous.oauth?.clientId !== next.oauth?.clientId || + previous.oauth?.clientSecret !== next.oauth?.clientSecret + ); } export function resolveMcpProtocolPreference(config: McpServerConfig): McpProtocolPreference { diff --git a/packages/mcp/src/__tests__/oauth.test.ts b/packages/mcp/src/__tests__/oauth.test.ts index 7f05710b76..60bb8b8253 100644 --- a/packages/mcp/src/__tests__/oauth.test.ts +++ b/packages/mcp/src/__tests__/oauth.test.ts @@ -223,7 +223,11 @@ describe('McpClientManager OAuth E2E', () => { remote: { url: fixture.mcpUrl, transport: 'streamable-http', - oauth: { clientId: 'static-client', clientSecret: secret }, + oauth: { + issuer: new URL(fixture.mcpUrl).origin, + clientId: 'static-client', + clientSecret: secret, + }, }, }, }); @@ -326,7 +330,7 @@ describe('McpClientManager OAuth E2E', () => { remote: { url: fixture.mcpUrl, transport: 'streamable-http', - oauth: { clientId: 'abc', clientSecret: 'abcde' }, + oauth: { issuer: new URL(fixture.mcpUrl).origin, clientId: 'abc', clientSecret: 'abcde' }, }, }, }); @@ -394,7 +398,11 @@ describe('McpClientManager OAuth E2E', () => { transport: 'streamable-http', // A 3-character secret cannot be spliced out without shredding the // message, so the whole message must be withheld instead. - oauth: { clientId: 'abc-client', clientSecret: 'k7#' }, + oauth: { + issuer: new URL(fixture.mcpUrl).origin, + clientId: 'abc-client', + clientSecret: 'k7#', + }, }, }, }); @@ -686,6 +694,34 @@ describe('McpClientManager OAuth E2E', () => { ); }); + test('static client credentials cannot follow a resource to a different issuer', async () => { + const fixture = await createOAuthFixture(); + const storage = createMemoryMcpOAuthStorage(); + const manager = new McpClientManager({ oauthStorage: storage }); + managers.push(manager); + await manager.sync({ + version: MCP_CONFIG_VERSION, + mcpServers: { + remote: { + url: fixture.mcpUrl, + enabled: false, + transport: 'streamable-http', + oauth: { + clientId: 'client-for-original-issuer', + clientSecret: 'original-secret', + issuer: 'https://original.example', + }, + }, + }, + }); + await assert.rejects( + manager.startAuthorization('remote', 'http://127.0.0.1:39991/callback'), + /issuer/iu, + ); + assert.equal(fixture.tokenExchanges.length, 0); + assert.equal(fixture.registrations.length, 0); + }); + test('the callback iss parameter reaches the SDK issuer validation', async () => { const fixture = await createOAuthFixture({ issueIss: true }); const storage = createMemoryMcpOAuthStorage(); @@ -1092,6 +1128,46 @@ describe('McpClientManager OAuth E2E', () => { } }); + test('static tokens survive restart only for their issued client and issuer', async () => { + const storage = createMemoryMcpOAuthStorage(); + const options = { + serverId: 'remote', + serverUrl: 'https://mcp.example/mcp', + storage, + clientName: 'maka', + clientVersion: '0.0.0', + }; + const config = { issuer: 'https://as.example', clientId: 'client-a' }; + const provider = new McpOAuthProvider({ ...options, config }); + await provider.saveTokens({ + issuer: config.issuer, + access_token: 'issued-token', + token_type: 'Bearer', + }); + assert.equal( + (await new McpOAuthProvider({ ...options, config }).tokens())?.access_token, + 'issued-token', + ); + assert.equal( + await new McpOAuthProvider({ + ...options, + config: { ...config, clientId: 'client-b' }, + }).tokens(), + undefined, + ); + assert.equal( + await new McpOAuthProvider({ + ...options, + config: { ...config, issuer: 'https://other.example' }, + }).tokens(), + undefined, + ); + await assert.rejects( + new McpOAuthProvider({ ...options, config: { clientId: 'client-a' } }).tokens(), + /oauth.issuer/u, + ); + }); + test('a discovery that moves to another authorization server drops the registered client', async () => { const storage = createMemoryMcpOAuthStorage(); await storage.set('remote', { diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index c66dd76787..5eb345ad06 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -20,6 +20,7 @@ import { randomBytes } from 'node:crypto'; import { auth, + validateAuthorizationResponseIssuer, Client, extractWWWAuthenticateParams, LATEST_PROTOCOL_VERSION, @@ -94,6 +95,8 @@ import { import { McpAuthRequiredError, McpOAuthProvider, + authorizationCallbackError, + type McpAuthorizationCallback, type McpOAuthRecord, type McpOAuthStorage, } from './oauth.js'; @@ -106,6 +109,7 @@ export { } from './credential-oauth-storage.js'; export { createMemoryMcpOAuthStorage, + type McpAuthorizationCallback, McpAuthRequiredError, McpOAuthProvider, type McpOAuthRecord, @@ -1695,7 +1699,7 @@ export class McpClientManager { * that check. */ async finishAuthorization( serverId: string, - callback: { code: string; iss?: string; state?: string }, + callback: McpAuthorizationCallback, options: { signal?: AbortSignal } = {}, ): Promise { try { @@ -1709,10 +1713,9 @@ export class McpClientManager { private async finishAuthorizationRound( serverId: string, - callback: { code: string; iss?: string; state?: string }, + callback: McpAuthorizationCallback, options: { signal?: AbortSignal } = {}, ): Promise { - const authorizationCode = callback.code; const { config } = this.requireRemoteEntry(serverId); // The immediate read doubles as the flow's generation/version pin. const storage = this.flowStorage(serverId, options.signal); @@ -1740,6 +1743,16 @@ export class McpClientManager { if (record?.pendingServerUrl !== config.url) { throw new Error(`MCP server "${serverId}" changed its URL during authorization`); } + const metadata = record.discovery?.authorizationServerMetadata; + validateAuthorizationResponseIssuer({ + iss: callback.iss, + expectedIssuer: metadata?.issuer, + issParameterSupported: metadata?.authorization_response_iss_parameter_supported === true, + }); + if ('error' in callback) { + throw authorizationCallbackError(callback.error); + } + const authorizationCode = callback.code; const provider = new McpOAuthProvider({ serverId, serverUrl: config.url, diff --git a/packages/mcp/src/oauth.ts b/packages/mcp/src/oauth.ts index 00151756b8..5a284e43f3 100644 --- a/packages/mcp/src/oauth.ts +++ b/packages/mcp/src/oauth.ts @@ -34,6 +34,7 @@ // login CAPTURES the authorization URL for the caller to open. import type { + OAuthClientInformationContext, OAuthClientMetadata, OAuthClientProvider, OAuthDiscoveryState, @@ -42,6 +43,31 @@ import type { } from '@modelcontextprotocol/client'; import type { McpOAuthConfig } from '@maka/core/mcp'; +export type McpAuthorizationCallback = ({ code: string } | { error: string }) & { + iss?: string; + state?: string; +}; + +const OAUTH_ERROR_CODES = new Set([ + 'invalid_request', + 'unauthorized_client', + 'access_denied', + 'unsupported_response_type', + 'invalid_scope', + 'server_error', + 'temporarily_unavailable', + 'invalid_client', + 'invalid_grant', + 'unsupported_grant_type', + 'interaction_required', + 'login_required', + 'consent_required', +]); + +export function authorizationCallbackError(code: string): Error { + return new Error(`Authorization failed: ${OAUTH_ERROR_CODES.has(code) ? code : 'unknown_error'}`); +} + /** Everything the provider persists for one server, as one JSON document. */ export interface McpOAuthRecord { /** Monotonic write version, stamped by the credential coordinator on @@ -193,10 +219,23 @@ export class McpOAuthProvider implements OAuthClientProvider { }; } - async clientInformation(): Promise { + async clientInformation( + context?: OAuthClientInformationContext, + ): Promise { const configured = this.options.config; if (configured?.clientId) { + if (!configured.issuer) { + throw new Error( + `MCP server "${this.options.serverId}" requires oauth.issuer for its pre-registered client`, + ); + } + if (context && context.issuer !== configured.issuer) { + throw new Error( + `MCP server "${this.options.serverId}" discovered a different OAuth issuer; reconfigure its client credentials`, + ); + } return { + issuer: configured.issuer, client_id: configured.clientId, ...(configured.clientSecret ? { client_secret: configured.clientSecret } : {}), }; @@ -219,12 +258,28 @@ export class McpOAuthProvider implements OAuthClientProvider { } async tokens(): Promise { - return (await this.read()).tokens; + if (this.options.config?.clientId) await this.clientInformation(); + const record = await this.read(); + const tokens = record.tokens; + if (this.options.config?.issuer && tokens?.issuer !== this.options.config.issuer) + return undefined; + if ( + this.options.config?.clientId && + record.clientInformation?.client_id !== this.options.config.clientId + ) + return undefined; + return tokens; } async saveTokens(tokens: StoredOAuthTokens): Promise { await this.mutate((record) => { record.tokens = tokens; + if (this.options.config?.clientId) { + record.clientInformation = { + client_id: this.options.config.clientId, + issuer: this.options.config.issuer, + }; + } // A fresh token set settles any pending interactive round. delete record.codeVerifier; delete record.pendingRedirectUrl; @@ -264,9 +319,7 @@ export class McpOAuthProvider implements OAuthClientProvider { // A dynamically registered client belongs to the authorization server // that issued it. When discovery moves the resource to a DIFFERENT // authorization server, carrying the old registration over would send - // one AS's client credentials (and any secret) to another. Static - // config-supplied clients are unaffected — they never live in the - // record. + // one AS's client credentials (and any secret) to another. const previousIssuer = record.discovery?.authorizationServerUrl; const nextIssuer = state.authorizationServerUrl; if (previousIssuer && nextIssuer && `${previousIssuer}` !== `${nextIssuer}`) { diff --git a/packages/storage/src/__tests__/mcp-config-store.test.ts b/packages/storage/src/__tests__/mcp-config-store.test.ts index cf9fa323a1..978833fd3b 100644 --- a/packages/storage/src/__tests__/mcp-config-store.test.ts +++ b/packages/storage/src/__tests__/mcp-config-store.test.ts @@ -479,13 +479,19 @@ test('normalizes and bounds the remote oauth block', async () => { mcpServers: { notion: { url: 'https://mcp.notion.com/mcp', - oauth: { clientId: 'abc', scopes: ['read', 'write'], callbackPort: 33389 }, + oauth: { + issuer: 'https://auth.example/tenant', + clientId: 'abc', + scopes: ['read', 'write'], + callbackPort: 33389, + }, }, }, }); const notion = normalized.mcpServers.notion; assert.ok(notion && 'url' in notion); assert.deepEqual(notion.oauth, { + issuer: 'https://auth.example/tenant', clientId: 'abc', scopes: ['read', 'write'], callbackPort: 33389, diff --git a/packages/storage/src/mcp-config-store.ts b/packages/storage/src/mcp-config-store.ts index afadeb316c..64f3f301bb 100644 --- a/packages/storage/src/mcp-config-store.ts +++ b/packages/storage/src/mcp-config-store.ts @@ -332,6 +332,22 @@ function normalizeServer( function normalizeOAuth(value: unknown, serverId: string): McpOAuthConfig { if (!isRecord(value)) throw new Error(`${serverId}.oauth must be an object`); const result: McpOAuthConfig = {}; + if (value.issuer !== undefined) { + const issuer = nonEmptyString(value.issuer, `${serverId}.oauth.issuer`); + const parsed = new URL(issuer); + if ( + !['https:', 'http:'].includes(parsed.protocol) || + parsed.username || + parsed.password || + parsed.search || + parsed.hash + ) { + throw new Error( + `${serverId}.oauth.issuer must be an HTTP(S) issuer URL without credentials, query or fragment`, + ); + } + result.issuer = issuer; + } if (value.clientId !== undefined) { result.clientId = nonEmptyString(value.clientId, `${serverId}.oauth.clientId`); } From fec0d9f5a60caab2ed3891a1669f7f407086e6f0 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 23 Sep 2026 01:50:57 +0800 Subject: [PATCH 03/29] fix(mcp): separate hashed and unmodified tool namespaces Generated-by: Codex --- packages/runtime/src/__tests__/mcp-tools.test.ts | 2 ++ packages/runtime/src/mcp-tools.ts | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/__tests__/mcp-tools.test.ts b/packages/runtime/src/__tests__/mcp-tools.test.ts index 33e31d0e36..aa8a592e6b 100644 --- a/packages/runtime/src/__tests__/mcp-tools.test.ts +++ b/packages/runtime/src/__tests__/mcp-tools.test.ts @@ -452,6 +452,8 @@ test('distinct MCP identities remain callable after name normalization', async ( ['server', '写入'], ['a__b', 'c'], ['a', 'b__c'], + ['a'.repeat(80), 'x'], + ['a'.repeat(47), 'bb9771ed28'], ]; const calls: McpToolBinding[] = []; const tools = buildMcpTools( diff --git a/packages/runtime/src/mcp-tools.ts b/packages/runtime/src/mcp-tools.ts index f72b6fd48d..3b6021c9d6 100644 --- a/packages/runtime/src/mcp-tools.ts +++ b/packages/runtime/src/mcp-tools.ts @@ -244,7 +244,9 @@ export function mcpProxyToolName(serverId: string, toolName: string): string { .update(`${serverId}\0${toolName}`) .digest('hex') .slice(0, HASH_CHARS); - return `${raw.slice(0, MAX_PROVIDER_TOOL_NAME - HASH_CHARS - 2)}__${hash}`; + // A truncated name must not become another identity's unmodified name. + const hashed = `mcp_h__${sanitizeNamePart(serverId)}__${sanitizeNamePart(toolName)}`; + return `${hashed.slice(0, MAX_PROVIDER_TOOL_NAME - HASH_CHARS - 2)}__${hash}`; } function sanitizeNamePart(value: string): string { From 5f611bb6365a0ec6873237328b123bc7f24a72a8 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 23 Sep 2026 01:50:57 +0800 Subject: [PATCH 04/29] fix(mcp): retain OAuth registration binding across restarts Generated-by: Codex --- .../__tests__/mcp-oauth-controller.test.ts | 6 +- packages/mcp/src/__tests__/oauth.test.ts | 106 ++++++++++++------ packages/mcp/src/index.ts | 17 ++- packages/mcp/src/oauth.ts | 31 +++-- 4 files changed, 108 insertions(+), 52 deletions(-) diff --git a/apps/desktop/src/main/__tests__/mcp-oauth-controller.test.ts b/apps/desktop/src/main/__tests__/mcp-oauth-controller.test.ts index 3d933ecf39..aae7b32cd5 100644 --- a/apps/desktop/src/main/__tests__/mcp-oauth-controller.test.ts +++ b/apps/desktop/src/main/__tests__/mcp-oauth-controller.test.ts @@ -97,7 +97,11 @@ test('an OAuth error callback is issuer-validated before its error is accepted', const response = await fetch(callback); assert.doesNotMatch(await response.text(), /access_denied/); } }); - await assert.rejects(controller.login('remote'), /issuer/iu); + await assert.rejects(controller.login('remote'), (error: Error) => { + assert.match(error.message, /issuer/iu); + assert.doesNotMatch(error.message, /unrelated\.example|access_denied/u); + return true; + }); assert.equal(await manager.pendingAuthorization('remote'), undefined); }); diff --git a/packages/mcp/src/__tests__/oauth.test.ts b/packages/mcp/src/__tests__/oauth.test.ts index 60bb8b8253..99bdd1f93b 100644 --- a/packages/mcp/src/__tests__/oauth.test.ts +++ b/packages/mcp/src/__tests__/oauth.test.ts @@ -33,6 +33,7 @@ import { createMemoryMcpOAuthStorage, McpClientManager, McpOAuthProvider, + McpAuthRequiredError, type McpOAuthRecord, type McpOAuthStorage, } from '../index.js'; @@ -722,6 +723,38 @@ describe('McpClientManager OAuth E2E', () => { assert.equal(fixture.registrations.length, 0); }); + test('metadata-less OAuth callbacks stay bound to the discovered authorization server', async () => { + const fixture = await createOAuthFixture({ omitAuthorizationMetadata: true }); + const storage = createMemoryMcpOAuthStorage(); + const manager = new McpClientManager({ oauthStorage: storage }); + managers.push(manager); + await manager.sync({ + version: MCP_CONFIG_VERSION, + mcpServers: { + remote: { + url: fixture.mcpUrl, + enabled: false, + transport: 'streamable-http', + oauth: { issuer: new URL(fixture.mcpUrl).origin, clientId: 'registered-client' }, + }, + }, + }); + const start = await manager.startAuthorization('remote', 'http://127.0.0.1:39991/callback', { + state: 'round', + }); + assert.equal(start.status, 'redirect'); + assert.equal((await storage.get('remote'))?.discovery?.authorizationServerMetadata, undefined); + await assert.rejects( + manager.finishAuthorization('remote', { + error: 'access_denied', + iss: 'https://other.example', + state: 'round', + }), + /issuer/iu, + ); + assert.equal(fixture.tokenExchanges.length, 0); + }); + test('the callback iss parameter reaches the SDK issuer validation', async () => { const fixture = await createOAuthFixture({ issueIss: true }); const storage = createMemoryMcpOAuthStorage(); @@ -1128,44 +1161,44 @@ describe('McpClientManager OAuth E2E', () => { } }); - test('static tokens survive restart only for their issued client and issuer', async () => { - const storage = createMemoryMcpOAuthStorage(); - const options = { + test('static credentials survive restart only with the same configured registration', async () => { + const config = { issuer: 'https://as.example', clientId: 'client-a', clientSecret: 'secret-a' }; + for (const changed of [ + undefined, + { ...config, clientId: 'client-b' }, + { ...config, issuer: 'https://other.example' }, + { ...config, clientSecret: 'secret-b' }, + ]) { + const storage = createMemoryMcpOAuthStorage(); + const options = { + serverId: 'remote', + serverUrl: 'https://mcp.example/mcp', + storage, + clientName: 'maka', + clientVersion: '0.0.0', + }; + await new McpOAuthProvider({ ...options, config }).saveTokens({ + issuer: config.issuer, + access_token: 'issued-token', + token_type: 'Bearer', + }); + assert.equal( + (await new McpOAuthProvider({ ...options, config }).tokens())?.access_token, + 'issued-token', + ); + const restarted = new McpOAuthProvider({ ...options, config: changed }); + assert.equal(await restarted.tokens(), undefined); + if (!changed) await assert.rejects(restarted.clientInformation(), McpAuthRequiredError); + } + const provider = new McpOAuthProvider({ serverId: 'remote', serverUrl: 'https://mcp.example/mcp', - storage, + storage: createMemoryMcpOAuthStorage(), clientName: 'maka', clientVersion: '0.0.0', - }; - const config = { issuer: 'https://as.example', clientId: 'client-a' }; - const provider = new McpOAuthProvider({ ...options, config }); - await provider.saveTokens({ - issuer: config.issuer, - access_token: 'issued-token', - token_type: 'Bearer', - }); - assert.equal( - (await new McpOAuthProvider({ ...options, config }).tokens())?.access_token, - 'issued-token', - ); - assert.equal( - await new McpOAuthProvider({ - ...options, - config: { ...config, clientId: 'client-b' }, - }).tokens(), - undefined, - ); - assert.equal( - await new McpOAuthProvider({ - ...options, - config: { ...config, issuer: 'https://other.example' }, - }).tokens(), - undefined, - ); - await assert.rejects( - new McpOAuthProvider({ ...options, config: { clientId: 'client-a' } }).tokens(), - /oauth.issuer/u, - ); + config: { clientId: 'client-a' }, + }); + await assert.rejects(provider.tokens(), /oauth.issuer/u); }); test('a discovery that moves to another authorization server drops the registered client', async () => { @@ -1497,6 +1530,7 @@ async function createOAuthFixture( reflectVerifierInTokenError?: boolean; /** The consent redirect carries an RFC 9207 `iss` parameter. */ issueIss?: boolean; + omitAuthorizationMetadata?: boolean; /** The token endpoint reflects the authorization code it received into * error_description. */ reflectCodeInTokenError?: boolean; @@ -1596,6 +1630,10 @@ async function createOAuthFixture( return; } if (url.pathname === '/.well-known/oauth-authorization-server' && req.method === 'GET') { + if (options.omitAuthorizationMetadata) { + res.writeHead(404).end(); + return; + } json(res, { issuer: origin, authorization_endpoint: `${origin}/authorize`, diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 5eb345ad06..585aae91a2 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -1744,11 +1744,18 @@ export class McpClientManager { throw new Error(`MCP server "${serverId}" changed its URL during authorization`); } const metadata = record.discovery?.authorizationServerMetadata; - validateAuthorizationResponseIssuer({ - iss: callback.iss, - expectedIssuer: metadata?.issuer, - issParameterSupported: metadata?.authorization_response_iss_parameter_supported === true, - }); + const expectedIssuer = metadata?.issuer ?? record.discovery?.authorizationServerUrl; + if (!expectedIssuer) throw new Error('OAuth callback has no recorded issuer'); + try { + validateAuthorizationResponseIssuer({ + iss: callback.iss, + expectedIssuer: String(expectedIssuer), + issParameterSupported: metadata?.authorization_response_iss_parameter_supported === true, + }); + } catch { + // SDK errors echo the untrusted iss parameter; it must not cross IPC. + throw new Error('OAuth callback issuer validation failed'); + } if ('error' in callback) { throw authorizationCallbackError(callback.error); } diff --git a/packages/mcp/src/oauth.ts b/packages/mcp/src/oauth.ts index 5a284e43f3..9fc31dcd2b 100644 --- a/packages/mcp/src/oauth.ts +++ b/packages/mcp/src/oauth.ts @@ -42,6 +42,7 @@ import type { StoredOAuthTokens, } from '@modelcontextprotocol/client'; import type { McpOAuthConfig } from '@maka/core/mcp'; +import { createHash } from 'node:crypto'; export type McpAuthorizationCallback = ({ code: string } | { error: string }) & { iss?: string; @@ -87,6 +88,9 @@ export interface McpOAuthRecord { * a different endpoint. Absent only on records written before this field * existed; they bind on their next save. */ serverUrl?: string; + /** Binds static registration material across offline config edits without + * copying its client secret into the credential record. Absent for DCR. */ + clientConfigHash?: string; tokens?: StoredOAuthTokens; clientInformation?: StoredOAuthClientInformation; /** RFC 9728 / AS metadata discovered on a previous round — including the @@ -199,6 +203,14 @@ export class McpOAuthProvider implements OAuthClientProvider { if (state) this.state = () => state; } + private get clientConfigHash(): string | undefined { + const config = this.options.config; + if (!config?.clientId) return undefined; + return createHash('sha256') + .update(JSON.stringify([config.issuer, config.clientId, config.clientSecret])) + .digest('hex'); + } + get redirectUrl(): string { return this.options.interactive?.redirectUrl ?? BACKGROUND_REDIRECT_URL; } @@ -263,23 +275,12 @@ export class McpOAuthProvider implements OAuthClientProvider { const tokens = record.tokens; if (this.options.config?.issuer && tokens?.issuer !== this.options.config.issuer) return undefined; - if ( - this.options.config?.clientId && - record.clientInformation?.client_id !== this.options.config.clientId - ) - return undefined; return tokens; } async saveTokens(tokens: StoredOAuthTokens): Promise { await this.mutate((record) => { record.tokens = tokens; - if (this.options.config?.clientId) { - record.clientInformation = { - client_id: this.options.config.clientId, - issuer: this.options.config.issuer, - }; - } // A fresh token set settles any pending interactive round. delete record.codeVerifier; delete record.pendingRedirectUrl; @@ -390,7 +391,11 @@ export class McpOAuthProvider implements OAuthClientProvider { record.pendingRedirectUrl || record.pendingServerUrl || record.pendingState; - return Boolean(boundable) && record.serverUrl !== this.options.serverUrl; + return ( + Boolean(boundable) && + (record.serverUrl !== this.options.serverUrl || + record.clientConfigHash !== this.clientConfigHash) + ); } /** The same fail-closed binding rule as read(), applied to a mutation @@ -411,6 +416,8 @@ export class McpOAuthProvider implements OAuthClientProvider { const stamp = (record: McpOAuthRecord): McpOAuthRecord => { apply(record); record.serverUrl = this.options.serverUrl; + if (this.clientConfigHash) record.clientConfigHash = this.clientConfigHash; + else delete record.clientConfigHash; return record; }; // The coordinator-provided storage view makes read-apply-write one From d7ec300ce35fc8cfe0e21f0146e1d6a61046e25b Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 23 Sep 2026 02:00:12 +0800 Subject: [PATCH 05/29] refactor(mcp): make configured connections the single authority Use one validated configuration transaction for Desktop and TUI. Remove catalog installation and rollback state, route MCP through Module Hub services, and share the aligned editor between templates and manual creation. Keep login ownership in main and expose its pending state to reopened pages. Generated-by: Codex --- .../__tests__/mcp-editor-validation.test.ts | 5 + .../__tests__/mcp-ipc-commit-unknown.test.ts | 32 -- .../src/main/__tests__/mcp-ipc-main.test.ts | 246 +------- .../module-hub-mcp-controller.test.ts | 126 ++++ apps/desktop/src/main/mcp-ipc-main.ts | 127 +---- apps/desktop/src/preload/bridge-contract.d.ts | 2 - apps/desktop/src/preload/preload.ts | 6 - .../controller/use-mcp-controller.ts | 135 +++++ .../src/renderer/features/module-hub/ports.ts | 16 + .../renderer/features/module-hub/testing.ts | 14 + .../module-hub/ui/module-hub-host.tsx | 2 - apps/desktop/src/renderer/locales/mcp-copy.ts | 95 ++-- .../src/renderer/mcp-editor-validation.ts | 12 +- apps/desktop/src/renderer/mcp-page-model.ts | 3 - apps/desktop/src/renderer/mcp-page.tsx | 537 ++++-------------- .../desktop/create-module-hub-services.ts | 3 +- .../src/renderer/styles/module-pages/mcp.css | 40 +- apps/desktop/stories/module-hubs.stories.tsx | 153 ++--- .../mcp-runtime-architecture-draft.zh-CN.md | 51 +- packages/cli/src/tui-mcp-control.ts | 51 +- packages/core/src/mcp.ts | 3 + packages/storage/src/mcp-config-store.ts | 33 ++ scripts/storybook-visual-smoke.mjs | 2 + 23 files changed, 657 insertions(+), 1037 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/module-hub-mcp-controller.test.ts create mode 100644 apps/desktop/src/renderer/features/module-hub/controller/use-mcp-controller.ts diff --git a/apps/desktop/src/main/__tests__/mcp-editor-validation.test.ts b/apps/desktop/src/main/__tests__/mcp-editor-validation.test.ts index cdb06cefab..0a3131cc41 100644 --- a/apps/desktop/src/main/__tests__/mcp-editor-validation.test.ts +++ b/apps/desktop/src/main/__tests__/mcp-editor-validation.test.ts @@ -22,6 +22,11 @@ import { describe, it } from 'node:test'; import { validateMcpEditorDraft } from '../../renderer/mcp-editor-validation.js'; describe('MCP editor validation', () => { + it('requires an issuer for a pre-registered OAuth client', () => { + const draft = { id: 'remote', kind: 'remote' as const, commandLine: '', url: 'https://mcp.example', oauth: { clientId: 'client' } }; + assert.deepEqual(validateMcpEditorDraft(draft), { oauthIssuer: 'required' }); + assert.deepEqual(validateMcpEditorDraft({ ...draft, oauth: { ...draft.oauth, issuer: 'https://issuer.example' } }), {}); + }); it('requires a server id and the selected transport endpoint', () => { assert.deepEqual( validateMcpEditorDraft({ diff --git a/apps/desktop/src/main/__tests__/mcp-ipc-commit-unknown.test.ts b/apps/desktop/src/main/__tests__/mcp-ipc-commit-unknown.test.ts index c9f12ae74c..e2ad5311e9 100644 --- a/apps/desktop/src/main/__tests__/mcp-ipc-commit-unknown.test.ts +++ b/apps/desktop/src/main/__tests__/mcp-ipc-commit-unknown.test.ts @@ -148,37 +148,6 @@ test('MCP pre-publication failure does not reconcile or retry the failed mutatio assert.deepEqual(ipc.emitted, []); }); -test('MCP cancelled install does not start a new connection during post-rename reconciliation', { - skip: process.platform === 'win32', - timeout: 5_000, -}, async (t) => { - const { root, store } = await fixtureStore(t); - let published!: () => void; - const publication = new Promise((resolve) => { published = resolve; }); - let finishSync!: () => void; - const syncGate = new Promise((resolve) => { finishSync = resolve; }); - const fault = failDirectorySync(t, root, async () => { - published(); - await syncGate; - }); - const ipc = mutationHarness(store); - const installing = ipc.invoke('mcp:install', 'fixture', { command: 'node' }).catch((error) => error); - await publication; - const cancelling = ipc.invoke('mcp:cancelInstall', 'fixture'); - finishSync(); - const installationError = await installing; - const cancelled = await cancelling; - assert.ok(installationError instanceof AggregateError); - assert.ok(installationError.cause instanceof AtomicFileWriteCommitUnknownError); - assert.equal(installationError.cause.cause, fault.error); - assert.match(installationError.message, /out of sync/u); - assert.match(installationError.errors[1].message, /cancelled/u); - const empty = { version: MCP_CONFIG_VERSION, mcpServers: {} }; - assert.deepEqual(cancelled, empty); - assert.deepEqual(await diskConfig(root), empty); - assert.deepEqual(ipc.synced, [empty], 'only the cancellation rollback may sync the manager'); -}); - async function fixtureStore(t: TestContext): Promise<{ root: string; store: McpConfigStore }> { const root = await mkdtemp(join(tmpdir(), 'mcp-ipc-commit-unknown-')); t.after(async () => { @@ -243,7 +212,6 @@ function mutationHarness(store: McpConfigStore, overrides: Partial Promise); } }, store, manager: { - cancelConnect: () => false, forgetServerCredentials: async (serverId) => { retired.push(serverId); }, sync: async (next) => { synced.push(structuredClone(next)); }, statuses: () => [], diff --git a/apps/desktop/src/main/__tests__/mcp-ipc-main.test.ts b/apps/desktop/src/main/__tests__/mcp-ipc-main.test.ts index 996cf0614a..d106fb63a0 100644 --- a/apps/desktop/src/main/__tests__/mcp-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/mcp-ipc-main.test.ts @@ -30,6 +30,7 @@ test('MCP IPC commits config before publishing capabilities and emitting status' const handlers = new Map Promise>(); let config: McpConfigFile = { version: MCP_CONFIG_VERSION, mcpServers: {} }; const calls: string[] = []; + let activeLogin = false; const connected: McpServerStatus = { serverId: 'fixture', state: 'connected', transport: 'stdio', toolCount: 1, tools: [{ serverId: 'fixture', name: 'echo', inputSchema: { type: 'object' } }], updatedAt: 1, @@ -55,14 +56,13 @@ test('MCP IPC commits config before publishing capabilities and emitting status' }, }, manager: { - cancelConnect: () => { calls.push('cancel'); return true; }, forgetServerCredentials: async () => { calls.push('forget'); }, sync: async () => { calls.push('sync'); }, statuses: () => [connected], test: async () => ({ ok: true, status: connected, latencyMs: 1 }), }, oauth: { - isActive: () => false, + isActive: () => activeLogin, cancelLogin: () => false, login: async () => connected, logout: async () => connected, @@ -77,7 +77,7 @@ test('MCP IPC commits config before publishing capabilities and emitting status' const upsert = handlers.get('mcp:upsert'); assert.ok(upsert); const result = await upsert({}, 'fixture', { command: 'node' }); - assert.deepEqual(result.mcpServers.fixture, { command: 'node' }); + assert.deepEqual(result.mcpServers.fixture, { command: 'node', enabled: true }); assert.deepEqual(calls, ['store', 'sync', 'emit', 'publish']); calls.length = 0; @@ -89,7 +89,7 @@ test('MCP IPC commits config before publishing capabilities and emitting status' ); assert.equal(imported.status, 'imported'); assert.deepEqual(imported.config.mcpServers, { - fixture: { command: 'node' }, + fixture: { command: 'node', enabled: true }, remote: { url: 'https://example.com/mcp', enabled: false, transport: 'auto' }, }); assert.deepEqual(calls, ['store', 'sync', 'emit', 'publish']); @@ -109,7 +109,7 @@ test('MCP IPC commits config before publishing capabilities and emitting status' assert.ok(add); const added = await add({}, 'brave', { command: 'npx' }); assert.equal(added.status, 'added'); - assert.deepEqual(added.config.mcpServers.brave, { command: 'npx' }); + assert.deepEqual(added.config.mcpServers.brave, { command: 'npx', enabled: true }); assert.deepEqual(calls, ['store', 'sync', 'emit', 'publish']); // A taken id comes back as data, not an IPC error. The check runs against // the locked transaction snapshot, but reaches neither credential cleanup @@ -124,13 +124,13 @@ test('MCP IPC commits config before publishing capabilities and emitting status' assert.equal((await testHandler({}, 'fixture')).ok, true); assert.deepEqual(calls, ['ready', 'emit']); - calls.length = 0; - config = { version: MCP_CONFIG_VERSION, mcpServers: { fixture: { command: 'node' } } }; - const cancelInstall = handlers.get('mcp:cancelInstall'); - assert.ok(cancelInstall); - const cancelled = await cancelInstall({}, 'fixture'); - assert.equal(cancelled.mcpServers.fixture, undefined); - assert.deepEqual(calls, ['cancel', 'store', 'forget', 'sync', 'emit', 'publish']); + activeLogin = true; + assert.equal((await handlers.get('mcp:listStatuses')!({}))[0].authorizationPending, true); + assert.equal(connected.authorizationPending, undefined); + activeLogin = false; + assert.equal((await handlers.get('mcp:listStatuses')!({}))[0].authorizationPending, undefined); + + }); test('MCP remove aborts before touching the config when credential deletion fails', async () => { @@ -157,7 +157,6 @@ test('MCP remove aborts before touching the config when credential deletion fail }, }, manager: { - cancelConnect: () => false, forgetServerCredentials: async () => { throw new Error('credential store unavailable'); }, sync: async () => {}, statuses: () => [], @@ -223,7 +222,6 @@ test('MCP IPC redacts clientSecret toward the renderer and restores the sentinel }, }, manager: { - cancelConnect: () => false, forgetServerCredentials: async () => {}, sync: async (next) => { synced.push(next); }, statuses: () => [], @@ -277,7 +275,7 @@ test('MCP IPC redacts clientSecret toward the renderer and restores the sentinel assert.ok(echoed && 'url' in echoed); assert.notEqual(echoed.oauth?.clientSecret, 'real-secret'); - // Removing or cancelling an unrelated server also returns a full config + // Removing an unrelated server also returns a full config // crossing toward the renderer — the survivors' secrets stay sentinels. const remove = handlers.get('mcp:remove'); assert.ok(remove); @@ -287,97 +285,7 @@ test('MCP IPC redacts clientSecret toward the renderer and restores the sentinel assert.ok(survivorAfterRemove.oauth?.clientSecret); assert.notEqual(survivorAfterRemove.oauth?.clientSecret, 'real-secret'); - config = { - version: MCP_CONFIG_VERSION, - mcpServers: { ...config.mcpServers, doomed: { command: 'npx' } }, - }; - const cancelInstall = handlers.get('mcp:cancelInstall'); - assert.ok(cancelInstall); - const afterCancel = await cancelInstall({}, 'doomed'); - assert.equal(afterCancel.mcpServers.doomed, undefined); - const survivorAfterCancel = afterCancel.mcpServers.notion; - assert.ok(survivorAfterCancel && 'url' in survivorAfterCancel); - assert.ok(survivorAfterCancel.oauth?.clientSecret); - assert.notEqual(survivorAfterCancel.oauth?.clientSecret, 'real-secret'); -}); - -test('MCP market cancellation waits for an in-flight config write before rolling it back', async () => { - const handlers = new Map Promise>(); - let config: McpConfigFile = { version: MCP_CONFIG_VERSION, mcpServers: {} }; - let releaseWrite!: () => void; - let markWriteStarted!: () => void; - const writeGate = new Promise((resolve) => { releaseWrite = resolve; }); - const writeStarted = new Promise((resolve) => { markWriteStarted = resolve; }); - const calls: string[] = []; - registerMcpIpcMain({ - ipcMain: { handle(channel, handler) { handlers.set(channel, handler as (...args: any[]) => Promise); } }, - store: { - get: async () => config, - transform: async (apply) => { - calls.push('transaction:start'); - markWriteStarted(); - await writeGate; - const next = await apply(config); - calls.push('write'); - config = next; - calls.push('transaction:end'); - return config; - }, - upsert: async (serverId, server) => { - config = { version: MCP_CONFIG_VERSION, mcpServers: { ...config.mcpServers, [serverId]: server } }; - return config; - }, - remove: async (serverId) => { - calls.push('remove'); - const { [serverId]: _removed, ...mcpServers } = config.mcpServers; - config = { version: MCP_CONFIG_VERSION, mcpServers }; - return config; - }, - }, - manager: { - cancelConnect: () => { calls.push('cancel'); return true; }, - forgetServerCredentials: async () => { calls.push('forget'); }, - sync: async () => { calls.push('sync'); }, - statuses: () => [], - test: async () => { throw new Error('not used'); }, - }, - oauth: { - isActive: () => false, - cancelLogin: () => false, - login: async () => { throw new Error('not used'); }, - logout: async () => { throw new Error('not used'); }, - resumeLogin: async () => undefined, - }, - ensureReady: async () => {}, - publishCapabilities: async () => { calls.push('publish'); }, - onPublicationError: () => { calls.push('publication:error'); }, - emitChanged: () => { calls.push('emit'); }, - }); - - const install = handlers.get('mcp:install'); - const cancelInstall = handlers.get('mcp:cancelInstall'); - assert.ok(install); - assert.ok(cancelInstall); - - // The fake store skips normalizeMcpConfig, so the install config is given - // in its normal form — the real-store variant below covers the - // normalization mismatch. - const installing = install({}, 'fixture', { enabled: true, command: 'node' }); - await writeStarted; - const cancelling = cancelInstall({}, 'fixture'); - releaseWrite(); - - const [, cancelled] = await Promise.all([installing, cancelling]); - assert.equal(cancelled.mcpServers.fixture, undefined); - assert.equal(config.mcpServers.fixture, undefined); - // The cancellation's own removal is a full transaction on the same lane: - // credentials retire first, then the conditional write. - assert.deepEqual(calls, [ - 'transaction:start', 'cancel', 'write', 'transaction:end', - 'transaction:start', 'forget', 'write', 'transaction:end', - 'sync', 'emit', 'publish', - ]); }); test('an active login on a secret-bearing server does not veto edits to another server', async () => { @@ -401,7 +309,6 @@ test('an active login on a secret-bearing server does not veto edits to another remove: async () => config, }, manager: { - cancelConnect: () => false, forgetServerCredentials: async () => {}, sync: async () => {}, statuses: () => [], @@ -465,7 +372,6 @@ test('a URL change retires the old endpoint credentials before the write, and an remove: async () => config, }, manager: { - cancelConnect: () => false, forgetServerCredentials: async () => { calls.push('forget'); if (eraseFails) throw new Error('credential store unavailable'); @@ -520,70 +426,6 @@ test('a URL change retires the old endpoint credentials before the write, and an assert.deepEqual(calls, ['transaction:start', 'write', 'sync']); }); -test('cancelling an install rolls back only its own write, never a newer same-id config', async () => { - const handlers = new Map Promise>(); - let config: McpConfigFile = { version: MCP_CONFIG_VERSION, mcpServers: {} }; - let releaseInstallSync!: () => void; - const installSyncGate = new Promise((resolve) => { releaseInstallSync = resolve; }); - let syncs = 0; - registerMcpIpcMain({ - ipcMain: { handle(channel, handler) { handlers.set(channel, handler as (...args: any[]) => Promise); } }, - store: { - get: async () => config, - transform: async (apply) => { config = await apply(config); return config; }, - upsert: async (_serverId, _server) => config, - remove: async () => config, - }, - manager: { - cancelConnect: () => { releaseInstallSync(); return true; }, - forgetServerCredentials: async () => {}, - sync: async () => { - syncs += 1; - // Only the install's connect parks; later syncs pass through. - if (syncs === 1) await installSyncGate; - }, - statuses: () => [], - test: async () => { throw new Error('not used'); }, - }, - oauth: { - isActive: () => false, - cancelLogin: () => false, - login: async () => { throw new Error('not used'); }, - logout: async () => { throw new Error('not used'); }, - resumeLogin: async () => undefined, - }, - ensureReady: async () => {}, - publishCapabilities: async () => {}, - onPublicationError: () => {}, - emitChanged: () => {}, - }); - - const install = handlers.get('mcp:install'); - const upsert = handlers.get('mcp:upsert'); - const cancelInstall = handlers.get('mcp:cancelInstall'); - assert.ok(install); - assert.ok(upsert); - assert.ok(cancelInstall); - - // The install commits A and parks in its connect; a newer same-id config - // B lands through upsert while it waits. - const installing = install({}, 'x', { command: 'installed-a' }); - await new Promise((resolve) => setImmediate(resolve)); - await upsert({}, 'x', { command: 'newer-b' }); - - const cancelled = await cancelInstall({}, 'x'); - await installing; - - // The cancellation found B where it committed A: it must decline the - // rollback instead of deleting the newer server (and its credentials). - const survivor = config.mcpServers.x; - assert.ok(survivor && 'command' in survivor); - assert.equal(survivor.command, 'newer-b'); - const echoed = cancelled.mcpServers.x; - assert.ok(echoed && 'command' in echoed); - assert.equal(echoed.command, 'newer-b'); -}); - test('a login claim travels the shared lane and cannot land inside an open transaction', async () => { const handlers = new Map Promise>(); let config: McpConfigFile = { @@ -610,7 +452,6 @@ test('a login claim travels the shared lane and cannot land inside an open trans remove: async () => config, }, manager: { - cancelConnect: () => false, forgetServerCredentials: async () => {}, sync: async () => {}, statuses: () => [], @@ -658,63 +499,6 @@ test('a login claim travels the shared lane and cannot land inside an open trans ); }); -test('cancelling an install through the REAL store rolls the entry back despite normalization', async () => { - // The fake stores in this file skip normalizeMcpConfig; the real store - // rebuilds each server (key order, defaulted enabled/transport, WHATWG - // URL) on write. The cancellation's identity check must compare in that - // normal form, or it mismatches its own persisted entry and silently - // keeps the cancelled server installed. - const root = await mkdtemp(join(tmpdir(), 'mcp-ipc-real-')); - try { - const store = createMcpConfigStore(root); - const handlers = new Map Promise>(); - let releaseInstallSync!: () => void; - const installSyncGate = new Promise((resolve) => { releaseInstallSync = resolve; }); - let syncs = 0; - registerMcpIpcMain({ - ipcMain: { handle(channel, handler) { handlers.set(channel, handler as (...args: any[]) => Promise); } }, - store, - manager: { - cancelConnect: () => { releaseInstallSync(); return true; }, - forgetServerCredentials: async () => {}, - sync: async () => { - syncs += 1; - if (syncs === 1) await installSyncGate; - }, - statuses: () => [], - test: async () => { throw new Error('not used'); }, - }, - oauth: { - isActive: () => false, - cancelLogin: () => false, - login: async () => { throw new Error('not used'); }, - logout: async () => { throw new Error('not used'); }, - resumeLogin: async () => undefined, - }, - ensureReady: async () => {}, - publishCapabilities: async () => {}, - onPublicationError: () => {}, - emitChanged: () => {}, - }); - - const install = handlers.get('mcp:install'); - const cancelInstall = handlers.get('mcp:cancelInstall'); - assert.ok(install); - assert.ok(cancelInstall); - - // No `enabled`, no `transport`: the store materializes both on write. - const installing = install({}, 'market', { url: 'https://mcp.vercel.com' }); - await new Promise((resolve) => setImmediate(resolve)); - const cancelled = await cancelInstall({}, 'market'); - await installing; - - assert.equal(cancelled.mcpServers.market, undefined); - assert.equal((await store.get()).mcpServers.market, undefined); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); - test('the config commit applies its mutation to the transaction snapshot', async () => { const handlers = new Map Promise>(); const config: McpConfigFile = { version: MCP_CONFIG_VERSION, mcpServers: {} }; @@ -738,7 +522,6 @@ test('the config commit applies its mutation to the transaction snapshot', async remove: async () => config, }, manager: { - cancelConnect: () => false, forgetServerCredentials: async () => {}, sync: async () => {}, statuses: () => [], @@ -788,7 +571,6 @@ test('MCP config commit is not rolled back by a capability publication failure', remove: async () => config, }, manager: { - cancelConnect: () => false, forgetServerCredentials: async () => {}, sync: async () => {}, statuses: () => [], @@ -812,7 +594,7 @@ test('MCP config commit is not rolled back by a capability publication failure', const upsert = handlers.get('mcp:upsert'); assert.ok(upsert); const committed = await upsert({}, 'fixture', { command: 'node' }); - assert.deepEqual(committed.mcpServers.fixture, { command: 'node' }); + assert.deepEqual(committed.mcpServers.fixture, { command: 'node', enabled: true }); await new Promise((resolve) => setImmediate(resolve)); assert.deepEqual(publicationErrors.map((error) => (error as Error).message), [ 'Host disconnected', diff --git a/apps/desktop/src/main/__tests__/module-hub-mcp-controller.test.ts b/apps/desktop/src/main/__tests__/module-hub-mcp-controller.test.ts new file mode 100644 index 0000000000..19b5045f9e --- /dev/null +++ b/apps/desktop/src/main/__tests__/module-hub-mcp-controller.test.ts @@ -0,0 +1,126 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { act, createElement } from 'react'; +import { deferred } from '@maka/core/test-only/async-primitives'; +import { createDefaultMcpConfig, type McpConfigFile, type McpServerStatus } from '@maka/core/mcp'; +import { createFakeModuleHubServices, ModuleHubServicesProvider } from '../../renderer/features/module-hub/testing.js'; +import { useMcpController } from '../../renderer/features/module-hub/controller/use-mcp-controller.js'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; + +afterEach(cleanupFakeDom); + +test('MCP create rejects an occupied ID and refreshes committed config after a failed connection', async () => { + const { root } = installReactRenderer(); + const defaults = createFakeModuleHubServices(); + let saved: McpConfigFile = { ...createDefaultMcpConfig(), mcpServers: { existing: { command: 'original' } } }; + let updates = 0; + const services = createFakeModuleHubServices({ mcp: { + ...defaults.mcp, + getConfig: async () => saved, + add: async (id, server) => { + if (id in saved.mcpServers) return { status: 'exists' }; + saved = { ...saved, mcpServers: { ...saved.mcpServers, [id]: server } }; + throw new Error('connection failed after save'); + }, + upsert: async () => { updates++; return saved; }, + } }); + let controller!: ReturnType; + function Probe() { controller = useMcpController(); return null; } + await act(async () => root.render(createElement(ModuleHubServicesProvider, { services }, createElement(Probe)))); + await act(async () => assert.deepEqual(await controller.save('existing', { command: 'replacement' }, true), { status: 'exists' })); + assert.deepEqual(saved.mcpServers.existing, { command: 'original' }); + assert.equal(updates, 0); + await act(async () => { await controller.save('new', { command: 'server' }, true); }); + assert.deepEqual(controller.config.mcpServers.new, { command: 'server' }); + assert.match(String(controller.error), /connection failed/); + assert.equal(controller.busy, null); +}); + +test('MCP login can be cancelled on its original Host and never writes a late result into another Host', async () => { + const { root } = installReactRenderer(); + const defaults = createFakeModuleHubServices(); + let host = { profileId: 'a', hostId: 'a' }; + let changed!: () => void; + const login = deferred(); + const started = deferred(); + const cancelled: string[] = []; + const services = createFakeModuleHubServices({ + runtimeHosts: { getDefault: async () => host, subscribeChanges: (handler) => { changed = () => handler({ ...host, isDefault: true, readiness: 'ready' }); return () => {}; } }, + mcp: { + ...defaults.mcp, + getConfig: async (scope) => ({ ...createDefaultMcpConfig(), mcpServers: { [scope.hostId]: { command: 'server' } } }), + login: async () => { started.resolve(); return login.promise; }, + cancelLogin: async (_id, scope) => { cancelled.push(scope.hostId); login.reject(new Error('Login cancelled')); return true; }, + }, + }); + let controller!: ReturnType; + function Probe() { controller = useMcpController(); return null; } + await act(async () => root.render(createElement(ModuleHubServicesProvider, { services }, createElement(Probe)))); + let pending!: Promise; + await act(async () => { pending = controller.login('remote'); await started.promise; }); + assert.equal(controller.busy, 'login:remote'); + await act(async () => { host = { profileId: 'b', hostId: 'b' }; changed(); }); + await act(async () => { await controller.cancelLogin('remote'); await pending; }); + assert.deepEqual(cancelled, ['a']); + assert.equal(controller.error, null); + assert.deepEqual(Object.keys(controller.config.mcpServers), ['b']); + assert.equal(controller.busy, null); +}); + +test('MCP ignores an older config read after a change notification', async () => { + const { root } = installReactRenderer(); + const defaults = createFakeModuleHubServices(); + const oldRead = deferred(); + let first = true; + let changed!: () => void; + let unsubscribed = false; + const services = createFakeModuleHubServices({ mcp: { + ...defaults.mcp, + getConfig: async () => { if (first) { first = false; return oldRead.promise; } return { ...createDefaultMcpConfig(), mcpServers: { newer: { command: 'server' } } }; }, + subscribeChanges: (handler) => { changed = handler; return () => { unsubscribed = true; }; }, + } }); + let controller!: ReturnType; + function Probe() { controller = useMcpController(); return null; } + await act(async () => root.render(createElement(ModuleHubServicesProvider, { services }, createElement(Probe)))); + await act(async () => changed()); + await act(async () => oldRead.resolve(createDefaultMcpConfig())); + assert.deepEqual(Object.keys(controller.config.mcpServers), ['newer']); + await act(async () => root.unmount()); + assert.equal(unsubscribed, true); +}); + +test('MCP can cancel an active login after reopening the page', async () => { + const { root } = installReactRenderer(); + const defaults = createFakeModuleHubServices(); + let pending = true; + const services = createFakeModuleHubServices({ mcp: { + ...defaults.mcp, + listStatuses: async () => [{ serverId: 'remote', state: 'needs-auth', toolCount: 0, tools: [], updatedAt: 1, authorizationPending: pending }], + cancelLogin: async () => { pending = false; return true; }, + } }); + let controller!: ReturnType; + function Probe() { controller = useMcpController(); return null; } + await act(async () => root.render(createElement(ModuleHubServicesProvider, { services }, createElement(Probe)))); + assert.equal(controller.statuses[0]?.authorizationPending, true); + await act(async () => { await controller.cancelLogin('remote'); }); + assert.equal(controller.statuses[0]?.authorizationPending, false); +}); diff --git a/apps/desktop/src/main/mcp-ipc-main.ts b/apps/desktop/src/main/mcp-ipc-main.ts index 368155cfb1..5b530e9c1d 100644 --- a/apps/desktop/src/main/mcp-ipc-main.ts +++ b/apps/desktop/src/main/mcp-ipc-main.ts @@ -20,7 +20,6 @@ import type { IpcMain } from 'electron'; import { MCP_CONFIG_VERSION, - mcpConfigChangeRetiresCredentials, type McpConfigAddResult, type McpConfigFile, type McpConfigImportResult, @@ -30,10 +29,9 @@ import { import type { McpClientManager } from '@maka/mcp'; import { AtomicFileWriteCommitUnknownError, - assertMcpEndpointPolicyOnChanges, McpServerExistsError, McpConfigSourceError, - normalizeMcpConfig, + updateMcpConfiguration, normalizeMcpImport, type McpConfigStore, } from '@maka/storage/mcp-config-store'; @@ -49,7 +47,7 @@ export interface McpIpcMainDeps { store: McpConfigStore; manager: Pick< McpClientManager, - 'sync' | 'statuses' | 'test' | 'cancelConnect' | 'forgetServerCredentials' + 'sync' | 'statuses' | 'test' | 'forgetServerCredentials' >; oauth: McpOAuthController; /** Shared with the OAuth controller (see createMcpExclusiveLane). Falls @@ -81,10 +79,6 @@ export function createMcpExclusiveLane(): McpExclusiveLane { } export function registerMcpIpcMain(deps: McpIpcMainDeps): void { - const installs = new Map< - string, - { cancelled: boolean; committed?: string; settled: Promise; settle(): void } - >(); // Main is the authority on operation exclusivity, not the renderer's // advisory locks: while a login round owns a server, a config mutation // would race the browser callback against a changed or absent server. @@ -108,12 +102,10 @@ export function registerMcpIpcMain(deps: McpIpcMainDeps): void { const inMutationLane = deps.exclusiveLane ?? createMcpExclusiveLane(); const commitConfig = async ( mutate: (current: McpConfigFile) => McpConfigFile, - assertReconciliationAllowed?: () => void, ): Promise => { try { - return await deps.store.transform(async (current) => { + return await updateMcpConfiguration(deps.store, (current) => { const next = mutate(current); - assertMcpEndpointPolicyOnChanges(current, next); // The authoritative gate: every server this commit semantically touches // is re-checked INSIDE the lane. The handler-entry checks are advisory // fast-fails; this one cannot race a login claim, because claims travel @@ -126,16 +118,8 @@ export function registerMcpIpcMain(deps: McpIpcMainDeps): void { const after = next.mcpServers[serverId]; if (JSON.stringify(before) !== JSON.stringify(after)) assertNoActiveLogin(serverId); } - // Erases are per-server and not transactional as a set: if one fails - // partway, the commit aborts with the EARLIER servers already logged - // out. That partial effect is deliberately in the fail-closed direction - // — a re-login is recoverable, a credential outliving its removed or - // repointed config is not. - for (const serverId of credentialRetirements(current, next)) { - await deps.manager.forgetServerCredentials(serverId); - } return next; - }); + }, (serverId, previous) => deps.manager.forgetServerCredentials(serverId, previous)); } catch (error) { if (!(error instanceof AtomicFileWriteCommitUnknownError)) throw error; // Rename has already published a file even though its durability fence @@ -145,7 +129,6 @@ export function registerMcpIpcMain(deps: McpIpcMainDeps): void { // the reconciliation, and retain the original durability failure. try { const authoritative = await deps.store.get(); - assertReconciliationAllowed?.(); await deps.manager.sync(authoritative); changed(deps); } catch (reconciliationError) { @@ -168,7 +151,10 @@ export function registerMcpIpcMain(deps: McpIpcMainDeps): void { }); deps.ipcMain.handle('mcp:listStatuses', async () => { await deps.ensureReady(); - return deps.manager.statuses(); + return deps.manager.statuses().map((status) => ({ + ...status, + ...(deps.oauth.isActive(status.serverId) ? { authorizationPending: true } : {}), + })); }); deps.ipcMain.handle( 'mcp:importConfig', @@ -248,62 +234,6 @@ export function registerMcpIpcMain(deps: McpIpcMainDeps): void { changed(deps); return redactMcpConfigSecrets(next); }); - deps.ipcMain.handle('mcp:install', async (_event, serverId: string, config: McpServerConfig) => { - assertNoActiveLogin(serverId); - if (installs.has(serverId)) throw new Error(`MCP install already in progress: ${serverId}`); - let settle!: () => void; - const operation = { - cancelled: false, - committed: undefined as string | undefined, - settled: new Promise((resolve) => { settle = resolve; }), - settle: () => settle(), - }; - installs.set(serverId, operation); - try { - const next = await inMutationLane(() => - commitConfig((current) => { - const installed = restoreMcpServerSecret(serverId, config, current); - // What THIS install committed, for the cancellation to compare - // against: a cancel must only roll back its own write, never a - // newer same-id configuration that landed after it. Recorded in - // the STORE's normal form — the real store normalizes on write - // (key order, defaulted enabled/transport, WHATWG URL), so the - // raw restored shape would mismatch its own persisted entry and - // the rollback would silently no-op. - operation.committed = JSON.stringify( - normalizeMcpConfig({ - version: MCP_CONFIG_VERSION, - mcpServers: { [serverId]: installed }, - }).mcpServers[serverId], - ); - return { - ...current, - mcpServers: { ...current.mcpServers, [serverId]: installed }, - }; - }, () => { - // Cancellation may have called cancelConnect while the write was - // pending. Do not start a new connection after that cancellation; - // the existing settled/rollback path will reconcile the removal. - if (operation.cancelled) { - throw new Error('MCP installation cancelled; awaiting configuration rollback'); - } - }), - ); - if (operation.cancelled) return redactMcpConfigSecrets(next); - // The connect runs OUTSIDE the mutation lane: a cancellation must be - // able to interrupt it, and its own removal transaction needs the lane. - try { - await deps.manager.sync(next); - } catch (error) { - if (!operation.cancelled) throw error; - } - if (!operation.cancelled) changed(deps); - return redactMcpConfigSecrets(next); - } finally { - if (installs.get(serverId) === operation) installs.delete(serverId); - operation.settle(); - } - }); const removeServer = async (serverId: string): Promise => inMutationLane(() => commitConfig((current) => { @@ -320,31 +250,6 @@ export function registerMcpIpcMain(deps: McpIpcMainDeps): void { // servers' secrets must leave as sentinels here too. return redactMcpConfigSecrets(next); }); - deps.ipcMain.handle('mcp:cancelInstall', async (_event, serverId: string) => { - assertNoActiveLogin(serverId); - const operation = installs.get(serverId); - if (operation) operation.cancelled = true; - deps.manager.cancelConnect(serverId); - await operation?.settled; - // Roll back only the install's OWN write. While the cancel waited, an - // upsert can have replaced the entry with a newer same-id config — - // removing whatever is current would delete that newer server and - // retire its credentials. - const next = await inMutationLane(() => - commitConfig((current) => { - const entry = current.mcpServers[serverId]; - if (entry === undefined) return current; - if (operation?.committed !== undefined && JSON.stringify(entry) !== operation.committed) { - return current; - } - const { [serverId]: _removed, ...mcpServers } = current.mcpServers; - return { ...current, mcpServers }; - }), - ); - await deps.manager.sync(next); - changed(deps); - return redactMcpConfigSecrets(next); - }); deps.ipcMain.handle('mcp:test', async (_event, serverId: string) => { await deps.ensureReady(); const result = await deps.manager.test(serverId); @@ -383,22 +288,6 @@ export function registerMcpIpcMain(deps: McpIpcMainDeps): void { }); } -/** Servers whose stored credentials this commit orphans: removed outright, - * repointed to a different endpoint, or converted away from remote. An - * unchanged endpoint keeps its credentials. Removals retire regardless of - * kind — a stale record under a formerly-remote id must not survive the id - * being freed for reuse. */ -function credentialRetirements(current: McpConfigFile, next: McpConfigFile): string[] { - const retired: string[] = []; - for (const [serverId, server] of Object.entries(current.mcpServers)) { - const incoming = Object.hasOwn(next.mcpServers, serverId) - ? next.mcpServers[serverId] - : undefined; - if (mcpConfigChangeRetiresCredentials(server, incoming)) retired.push(serverId); - } - return retired; -} - function changed(deps: McpIpcMainDeps): void { deps.emitChanged(deps.manager.statuses()); void Promise.resolve() diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 36f4c7297d..720820325f 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -1583,9 +1583,7 @@ export interface MakaBridge { * instead of an error, so the dialog can put it on the id field. */ add(serverId: string, config: McpServerConfig, host?: DesktopRuntimeHostRef): Promise; upsert(serverId: string, config: McpServerConfig, host?: DesktopRuntimeHostRef): Promise; - install(serverId: string, config: McpServerConfig, host?: DesktopRuntimeHostRef): Promise; remove(serverId: string, host?: DesktopRuntimeHostRef): Promise; - cancelInstall(serverId: string, host?: DesktopRuntimeHostRef): Promise; test(serverId: string, host?: DesktopRuntimeHostRef): Promise; login(serverId: string, host?: DesktopRuntimeHostRef): Promise; /** Ends an in-flight login round; resolves false when none is active. */ diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index b0454298d2..5bffc127b4 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -3172,15 +3172,9 @@ const makaBridge = { upsert(serverId: string, config: McpServerConfig, host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'mcp:upsert', serverId, config); }, - install(serverId: string, config: McpServerConfig, host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'mcp:install', serverId, config); - }, remove(serverId: string, host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'mcp:remove', serverId); }, - cancelInstall(serverId: string, host?: DesktopRuntimeHostRef): Promise { - return invokeSelectedRuntimeHost(host, 'mcp:cancelInstall', serverId); - }, test(serverId: string, host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'mcp:test', serverId); }, diff --git a/apps/desktop/src/renderer/features/module-hub/controller/use-mcp-controller.ts b/apps/desktop/src/renderer/features/module-hub/controller/use-mcp-controller.ts new file mode 100644 index 0000000000..dd8974c331 --- /dev/null +++ b/apps/desktop/src/renderer/features/module-hub/controller/use-mcp-controller.ts @@ -0,0 +1,135 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + createDefaultMcpConfig, + type McpConfigAddResult, + type McpConfigFile, + type McpServerConfig, + type McpServerStatus, +} from '@maka/core/mcp'; +import { useMountedRef } from '@maka/ui'; +import { useModuleHubServices } from '../services-context.js'; +import type { ModuleHubRuntimeHostRef } from '../ports.js'; +import { isDefaultRuntimeHostCurrent, runOnDefaultRuntimeHost } from './default-runtime-host.js'; + +export function useMcpController() { + const { mcp, runtimeHosts } = useModuleHubServices(); + const mounted = useMountedRef(); + const [config, setConfig] = useState(createDefaultMcpConfig); + const [statuses, setStatuses] = useState([]); + const [busy, setBusy] = useState('load'); + const [error, setError] = useState(null); + const operation = useRef<{ key: string; host?: ModuleHubRuntimeHostRef; cancelled?: boolean } | null>(null); + const revision = useRef(0); + + const reload = useCallback(async () => { + const request = ++revision.current; + try { + const result = await runOnDefaultRuntimeHost(runtimeHosts, (host) => + Promise.all([mcp.getConfig(host), mcp.listStatuses(host)]), + ); + if ( + !await isDefaultRuntimeHostCurrent(runtimeHosts, result.host) || + !mounted.current || request !== revision.current + ) return; + setConfig(result.value[0]); + setStatuses(result.value[1]); + } catch (failure) { + if (mounted.current && request === revision.current) setError(failure); + } finally { + if (mounted.current && request === revision.current && !operation.current) setBusy(null); + } + }, [mcp, runtimeHosts, mounted]); + + useEffect(() => { + void reload(); + const unsubscribe = mcp.subscribeChanges(() => void reload()); + const unsubscribeHosts = runtimeHosts.subscribeChanges(() => { + setConfig(createDefaultMcpConfig()); + setStatuses([]); + void reload(); + }); + return () => { + ++revision.current; + unsubscribe(); + unsubscribeHosts(); + }; + }, [mcp, runtimeHosts, reload]); + + async function run(key: string, action: (host: ModuleHubRuntimeHostRef) => Promise): Promise { + if (operation.current) return undefined; + const current: { key: string; host?: ModuleHubRuntimeHostRef; cancelled?: boolean } = { key }; + operation.current = current; + setBusy(key); + setError(null); + try { + const result = await runOnDefaultRuntimeHost(runtimeHosts, (host) => { + current.host = host; + return action(host); + }); + if (mounted.current && await isDefaultRuntimeHostCurrent(runtimeHosts, result.host)) return result.value; + } catch (failure) { + if (mounted.current && !current.cancelled) setError(failure); + } finally { + operation.current = null; + if (mounted.current) { + setBusy(null); + await reload(); + } + } + return undefined; + } + + return { + config, + statuses, + busy, + error, + reload, + save: (id: string, config: McpServerConfig, creating: boolean) => + run('save', async (host) => creating + ? mcp.add(id, config, host) + : { status: 'added', config: await mcp.upsert(id, config, host) }, + ), + importConfig: (source: string) => run('import', (host) => mcp.importConfig(source, host)), + setEnabled: (id: string, config: McpServerConfig, enabled: boolean) => + run(`toggle:${id}`, (host) => mcp.upsert(id, { ...config, enabled }, host)), + remove: (id: string) => run(`remove:${id}`, (host) => mcp.remove(id, host)), + test: (id: string) => run(`test:${id}`, (host) => mcp.test(id, host)), + login: (id: string) => run(`login:${id}`, (host) => mcp.login(id, host)), + logout: (id: string) => run(`logout:${id}`, (host) => mcp.logout(id, host)), + async cancelLogin(id: string) { + const current = operation.current; + if (!current) { + await run(`cancel:${id}`, (host) => mcp.cancelLogin(id, host)); + return; + } + if (current.key !== `login:${id}` || !current.host) return; + current.cancelled = true; + try { + await mcp.cancelLogin(id, current.host); + } catch (failure) { + current.cancelled = false; + if (mounted.current) setError(failure); + } + }, + }; +} diff --git a/apps/desktop/src/renderer/features/module-hub/ports.ts b/apps/desktop/src/renderer/features/module-hub/ports.ts index f8609a2dba..0cd7cd9a52 100644 --- a/apps/desktop/src/renderer/features/module-hub/ports.ts +++ b/apps/desktop/src/renderer/features/module-hub/ports.ts @@ -17,6 +17,7 @@ * under the License. */ +import type { McpConfigFile, McpServerStatus, McpServerConfig, McpConfigAddResult, McpConfigImportResult, McpTestResult } from '@maka/core/mcp'; import type { DailyReviewArchive, DailyReviewArchiveSummary, @@ -248,7 +249,22 @@ export interface ModuleHubClipboardService { } /** Environment capabilities owned by the Module Hub feature slice. */ +export interface ModuleHubMcpService { + getConfig(host: ModuleHubRuntimeHostRef): Promise; + listStatuses(host: ModuleHubRuntimeHostRef): Promise; + add(id: string, config: McpServerConfig, host: ModuleHubRuntimeHostRef): Promise; + upsert(id: string, config: McpServerConfig, host: ModuleHubRuntimeHostRef): Promise; + importConfig(source: string, host: ModuleHubRuntimeHostRef): Promise; + remove(id: string, host: ModuleHubRuntimeHostRef): Promise; + test(id: string, host: ModuleHubRuntimeHostRef): Promise; + login(id: string, host: ModuleHubRuntimeHostRef): Promise; + cancelLogin(id: string, host: ModuleHubRuntimeHostRef): Promise; + logout(id: string, host: ModuleHubRuntimeHostRef): Promise; + subscribeChanges(handler: () => void): ModuleHubUnsubscribe; +} + export interface ModuleHubServices { + mcp: ModuleHubMcpService; runtimeHosts: ModuleHubRuntimeHostsService; skills: ModuleHubSkillsService; scheduledTasks: ModuleHubScheduledTasksService; diff --git a/apps/desktop/src/renderer/features/module-hub/testing.ts b/apps/desktop/src/renderer/features/module-hub/testing.ts index 6472acd684..c8520368c0 100644 --- a/apps/desktop/src/renderer/features/module-hub/testing.ts +++ b/apps/desktop/src/renderer/features/module-hub/testing.ts @@ -132,6 +132,20 @@ export function createFakeModuleHubServices( overrides: Partial = {}, ): ModuleHubServices { return { + mcp: { + getConfig: async () => ({ version: 3, mcpServers: {} }), + listStatuses: async () => [], + add: async () => notConfigured("mcp.add"), + upsert: async () => notConfigured("mcp.upsert"), + importConfig: async () => notConfigured("mcp.importConfig"), + remove: async () => notConfigured("mcp.remove"), + test: async () => notConfigured("mcp.test"), + login: async () => notConfigured("mcp.login"), + logout: async () => notConfigured("mcp.logout"), + cancelLogin: async () => false, + subscribeChanges: noopSubscription, + }, + runtimeHosts: { getDefault: async () => ({ profileId: "local", hostId: "local" }), subscribeChanges: noopSubscription, diff --git a/apps/desktop/src/renderer/features/module-hub/ui/module-hub-host.tsx b/apps/desktop/src/renderer/features/module-hub/ui/module-hub-host.tsx index 7792351cd1..3716fc9dfd 100644 --- a/apps/desktop/src/renderer/features/module-hub/ui/module-hub-host.tsx +++ b/apps/desktop/src/renderer/features/module-hub/ui/module-hub-host.tsx @@ -57,8 +57,6 @@ export function ModuleHubHostView({ model }: { model: ModuleHubHostModel }) { ), }; if (route === 'mcp') { - // Explicit leaf-owner exception: MCP keeps its existing page-owned - // controller and direct bridge; Module Hub only selects and mounts it. return ; } return ( diff --git a/apps/desktop/src/renderer/locales/mcp-copy.ts b/apps/desktop/src/renderer/locales/mcp-copy.ts index 12f67ae260..c2b96945a2 100644 --- a/apps/desktop/src/renderer/locales/mcp-copy.ts +++ b/apps/desktop/src/renderer/locales/mcp-copy.ts @@ -21,26 +21,25 @@ import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; export type McpCopy = { errors: { - load: string; install(name: string): string; cancelInstall(name: string): string; save: string; import: string; + load: string; save: string; import: string; update: string; test: string; remove: string; unavailableStatus: string; mapLine(line: number): string; importJson: string; importObject: string; importVersion(version: string): string; importServersObject: string; importProtocolVersion: string; writeDurabilityUnknown: string; writeOutOfSync: string; }; toast: { - templateInstalled(name: string): string; templateInstalledDetail: string; installed(name: string): string; - installedDetail: string; installCancelled(name: string): string; saved: string; savedDetail: string; + saved: string; savedDetail: string; imported: string; importedDetail(count: number): string; connectionOk: string; toolLatency(count: number, latencyMs: number): string; connectionFailed: string; removed: string; }; remove: { title(id: string): string; description: string; confirm: string; cancel: string }; page: { actionsAria: string; refreshing: string; refresh: string; add: string; - metaInstalled(count: number): string; metaErrors(count: number): string; + metaConnections(count: number): string; metaErrors(count: number): string; searchMatches(count: number): string; workspaceAria: string; toolbarAria: string; setupTitle: string; setupDescription: string; localStdio: string; - categoriesAria: string; market: string; installed: string; searchPlaceholder: string; searchAria: string; - noMarket: string; noMarketDetail(query: string): string; clearSearch: string; loading: string; - noInstalled: string; noInstalledDetail: string; browseMarket: string; noInstalledMatch: string; noInstalledMatchDetail(query: string): string; + categoriesAria: string; templates: string; connections: string; searchPlaceholder: string; searchAria: string; + noTemplates: string; noTemplatesDetail(query: string): string; clearSearch: string; loading: string; + noConnections: string; noConnectionsDetail: string; browseTemplates: string; noConnectionsMatch: string; noConnectionsMatchDetail(query: string): string; }; detail: { label: string; enabled: string; transport: string; endpoint: string; @@ -48,11 +47,9 @@ export type McpCopy = { negotiatedProtocol(era: 'legacy' | 'modern', revision: string): string; inspectorOpened(id: string): string; }; - card: { - macOnly: string; manage: string; cancellingAria(name: string): string; cancelAria(name: string): string; installAria(name: string): string; - cancelling: string; cancel: string; install: string; - }; + card: { macOnly: string; useTemplate: string }; row: { + needsAuth: string; login: string; loginPending: string; cancelLogin: string; logout: string; testing: string; test: string; edit: string; delete: string; tools(count: number): string; disabled: string; disconnected: string; connecting: string; connected(count: number): string; failed: string; @@ -64,6 +61,7 @@ export type McpCopy = { serverId: string; command: string; commandPlaceholder: string; commandHelp: string; workingDirectory: string; workingDirectoryPlaceholder: string; environment: string; environmentHelp: string; url: string; headers: string; headersHelp: string; saveConnect: string; + idExists: string; oauth: string; oauthHelp: string; issuer: string; clientId: string; clientSecret: string; scopes: string; callbackPort: string; required: string; invalidUrl: string; unbalancedQuote: string; transportLabel: string; transportAuto: string; transportStreamableHttp: string; transportLegacySse: string; protocolLabel: string; protocolLegacy: string; protocolAuto: string; protocolModern: string; @@ -74,7 +72,7 @@ export type McpCopy = { const MCP_COPY = { 'zh-CN': { errors: { - load: '载入 MCP 失败', install: (name) => `安装 ${name} 失败`, cancelInstall: (name) => `取消安装 ${name} 失败`, save: '保存 MCP 失败', + load: '载入 MCP 失败', save: '保存 MCP 失败', writeDurabilityUnknown: '写入已发布,但无法确认断电后是否保留。请检查刷新后的配置再决定是否重试。', writeOutOfSync: '写入的持久性尚未确认,MCP 运行状态也未能与配置同步。请检查配置并重新同步后再重试。', import: '导入 MCP 失败', update: '更新 MCP 失败', test: 'MCP 测试失败', remove: '删除 MCP 失败', unavailableStatus: 'Server 没有返回可用状态。', @@ -83,8 +81,6 @@ const MCP_COPY = { importProtocolVersion: 'remote 的 protocol 需要 version 2 或 3;stdio 的 protocol 需要 version 3', }, toast: { - templateInstalled: (name) => `${name} 模板已安装`, templateInstalledDetail: '请在「已安装」中完成凭据配置,再启用连接。', - installed: (name) => `${name} 已安装`, installedDetail: '发现的工具会从下一次 agent turn 开始生效。', installCancelled: (name) => `已取消安装 ${name}`, saved: 'MCP 已保存', savedDetail: '新工具会从下一次 agent turn 开始生效。', imported: '已导入 MCP', importedDetail: (count) => `本次导入 ${count} 个 server。`, connectionOk: 'MCP 连接正常', toolLatency: (count, latencyMs) => `${count} 个工具 · ${latencyMs} ms`, connectionFailed: 'MCP 连接失败', removed: 'MCP 已删除', @@ -92,13 +88,13 @@ const MCP_COPY = { remove: { title: (id) => `删除 MCP「${id}」?`, description: '它提供的工具会从下一次 agent turn 中移除,配置无法自动恢复。', confirm: '删除', cancel: '取消' }, page: { actionsAria: 'MCP 操作', refreshing: '刷新中…', refresh: '刷新', add: '添加 MCP', - metaInstalled: (count) => `${count} 个已安装`, metaErrors: (count) => `${count} 个连接异常`, + metaConnections: (count) => `${count} 个连接`, metaErrors: (count) => `${count} 个连接异常`, searchMatches: (count) => `${count} 个匹配`, - workspaceAria: 'MCP 市场与已安装项', toolbarAria: 'MCP 浏览操作', setupTitle: '把 Maka 连接到你的工作环境', setupDescription: '从精选模板开始,或添加任意 stdio、Streamable HTTP 与 SSE server。', - localStdio: '本地 stdio', categoriesAria: 'MCP 分类', market: '市场', installed: '已安装', - searchPlaceholder: '搜索 MCP…', searchAria: '搜索 MCP', noMarket: '没有找到匹配的 MCP', noMarketDetail: (query) => `换一个关键词,或清空「${query}」查看全部模板。`, - clearSearch: '清空搜索', loading: '正在读取 MCP 配置…', noInstalled: '还没有安装 MCP', noInstalledDetail: '从市场选择模板,或手动添加你自己的 server。', - browseMarket: '浏览市场', noInstalledMatch: '没有匹配的已安装 MCP', noInstalledMatchDetail: (query) => `换一个关键词,或清空「${query}」查看全部已安装项。`, + workspaceAria: 'MCP 市场与连接', toolbarAria: 'MCP 浏览操作', setupTitle: '把 Maka 连接到你的工作环境', setupDescription: '从精选模板开始,或添加任意 stdio、Streamable HTTP 与 SSE server。', + localStdio: '本地 stdio', categoriesAria: 'MCP 分类', templates: '模板', connections: '连接', + searchPlaceholder: '搜索 MCP…', searchAria: '搜索 MCP', noTemplates: '没有找到匹配的 MCP', noTemplatesDetail: (query) => `换一个关键词,或清空「${query}」查看全部模板。`, + clearSearch: '清空搜索', loading: '正在读取 MCP 配置…', noConnections: '还没有 MCP 连接', noConnectionsDetail: '选择模板,或手动添加你自己的 server。', + browseTemplates: '浏览模板', noConnectionsMatch: '没有匹配的MCP 连接', noConnectionsMatchDetail: (query) => `换一个关键词,或清空「${query}」查看全部连接。`, }, detail: { label: '服务器详情', enabled: '启用', transport: '传输方式', endpoint: '端点', @@ -106,16 +102,15 @@ const MCP_COPY = { negotiatedProtocol: (era, revision) => `${era === 'modern' ? '现代' : '传统'} · ${revision}`, inspectorOpened: (id) => `已打开 ${id} 的详情`, }, - card: { - macOnly: '仅 macOS', manage: '管理', cancellingAria: (name) => `正在取消安装 ${name}`, cancelAria: (name) => `取消安装 ${name}`, installAria: (name) => `安装 ${name}`, - cancelling: '正在取消…', cancel: '取消安装', install: '安装', - }, + card: { macOnly: '仅 macOS', useTemplate: '使用模板' }, row: { + needsAuth: '需要登录', login: '登录', loginPending: '请在浏览器中完成授权', cancelLogin: '取消登录', logout: '退出授权', testing: '测试中…', test: '测试', edit: '编辑', delete: '删除', tools: (count) => `${count} 个工具`, disabled: '已停用', disconnected: '未连接', connecting: '连接中', connected: (count) => `${count} 个工具`, failed: '连接失败', }, editor: { + idExists: '此 ID 已存在,请使用其他名称。', oauth: 'OAuth 设置', oauthHelp: '通常自动发现。使用预注册客户端时,必须填写其所属授权服务器的 issuer。', issuer: 'OAuth issuer', clientId: '客户端 ID', clientSecret: '客户端密钥', scopes: '权限范围(空格分隔)', callbackPort: '回调端口(可选)', importTitle: '通过 JSON 导入', editTitle: (id) => `编辑 ${id}`, addTitle: '添加 MCP', importSubtitle: '粘贴 mcpServers 配置,同名 server 会被更新。', manualSubtitle: '配置保存在当前工作区的 mcp.json。', modeAria: 'MCP 添加方式', manual: '手动配置', pasteJson: '粘贴 JSON', jsonConfig: 'JSON 配置', jsonHelp: '支持完整 mcpServers 配置或直接的 server map。未在本次导入中出现的已有 MCP 会保留。', cancel: '取消', importConnect: '导入并连接', @@ -125,7 +120,7 @@ const MCP_COPY = { commandHelp: '完整命令行;含空格的参数用引号包裹,不经过 shell 解析。', workingDirectory: '工作目录', workingDirectoryPlaceholder: '可选,例如 /path/to/project', environment: '环境变量', environmentHelp: '每行一个 KEY=value;按 MCP 要求填写。', url: 'MCP URL', headers: 'HTTP 请求头', headersHelp: '每行一个 Header=value。', - saveConnect: '保存并连接', + saveConnect: '保存连接', required: '此字段为必填项。', invalidUrl: '请输入有效的 HTTP 或 HTTPS URL。', unbalancedQuote: '引号未闭合。', transportLabel: '传输协议', transportAuto: '自动回退', transportStreamableHttp: 'Streamable HTTP', transportLegacySse: '旧版 SSE', protocolLabel: '协议偏好', protocolLegacy: '传统', protocolAuto: '自动协商', protocolModern: '仅 2026-07-28', @@ -135,7 +130,7 @@ const MCP_COPY = { }, 'zh-TW': { errors: { - load: '載入 MCP 失敗', install: (name) => `安裝 ${name} 失敗`, cancelInstall: (name) => `取消安裝 ${name} 失敗`, save: '儲存 MCP 失敗', + load: '載入 MCP 失敗', save: '儲存 MCP 失敗', writeDurabilityUnknown: '寫入已發布,但無法確認斷電後是否保留。請檢查重新整理後的設定再決定是否重試。', writeOutOfSync: '寫入的持久性尚未確認,MCP 執行狀態也未能與設定同步。請檢查設定並重新同步後再重試。', import: '匯入 MCP 失敗', update: '更新 MCP 失敗', test: 'MCP 測試失敗', remove: '刪除 MCP 失敗', unavailableStatus: 'Server 沒有返回可用狀態。', @@ -144,8 +139,6 @@ const MCP_COPY = { importProtocolVersion: 'remote 的 protocol 需要 version 2 或 3;stdio 的 protocol 需要 version 3', }, toast: { - templateInstalled: (name) => `${name} 模板已安裝`, templateInstalledDetail: '請在「已安裝」中完成憑據設定,再啟用連線。', - installed: (name) => `${name} 已安裝`, installedDetail: '發現的工具會從下一次 agent turn 開始生效。', installCancelled: (name) => `已取消安裝 ${name}`, saved: 'MCP 已儲存', savedDetail: '新工具會從下一次 agent turn 開始生效。', imported: '已匯入 MCP', importedDetail: (count) => `本次匯入 ${count} 個 server。`, connectionOk: 'MCP 連線正常', toolLatency: (count, latencyMs) => `${count} 個工具 · ${latencyMs} ms`, connectionFailed: 'MCP 連線失敗', removed: 'MCP 已刪除', @@ -153,13 +146,13 @@ const MCP_COPY = { remove: { title: (id) => `刪除 MCP「${id}」?`, description: '它提供的工具會從下一次 agent turn 中移除,設定無法自動恢復。', confirm: '刪除', cancel: '取消' }, page: { actionsAria: 'MCP 操作', refreshing: '重新整理中…', refresh: '重新整理', add: '新增 MCP', - metaInstalled: (count) => `${count} 個已安裝`, metaErrors: (count) => `${count} 個連線異常`, + metaConnections: (count) => `${count} 個連線`, metaErrors: (count) => `${count} 個連線異常`, searchMatches: (count) => `${count} 個符合`, - workspaceAria: 'MCP 市場與已安裝項', toolbarAria: 'MCP 瀏覽操作', setupTitle: '把 Maka 連線到你的工作環境', setupDescription: '從精選模板開始,或新增任意 stdio、Streamable HTTP 與 SSE server。', - localStdio: '本地 stdio', categoriesAria: 'MCP 分類', market: '市場', installed: '已安裝', - searchPlaceholder: '搜尋 MCP…', searchAria: '搜尋 MCP', noMarket: '沒有找到符合的 MCP', noMarketDetail: (query) => `換一個關鍵詞,或清空「${query}」檢視全部模板。`, - clearSearch: '清空搜尋', loading: '正在讀取 MCP 設定…', noInstalled: '還沒有安裝 MCP', noInstalledDetail: '從市場選擇模板,或手動新增你自己的 server。', - browseMarket: '瀏覽市場', noInstalledMatch: '沒有符合的已安裝 MCP', noInstalledMatchDetail: (query) => `換一個關鍵詞,或清空「${query}」檢視全部已安裝項。`, + workspaceAria: 'MCP 市場與連線', toolbarAria: 'MCP 瀏覽操作', setupTitle: '把 Maka 連線到你的工作環境', setupDescription: '從精選模板開始,或新增任意 stdio、Streamable HTTP 與 SSE server。', + localStdio: '本地 stdio', categoriesAria: 'MCP 分類', templates: '模板', connections: '連線', + searchPlaceholder: '搜尋 MCP…', searchAria: '搜尋 MCP', noTemplates: '沒有找到符合的 MCP', noTemplatesDetail: (query) => `換一個關鍵詞,或清空「${query}」檢視全部模板。`, + clearSearch: '清空搜尋', loading: '正在讀取 MCP 設定…', noConnections: '還沒有安裝 MCP', noConnectionsDetail: '選擇模板,或手動新增你自己的 server。', + browseTemplates: '瀏覽模板', noConnectionsMatch: '沒有符合的MCP 連線', noConnectionsMatchDetail: (query) => `換一個關鍵詞,或清空「${query}」檢視全部連線。`, }, detail: { label: '伺服器詳情', enabled: '啟用', transport: '傳輸方式', endpoint: '端點', @@ -167,16 +160,15 @@ const MCP_COPY = { negotiatedProtocol: (era, revision) => `${era === 'modern' ? '現代' : '傳統'} · ${revision}`, inspectorOpened: (id) => `已開啟 ${id} 的詳情`, }, - card: { - macOnly: '僅 macOS', manage: '管理', cancellingAria: (name) => `正在取消安裝 ${name}`, cancelAria: (name) => `取消安裝 ${name}`, installAria: (name) => `安裝 ${name}`, - cancelling: '正在取消…', cancel: '取消安裝', install: '安裝', - }, + card: { macOnly: '僅 macOS', useTemplate: '使用模板' }, row: { + needsAuth: '需要登入', login: '登入', loginPending: '請在瀏覽器中完成授權', cancelLogin: '取消登入', logout: '登出授權', testing: '測試中…', test: '測試', edit: '編輯', delete: '刪除', tools: (count) => `${count} 個工具`, disabled: '已停用', disconnected: '未連線', connecting: '連線中', connected: (count) => `${count} 個工具`, failed: '連線失敗', }, editor: { + idExists: '此 ID 已存在,請使用其他名稱。', oauth: 'OAuth 設定', oauthHelp: '通常自動探索。使用預註冊用戶端時,必須填寫所屬授權伺服器的 issuer。', issuer: 'OAuth issuer', clientId: '用戶端 ID', clientSecret: '用戶端密鑰', scopes: '權限範圍(空格分隔)', callbackPort: '回呼連接埠(選填)', importTitle: '透過 JSON 匯入', editTitle: (id) => `編輯 ${id}`, addTitle: '新增 MCP', importSubtitle: '貼上 mcpServers 設定,同名 server 會被更新。', manualSubtitle: '設定儲存在目前工作區的 mcp.json。', modeAria: 'MCP 新增方式', manual: '手動設定', pasteJson: '貼上 JSON', jsonConfig: 'JSON 設定', jsonHelp: '支援完整 mcpServers 設定或直接的 server map。未在本次匯入中出現的已有 MCP 會保留。', cancel: '取消', importConnect: '匯入並連線', @@ -186,7 +178,7 @@ const MCP_COPY = { commandHelp: '完整命令列;含空格的引數用引號包裹,不經過 shell 解析。', workingDirectory: '工作目錄', workingDirectoryPlaceholder: '可選,例如 /path/to/project', environment: '環境變數', environmentHelp: '每行一個 KEY=value;按 MCP 要求填寫。', url: 'MCP URL', headers: 'HTTP 請求頭', headersHelp: '每行一個 Header=value。', - saveConnect: '儲存並連線', + saveConnect: '儲存連線', required: '此欄位為必填項。', invalidUrl: '請輸入有效的 HTTP 或 HTTPS URL。', unbalancedQuote: '引號未閉合。', transportLabel: '傳輸協議', transportAuto: '自動回退', transportStreamableHttp: 'Streamable HTTP', transportLegacySse: '舊版 SSE', protocolLabel: '協議偏好', protocolLegacy: '傳統', protocolAuto: '自動協商', protocolModern: '僅 2026-07-28', @@ -196,7 +188,7 @@ const MCP_COPY = { }, en: { errors: { - load: 'Failed to load MCP', install: (name) => `Failed to install ${name}`, cancelInstall: (name) => `Failed to cancel installation of ${name}`, save: 'Failed to save MCP', + load: 'Failed to load MCP', save: 'Failed to save MCP', writeDurabilityUnknown: 'The write was published, but survival after power loss could not be confirmed. Check the refreshed configuration before retrying.', writeOutOfSync: 'Write durability could not be confirmed, and MCP runtime state is out of sync with the configuration. Check the configuration and resynchronize before retrying.', import: 'Failed to import MCP', update: 'Failed to update MCP', test: 'MCP test failed', remove: 'Failed to delete MCP', unavailableStatus: 'The server did not return an available status.', @@ -205,8 +197,6 @@ const MCP_COPY = { importProtocolVersion: 'Remote protocol preferences require version 2 or 3; stdio protocol preferences require version 3', }, toast: { - templateInstalled: (name) => `${name} template installed`, templateInstalledDetail: 'Finish configuring credentials under Installed before enabling the connection.', - installed: (name) => `${name} installed`, installedDetail: 'Discovered tools take effect from the next agent turn.', installCancelled: (name) => `Cancelled installation of ${name}`, saved: 'MCP saved', savedDetail: 'New tools take effect from the next agent turn.', imported: 'MCP imported', importedDetail: (count) => `Imported ${count} ${count === 1 ? 'server' : 'servers'}.`, connectionOk: 'MCP connection healthy', toolLatency: (count, latencyMs) => `${count} ${count === 1 ? 'tool' : 'tools'} · ${latencyMs} ms`, connectionFailed: 'MCP connection failed', removed: 'MCP deleted', @@ -214,13 +204,13 @@ const MCP_COPY = { remove: { title: (id) => `Delete MCP “${id}”?`, description: 'Its tools will be removed from the next agent turn, and the configuration cannot be restored automatically.', confirm: 'Delete', cancel: 'Cancel' }, page: { actionsAria: 'MCP actions', refreshing: 'Refreshing…', refresh: 'Refresh', add: 'Add MCP', - metaInstalled: (count) => `${count} installed`, metaErrors: (count) => `${count} ${count === 1 ? 'connection error' : 'connection errors'}`, + metaConnections: (count) => `${count} connections`, metaErrors: (count) => `${count} ${count === 1 ? 'connection error' : 'connection errors'}`, searchMatches: (count) => `${count} ${count === 1 ? 'match' : 'matches'}`, - workspaceAria: 'MCP marketplace and installed servers', toolbarAria: 'MCP browser controls', setupTitle: 'Connect Maka to your work environment', setupDescription: 'Start with a curated template, or add any stdio, Streamable HTTP, or SSE server.', - localStdio: 'Local stdio', categoriesAria: 'MCP categories', market: 'Marketplace', installed: 'Installed', - searchPlaceholder: 'Search MCP…', searchAria: 'Search MCP', noMarket: 'No matching MCP servers', noMarketDetail: (query) => `Try another keyword, or clear “${query}” to view every template.`, - clearSearch: 'Clear search', loading: 'Reading MCP configuration…', noInstalled: 'No MCP servers installed', noInstalledDetail: 'Choose a template from the marketplace, or add your own server manually.', - browseMarket: 'Browse marketplace', noInstalledMatch: 'No matching installed MCP servers', noInstalledMatchDetail: (query) => `Try another keyword, or clear “${query}” to view every installed server.`, + workspaceAria: 'MCP marketplace and connections', toolbarAria: 'MCP browser controls', setupTitle: 'Connect Maka to your work environment', setupDescription: 'Start with a curated template, or add any stdio, Streamable HTTP, or SSE server.', + localStdio: 'Local stdio', categoriesAria: 'MCP categories', templates: 'Templates', connections: 'Connections', + searchPlaceholder: 'Search MCP…', searchAria: 'Search MCP', noTemplates: 'No matching MCP servers', noTemplatesDetail: (query) => `Try another keyword, or clear “${query}” to view every template.`, + clearSearch: 'Clear search', loading: 'Reading MCP configuration…', noConnections: 'No MCP connections', noConnectionsDetail: 'Choose a template from the templates, or add your own server manually.', + browseTemplates: 'Browse templates', noConnectionsMatch: 'No matching MCP connections', noConnectionsMatchDetail: (query) => `Try another keyword, or clear “${query}” to view every connection.`, }, detail: { label: 'Server details', enabled: 'Enabled', transport: 'Transport', endpoint: 'Endpoint', @@ -228,16 +218,15 @@ const MCP_COPY = { negotiatedProtocol: (era, revision) => `${era === 'modern' ? 'Modern' : 'Legacy'} · ${revision}`, inspectorOpened: (id) => `${id} details opened`, }, - card: { - macOnly: 'macOS only', manage: 'Manage', cancellingAria: (name) => `Cancelling installation of ${name}`, cancelAria: (name) => `Cancel installation of ${name}`, installAria: (name) => `Install ${name}`, - cancelling: 'Cancelling…', cancel: 'Cancel installation', install: 'Install', - }, + card: { macOnly: 'macOS only', useTemplate: 'Use template' }, row: { + needsAuth: 'Login required', login: 'Log in', loginPending: 'Complete authorization in your browser', cancelLogin: 'Cancel login', logout: 'Log out', testing: 'Testing…', test: 'Test', edit: 'Edit', delete: 'Delete', tools: (count) => `${count} ${count === 1 ? 'tool' : 'tools'}`, disabled: 'Disabled', disconnected: 'Disconnected', connecting: 'Connecting', connected: (count) => `${count} ${count === 1 ? 'tool' : 'tools'}`, failed: 'Connection failed', }, editor: { + idExists: 'This ID already exists. Choose another name.', oauth: 'OAuth settings', oauthHelp: 'Usually discovered automatically. Pre-registered clients must specify their authorization server issuer.', issuer: 'OAuth issuer', clientId: 'Client ID', clientSecret: 'Client secret', scopes: 'Scopes (space separated)', callbackPort: 'Callback port (optional)', importTitle: 'Import from JSON', editTitle: (id) => `Edit ${id}`, addTitle: 'Add MCP', importSubtitle: 'Paste an mcpServers configuration; servers with matching names will be updated.', manualSubtitle: 'Configuration is saved in mcp.json for the current workspace.', modeAria: 'MCP add method', manual: 'Manual configuration', pasteJson: 'Paste JSON', jsonConfig: 'JSON configuration', jsonHelp: 'Supports a complete mcpServers configuration or a server map. Existing MCP servers omitted from this import are preserved.', cancel: 'Cancel', importConnect: 'Import and connect', @@ -247,7 +236,7 @@ const MCP_COPY = { commandHelp: 'Full command line; quote arguments containing spaces. Not interpreted by a shell.', workingDirectory: 'Working directory', workingDirectoryPlaceholder: 'Optional, for example /path/to/project', environment: 'Environment', environmentHelp: 'One KEY=value entry per line; complete the variables required by this MCP.', url: 'MCP URL', headers: 'HTTP headers', headersHelp: 'One Header=value entry per line.', - saveConnect: 'Save and connect', + saveConnect: 'Save connection', required: 'This field is required.', invalidUrl: 'Enter a valid HTTP or HTTPS URL.', unbalancedQuote: 'Unclosed quote.', transportLabel: 'Transport', transportAuto: 'Auto fallback', transportStreamableHttp: 'Streamable HTTP', transportLegacySse: 'Legacy SSE', protocolLabel: 'Protocol preference', protocolLegacy: 'Legacy', protocolAuto: 'Auto-negotiate', protocolModern: '2026-07-28 only', diff --git a/apps/desktop/src/renderer/mcp-editor-validation.ts b/apps/desktop/src/renderer/mcp-editor-validation.ts index 74435d852b..2f8369a0f6 100644 --- a/apps/desktop/src/renderer/mcp-editor-validation.ts +++ b/apps/desktop/src/renderer/mcp-editor-validation.ts @@ -18,20 +18,23 @@ */ import { parseCommandLine } from './mcp-command-line.js'; +import type { McpOAuthConfig } from '@maka/core/mcp'; export type McpEditorDraft = { id: string; kind: 'stdio' | 'remote'; commandLine: string; url: string; + oauth?: McpOAuthConfig; }; export type McpEditorValidationCode = + | 'exists' | 'required' | 'invalid-url' | 'unbalanced-quote'; export type McpEditorErrors = Partial< - Record<'id' | 'commandLine' | 'url', McpEditorValidationCode> + Record<'id' | 'commandLine' | 'url' | 'oauthIssuer', McpEditorValidationCode> >; export function validateMcpEditorDraft( @@ -51,6 +54,13 @@ export function validateMcpEditorDraft( } const value = draft.url.trim(); + if (draft.oauth?.clientId && !draft.oauth.issuer?.trim()) errors.oauthIssuer = 'required'; + if (draft.oauth?.issuer) { + try { + const issuer = new URL(draft.oauth.issuer); + if (!['http:', 'https:'].includes(issuer.protocol) || issuer.username || issuer.password || issuer.search || issuer.hash) errors.oauthIssuer = 'invalid-url'; + } catch { errors.oauthIssuer = 'invalid-url'; } + } if (!value) { errors.url = 'required'; return errors; diff --git a/apps/desktop/src/renderer/mcp-page-model.ts b/apps/desktop/src/renderer/mcp-page-model.ts index 5ac7688628..eb1dddabf0 100644 --- a/apps/desktop/src/renderer/mcp-page-model.ts +++ b/apps/desktop/src/renderer/mcp-page-model.ts @@ -53,9 +53,6 @@ export type McpEditorDraft = { * Stored configs are projected to an explicit value before editing. */ protocol?: McpProtocolPreference; headers: string; - /** Opaque round-trip state: the editor has no OAuth fields, but an - * edit → save of an OAuth-configured server must not delete the block - * (the masked clientSecret sentinel restores from disk in main). */ oauth?: McpOAuthConfig; }; diff --git a/apps/desktop/src/renderer/mcp-page.tsx b/apps/desktop/src/renderer/mcp-page.tsx index 96aac359a2..4558abd3c2 100644 --- a/apps/desktop/src/renderer/mcp-page.tsx +++ b/apps/desktop/src/renderer/mcp-page.tsx @@ -17,28 +17,10 @@ * under the License. */ -// apps/desktop/src/renderer/mcp-page.tsx -// -// The MCP module page, on the shared ModulePage shell (Astryx Layout, the -// vendor's incident-console archetype) — the same surface as 技能 and -// 定时任务: -// -// - header: title, a live count line, 添加 MCP and refresh; -// - toolbar (the header's last row, fixed): the hub switch, the 市场 / -// 已安装 SegmentedControl and search; -// - content: dense Astryx List rows — the market's card grid is gone, brand -// marks ride the rows; -// - inspector: selecting an installed row opens it, and every per-server -// control (enable / test / edit / delete) plus the connection -// diagnostics live there. -// -// Layout owns scroll containment, so the view switch stays put while rows -// scroll — the same contract as the Skills page (#2236). - import { useEffect, useMemo, useRef, useState } from 'react'; import type { - McpConfigFile, McpConfigImportResult, + McpOAuthConfig, McpProtocolPreference, McpServerConfig, McpServerStatus, @@ -84,22 +66,19 @@ import { useUiLocale, type ModuleHubHeader, dotForStatus, - type StatusSemantic, } from '@maka/ui'; import { ICON_SIZE, FileCode, Globe, - Loader2, Plug, Plus, RefreshCcw, Search, Terminal, - X, } from '@maka/ui/icons'; -import { getMcpCatalog, catalogEntryMatches, type McpCatalogEntry } from './mcp-catalog'; +import { getMcpCatalog, catalogEntryMatches } from './mcp-catalog'; import { McpBrandMark, hasMcpBrandMark } from './mcp-brand-marks'; import { createEmptyMcpDraft, @@ -113,11 +92,8 @@ import { import { settingsActionErrorMessage } from './settings/settings-error-copy'; import { getMcpCopy, type McpCopy } from './locales/mcp-copy'; import { formatCommandLine } from './mcp-command-line'; -import { - defaultRuntimeHostDiagnosticTarget, - runOnDefaultRuntimeHost, - type DefaultRuntimeHostDiagnosticTarget, -} from './default-runtime-host-operation.js'; +import { defaultRuntimeHostDiagnosticTarget } from './features/module-hub/controller/default-runtime-host.js'; +import { useMcpController } from './features/module-hub/controller/use-mcp-controller.js'; import { validateMcpEditorDraft, type McpEditorErrors, @@ -128,40 +104,31 @@ type EditorState = | { mode: 'json'; source: string } | null; -const EMPTY_CONFIG: McpConfigFile = { version: MCP_CONFIG_VERSION, mcpServers: {} }; -const MIN_INSTALL_INDICATOR_MS = 500; - -type InstallPhase = 'installing' | 'cancelling'; -type McpTab = 'market' | 'installed'; +type McpTab = 'templates' | 'connections'; export function McpPage(props: { hubHeader?: ModuleHubHeader }) { const locale = useUiLocale(); const copy = getMcpCopy(locale); const catalog = getMcpCatalog(locale); - const [config, setConfig] = useState(EMPTY_CONFIG); - const [statuses, setStatuses] = useState([]); + const controller = useMcpController(); + const { config, statuses, busy, reload, error } = controller; const [editor, setEditor] = useState(null); const [editorErrors, setEditorErrors] = useState({}); const [editorOpen, setEditorOpen] = useState(false); - const [activeTab, setActiveTab] = useState('market'); + const [activeTab, setActiveTab] = useState('connections'); const [query, setQuery] = useState(''); const [selectedServerId, setSelectedServerId] = useState(null); - const [busy, setBusy] = useState('load'); - const [installPhases, setInstallPhases] = useState>({}); - const cancelledInstalls = useRef(new Set()); const editorSessionRef = useRef(0); const mounted = useMountedRef(); const toast = useToast(); - const reportRuntimeHostError = ( - title: string, - description: string | undefined, - diagnosticTarget?: DefaultRuntimeHostDiagnosticTarget, - ) => toast.error(title, description, undefined, diagnosticTarget); + useEffect(() => { + if (error) toast.error(copy.errors.update, mcpWriteFailureMessage(error, copy) ?? settingsActionErrorMessage(error, locale), undefined, defaultRuntimeHostDiagnosticTarget(error)); + }, [error, locale, copy, toast]); // Set when a remove starts, consumed once the row has actually left the // list — which only happens when the config write lands. const rowsContainerRef = useRef(null); const focusRowAfterRemovalRef = useRef(null); - // One tab stop for the whole installed list, same keyboard contract as the + // One tab stop for the whole connection list, same keyboard contract as the // skills and 定时任务 pages: without it, reaching the inspector from row // k of N costs N−k presses. const rovingRows = useRovingRowFocus(rowsContainerRef); @@ -173,46 +140,14 @@ export function McpPage(props: { hubHeader?: ModuleHubHeader }) { setActiveTab(next); } - async function reload() { - setBusy((current) => current ?? 'load'); - try { - const { value: [nextConfig, nextStatuses] } = await runOnDefaultRuntimeHost((host) => - Promise.all([ - window.maka.mcp.getConfig(host), - window.maka.mcp.listStatuses(host), - ]), - ); - if (!mounted.current) return; - setConfig(nextConfig); - setStatuses(nextStatuses); - } catch (error) { - if (mounted.current) { - reportRuntimeHostError( - copy.errors.load, - settingsActionErrorMessage(error, locale), - defaultRuntimeHostDiagnosticTarget(error), - ); - } - } finally { - if (mounted.current) setBusy(null); - } - } - - useEffect(() => { - void reload(); - return window.maka.mcp.subscribeChanges((next) => { - if (mounted.current) setStatuses(next); - }); - }, [locale]); - const statusById = useMemo( () => new Map(statuses.map((status) => [status.serverId, status])), [statuses], ); const entries = Object.entries(config.mcpServers); const normalizedQuery = query.trim().toLocaleLowerCase(); - const marketEntries = catalog.filter((entry) => catalogEntryMatches(entry, normalizedQuery)); - const installedEntries = entries.filter(([serverId, server]) => { + const templateEntries = catalog.filter((entry) => catalogEntryMatches(entry, normalizedQuery)); + const connectionEntries = entries.filter(([serverId, server]) => { if (!normalizedQuery) return true; const status = statusById.get(serverId); return [serverId, endpointFor(server), ...status?.tools.map((tool) => tool.name) ?? []] @@ -221,8 +156,8 @@ export function McpPage(props: { hubHeader?: ModuleHubHeader }) { // Derived, not stored: whatever hides the row — deletion, a filter, a // view switch — closes the inspector without a reconciliation step. - const selectedServer = activeTab === 'installed' - ? installedEntries.find(([serverId]) => serverId === selectedServerId) ?? null + const selectedServer = activeTab === 'connections' + ? connectionEntries.find(([serverId]) => serverId === selectedServerId) ?? null : null; // Synchronising focus with the DOM once the list it points into has been @@ -269,6 +204,7 @@ export function McpPage(props: { hubHeader?: ModuleHubHeader }) { } function openEdit(serverId: string, server: McpServerConfig) { + setSelectedServerId(null); openEditor({ mode: 'manual', draft: mcpDraftFromConfig(serverId, server), @@ -276,246 +212,77 @@ export function McpPage(props: { hubHeader?: ModuleHubHeader }) { }); } - async function installCatalogEntry(entry: McpCatalogEntry) { - if (installPhases[entry.id] || config.mcpServers[entry.id]) return; - cancelledInstalls.current.delete(entry.id); - setInstallPhases((current) => ({ ...current, [entry.id]: 'installing' })); - try { - const minimumIndicator = delay(MIN_INSTALL_INDICATOR_MS); - const next = await runOnDefaultRuntimeHost((host) => - window.maka.mcp.install(entry.id, structuredClone(entry.config), host), - ); - await minimumIndicator; - if (!mounted.current || cancelledInstalls.current.has(entry.id)) return; - setConfig(next.value); - if (entry.setupRequired) { - toast.success(copy.toast.templateInstalled(entry.name), copy.toast.templateInstalledDetail); - } else { - toast.success(copy.toast.installed(entry.name), copy.toast.installedDetail); - } - } catch (error) { - if (mounted.current && !cancelledInstalls.current.has(entry.id)) { - reportRuntimeHostError( - copy.errors.install(entry.name), - mcpWriteFailureMessage(error, copy) ?? settingsActionErrorMessage(error, locale), - defaultRuntimeHostDiagnosticTarget(error), - ); - await reload(); - } - } finally { - const wasCancelled = cancelledInstalls.current.delete(entry.id); - if (mounted.current && !wasCancelled) { - setInstallPhases((current) => omitKey(current, entry.id)); - } - } - } - - async function cancelCatalogInstall(entry: McpCatalogEntry) { - if (installPhases[entry.id] !== 'installing') return; - cancelledInstalls.current.add(entry.id); - setInstallPhases((current) => ({ ...current, [entry.id]: 'cancelling' })); - try { - const next = await runOnDefaultRuntimeHost((host) => - window.maka.mcp.cancelInstall(entry.id, host), - ); - if (!mounted.current) return; - setConfig(next.value); - setStatuses((current) => current.filter((status) => status.serverId !== entry.id)); - toast.info(copy.toast.installCancelled(entry.name)); - } catch (error) { - cancelledInstalls.current.delete(entry.id); - if (mounted.current) { - reportRuntimeHostError( - copy.errors.cancelInstall(entry.name), - mcpWriteFailureMessage(error, copy) ?? settingsActionErrorMessage(error, locale), - defaultRuntimeHostDiagnosticTarget(error), - ); - await reload(); - } - } finally { - if (mounted.current) setInstallPhases((current) => omitKey(current, entry.id)); - } - } - async function saveDraft(event: React.FormEvent) { event.preventDefault(); if (!editor || editor.mode !== 'manual') return; const validation = validateMcpEditorDraft(editor.draft); - if (Object.keys(validation).length > 0) { - setEditorErrors(validation); - return; - } - setEditorErrors({}); - setBusy('save'); - try { - const next = await runOnDefaultRuntimeHost((host) => - window.maka.mcp.upsert( - editor.draft.id.trim(), - mcpConfigFromDraft(editor.draft, copy), - host, - ), - ); - if (!mounted.current) return; - setConfig(next.value); - closeEditor(); - switchTab('installed'); - toast.success(copy.toast.saved, copy.toast.savedDetail); - } catch (error) { - if (mounted.current) { - reportRuntimeHostError( - copy.errors.save, - mcpWriteFailureMessage(error, copy) ?? settingsActionErrorMessage(error, locale), - defaultRuntimeHostDiagnosticTarget(error), - ); - // A rejected mutation may already have replaced mcp.json. Refresh - // both the configured rows and statuses before another user action. - await reload(); - } - } finally { - if (mounted.current) setBusy(null); - } + setEditorErrors(validation); + if (Object.keys(validation).length) return; + let server: McpServerConfig; + try { server = mcpConfigFromDraft(editor.draft, copy); } + catch (failure) { toast.error(copy.errors.save, settingsActionErrorMessage(failure, locale)); return; } + const id = editor.draft.id.trim(); + const result = await controller.save(id, server, editor.editingId === null); + if (!result || !mounted.current) return; + if (result.status === 'exists') { setEditorErrors({ id: 'exists' }); return; } + closeEditor(); + setActiveTab('connections'); + setSelectedServerId(id); + toast.success(copy.toast.saved, copy.toast.savedDetail); } async function importJson(event: React.FormEvent) { event.preventDefault(); if (!editor || editor.mode !== 'json') return; - setBusy('import'); - try { - const next = await runOnDefaultRuntimeHost((host) => - window.maka.mcp.importConfig(editor.source, host), - ); - if (!mounted.current) return; - if (next.value.status === 'invalid') { - toast.error(copy.errors.import, mcpImportFailureMessage(next.value, copy)); - return; - } - setConfig(next.value.config); - closeEditor(); - switchTab('installed'); - toast.success(copy.toast.imported, copy.toast.importedDetail(next.value.importedCount)); - } catch (error) { - if (mounted.current) { - reportRuntimeHostError( - copy.errors.import, - mcpWriteFailureMessage(error, copy) ?? settingsActionErrorMessage(error, locale), - defaultRuntimeHostDiagnosticTarget(error), - ); - await reload(); - } - } finally { - if (mounted.current) setBusy(null); - } - } - - async function toggle(serverId: string, server: McpServerConfig, enabled: boolean) { - setBusy(`toggle:${serverId}`); - try { - const next = await runOnDefaultRuntimeHost((host) => - window.maka.mcp.upsert(serverId, { ...server, enabled }, host), - ); - if (mounted.current) setConfig(next.value); - } catch (error) { - if (mounted.current) { - reportRuntimeHostError( - copy.errors.update, - mcpWriteFailureMessage(error, copy) ?? settingsActionErrorMessage(error, locale), - defaultRuntimeHostDiagnosticTarget(error), - ); - await reload(); - } - } finally { - if (mounted.current) setBusy(null); - } + const result = await controller.importConfig(editor.source); + if (!result || !mounted.current) return; + if (result.status === 'invalid') { toast.error(copy.errors.import, mcpImportFailureMessage(result, copy)); return; } + closeEditor(); + switchTab('connections'); + toast.success(copy.toast.imported, copy.toast.importedDetail(result.importedCount)); } async function testServer(serverId: string) { - setBusy(`test:${serverId}`); - try { - const { value: result, diagnosticTarget } = await runOnDefaultRuntimeHost((host) => - window.maka.mcp.test(serverId, host), - ); - if (!mounted.current) return; - setStatuses((current) => replaceStatus(current, result.status)); - if (result.ok) toast.success(copy.toast.connectionOk, copy.toast.toolLatency(result.status.toolCount, result.latencyMs)); - else { - reportRuntimeHostError( - copy.toast.connectionFailed, - result.status.error ?? copy.errors.unavailableStatus, - diagnosticTarget, - ); - } - } catch (error) { - if (mounted.current) { - reportRuntimeHostError( - copy.errors.test, - settingsActionErrorMessage(error, locale), - defaultRuntimeHostDiagnosticTarget(error), - ); - } - } finally { - if (mounted.current) setBusy(null); - } + const result = await controller.test(serverId); + if (!result || !mounted.current) return; + if (result.ok) toast.success(copy.toast.connectionOk, copy.toast.toolLatency(result.status.toolCount, result.latencyMs)); + else if (result.status.state !== 'needs-auth') toast.error(copy.toast.connectionFailed, result.status.error ?? copy.errors.unavailableStatus); } async function remove(serverId: string) { const confirmed = await toast.confirm({ - title: copy.remove.title(serverId), - description: copy.remove.description, + title: copy.remove.title(serverId), description: copy.remove.description, confirmLabel: copy.remove.confirm, cancelLabel: copy.remove.cancel, destructive: true, }); if (!confirmed || !mounted.current) return; - // The 删除 button is about to unmount with the whole inspector, and - // nothing else would claim focus — hand it to the row that takes the - // deleted one's place. - focusRowAfterRemovalRef.current = installedEntries.findIndex(([id]) => id === serverId); - setBusy(`remove:${serverId}`); - try { - const next = await runOnDefaultRuntimeHost((host) => - window.maka.mcp.remove(serverId, host), - ); - if (!mounted.current) return; - setConfig(next.value); - setStatuses((current) => current.filter((status) => status.serverId !== serverId)); - // Drop the id too — keeping it would reopen the inspector if a server - // with the same id is added back later, without any user action. - setSelectedServerId((current) => (current === serverId ? null : current)); - toast.success(copy.toast.removed); - } catch (error) { - if (mounted.current) { - reportRuntimeHostError( - copy.errors.remove, - mcpWriteFailureMessage(error, copy) ?? settingsActionErrorMessage(error, locale), - defaultRuntimeHostDiagnosticTarget(error), - ); - await reload(); - } - } finally { - if (mounted.current) setBusy(null); - } + focusRowAfterRemovalRef.current = connectionEntries.findIndex(([id]) => id === serverId); + const result = await controller.remove(serverId); + if (!result || !mounted.current) return; + setSelectedServerId(null); + toast.success(copy.toast.removed); } const connectionErrorCount = statuses.filter((status) => status.error).length; const searchSummary = normalizedQuery ? (
- {copy.page.searchMatches(activeTab === 'market' ? marketEntries.length : installedEntries.length)} + {copy.page.searchMatches(activeTab === 'templates' ? templateEntries.length : connectionEntries.length)}
) : null; - const marketPanel = ( + const templatesPanel = (
{searchSummary} - {marketEntries.length === 0 ? ( + {templateEntries.length === 0 ? ( } - title={copy.page.noMarket} - description={copy.page.noMarketDetail(query)} + title={copy.page.noTemplates} + description={copy.page.noTemplatesDetail(query)} actions={
); - const installedPanel = ( + const connectionsPanel = (
{/* Selecting a row moves no focus, so nothing else would tell a screen reader that the details opened. This says so, politely. */} @@ -573,7 +325,7 @@ export function McpPage(props: { hubHeader?: ModuleHubHeader }) {

{searchSummary} {busy === 'load' ? ( - /* Loading (DESIGN.md §10): the installed list's structure is predictable, + /* Loading (DESIGN.md §10): the connection list's structure is predictable, so it loads as row-shaped skeletons in the rows' own geometry — three rows, this surface's typical ready count. Skeleton's radius scale has no 6px step, so the tile takes the nearest one (8px) to the real @@ -592,15 +344,15 @@ export function McpPage(props: { hubHeader?: ModuleHubHeader }) { ) : entries.length === 0 ? ( } - title={copy.page.noInstalled} - description={copy.page.noInstalledDetail} - actions={
) : null} - {activeTab === 'market' ? marketPanel : installedPanel} + {activeTab === 'templates' ? templatesPanel : connectionsPanel} {editor && ( @@ -743,34 +498,9 @@ export function McpPage(props: { hubHeader?: ModuleHubHeader }) { errors={editorErrors} copy={copy} saving={busy === 'save' || busy === 'import'} - onChange={(next, changedKey) => { + onChange={(next) => { setEditor(next); - setEditorErrors((current) => { - if (changedKey === undefined) { - return {}; - } - if (Object.keys(current).length === 0 || next.mode !== 'manual') { - return current; - } - if (changedKey === 'kind') { - return validateMcpEditorDraft(next.draft); - } - if ( - changedKey !== 'id' && - changedKey !== 'commandLine' && - changedKey !== 'url' - ) { - return current; - } - const nextErrors = { ...current }; - const changedError = validateMcpEditorDraft(next.draft)[changedKey]; - if (changedError) { - nextErrors[changedKey] = changedError; - } else { - delete nextErrors[changedKey]; - } - return nextErrors; - }); + setEditorErrors((current) => next.mode === 'manual' && Object.keys(current).length ? validateMcpEditorDraft(next.draft) : {}); }} onOpenChange={(open) => { if (!open) closeEditor(); @@ -801,34 +531,6 @@ function mcpImportFailureMessage( } } -function McpInstallButton(props: { - entry: McpCatalogEntry; - copy: McpCopy; - phase?: InstallPhase; - onInstall(): void; - onCancel(): void; -}) { - const installing = props.phase === 'installing'; - const cancelling = props.phase === 'cancelling'; - return ( - -