Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"use client";

import { useState } from "react";

export type Grant = {
id: string;
client_name: string;
client_uri: string | null;
created_at: string;
last_used_at: string | null;
};

/** Apps signed in to the MCP server over OAuth (ChatGPT, claude.ai, Claude Code), with Disconnect. */
export function ConnectedApps({ grants: initial }: { grants: Grant[] }) {
const [grants, setGrants] = useState(initial);
const [error, setError] = useState<string | null>(null);

async function disconnect(id: string) {
setError(null);
const res = await fetch(`/api/oauth/grants?id=${encodeURIComponent(id)}`, { method: "DELETE" });
if (res.ok) setGrants((g) => g.filter((x) => x.id !== id));
else setError("Could not disconnect that app. Try again.");
}

if (grants.length === 0) {
return <p className="text-sm text-[var(--color-muted)]">No apps connected yet.</p>;
}
return (
<div className="space-y-2">
<ul className="divide-y divide-[var(--color-border)] rounded border border-[var(--color-border)]">
{grants.map((g) => (
<li key={g.id} className="flex items-center justify-between gap-3 p-3 text-sm">
<div className="min-w-0">
<div className="truncate font-medium">{g.client_name}</div>
<div className="text-xs text-[var(--color-muted)]">
Connected {new Date(g.created_at).toLocaleDateString()}
{g.last_used_at ? ` · last used ${new Date(g.last_used_at).toLocaleString()}` : ""}
</div>
</div>
<button type="button" className="btn" onClick={() => disconnect(g.id)}>
Disconnect
</button>
</li>
))}
</ul>
{error && <p className="text-sm text-[var(--color-fail)]">{error}</p>}
</div>
);
}
17 changes: 17 additions & 0 deletions app/(app)/dashboard/projects/[id]/social/api-tokens/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import Link from "next/link";
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { ApiTokensClient } from "./client";
import { ConnectedApps, type Grant } from "./connected-apps";
import { listGrants } from "@/lib/mcp-oauth";
import { serviceClient } from "@/lib/supabase/service";

export const metadata = { title: "Social · API tokens" };

Expand Down Expand Up @@ -32,6 +35,7 @@ export default async function ApiTokensPage({
.select("id, name, prefix, last_used_at, revoked_at, created_at, scope")
.eq("user_id", user.id)
.order("created_at", { ascending: false });
const grants = (await listGrants(serviceClient(), user.id)) as Grant[];

return (
<div className="mx-auto max-w-2xl space-y-8">
Expand Down Expand Up @@ -70,6 +74,19 @@ export default async function ApiTokensPage({
<ApiTokensClient tokens={(tokens ?? []) as TokenRow[]} />

<McpServerSection />

<section className="card space-y-3 p-5">
<div>
<h2 className="text-lg font-semibold">Connected apps</h2>
<p className="mt-1 text-sm text-[var(--color-muted)]">
ChatGPT, claude.ai and Claude Code need no token: add{" "}
<code>{`${process.env.NEXT_PUBLIC_SITE_URL ?? "https://crawlproof.com"}/api/mcp`}</code> as a
connector and sign in when asked (OAuth 2.1). Apps you allowed are listed here; disconnecting one stops
its access at once.
</p>
</div>
<ConnectedApps grants={grants} />
</section>
</div>
);
}
Expand Down
3 changes: 3 additions & 0 deletions app/.well-known/oauth-authorization-server/[...path]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { GET, OPTIONS } from "../route";

export const dynamic = "force-dynamic";
16 changes: 16 additions & 0 deletions app/.well-known/oauth-authorization-server/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { authorizationServerMetadata, CORS_HEADERS } from "@/lib/mcp-oauth";

export const dynamic = "force-dynamic";

// RFC 8414 authorization server metadata. The [...path] sibling answers the
// path-suffixed form some clients try first.
export function GET() {
return NextResponse.json(authorizationServerMetadata(), {
headers: { ...CORS_HEADERS, "cache-control": "public, max-age=3600" },
});
}

export function OPTIONS() {
return new NextResponse(null, { status: 204, headers: CORS_HEADERS });
}
3 changes: 3 additions & 0 deletions app/.well-known/oauth-protected-resource/[...path]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { GET, OPTIONS } from "../route";

export const dynamic = "force-dynamic";
16 changes: 16 additions & 0 deletions app/.well-known/oauth-protected-resource/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { CORS_HEADERS, protectedResourceMetadata } from "@/lib/mcp-oauth";

export const dynamic = "force-dynamic";

// RFC 9728 protected-resource metadata for the MCP server at /api/mcp. The
// [...path] sibling answers the path-suffixed form (/.well-known/oauth-protected-resource/api/mcp).
export function GET() {
return NextResponse.json(protectedResourceMetadata(), {
headers: { ...CORS_HEADERS, "cache-control": "public, max-age=3600" },
});
}

export function OPTIONS() {
return new NextResponse(null, { status: 204, headers: CORS_HEADERS });
}
36 changes: 34 additions & 2 deletions app/api/mcp/route.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
// CrawlProof MCP server — a real Model Context Protocol server (official
// @modelcontextprotocol/sdk via the mcp-handler Next adapter) mounted on the
// existing app at https://crawlproof.com/api/mcp. Agents connect here with a
// crp_ API token (Authorization: Bearer crp_…) and get the Promote toolset.
// crp_ API token (Authorization: Bearer crp_…) or an OAuth 2.1 access token
// (crp_oat_…, lib/mcp-oauth.ts) that ChatGPT, claude.ai and Claude Code obtain
// themselves via dynamic client registration + PKCE. Any request without a
// valid credential is 401 + WWW-Authenticate resource_metadata, which is what
// starts the OAuth flow in those clients.
//
// Add capabilities by registering more tool modules in the initializer below.

import { createMcpHandler, withMcpAuth } from "mcp-handler";
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
import { authenticateToken } from "@/lib/sp/apiAuth";
import { ACCESS_PREFIX, resolveAccessToken, siteOrigin } from "@/lib/mcp-oauth";
import { serviceClient } from "@/lib/supabase/service";
import { registerPromoteTools } from "@/lib/mcp/promote";
import { registerStatsTools } from "@/lib/mcp/stats";
import { registerAuditTools } from "@/lib/mcp/audits";
Expand All @@ -34,6 +40,7 @@ const handler = createMcpHandler(
registerCreditTools(server);
registerWebhookTools(server);
registerTrendTools(server);
addListCacheFields(server);
},
{},
// The route is mounted at /api/mcp, so mcp-handler must derive its endpoint
Expand All @@ -43,11 +50,33 @@ const handler = createMcpHandler(
{ basePath: "/api", maxDuration: 120 },
);

type ListHandler = (req: unknown, extra: unknown) => Promise<Record<string, unknown>>;

// Claude Code's MCP client (revision 2026-07-28) rejects a tools/list result
// without resultType + ttlMs + cacheScope ("connected · tools fetch failed").
// The SDK does not add them, so wrap its tools/list handler.
function addListCacheFields(server: { server: unknown }) {
const handlers = (server.server as { _requestHandlers?: Map<string, ListHandler> })._requestHandlers;
const original = handlers?.get("tools/list");
if (!handlers || !original) return;
handlers.set("tools/list", async (req, extra) => ({
...(await original(req, extra)),
resultType: "complete",
ttlMs: 300_000,
cacheScope: "private",
}));
}

// Resolve the crp_ bearer token to a CrawlProof user; stash the user id on the
// AuthInfo so tool handlers can scope to the caller. Returning undefined (with
// required:true) makes mcp-handler answer 401.
async function verifyToken(_req: Request, bearerToken?: string): Promise<AuthInfo | undefined> {
if (!bearerToken) return undefined;
if (bearerToken.startsWith(ACCESS_PREFIX)) {
const grant = await resolveAccessToken(serviceClient(), bearerToken);
if (!grant) return undefined;
return { token: bearerToken, clientId: grant.userId, scopes: ["mcp"], extra: { userId: grant.userId, via: "oauth" } };
}
const auth = await authenticateToken(bearerToken);
if (!auth.ok) return undefined;
return {
Expand All @@ -58,6 +87,9 @@ async function verifyToken(_req: Request, bearerToken?: string): Promise<AuthInf
};
}

const authHandler = withMcpAuth(handler, verifyToken, { required: true });
// resourceUrl pins the resource_metadata origin to the public site, whatever
// Host the container sees. Built per request: siteOrigin() reads env at runtime.
const authHandler = (req: Request) =>
withMcpAuth(handler, verifyToken, { required: true, resourceUrl: siteOrigin() })(req);

export { authHandler as GET, authHandler as POST, authHandler as DELETE };
60 changes: 60 additions & 0 deletions app/api/oauth/authorize/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { NextResponse, type NextRequest } from "next/server";
import { createClient } from "@/lib/supabase/server";
import { serviceClient } from "@/lib/supabase/service";
import { AUTHORIZE_FIELDS, checkAuthorize, errorRedirect, issueCode, OAuthError, sameOrigin, siteOrigin, type AuthorizeParams } from "@/lib/mcp-oauth";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const NO_STORE = { "cache-control": "no-store" };

const see = (url: string) => NextResponse.redirect(url, { status: 303, headers: NO_STORE });

/**
* POST /api/oauth/authorize: the consent form at /oauth/authorize (the
* advertised authorization_endpoint) posts here, form-encoded, with
* decision=allow|deny and the original request as hidden fields. The user is
* the signed-in Supabase session (cookie). Answers 303 to the client's
* redirect_uri with code, state and iss (RFC 9207), or with the error.
*/
export async function POST(req: NextRequest) {
if (!sameOrigin(req)) {
return NextResponse.json({ error: "invalid_request", error_description: "Cross-site request refused" }, { status: 403, headers: NO_STORE });
}
const form = new URLSearchParams(await req.text());
const p: AuthorizeParams = {};
for (const k of AUTHORIZE_FIELDS) {
const v = form.get(k);
if (v !== null) (p as Record<string, unknown>)[k] = v;
}
const decision = form.get("decision");
if (decision !== "allow" && decision !== "deny") {
return NextResponse.json({ error: "invalid_request", error_description: "decision must be allow or deny" }, { status: 400, headers: NO_STORE });
}

const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
const qs = new URLSearchParams(p as Record<string, string>).toString();
if (!user) {
return see(`${siteOrigin()}/login?redirect=${encodeURIComponent(`/oauth/authorize?${qs}`)}`);
}

const admin = serviceClient();
const check = await checkAuthorize(admin, p);
if (!check.ok) {
if ("redirect" in check) return see(check.redirect);
return NextResponse.json({ error: check.error, error_description: check.description }, { status: 400, headers: NO_STORE });
}
if (decision === "deny") {
return see(errorRedirect(check.redirectUri, "access_denied", "The user denied the request", check.state));
}
try {
return see(await issueCode(admin, check, user.id));
} catch (err) {
if (err instanceof OAuthError) return NextResponse.json(err.body(), { status: err.status, headers: NO_STORE });
console.error("[oauth] authorize failed", (err as Error).message);
return NextResponse.json({ error: "server_error" }, { status: 500, headers: NO_STORE });
}
}
36 changes: 36 additions & 0 deletions app/api/oauth/grants/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { NextResponse, type NextRequest } from "next/server";
import { createClient } from "@/lib/supabase/server";
import { serviceClient } from "@/lib/supabase/service";
import { listGrants, revokeGrant } from "@/lib/mcp-oauth";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

async function userId(): Promise<string | null> {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
return user?.id ?? null;
}

const unauthorized = () => NextResponse.json({ error: "unauthenticated" }, { status: 401 });

// GET /api/oauth/grants: the signed-in user's connected apps (ChatGPT, Claude, …).
export async function GET() {
const uid = await userId();
if (!uid) return unauthorized();
return NextResponse.json({ grants: await listGrants(serviceClient(), uid) }, { headers: { "cache-control": "no-store" } });
}

// DELETE /api/oauth/grants?id=<grant id>: disconnect one app; its tokens stop working at once.
export async function DELETE(req: NextRequest) {
const uid = await userId();
if (!uid) return unauthorized();
const id = req.nextUrl.searchParams.get("id") ?? "";
if (!UUID.test(id)) return NextResponse.json({ error: "id is required" }, { status: 400 });
const ok = await revokeGrant(serviceClient(), uid, id);
return ok ? NextResponse.json({ revoked: true }) : NextResponse.json({ error: "No such connected app" }, { status: 404 });
}
32 changes: 32 additions & 0 deletions app/api/oauth/register/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { NextResponse, type NextRequest } from "next/server";
import { CORS_HEADERS, OAuthError, registerClient } from "@/lib/mcp-oauth";
import { serviceClient } from "@/lib/supabase/service";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

// POST /api/oauth/register: RFC 7591 dynamic client registration (public clients).
export async function POST(req: NextRequest) {
let body: Record<string, unknown>;
try {
body = (await req.json()) as Record<string, unknown>;
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error();
} catch {
return NextResponse.json(
{ error: "invalid_client_metadata", error_description: "Send the client metadata as a JSON object" },
{ status: 400, headers: CORS_HEADERS },
);
}
try {
const client = await registerClient(serviceClient(), body);
return NextResponse.json(client, { status: 201, headers: { ...CORS_HEADERS, "cache-control": "no-store" } });
} catch (err) {
if (err instanceof OAuthError) return NextResponse.json(err.body(), { status: err.status, headers: CORS_HEADERS });
console.error("[oauth] register failed", (err as Error).message);
return NextResponse.json({ error: "server_error" }, { status: 500, headers: CORS_HEADERS });
}
}

export function OPTIONS() {
return new NextResponse(null, { status: 204, headers: CORS_HEADERS });
}
20 changes: 20 additions & 0 deletions app/api/oauth/revoke/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { NextResponse, type NextRequest } from "next/server";
import { CORS_HEADERS, revokeToken } from "@/lib/mcp-oauth";
import { serviceClient } from "@/lib/supabase/service";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

// POST /api/oauth/revoke: RFC 7009. Always 200; revoking either token ends the grant.
export async function POST(req: NextRequest) {
const type = req.headers.get("content-type") ?? "";
const p = type.includes("application/json")
? (((await req.json().catch(() => null)) as Record<string, unknown> | null) ?? {})
: Object.fromEntries(new URLSearchParams(await req.text()));
if (typeof p.token === "string" && p.token) await revokeToken(serviceClient(), p.token);
return new NextResponse(null, { status: 200, headers: CORS_HEADERS });
}

export function OPTIONS() {
return new NextResponse(null, { status: 204, headers: CORS_HEADERS });
}
39 changes: 39 additions & 0 deletions app/api/oauth/token/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { NextResponse, type NextRequest } from "next/server";
import { CORS_HEADERS, exchangeCode, OAuthError, refreshTokens } from "@/lib/mcp-oauth";
import { serviceClient } from "@/lib/supabase/service";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const NO_STORE = { ...CORS_HEADERS, "cache-control": "no-store", pragma: "no-cache" };

/** The token endpoint takes application/x-www-form-urlencoded (RFC 6749); JSON is accepted too. */
async function params(req: NextRequest): Promise<Record<string, unknown>> {
const type = req.headers.get("content-type") ?? "";
if (type.includes("application/json")) {
const body = await req.json().catch(() => null);
return body && typeof body === "object" ? (body as Record<string, unknown>) : {};
}
return Object.fromEntries(new URLSearchParams(await req.text()));
}

// POST /api/oauth/token: authorization_code (PKCE S256) and refresh_token grants.
export async function POST(req: NextRequest) {
try {
const p = await params(req);
const admin = serviceClient();
let out;
if (p.grant_type === "authorization_code") out = await exchangeCode(admin, p);
else if (p.grant_type === "refresh_token") out = await refreshTokens(admin, p);
else throw new OAuthError("unsupported_grant_type", "grant_type must be authorization_code or refresh_token");
return NextResponse.json(out, { headers: NO_STORE });
} catch (err) {
if (err instanceof OAuthError) return NextResponse.json(err.body(), { status: err.status, headers: NO_STORE });
console.error("[oauth] token failed", (err as Error).message);
return NextResponse.json({ error: "server_error" }, { status: 500, headers: NO_STORE });
}
}

export function OPTIONS() {
return new NextResponse(null, { status: 204, headers: CORS_HEADERS });
}
Loading