mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-02 08:28:18 +00:00
mcp: wire the OAuth provider — lazy 401, RFC 9728/8414 metadata, DCR, CIMD, validating /authorize
src/auth/oauth.ts configures @cloudflare/workers-oauth-provider: /mcp and legacy /sse protected; /authorize (ours), /token and /register (library); CIMD enabled (with global_fetch_strictly_public in wrangler.toml); scopes read:account/read:jobs/read:catalog; 1h access, 30d idle refresh; error logging by code/category only. `resource` and `authorization_servers` are derived per request origin so one build serves every environment; production sets workers_dev = false so its only origin is canonical. src/index.ts checks the environment invariant first (a misconfigured deploy fails even /token), then hands off to the provider. src/runtime.ts holds the memoized config+app. src/auth/authorize.ts validates the OAuth request via parseAuthRequest, renders unknown-client errors locally and client-validated errors as redirects, and stops at 501 — it never auto-approves. src/mcp/handler.ts is a 501 stub reachable only with a valid token. Tests (10): 401 + resource_metadata challenge on /mcp and /sse, bogus bearer → 401 not 500, health still reachable; PRM at root and /mcp paths with derived resource and this origin as issuer; AS metadata with S256, CIMD flag, "none" auth method, iss support, registration and revocation endpoints; /authorize 400 locally on malformed and unknown client, 201 DCR then 501 with no redirect. pnpm check: typecheck, eslint, prettier, 87 tests passing. Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
This commit is contained in:
1 parent
7e2708e589
commit
c1c33caab5
11 files changed
+374
-40
No files matched your search
@@ -23,6 +23,7 @@
|
||||
"check": "pnpm typecheck && pnpm lint && pnpm format && pnpm test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cloudflare/workers-oauth-provider": "0.10.3",
|
||||
"hono": "4.13.4",
|
||||
"openapi-fetch": "0.17.0",
|
||||
"zod": "4.4.3"
|
||||
|
||||
Generated
+8
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@cloudflare/workers-oauth-provider':
|
||||
specifier: 0.10.3
|
||||
version: 0.10.3
|
||||
hono:
|
||||
specifier: 4.13.4
|
||||
version: 4.13.4
|
||||
@@ -149,6 +152,9 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@cloudflare/workers-oauth-provider@0.10.3':
|
||||
resolution: {integrity: sha512-25ufMONJir9PllqVpK4GwOOoSFgpYm3+bM6NBedj7ufMCIfNf5jk4OI0LPuTJBaJgLl2lGCLqFqEPJXcSuPopQ==}
|
||||
|
||||
'@cloudflare/workers-types@5.20260825.1':
|
||||
resolution: {integrity: sha512-/XZntbK+BlJWC5jxkaDNhnDLr2Bf2627sZ6VMrxqKrnc84pc6gNu5NdAyaTkZn1LzQVFIa3SL7V/7veLF59P7w==}
|
||||
|
||||
@@ -1555,6 +1561,8 @@ snapshots:
|
||||
'@cloudflare/workerd-windows-64@1.20260820.1':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workers-oauth-provider@0.10.3': {}
|
||||
|
||||
'@cloudflare/workers-types@5.20260825.1':
|
||||
optional: true
|
||||
|
||||
|
||||
+7
-4
@@ -1,13 +1,14 @@
|
||||
import { Hono } from "hono";
|
||||
|
||||
import { authorizeRoutes } from "./auth/authorize";
|
||||
import type { Config } from "./config";
|
||||
|
||||
/**
|
||||
* The HTTP surface of the Worker. Built once from a validated `Config`; routes are added here
|
||||
* as milestones land (see mcp/CLAUDE.md → Architecture).
|
||||
* The unprotected HTTP surface: health, the authorization endpoint, and (later) the pages.
|
||||
* Protected MCP routes never reach this app — the OAuth provider owns them.
|
||||
*/
|
||||
export function createApp(config: Config): Hono {
|
||||
const app = new Hono();
|
||||
export function createApp(config: Config): Hono<{ Bindings: Cloudflare.Env }> {
|
||||
const app = new Hono<{ Bindings: Cloudflare.Env }>();
|
||||
|
||||
app.get("/healthz", (c) =>
|
||||
c.json({
|
||||
@@ -16,5 +17,7 @@ export function createApp(config: Config): Hono {
|
||||
}),
|
||||
);
|
||||
|
||||
app.route("/", authorizeRoutes);
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { AuthorizationError, type AuthRequest } from "@cloudflare/workers-oauth-provider";
|
||||
import { Hono } from "hono";
|
||||
|
||||
import { OAUTH_ENDPOINTS } from "./oauth";
|
||||
|
||||
/**
|
||||
* The application-owned authorization endpoint. The provider validates the OAuth request
|
||||
* (client, exact redirect URI, response type, resource, PKCE); this module owns what happens
|
||||
* next: sign-in and consent, which arrive with the `Authenticator` seam in the next PR.
|
||||
*
|
||||
* Until then the endpoint validates and stops. It must never auto-approve.
|
||||
*/
|
||||
export const authorizeRoutes = new Hono<{ Bindings: Cloudflare.Env }>();
|
||||
|
||||
authorizeRoutes.get(OAUTH_ENDPOINTS.authorize, async (c) => {
|
||||
let authRequest: AuthRequest;
|
||||
try {
|
||||
authRequest = await c.env.OAUTH_PROVIDER.parseAuthRequest(c.req.raw);
|
||||
} catch (error) {
|
||||
if (!(error instanceof AuthorizationError)) throw error;
|
||||
// Only once the client and its exact registered redirect URI are validated may an error
|
||||
// be sent back to the client; otherwise it is rendered locally.
|
||||
if (!error.redirectUri) {
|
||||
return c.text(`Invalid authorization request: ${error.description}`, 400);
|
||||
}
|
||||
const redirect = new URL(error.redirectUri);
|
||||
redirect.searchParams.set("error", error.code);
|
||||
redirect.searchParams.set("error_description", error.description);
|
||||
if (error.state) redirect.searchParams.set("state", error.state);
|
||||
if (error.issuer) redirect.searchParams.set("iss", error.issuer);
|
||||
return c.redirect(redirect.toString(), 302);
|
||||
}
|
||||
|
||||
const client = await c.env.OAUTH_PROVIDER.lookupClient(authRequest.clientId);
|
||||
if (!client) {
|
||||
return c.text("Unknown OAuth client", 400);
|
||||
}
|
||||
|
||||
return c.text("Sign-in is not available yet.", 501);
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { OAuthProvider, type OAuthProviderOptions } from "@cloudflare/workers-oauth-provider";
|
||||
|
||||
/**
|
||||
* Configuration of the OAuth 2.1 authorization server that fronts the MCP endpoint. The
|
||||
* library (`@cloudflare/workers-oauth-provider`) owns discovery metadata, client registration
|
||||
* (CIMD and DCR), PKCE, the token endpoint with refresh rotation, and revocation. We own the
|
||||
* `/authorize` UI (sign-in + consent, see auth/authorize.ts) and the protected MCP handler.
|
||||
*
|
||||
* Everything here is static per Worker: the public origin is taken from each request by the
|
||||
* library, so one configuration serves local, preview, and production unchanged.
|
||||
*/
|
||||
|
||||
export const OAUTH_ENDPOINTS = {
|
||||
authorize: "/authorize",
|
||||
token: "/token",
|
||||
register: "/register",
|
||||
} as const;
|
||||
|
||||
/** Protected route prefixes. `/sse` is the legacy transport some clients still default to. */
|
||||
export const MCP_ROUTES = ["/mcp", "/sse"] as const;
|
||||
|
||||
/** Scopes a grant may carry in M1. `generate` is deliberately absent until it has a design. */
|
||||
export const SCOPES = ["read:account", "read:jobs", "read:catalog"] as const;
|
||||
export type Scope = (typeof SCOPES)[number];
|
||||
|
||||
/** Access tokens are short-lived; refresh tokens rotate and expire after this much idle time. */
|
||||
export const ACCESS_TOKEN_TTL_SECONDS = 60 * 60;
|
||||
export const REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60;
|
||||
|
||||
/**
|
||||
* Absolute lifetime of a grant regardless of refresh activity. The upstream session cannot
|
||||
* be refreshed, only held or deleted, so grants must re-consent eventually; this is also the
|
||||
* runway for swapping the upstream credential later (mcp/CLAUDE.md → Lifetimes).
|
||||
*/
|
||||
export const GRANT_MAX_AGE_SECONDS = 90 * 24 * 60 * 60;
|
||||
|
||||
export const RESOURCE_NAME = "Artcraft";
|
||||
|
||||
/** An ExportedHandler that definitely has `fetch` — what the provider accepts for handlers. */
|
||||
export type FetchHandler = ExportedHandler<Cloudflare.Env> & {
|
||||
fetch: NonNullable<ExportedHandler<Cloudflare.Env>["fetch"]>;
|
||||
};
|
||||
|
||||
export interface OAuthHandlers {
|
||||
/** Unprotected routes: `/authorize`, pages, health. */
|
||||
readonly defaultHandler: FetchHandler;
|
||||
/** The MCP transport; only reached with a valid access token. */
|
||||
readonly apiHandler: FetchHandler;
|
||||
}
|
||||
|
||||
/** Exported separately so tests can call `getOAuthApi(options, env)` with the same options. */
|
||||
export function createOAuthProviderOptions(handlers: OAuthHandlers): OAuthProviderOptions {
|
||||
return {
|
||||
apiRoute: [...MCP_ROUTES],
|
||||
apiHandler: handlers.apiHandler,
|
||||
defaultHandler: handlers.defaultHandler,
|
||||
|
||||
authorizeEndpoint: OAUTH_ENDPOINTS.authorize,
|
||||
tokenEndpoint: OAUTH_ENDPOINTS.token,
|
||||
// DCR stays enabled as the fallback for clients without CIMD support.
|
||||
clientRegistrationEndpoint: OAUTH_ENDPOINTS.register,
|
||||
// Preferred registration for Claude and ChatGPT; needs `global_fetch_strictly_public`.
|
||||
clientIdMetadataDocumentEnabled: true,
|
||||
|
||||
scopesSupported: [...SCOPES],
|
||||
resourceMetadata: {
|
||||
// `resource` and `authorization_servers` are derived from the request origin, so the
|
||||
// same build serves every environment. Production has exactly one public origin
|
||||
// (workers_dev = false), which is what makes the derived resource canonical.
|
||||
scopes_supported: [...SCOPES],
|
||||
bearer_methods_supported: ["header"],
|
||||
resource_name: RESOURCE_NAME,
|
||||
},
|
||||
|
||||
accessTokenTTL: ACCESS_TOKEN_TTL_SECONDS,
|
||||
refreshTokenTTL: REFRESH_TOKEN_TTL_SECONDS,
|
||||
|
||||
onError(error) {
|
||||
// Codes and categories only: descriptions can echo client-supplied input.
|
||||
console.warn(
|
||||
JSON.stringify({
|
||||
event: "oauth_error",
|
||||
code: error.code,
|
||||
status: error.status,
|
||||
category: error.internal?.category,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createOAuthProvider(handlers: OAuthHandlers): OAuthProvider {
|
||||
return new OAuthProvider(createOAuthProviderOptions(handlers));
|
||||
}
|
||||
+22
-35
@@ -1,48 +1,35 @@
|
||||
import type { Hono } from "hono";
|
||||
|
||||
import { createApp } from "./app";
|
||||
import { ConfigError, loadConfig } from "./config";
|
||||
import { createOAuthProvider } from "./auth/oauth";
|
||||
import { ConfigError } from "./config";
|
||||
import { mcpApiHandler } from "./mcp/handler";
|
||||
import { getRuntime } from "./runtime";
|
||||
|
||||
/**
|
||||
* Worker entry point. The app is built once per isolate from the bindings. If the bindings
|
||||
* violate the environment invariant, every request fails with a 500 that names the problem —
|
||||
* a misconfigured deploy must be impossible to miss.
|
||||
* Worker entry point. Every request first passes the environment invariant (a misconfigured
|
||||
* deploy fails everything, including the OAuth endpoints), then the OAuth provider routes it:
|
||||
* protected MCP routes require a valid access token; everything else reaches the Hono app.
|
||||
*/
|
||||
let cachedApp: Hono | undefined;
|
||||
let cachedFailure: ConfigError | undefined;
|
||||
|
||||
function getApp(env: unknown): Hono {
|
||||
if (cachedApp) return cachedApp;
|
||||
if (cachedFailure) throw cachedFailure;
|
||||
try {
|
||||
cachedApp = createApp(loadConfig(env));
|
||||
return cachedApp;
|
||||
} catch (error) {
|
||||
if (error instanceof ConfigError) {
|
||||
cachedFailure = error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const provider = createOAuthProvider({
|
||||
defaultHandler: {
|
||||
fetch: (request, env, ctx) => getRuntime(env).app.fetch(request, env, ctx),
|
||||
},
|
||||
apiHandler: mcpApiHandler,
|
||||
});
|
||||
|
||||
export default {
|
||||
fetch(
|
||||
request: Request,
|
||||
env: Cloudflare.Env,
|
||||
ctx: ExecutionContext,
|
||||
): Response | Promise<Response> {
|
||||
let app: Hono;
|
||||
fetch(request: Request, env: Cloudflare.Env, ctx: ExecutionContext): Promise<Response> {
|
||||
try {
|
||||
app = getApp(env);
|
||||
getRuntime(env);
|
||||
} catch (error) {
|
||||
if (error instanceof ConfigError) {
|
||||
return new Response(`Worker misconfigured: ${error.message}`, {
|
||||
status: 500,
|
||||
headers: { "content-type": "text/plain; charset=utf-8" },
|
||||
});
|
||||
return Promise.resolve(
|
||||
new Response(`Worker misconfigured: ${error.message}`, {
|
||||
status: 500,
|
||||
headers: { "content-type": "text/plain; charset=utf-8" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return app.fetch(request, env, ctx);
|
||||
return provider.fetch(request, env, ctx);
|
||||
},
|
||||
} satisfies ExportedHandler<Cloudflare.Env>;
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { FetchHandler } from "../auth/oauth";
|
||||
|
||||
/**
|
||||
* The protected MCP route. The OAuth provider only invokes this after validating a bearer
|
||||
* token, and exposes the decrypted grant props on `ctx.props`. The transport itself lands in
|
||||
* a later PR; until then this answers 501 so a valid token can be proven end to end without
|
||||
* anything being callable.
|
||||
*/
|
||||
export const mcpApiHandler: FetchHandler = {
|
||||
fetch(_request, _env, ctx) {
|
||||
const props: unknown = (ctx as ExecutionContext & { props?: unknown }).props;
|
||||
return Promise.resolve(
|
||||
Response.json(
|
||||
{ error: "MCP transport not available yet", authenticated: props !== undefined },
|
||||
{ status: 501 },
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
import type { OAuthHelpers } from "@cloudflare/workers-oauth-provider";
|
||||
|
||||
// The provider injects its helpers into `env` for every handler it invokes. The generated
|
||||
// worker-configuration.d.ts knows only the wrangler.toml bindings, so add this one here.
|
||||
declare global {
|
||||
namespace Cloudflare {
|
||||
interface Env {
|
||||
OAUTH_PROVIDER: OAuthHelpers;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Hono } from "hono";
|
||||
|
||||
import { createApp } from "./app";
|
||||
import { type Config, ConfigError, loadConfig } from "./config";
|
||||
|
||||
/**
|
||||
* Per-isolate state built once from the bindings. If the bindings violate the environment
|
||||
* invariant, the failure is memoized and rethrown on every call so a misconfigured deploy
|
||||
* can never serve a single successful request.
|
||||
*/
|
||||
export interface Runtime {
|
||||
readonly config: Config;
|
||||
readonly app: Hono<{ Bindings: Cloudflare.Env }>;
|
||||
}
|
||||
|
||||
let cachedRuntime: Runtime | undefined;
|
||||
let cachedFailure: ConfigError | undefined;
|
||||
|
||||
export function getRuntime(env: unknown): Runtime {
|
||||
if (cachedRuntime) return cachedRuntime;
|
||||
if (cachedFailure) throw cachedFailure;
|
||||
try {
|
||||
const config = loadConfig(env);
|
||||
cachedRuntime = { config, app: createApp(config) };
|
||||
return cachedRuntime;
|
||||
} catch (error) {
|
||||
if (error instanceof ConfigError) cachedFailure = error;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { createExecutionContext, waitOnExecutionContext } from "cloudflare:test";
|
||||
import { env } from "cloudflare:workers";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { MCP_ROUTES, OAUTH_ENDPOINTS, SCOPES } from "../../src/auth/oauth";
|
||||
import worker from "../../src/index";
|
||||
|
||||
const ORIGIN = "https://mcp.test";
|
||||
|
||||
async function call(path: string, init?: RequestInit): Promise<Response> {
|
||||
const ctx = createExecutionContext();
|
||||
const response = await worker.fetch(new Request(`${ORIGIN}${path}`, init), env, ctx);
|
||||
await waitOnExecutionContext(ctx);
|
||||
return response;
|
||||
}
|
||||
|
||||
async function json(path: string): Promise<Record<string, unknown>> {
|
||||
const response = await call(path);
|
||||
expect(response.status).toBe(200);
|
||||
return response.json<Record<string, unknown>>();
|
||||
}
|
||||
|
||||
describe("lazy authentication on the MCP route", () => {
|
||||
it.each(MCP_ROUTES)("answers %s with 401 and a resource_metadata challenge", async (route) => {
|
||||
const response = await call(route, { method: "POST" });
|
||||
expect(response.status).toBe(401);
|
||||
const challenge = response.headers.get("www-authenticate") ?? "";
|
||||
expect(challenge).toMatch(/^Bearer /);
|
||||
expect(challenge).toContain(
|
||||
`resource_metadata="${ORIGIN}/.well-known/oauth-protected-resource${route}"`,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a made-up bearer token with 401, not 500", async () => {
|
||||
const response = await call("/mcp", {
|
||||
method: "POST",
|
||||
headers: { authorization: "Bearer not-a-real-token" },
|
||||
});
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("keeps the unprotected routes reachable", async () => {
|
||||
const response = await call("/healthz");
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ ok: true, environment: "local" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("protected resource metadata (RFC 9728)", () => {
|
||||
it("is served at the root well-known path with a derived resource and this origin as issuer", async () => {
|
||||
const metadata = await json("/.well-known/oauth-protected-resource");
|
||||
expect(metadata.authorization_servers).toEqual([ORIGIN]);
|
||||
expect(metadata.scopes_supported).toEqual([...SCOPES]);
|
||||
expect(metadata.bearer_methods_supported).toEqual(["header"]);
|
||||
expect(metadata.resource_name).toBe("Artcraft");
|
||||
expect(typeof metadata.resource).toBe("string");
|
||||
expect(metadata.resource).toMatch(new RegExp(`^${ORIGIN}`));
|
||||
});
|
||||
|
||||
it("derives the MCP endpoint as the resource on the path-specific document", async () => {
|
||||
const metadata = await json("/.well-known/oauth-protected-resource/mcp");
|
||||
expect(metadata.resource).toBe(`${ORIGIN}/mcp`);
|
||||
expect(metadata.authorization_servers).toEqual([ORIGIN]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("authorization server metadata (RFC 8414)", () => {
|
||||
it("advertises exactly what Claude and ChatGPT need to pick CIMD and S256 PKCE", async () => {
|
||||
const metadata = await json("/.well-known/oauth-authorization-server");
|
||||
expect(metadata.issuer).toBe(ORIGIN);
|
||||
expect(metadata.authorization_endpoint).toBe(`${ORIGIN}${OAUTH_ENDPOINTS.authorize}`);
|
||||
expect(metadata.token_endpoint).toBe(`${ORIGIN}${OAUTH_ENDPOINTS.token}`);
|
||||
expect(metadata.registration_endpoint).toBe(`${ORIGIN}${OAUTH_ENDPOINTS.register}`);
|
||||
expect(metadata.code_challenge_methods_supported).toEqual(["S256"]);
|
||||
expect(metadata.client_id_metadata_document_supported).toBe(true);
|
||||
expect(metadata.token_endpoint_auth_methods_supported).toContain("none");
|
||||
expect(metadata.authorization_response_iss_parameter_supported).toBe(true);
|
||||
expect(metadata.scopes_supported).toEqual([...SCOPES]);
|
||||
expect(metadata.response_types_supported).toEqual(["code"]);
|
||||
expect(metadata.grant_types_supported).toEqual(
|
||||
expect.arrayContaining(["authorization_code", "refresh_token"]),
|
||||
);
|
||||
expect(typeof metadata.revocation_endpoint).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the authorization endpoint before sign-in exists", () => {
|
||||
it("renders a malformed request locally as 400", async () => {
|
||||
const response = await call(`${OAUTH_ENDPOINTS.authorize}?response_type=code`);
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.text()).toMatch(/^Invalid authorization request/);
|
||||
});
|
||||
|
||||
it("renders an unknown client locally as 400 and never redirects", async () => {
|
||||
const params = new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: "does-not-exist",
|
||||
redirect_uri: "https://client.example/callback",
|
||||
state: "s",
|
||||
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
||||
code_challenge_method: "S256",
|
||||
});
|
||||
const response = await call(`${OAUTH_ENDPOINTS.authorize}?${params.toString()}`);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.headers.get("location")).toBeNull();
|
||||
});
|
||||
|
||||
it("validates a registered client's request and then stops with 501 — never auto-approves", async () => {
|
||||
const registration = await call(OAUTH_ENDPOINTS.register, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
client_name: "Test client",
|
||||
redirect_uris: ["https://client.example/callback"],
|
||||
token_endpoint_auth_method: "none",
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
response_types: ["code"],
|
||||
}),
|
||||
});
|
||||
expect(registration.status).toBe(201);
|
||||
const client = await registration.json<{ client_id: string }>();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: client.client_id,
|
||||
redirect_uri: "https://client.example/callback",
|
||||
scope: "read:account read:jobs",
|
||||
state: "s",
|
||||
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
||||
code_challenge_method: "S256",
|
||||
resource: `${ORIGIN}/mcp`,
|
||||
});
|
||||
const response = await call(`${OAUTH_ENDPOINTS.authorize}?${params.toString()}`);
|
||||
expect(response.status).toBe(501);
|
||||
expect(response.headers.get("location")).toBeNull();
|
||||
});
|
||||
});
|
||||
+3
-1
@@ -8,7 +8,8 @@
|
||||
name = "artcraft-mcp"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2026-08-01"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
# global_fetch_strictly_public: required by workers-oauth-provider for CIMD fetches (SSRF protection).
|
||||
compatibility_flags = ["nodejs_compat", "global_fetch_strictly_public"]
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# local — `wrangler dev`. Talks to the fake upstream on localhost. `.dev.vars` may override.
|
||||
@@ -40,6 +41,7 @@ id = "REPLACE_WITH_PREVIEW_KV_ID" # from docs/infra-request.md §1
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
[env.production]
|
||||
name = "artcraft-mcp"
|
||||
workers_dev = false # one canonical public URL: the custom domain. The OAuth `resource` must match it exactly.
|
||||
# Enable once the getartcraft.com zone is on Cloudflare (docs/infra-request.md §1):
|
||||
# routes = [{ pattern = "mcp.getartcraft.com", custom_domain = true }]
|
||||
|
||||
|
||||
Reference in new issue
Block a user