feat: support OAuth for MCP clients. (#923)

* feat: support OAuth for MCP clients.

* fix: scheme is case-insensitive.

* fix: expose WWW-Authenticate header for cross-origin clients.
This commit is contained in:
ajspig 2026-07-21 15:31:09 -04:00 committed by GitHub
parent 063aaa97a6
commit 4f9a41360a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 39 additions and 25 deletions

View File

@ -16,13 +16,10 @@ A Cloudflare Worker that implements the [Model Context Protocol (MCP)](https://m
"mcp-remote",
"https://mcp.honcho.dev",
"--header",
"Authorization:${AUTH_HEADER}",
"--header",
"X-Honcho-User-Name:${USER_NAME}"
"Authorization:${AUTH_HEADER}"
],
"env": {
"AUTH_HEADER": "Bearer <your-honcho-key>",
"USER_NAME": "<your-name>"
"AUTH_HEADER": "Bearer <your-honcho-key>"
}
}
}
@ -115,8 +112,7 @@ bun run tsc --noEmit
```bash
bunx mcp-remote http://localhost:8787 \
--header "Authorization:Bearer <key>" \
--header "X-Honcho-User-Name:test"
--header "Authorization:Bearer <key>"
```
### Deploy

View File

@ -2,8 +2,6 @@ import { Honcho } from "@honcho-ai/sdk";
export interface HonchoConfig {
apiKey: string;
userName: string;
assistantName: string;
baseUrl: string;
workspaceId: string;
}
@ -14,7 +12,7 @@ export interface Env {
/**
* Parse configuration from request headers and Worker env bindings.
* Throws on missing required fields so callers get clear errors.
* Throws only when the Authorization bearer token is missing/empty.
*
* The Honcho API URL is read from the `HONCHO_API_URL` env var when set,
* allowing operators to run this Worker alongside a self-hosted Honcho
@ -24,29 +22,19 @@ export interface Env {
*/
export function parseConfig(request: Request, env: Env = {}): HonchoConfig {
const authHeader = request.headers.get("Authorization");
const trimmedAuthHeader = authHeader?.trim();
if (!trimmedAuthHeader?.startsWith("Bearer ")) {
const bearerMatch = authHeader?.trim().match(/^Bearer\s+(.*)$/i);
if (!bearerMatch) {
throw new Error(
"Missing Authorization header. Provide 'Authorization: Bearer <your-honcho-key>'.",
);
}
const apiKey = trimmedAuthHeader.substring(7).trim();
const apiKey = bearerMatch[1].trim();
if (!apiKey) {
throw new Error("Authorization header is empty after 'Bearer '.");
}
const rawUserName = request.headers.get("X-Honcho-User-Name");
const userName = rawUserName?.trim();
if (!userName) {
throw new Error(
"Missing X-Honcho-User-Name header. Provide 'X-Honcho-User-Name: <your-name>'.",
);
}
return {
apiKey,
userName,
assistantName: request.headers.get("X-Honcho-Assistant-Name")?.trim() || "Assistant",
baseUrl: env.HONCHO_API_URL?.trim() || "https://api.honcho.dev",
workspaceId: request.headers.get("X-Honcho-Workspace-ID")?.trim() || "default",
};

View File

@ -5,14 +5,25 @@ import { createServer } from "./server.js";
const CORS_ORIGIN = "*";
const CORS_METHODS = "GET, POST, DELETE, OPTIONS";
const CORS_ALLOWED_HEADERS =
"Content-Type, Authorization, X-Honcho-User-Name, X-Honcho-Workspace-ID, X-Honcho-Assistant-Name";
"Content-Type, Authorization, X-Honcho-Workspace-ID";
const CORS_HEADERS = {
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": CORS_METHODS,
"Access-Control-Allow-Headers": CORS_ALLOWED_HEADERS,
"Access-Control-Expose-Headers": "WWW-Authenticate",
};
const PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource";
function resourceUrl(request: Request): string {
return new URL(request.url).origin;
}
function authorizationServer(env: Env): string {
return env.HONCHO_API_URL?.trim() || "https://api.honcho.dev";
}
export default {
async fetch(
request: Request,
@ -23,15 +34,34 @@ export default {
return new Response(null, { status: 204, headers: CORS_HEADERS });
}
// Protected Resource Metadata (RFC 9728) — served without auth so clients
// can discover the authorization server.
if (new URL(request.url).pathname === PROTECTED_RESOURCE_PATH) {
return Response.json(
{
resource: resourceUrl(request),
authorization_servers: [authorizationServer(env)],
bearer_methods_supported: ["header"],
},
{ headers: CORS_HEADERS },
);
}
let config;
try {
config = parseConfig(request, env);
} catch (e) {
const message =
e instanceof Error ? e.message : "Invalid request";
// WWW-Authenticate points clients at the metadata so they start the OAuth flow.
const resourceMetadata = `${resourceUrl(request)}${PROTECTED_RESOURCE_PATH}`;
return new Response(JSON.stringify({ error: message }), {
status: 401,
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
headers: {
"Content-Type": "application/json",
"WWW-Authenticate": `Bearer resource_metadata="${resourceMetadata}"`,
...CORS_HEADERS,
},
});
}