Skip to content

Commit 535eeab

Browse files
committed
fix: add hosted MCP signup link to OAuth page
1 parent 87d2fa2 commit 535eeab

13 files changed

Lines changed: 415 additions & 19 deletions

File tree

mcp-server/api/mcp.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createServer } from "../src/server.js";
22
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
33
import { getAuthorizedTokenPrefix, getUnauthorizedResponse } from "../src/http-auth.js";
4+
import { publicBaseUrl } from "../src/oauth.js";
45
import type { LoadedSkill } from "../src/types.js";
56
import type { IncomingMessage, ServerResponse } from "node:http";
67

@@ -33,7 +34,8 @@ export default async function handler(
3334
});
3435

3536
if (!tokenPrefix) {
36-
const unauthorized = getUnauthorizedResponse(req.url ?? "/mcp", req.method);
37+
const baseUrl = publicBaseUrl(req);
38+
const unauthorized = getUnauthorizedResponse(`${baseUrl}/mcp`, req.method, `${baseUrl}/.well-known/oauth-protected-resource/mcp`);
3739
for (const [key, value] of Object.entries(unauthorized.headers)) {
3840
res.setHeader(key, value);
3941
}

mcp-server/api/oauth/authorize.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import type { IncomingMessage, ServerResponse } from "node:http";
2+
import { authorizationPage, createAuthorizationCode, isIssuedAccessToken } from "../../src/oauth.js";
3+
4+
async function readBody(req: IncomingMessage & { body?: unknown }): Promise<URLSearchParams> {
5+
if (typeof req.body === "string") return new URLSearchParams(req.body);
6+
if (req.body && typeof req.body === "object") return new URLSearchParams(req.body as Record<string, string>);
7+
const chunks: Buffer[] = [];
8+
for await (const chunk of req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
9+
return new URLSearchParams(Buffer.concat(chunks).toString("utf8"));
10+
}
11+
12+
export default async function handler(req: IncomingMessage & { body?: unknown }, res: ServerResponse) {
13+
if (req.method === "GET") {
14+
const url = new URL(req.url || "/api/oauth/authorize", "https://example.invalid");
15+
res.setHeader("content-type", "text/html; charset=utf-8");
16+
res.writeHead(200);
17+
res.end(authorizationPage(url.searchParams));
18+
return;
19+
}
20+
21+
if (req.method !== "POST") {
22+
res.writeHead(405, { allow: "GET, POST" });
23+
res.end("Method not allowed");
24+
return;
25+
}
26+
27+
const form = await readBody(req);
28+
const accessToken = form.get("access_token")?.trim() || "";
29+
const redirectUri = form.get("redirect_uri")?.trim() || "";
30+
const clientId = form.get("client_id")?.trim() || "claude-ai-custom-connector";
31+
const state = form.get("state") || "";
32+
const responseType = form.get("response_type") || "code";
33+
34+
if (responseType !== "code" || !redirectUri) {
35+
res.writeHead(400, { "content-type": "text/html; charset=utf-8" });
36+
res.end(authorizationPage(form, "Invalid OAuth request from client."));
37+
return;
38+
}
39+
40+
if (!isIssuedAccessToken(accessToken)) {
41+
res.writeHead(401, { "content-type": "text/html; charset=utf-8" });
42+
res.end(authorizationPage(form, "That access token was not recognized. Paste the token from the Education Agent Skills access email."));
43+
return;
44+
}
45+
46+
const code = createAuthorizationCode({
47+
token: accessToken,
48+
redirectUri,
49+
clientId,
50+
codeChallenge: form.get("code_challenge") || undefined,
51+
codeChallengeMethod: form.get("code_challenge_method") || undefined,
52+
});
53+
54+
const redirect = new URL(redirectUri);
55+
redirect.searchParams.set("code", code);
56+
if (state) redirect.searchParams.set("state", state);
57+
res.writeHead(302, { location: redirect.toString(), "cache-control": "no-store" });
58+
res.end();
59+
}

mcp-server/api/oauth/register.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { IncomingMessage, ServerResponse } from "node:http";
2+
import { dynamicClientRegistrationResponse, publicBaseUrl } from "../../src/oauth.js";
3+
4+
async function readJson(req: IncomingMessage & { body?: unknown }): Promise<Record<string, unknown>> {
5+
if (req.body && typeof req.body === "object") return req.body as Record<string, unknown>;
6+
const chunks: Buffer[] = [];
7+
for await (const chunk of req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
8+
const raw = Buffer.concat(chunks).toString("utf8");
9+
if (!raw.trim()) return {};
10+
try { return JSON.parse(raw) as Record<string, unknown>; } catch { return {}; }
11+
}
12+
13+
export default async function handler(req: IncomingMessage & { body?: unknown; headers: Record<string, string | string[] | undefined> }, res: ServerResponse) {
14+
if (req.method !== "POST") {
15+
res.writeHead(405, { allow: "POST" });
16+
res.end("Method not allowed");
17+
return;
18+
}
19+
const body = await readJson(req);
20+
res.writeHead(201, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
21+
res.end(JSON.stringify(dynamicClientRegistrationResponse(publicBaseUrl(req), body)));
22+
}

mcp-server/api/oauth/token.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { IncomingMessage, ServerResponse } from "node:http";
2+
import { createRefreshToken, verifyAuthorizationCode, verifyRefreshToken } from "../../src/oauth.js";
3+
4+
async function readBody(req: IncomingMessage & { body?: unknown }): Promise<URLSearchParams> {
5+
if (typeof req.body === "string") return new URLSearchParams(req.body);
6+
if (req.body && typeof req.body === "object") return new URLSearchParams(req.body as Record<string, string>);
7+
const chunks: Buffer[] = [];
8+
for await (const chunk of req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
9+
return new URLSearchParams(Buffer.concat(chunks).toString("utf8"));
10+
}
11+
12+
export default async function handler(req: IncomingMessage & { body?: unknown }, res: ServerResponse) {
13+
if (req.method !== "POST") {
14+
res.writeHead(405, { allow: "POST" });
15+
res.end("Method not allowed");
16+
return;
17+
}
18+
19+
const body = await readBody(req);
20+
const grantType = body.get("grant_type") || "authorization_code";
21+
let accessToken: string | null = null;
22+
23+
if (grantType === "authorization_code") {
24+
const code = body.get("code") || "";
25+
const verifier = body.get("code_verifier") || undefined;
26+
const payload = verifyAuthorizationCode(code, verifier);
27+
accessToken = payload?.token || null;
28+
} else if (grantType === "refresh_token") {
29+
accessToken = verifyRefreshToken(body.get("refresh_token") || "");
30+
}
31+
32+
if (!accessToken) {
33+
res.writeHead(400, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
34+
res.end(JSON.stringify({ error: "invalid_grant" }));
35+
return;
36+
}
37+
38+
res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
39+
res.end(JSON.stringify({
40+
access_token: accessToken,
41+
token_type: "Bearer",
42+
expires_in: 60 * 60 * 24 * 30,
43+
refresh_token: createRefreshToken(accessToken),
44+
scope: "mcp",
45+
}));
46+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import type { IncomingMessage, ServerResponse } from "node:http";
2+
import { authorizationServerMetadata, publicBaseUrl } from "../../src/oauth.js";
3+
4+
export default function handler(req: IncomingMessage & { headers: Record<string, string | string[] | undefined> }, res: ServerResponse) {
5+
res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
6+
res.end(JSON.stringify(authorizationServerMetadata(publicBaseUrl(req))));
7+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import type { IncomingMessage, ServerResponse } from "node:http";
2+
import { protectedResourceMetadata, publicBaseUrl } from "../../../src/oauth.js";
3+
4+
export default function handler(req: IncomingMessage & { headers: Record<string, string | string[] | undefined> }, res: ServerResponse) {
5+
res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
6+
res.end(JSON.stringify(protectedResourceMetadata(publicBaseUrl(req))));
7+
}

mcp-server/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
],
1515
"scripts": {
1616
"build": "tsc",
17+
"vercel-build": "npm run build",
1718
"postbuild": "cp src/skills.json dist/skills.json",
1819
"bundle-skills": "tsx scripts/bundle-skills.ts",
1920
"start": "node dist/index.js",

mcp-server/public/index.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Education Agent Skills MCP server

mcp-server/src/access.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const HOSTED_MCP_ACCESS_SIGNUP_URL = "https://docs.google.com/forms/d/e/1FAIpQLSdW1EdcmtjSPPq68Hx-bdth5hO2KNyjhAwEV9Ld0EwWL1Gr8Q/viewform";

mcp-server/src/http-auth.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
2+
import { HOSTED_MCP_ACCESS_SIGNUP_URL } from "./access.js";
23

34
export type AuthEnv = Record<string, string | undefined>;
45

@@ -24,22 +25,26 @@ export function hasConfiguredAuth(env: AuthEnv = process.env): boolean {
2425
);
2526
}
2627

27-
export function getUnauthorizedResponse(originUrl: string, method = "POST"): HttpAuthResponse {
28+
export function getUnauthorizedResponse(originUrl: string, method = "POST", metadataUrl?: string): HttpAuthResponse {
2829
const isGet = method.toUpperCase() === "GET";
30+
const challenge = metadataUrl
31+
? `Bearer resource_metadata="${metadataUrl}"`
32+
: `Bearer realm="education-agent-skills", error="invalid_token", error_description="Hosted MCP access token required"`;
2933
return {
3034
status: 401,
3135
headers: {
3236
"content-type": "application/json; charset=utf-8",
3337
"cache-control": "no-store",
34-
"www-authenticate": `Bearer realm="education-agent-skills", error="invalid_token", error_description="Hosted MCP access token required"`,
38+
"www-authenticate": challenge,
3539
"x-mcp-auth": isGet ? "required-fast-fail" : "required",
3640
},
3741
body: JSON.stringify({
3842
error: "Hosted MCP access token required",
3943
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",
44+
"This hosted MCP endpoint requires OAuth authorization in Claude.ai. Request an access token, then paste it into the browser authorization screen when Claude connects.",
45+
requestAccess: HOSTED_MCP_ACCESS_SIGNUP_URL,
4246
resource: originUrl,
47+
resourceMetadata: metadataUrl,
4348
}),
4449
};
4550
}

0 commit comments

Comments
 (0)