Download src/api/mcp-service/mcp-service.api.ts from SaylorTwift/openhands: direct link, hf CLI and curl.
- Browser
- Download file 10.9 kB
-
https://huggingface.co/SaylorTwift/openhands/resolve/main/src/api/mcp-service/mcp-service.api.ts
- Command line
-
hf download hf://SaylorTwift/openhands/src/api/mcp-service/mcp-service.api.ts
-
curl -L -o mcp-service.api.ts https://huggingface.co/SaylorTwift/openhands/resolve/main/src/api/mcp-service/mcp-service.api.ts
10.9 kB
| import { MCPClient } from "@openhands/typescript-client/clients"; | |
| import type { AgentServerMCPTestRequest } from "@openhands/typescript-client"; | |
| import { getAgentServerClientOptions } from "../agent-server-client-options"; | |
| import { | |
| getActiveBackend, | |
| getRegisteredBackends, | |
| } from "../backend-registry/active-store"; | |
| import { | |
| getCredentialValidationForServer, | |
| type CredentialValidation, | |
| } from "#/utils/mcp-credential-validation"; | |
| import type { | |
| ExtendedMCPTestResponse, | |
| MCPOAuthStartResponse, | |
| MCPOAuthStatusResponse, | |
| MCPServerConfig, | |
| } from "#/types/mcp-server"; | |
| import { redactMcpSecrets } from "#/utils/redact-mcp-secrets"; | |
| import { substituteRedactedMcpCredentials } from "./mcp-redacted-credentials"; | |
| const OAUTH_MCP_TEST_TIMEOUT_SECONDS = 120; | |
| function toMcpServer( | |
| server: MCPServerConfig, | |
| ): AgentServerMCPTestRequest["server"] { | |
| if (server.type === "stdio") { | |
| return { | |
| type: "stdio", | |
| command: server.command!, | |
| ...(server.args?.length && { args: server.args }), | |
| ...(server.env && | |
| Object.keys(server.env).length > 0 && { env: server.env }), | |
| }; | |
| } | |
| return { | |
| type: server.type === "sse" ? "sse" : "http", | |
| url: server.url!, | |
| ...(server.headers && | |
| Object.keys(server.headers).length > 0 && { headers: server.headers }), | |
| ...(server.auth ? { auth: server.auth } : {}), | |
| }; | |
| } | |
| function getMcpTestTimeout(server: MCPServerConfig): number | undefined { | |
| if (server.auth?.strategy !== "oauth2") return server.timeout; | |
| return OAUTH_MCP_TEST_TIMEOUT_SECONDS; | |
| } | |
| async function buildMcpTestRequest(server: MCPServerConfig): Promise<{ | |
| request: AgentServerMCPTestRequest; | |
| substituted: MCPServerConfig; | |
| }> { | |
| const validation = getCredentialValidationForServer(server); | |
| const substituted = await substituteRedactedMcpCredentials(server); | |
| const serverSpec = toMcpServer(substituted); | |
| const timeout = getMcpTestTimeout(server); | |
| return { | |
| request: { | |
| server: serverSpec, | |
| ...(server.name ? { name: server.name } : {}), | |
| ...(timeout !== undefined ? { timeout } : {}), | |
| ...(validation ? { tool_call: validation.toolCall } : {}), | |
| }, | |
| substituted, | |
| }; | |
| } | |
| function redactMcpTestResponse( | |
| result: ExtendedMCPTestResponse, | |
| redactionSources: (MCPServerConfig | undefined)[], | |
| ): ExtendedMCPTestResponse { | |
| if (!result.ok) { | |
| return { | |
| ...result, | |
| error: redactMcpSecrets(result.error, ...redactionSources), | |
| }; | |
| } | |
| if (result.tool_result) { | |
| return { | |
| ...result, | |
| tool_result: { | |
| ...result.tool_result, | |
| text: redactMcpSecrets(result.tool_result.text, ...redactionSources), | |
| }, | |
| }; | |
| } | |
| return result; | |
| } | |
| /** | |
| * Display-boundary post-processing shared by the test and OAuth probes: | |
| * scrub secrets from error/tool-result text, then let the catalog entry's | |
| * credential validation turn a failed read-only tool call into a | |
| * `credentials` failure. The interpretation only runs when the probe tool | |
| * is actually advertised in `tools` — a server variant that doesn't expose | |
| * the tool (e.g. a hosted alternative to the stdio server the spec was | |
| * written for) must degrade to a connectivity-only success, not be | |
| * misreported as bad credentials. | |
| */ | |
| function finalizeMcpTestResponse( | |
| result: ExtendedMCPTestResponse, | |
| validation: CredentialValidation | undefined, | |
| redactionSources: (MCPServerConfig | undefined)[], | |
| ): ExtendedMCPTestResponse { | |
| const redacted = redactMcpTestResponse(result, redactionSources); | |
| if ( | |
| redacted.ok && | |
| validation && | |
| redacted.tool_result && | |
| redacted.tools.includes(validation.toolCall.name) | |
| ) { | |
| const credentialError = validation.interpret(redacted.tool_result); | |
| if (credentialError) { | |
| return { | |
| ok: false, | |
| error: credentialError, | |
| error_kind: "credentials", | |
| }; | |
| } | |
| } | |
| return redacted; | |
| } | |
| function getMcpProbeOptions(): { host: string; apiKey?: string } { | |
| const active = getActiveBackend().backend; | |
| if (active.kind === "local") { | |
| const { host, apiKey } = getAgentServerClientOptions(); | |
| return { host, ...(apiKey ? { apiKey } : {}) }; | |
| } | |
| const localBackend = getRegisteredBackends().find( | |
| (backend) => backend.kind === "local" && backend.host, | |
| ); | |
| if (localBackend) { | |
| return { | |
| host: localBackend.host.replace(/\/+$/, ""), | |
| ...(localBackend.apiKey ? { apiKey: localBackend.apiKey } : {}), | |
| }; | |
| } | |
| throw new Error("OAuth authorization requires a reachable local backend."); | |
| } | |
| function createMcpProbeClient(): MCPClient { | |
| const { host, apiKey } = getMcpProbeOptions(); | |
| return new MCPClient({ | |
| host, | |
| ...(apiKey ? { apiKey } : {}), | |
| timeout: OAUTH_MCP_TEST_TIMEOUT_SECONDS * 1000 + 5000, | |
| }); | |
| } | |
| function oauthStatusToTestResponse( | |
| status: MCPOAuthStatusResponse, | |
| ): ExtendedMCPTestResponse { | |
| if (status.status === "succeeded") { | |
| return { | |
| ok: true, | |
| tools: status.tools ?? [], | |
| ...(status.tool_result !== undefined && { | |
| tool_result: status.tool_result, | |
| }), | |
| ...(status.oauth_state !== undefined && { | |
| oauth_state: status.oauth_state, | |
| }), | |
| }; | |
| } | |
| return { | |
| ok: false, | |
| error: status.error || "OAuth authorization did not complete", | |
| error_kind: status.error_kind || "unknown", | |
| }; | |
| } | |
| const sleep = (ms: number) => | |
| new Promise<void>((resolve) => { | |
| window.setTimeout(resolve, ms); | |
| }); | |
| class McpService { | |
| static async testServer( | |
| server: MCPServerConfig, | |
| ): Promise<ExtendedMCPTestResponse> { | |
| // The MCP connectivity-test endpoint lives on the local agent-server. It | |
| // spawns the configured stdio command / opens an SSE-or-SHTTP connection | |
| // from that process's environment. Cloud backends don't expose this | |
| // endpoint to the frontend — the MCP server would actually run inside the | |
| // cloud sandbox, which isn't reachable from the browser before the user | |
| // starts a conversation. Calling `getAgentServerClientOptions()` here for | |
| // a cloud-active session would throw `NoBackendAvailableError("No backend | |
| // is configured.")` and block the install flow entirely. Short-circuit | |
| // with a synthetic success so saving proceeds; any real connection | |
| // failure surfaces inside the conversation runtime instead. | |
| if (getActiveBackend().backend.kind === "cloud") { | |
| return { ok: true, tools: [] }; | |
| } | |
| const validation = getCredentialValidationForServer(server); | |
| const { host, apiKey } = getAgentServerClientOptions(); | |
| const client = new MCPClient({ host, ...(apiKey ? { apiKey } : {}) }); | |
| try { | |
| const { request, substituted } = await buildMcpTestRequest(server); | |
| const result = (await client.testServer( | |
| request, | |
| )) as ExtendedMCPTestResponse; | |
| // Redact against both config forms: `server` may hold fresh plaintext | |
| // input, `substituted` the stored (encrypted) round-trip values. | |
| return finalizeMcpTestResponse(result, validation, [substituted, server]); | |
| } finally { | |
| client.close(); | |
| } | |
| } | |
| static async startOAuth( | |
| server: MCPServerConfig, | |
| ): Promise<MCPOAuthStartResponse> { | |
| const client = createMcpProbeClient(); | |
| try { | |
| return await McpService.startOAuthWithClient(client, server); | |
| } finally { | |
| client.close(); | |
| } | |
| } | |
| static async getOAuthStatus(jobId: string): Promise<MCPOAuthStatusResponse> { | |
| const client = createMcpProbeClient(); | |
| try { | |
| return await McpService.getOAuthStatusWithClient(client, jobId); | |
| } finally { | |
| client.close(); | |
| } | |
| } | |
| static async submitOAuthCallback( | |
| jobId: string, | |
| callbackUrl: string, | |
| ): Promise<MCPOAuthStatusResponse> { | |
| const client = createMcpProbeClient(); | |
| try { | |
| return await McpService.submitOAuthCallbackWithClient( | |
| client, | |
| jobId, | |
| callbackUrl, | |
| ); | |
| } finally { | |
| client.close(); | |
| } | |
| } | |
| static async authorizeOAuth( | |
| server: MCPServerConfig, | |
| ): Promise<ExtendedMCPTestResponse> { | |
| const validation = getCredentialValidationForServer(server); | |
| const finalize = (result: ExtendedMCPTestResponse) => | |
| finalizeMcpTestResponse(result, validation, [server]); | |
| const popup = window.open("about:blank", "_blank"); | |
| const client = createMcpProbeClient(); | |
| try { | |
| const start = await McpService.startOAuthWithClient(client, server); | |
| if (!start.ok || !start.job_id || !start.authorization_url) { | |
| popup?.close(); | |
| return finalize({ | |
| ok: false, | |
| error: start.error || "Could not start OAuth authorization", | |
| error_kind: start.error_kind || "unknown", | |
| }); | |
| } | |
| let status = await McpService.getOAuthStatusWithClient( | |
| client, | |
| start.job_id, | |
| ); | |
| for (let attempt = 0; attempt < 20; attempt += 1) { | |
| if (status.status === "succeeded" || status.status === "failed") { | |
| popup?.close(); | |
| return finalize(oauthStatusToTestResponse(status)); | |
| } | |
| if (status.callback_ready) break; | |
| await sleep(250); | |
| status = await McpService.getOAuthStatusWithClient( | |
| client, | |
| start.job_id, | |
| ); | |
| } | |
| if (popup) { | |
| popup.location.href = start.authorization_url; | |
| } | |
| for ( | |
| let attempt = 0; | |
| attempt < OAUTH_MCP_TEST_TIMEOUT_SECONDS; | |
| attempt += 1 | |
| ) { | |
| await sleep(1000); | |
| status = await McpService.getOAuthStatusWithClient( | |
| client, | |
| start.job_id, | |
| ); | |
| if (status.status === "succeeded" || status.status === "failed") { | |
| popup?.close(); | |
| return finalize(oauthStatusToTestResponse(status)); | |
| } | |
| } | |
| return { | |
| ok: false, | |
| error: "OAuth authorization timed out", | |
| error_kind: "timeout", | |
| }; | |
| } finally { | |
| client.close(); | |
| } | |
| } | |
| private static async startOAuthWithClient( | |
| client: MCPClient, | |
| server: MCPServerConfig, | |
| ): Promise<MCPOAuthStartResponse> { | |
| const { request } = await buildMcpTestRequest(server); | |
| return client.startOAuth(request); | |
| } | |
| // typescript-client 1.36 types the OAuth probes with the generated | |
| // agent-server models, which describe the opaque `oauth_state` JSON blobs as | |
| // `unknown` rather than the recursive `MCPJsonValue` the local types use. The | |
| // wire shape is unchanged, so narrow back to the app's boundary type. | |
| private static async getOAuthStatusWithClient( | |
| client: MCPClient, | |
| jobId: string, | |
| ): Promise<MCPOAuthStatusResponse> { | |
| return (await client.getOAuthStatus(jobId)) as MCPOAuthStatusResponse; | |
| } | |
| private static async submitOAuthCallbackWithClient( | |
| client: MCPClient, | |
| jobId: string, | |
| callbackUrl: string, | |
| ): Promise<MCPOAuthStatusResponse> { | |
| return (await client.submitOAuthCallback(jobId, { | |
| callback_url: callbackUrl, | |
| })) as MCPOAuthStatusResponse; | |
| } | |
| } | |
| export default McpService; | |