diff --git a/CHANGELOG.md b/CHANGELOG.md index d446e043..d156a7ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### A custom MCP server token is sent with the scheme it names + +Every token stored against a custom MCP server went out as `Bearer`, whatever the vendor asked for. +A server that forwards the header to an API speaking Basic auth still answers the handshake and the +tool listing, so the Plugins page showed the connector connected and its tools offered, and every +real call came back 401. DataForSEO's hosted server behaves exactly this way. + +A token that begins with `Basic ` or `Bearer ` is now sent as written, so paste the credential the +vendor gives you, scheme and all. A bare token is still sent as `Bearer`, so nothing already +working needs to change. + ### Coworkers are made in a wizard and managed in a dialog Creating a coworker is now a three-step wizard — who it is, who may see it, then where it runs, diff --git a/server/src/plugins/mcp.ts b/server/src/plugins/mcp.ts index 0942fd42..eba34a06 100644 --- a/server/src/plugins/mcp.ts +++ b/server/src/plugins/mcp.ts @@ -198,6 +198,22 @@ function vendorFailure(error: unknown): string { return error instanceof Error ? error.message : String(error); } +/** + * The Authorization header a stored token becomes. + * + * Bearer by default, which is what an MCP server's own token usually is. A token that already + * names its scheme is sent as written, because some vendors forward the header straight to an API + * that only speaks Basic: DataForSEO's hosted server answers the handshake and the tool listing to + * anything, then returns 401 on every real call made with Bearer, so a deployment that could only + * say Bearer looked connected and never worked. The scheme travels with the credential rather than + * as a setting on the server row, so rotating a token can change how it is presented and nothing + * else has to know. + */ +export function authorizationHeader(token: string): string { + const trimmed = token.trim(); + return /^(basic|bearer)\s+\S/i.test(trimmed) ? trimmed : `Bearer ${trimmed}`; +} + /** * Build, use and close a client. * @@ -210,7 +226,7 @@ async function withClient( ): Promise { const transport = new StreamableHTTPClientTransport(new URL(connection.url), { requestInit: connection.token - ? { headers: { Authorization: `Bearer ${connection.token}` } } + ? { headers: { Authorization: authorizationHeader(connection.token) } } : undefined, }); const client = new Client({ name: "openbot", version: "1.0.0" }); diff --git a/server/tests/plugin-mcp-authorization.test.ts b/server/tests/plugin-mcp-authorization.test.ts new file mode 100644 index 00000000..4a9bf657 --- /dev/null +++ b/server/tests/plugin-mcp-authorization.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; +import { authorizationHeader } from "../src/plugins/mcp"; + +describe("authorizationHeader", () => { + test("a bare token is sent as Bearer", () => { + expect(authorizationHeader("abc123")).toBe("Bearer abc123"); + }); + test("a token that names Basic is sent as written", () => { + expect(authorizationHeader("Basic dXNlcjpwYXNz")).toBe( + "Basic dXNlcjpwYXNz", + ); + }); + test("a token that names Bearer is not doubled", () => { + expect(authorizationHeader("Bearer abc123")).toBe("Bearer abc123"); + }); + test("the scheme is matched without regard to case, and whitespace is trimmed", () => { + expect(authorizationHeader(" basic dXNlcjpwYXNz ")).toBe( + "basic dXNlcjpwYXNz", + ); + }); + test("a scheme word with nothing after it is treated as a bare token", () => { + expect(authorizationHeader("Basic")).toBe("Bearer Basic"); + }); +});