Skip to content

Commit e1b3a75

Browse files
committed
feat: require tokens for hosted MCP access
1 parent 93ec5d9 commit e1b3a75

5 files changed

Lines changed: 212 additions & 3 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ node_modules/
33
test-results/
44
playwright-report/
55
skills/curriculum-assessment/threshold-concept-kud-translator/
6+
.vercel

mcp-server/api/mcp.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createServer } from "../src/server.js";
22
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
3+
import { getAuthorizedTokenPrefix, getUnauthorizedResponse } from "../src/http-auth.js";
34
import type { LoadedSkill } from "../src/types.js";
45
import type { IncomingMessage, ServerResponse } from "node:http";
56

@@ -17,15 +18,32 @@ export default async function handler(
1718
// CORS headers for cross-origin MCP clients
1819
res.setHeader("Access-Control-Allow-Origin", "*");
1920
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
20-
res.setHeader("Access-Control-Allow-Headers", "Content-Type, mcp-session-id, mcp-protocol-version");
21-
res.setHeader("Access-Control-Expose-Headers", "mcp-session-id");
21+
res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, mcp-session-id, mcp-protocol-version");
22+
res.setHeader("Access-Control-Expose-Headers", "mcp-session-id, WWW-Authenticate");
2223

2324
if (req.method === "OPTIONS") {
2425
res.writeHead(204);
2526
res.end();
2627
return;
2728
}
2829

30+
const tokenPrefix = getAuthorizedTokenPrefix({
31+
url: req.url,
32+
authorization: req.headers.authorization,
33+
});
34+
35+
if (!tokenPrefix) {
36+
const unauthorized = getUnauthorizedResponse(req.url ?? "/mcp", req.method);
37+
for (const [key, value] of Object.entries(unauthorized.headers)) {
38+
res.setHeader(key, value);
39+
}
40+
res.writeHead(unauthorized.status);
41+
res.end(unauthorized.body);
42+
return;
43+
}
44+
45+
res.setHeader("X-MCP-Access", "token");
46+
2947
const server = createServer(skills);
3048
const transport = new StreamableHTTPServerTransport({
3149
sessionIdGenerator: undefined, // stateless

mcp-server/package-lock.json

Lines changed: 4 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

mcp-server/src/http-auth.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
2+
3+
export type AuthEnv = Record<string, string | undefined>;
4+
5+
export type AuthCheckInput = {
6+
url?: string;
7+
authorization?: string | string[];
8+
env?: AuthEnv;
9+
};
10+
11+
export type HttpAuthResponse = {
12+
status: number;
13+
headers: Record<string, string>;
14+
body: string;
15+
};
16+
17+
const TOKEN_PREFIX_LENGTH = 18;
18+
19+
export function hasConfiguredAuth(env: AuthEnv = process.env): boolean {
20+
return Boolean(
21+
env.MCP_TOKEN_SIGNING_SECRET?.trim() ||
22+
env.MCP_ACCESS_TOKEN_HASHES?.trim() ||
23+
env.MCP_ACCESS_TOKENS?.trim(),
24+
);
25+
}
26+
27+
export function getUnauthorizedResponse(originUrl: string, method = "POST"): HttpAuthResponse {
28+
const isGet = method.toUpperCase() === "GET";
29+
return {
30+
status: 401,
31+
headers: {
32+
"content-type": "application/json; charset=utf-8",
33+
"cache-control": "no-store",
34+
"www-authenticate": `Bearer realm="education-agent-skills", error="invalid_token", error_description="Hosted MCP access token required"`,
35+
"x-mcp-auth": isGet ? "required-fast-fail" : "required",
36+
},
37+
body: JSON.stringify({
38+
error: "Hosted MCP access token required",
39+
message:
40+
"This hosted MCP endpoint now requires an access token. Request one via the hosted access form, or use the free local/plugin installation options.",
41+
requestAccess: "https://docs.google.com/forms/d/e/1FAIpQLSdW1EdcmtjSPPq68Hx-bdth5hO2KNyjhAwEV9Ld0EwWL1Gr8Q/viewform",
42+
resource: originUrl,
43+
}),
44+
};
45+
}
46+
47+
export function getAuthorizedTokenPrefix(input: AuthCheckInput): string | null {
48+
const env = input.env ?? process.env;
49+
const token = extractToken(input.url, input.authorization);
50+
if (!token || !hasConfiguredAuth(env)) return null;
51+
if (isSignedTokenValid(token, env.MCP_TOKEN_SIGNING_SECRET)) {
52+
return token.slice(0, TOKEN_PREFIX_LENGTH);
53+
}
54+
if (isHashTokenValid(token, env.MCP_ACCESS_TOKEN_HASHES)) {
55+
return token.slice(0, TOKEN_PREFIX_LENGTH);
56+
}
57+
if (isPlainTokenValid(token, env.MCP_ACCESS_TOKENS)) {
58+
return token.slice(0, TOKEN_PREFIX_LENGTH);
59+
}
60+
return null;
61+
}
62+
63+
export function extractToken(url?: string, authorization?: string | string[]): string | null {
64+
const header = Array.isArray(authorization) ? authorization[0] : authorization;
65+
const bearer = header?.match(/^Bearer\s+(.+)$/i)?.[1]?.trim();
66+
if (bearer) return bearer;
67+
if (!url) return null;
68+
try {
69+
const parsed = new URL(url, "https://example.invalid");
70+
return parsed.searchParams.get("token")?.trim() || null;
71+
} catch {
72+
return null;
73+
}
74+
}
75+
76+
function isSignedTokenValid(token: string, secret?: string): boolean {
77+
const cleanSecret = secret?.trim();
78+
if (!cleanSecret) return false;
79+
const dot = token.lastIndexOf(".");
80+
if (dot < 1) return false;
81+
const payload = token.slice(0, dot);
82+
const signature = token.slice(dot + 1);
83+
if (!payload.startsWith("eas_live_") || !signature) return false;
84+
const expected = createHmac("sha256", cleanSecret).update(payload).digest("base64url");
85+
return safeEqual(signature, expected);
86+
}
87+
88+
function isHashTokenValid(token: string, hashes?: string): boolean {
89+
const allowed = splitList(hashes);
90+
if (allowed.length === 0) return false;
91+
const digest = createHash("sha256").update(token).digest("hex");
92+
return allowed.some((hash) => safeEqual(digest, hash));
93+
}
94+
95+
function isPlainTokenValid(token: string, tokens?: string): boolean {
96+
const allowed = splitList(tokens);
97+
if (allowed.length === 0) return false;
98+
return allowed.some((allowedToken) => safeEqual(token, allowedToken));
99+
}
100+
101+
function splitList(raw?: string): string[] {
102+
return (raw ?? "")
103+
.split(/[\n,]/)
104+
.map((part) => part.trim())
105+
.filter(Boolean);
106+
}
107+
108+
function safeEqual(a: string, b: string): boolean {
109+
const aBuffer = Buffer.from(a);
110+
const bBuffer = Buffer.from(b);
111+
if (aBuffer.length !== bBuffer.length) return false;
112+
return timingSafeEqual(aBuffer, bBuffer);
113+
}

mcp-server/tests/http-auth.spec.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { test, expect } from "@playwright/test";
2+
import { createHmac, createHash } from "node:crypto";
3+
import {
4+
getAuthorizedTokenPrefix,
5+
getUnauthorizedResponse,
6+
hasConfiguredAuth,
7+
} from "../src/http-auth.js";
8+
9+
function signedToken(secret: string, nonce = "test-nonce") {
10+
const payload = `eas_live_${nonce}`;
11+
const signature = createHmac("sha256", secret).update(payload).digest("base64url");
12+
return `${payload}.${signature}`;
13+
}
14+
15+
test.describe("HTTP MCP auth", () => {
16+
test("fast-fails anonymous requests before the MCP transport can open SSE", () => {
17+
const response = getUnauthorizedResponse("https://example.com/mcp", "GET");
18+
19+
expect(response.status).toBe(401);
20+
expect(response.headers["www-authenticate"]).toContain("Bearer");
21+
expect(response.headers["content-type"]).toContain("application/json");
22+
expect(response.body).toContain("Hosted MCP access token required");
23+
});
24+
25+
test("accepts a signed bearer token generated from the shared signing secret", () => {
26+
const token = signedToken("test-secret");
27+
const prefix = getAuthorizedTokenPrefix({
28+
url: "https://example.com/mcp",
29+
authorization: `Bearer ${token}`,
30+
env: { MCP_TOKEN_SIGNING_SECRET: "test-secret" },
31+
});
32+
33+
expect(prefix).toBe("eas_live_test-nonc");
34+
});
35+
36+
test("accepts a token query parameter for clients that cannot set auth headers", () => {
37+
const token = signedToken("test-secret", "query-token");
38+
const prefix = getAuthorizedTokenPrefix({
39+
url: `https://example.com/mcp?token=${encodeURIComponent(token)}`,
40+
authorization: undefined,
41+
env: { MCP_TOKEN_SIGNING_SECRET: "test-secret" },
42+
});
43+
44+
expect(prefix).toBe("eas_live_query-tok");
45+
});
46+
47+
test("rejects invalid signed tokens", () => {
48+
const token = signedToken("wrong-secret");
49+
const prefix = getAuthorizedTokenPrefix({
50+
url: "https://example.com/mcp",
51+
authorization: `Bearer ${token}`,
52+
env: { MCP_TOKEN_SIGNING_SECRET: "test-secret" },
53+
});
54+
55+
expect(prefix).toBeNull();
56+
});
57+
58+
test("accepts pre-hashed tokens for emergency/manual issuance", () => {
59+
const token = "eas_live_manual_token";
60+
const hash = createHash("sha256").update(token).digest("hex");
61+
const prefix = getAuthorizedTokenPrefix({
62+
url: "https://example.com/mcp",
63+
authorization: `Bearer ${token}`,
64+
env: { MCP_ACCESS_TOKEN_HASHES: hash },
65+
});
66+
67+
expect(prefix).toBe("eas_live_manual_to");
68+
});
69+
70+
test("requires at least one configured auth source", () => {
71+
expect(hasConfiguredAuth({})).toBe(false);
72+
expect(hasConfiguredAuth({ MCP_TOKEN_SIGNING_SECRET: "test-secret" })).toBe(true);
73+
});
74+
});

0 commit comments

Comments
 (0)