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:
parent
063aaa97a6
commit
4f9a41360a
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue