Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 17 additions & 1 deletion server/src/plugins/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -210,7 +226,7 @@ async function withClient<T>(
): Promise<T> {
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" });
Expand Down
24 changes: 24 additions & 0 deletions server/tests/plugin-mcp-authorization.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});