From 2262ef945d49337053bc392f3672a49ff79e8776 Mon Sep 17 00:00:00 2001 From: Nick Launces <1409277+nicklaunches@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:05:43 -0700 Subject: [PATCH] Send a stored MCP token with the scheme it names Every token stored against a custom MCP server went out as Bearer. A vendor that forwards the Authorization header to an API which only speaks Basic still answers the handshake and the tool listing, so the connector looked connected and its tools were offered, and every real call came back 401. DataForSEO's hosted server behaves exactly that way. A token that already begins with Basic or Bearer is now sent as written; a bare token is still Bearer, so nothing already working changes. The scheme travels with the credential rather than as another setting on the server row, so rotating a token can change how it is presented and nothing else has to know. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WaHWJ1niprhBc5NzJ9pxme --- CHANGELOG.md | 11 +++++++++ server/src/plugins/mcp.ts | 18 +++++++++++++- server/tests/plugin-mcp-authorization.test.ts | 24 +++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 server/tests/plugin-mcp-authorization.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d446e0438..d156a7cee 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 0942fd421..eba34a060 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 000000000..4a9bf6575 --- /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"); + }); +});