File size: 10,930 Bytes
3d700dd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
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;