May 18, 2026narratr

How Narratr exposes its brand intelligence tools to ChatGPT (as a GPT Action) and to Claude.ai (via MCP over HTTP).

How Narratr exposes its brand intelligence tools to ChatGPT (as a GPT Action) and to Claude.ai (via MCP over HTTP). — explore insights on narratr, exposes and more.

narratrexposesbrandintelligencetoolschatgpt

Architecture overview

Both integrations share the same OAuth 2.0 server and the same 7 tool routes under /api/mcp/tools/. The difference is how each client authenticates:

ClientAuth methodClient secret?DiscoveryChatGPT GPT ActionAuthorization code + client_secretYes (MCP_GPT_CLIENT_ID / MCP_GPT_CLIENT_SECRET)OpenAPI YAMLClaude.ai MCPAuthorization code + PKCE (RFC 7636)No/.well-known metadata URLs

All tokens are Supabase JWTs. There is no token database — the OAuth code is a signed, short-lived payload containing the Supabase access + refresh tokens.

File map

app/
  api/mcp/
    route.ts                          # MCP JSON-RPC dispatcher + GET 401 for discovery
    oauth/
      authorize/route.ts              # Authorization endpoint — redirects to /login
      callback/route.ts               # Token endpoint — exchanges code for tokens
      complete/route.ts               # Post-login callback — builds and issues the code
      register/route.ts               # RFC 7591 dynamic client registration (Claude.ai)
    tools/
      brand-status/route.ts
      mi/route.ts
      audit/route.ts
      ai-readiness/route.ts
      ai-readiness-public/route.ts    # No auth required
      ai-visibility/route.ts
      indexnow/route.ts

  .well-known/
    oauth-authorization-server/
      route.ts                        # Auth server metadata (root)
      api/mcp/route.ts                # Auth server metadata (path-suffixed, Claude.ai)
    oauth-protected-resource/
      route.ts                        # Protected resource metadata (root)
      api/mcp/route.ts                # Protected resource metadata (path-suffixed, RFC 9207)

public/
  openapi-gpt-action.yaml             # OpenAPI spec uploaded to ChatGPT GPT Action

Part 1 — ChatGPT GPT Action

How it works

ChatGPT calls the tool routes directly over HTTPS using the OpenAPI spec. Auth uses OAuth 2.0 with client_secret_post. The GPT was published to the ChatGPT store.

Link: https://chatgpt.com/g/g-6a09fa9b7c188191a9f7e3edc5e101cb-narratr-brand-intelligence

OpenAPI spec: public/openapi-gpt-action.yaml

The spec is uploaded directly into the ChatGPT GPT Action UI. It defines all 7 tool routes as POST operations.

yamlopenapi: 3.1.0
info:
  title: Narratr Brand Intelligence API
  version: 1.0.0

servers:
  - url: https://narratr.ai

paths:
  /api/mcp/tools/brand-status:
    post:
      operationId: narratr_brand_status
      summary: Get current status and scores for a brand
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [website_url]
              properties:
                website_url:
                  type: string

  /api/mcp/tools/mi:
    post:
      operationId: narratr_mi
      # ...

  /api/mcp/tools/audit:
    post:
      operationId: narratr_audit
      # ...

  /api/mcp/tools/ai-readiness:
    post:
      operationId: narratr_ai_readiness
      # ...

  /api/mcp/tools/ai-visibility:
    post:
      operationId: narratr_ai_visibility
      # ...

  /api/mcp/tools/indexnow:
    post:
      operationId: narratr_indexnow
      # ...

  /api/mcp/tools/ai-readiness-public:
    post:
      operationId: narratr_ai_readiness_public
      # No auth required

Full spec is at public/openapi-gpt-action.yaml.

Environment variables

MCP_GPT_CLIENT_ID=<uuid>
MCP_GPT_CLIENT_SECRET=<secret>

Set in ChatGPT GPT Action UI → Authentication → OAuth → Client ID / Secret.


Part 2 — Claude MCP

How Claude.ai discovers the server

Claude.ai uses /.well-known metadata URLs defined in RFC 8414 and RFC 9207.

Step 1: GET /api/mcp

Claude.ai makes a GET request to the MCP server URL. It expects HTTP 401 with a WWW-Authenticate header pointing to the resource metadata URL.

ts// app/api/mcp/route.ts
export async function GET() {
  const base = getBaseUrl();
  return new NextResponse(null, {
    status: 401,
    headers: {
      'WWW-Authenticate': `Bearer realm="${base}/api/mcp", resource_metadata="${base}/.well-known/oauth-protected-resource/api/mcp"`,
    },
  });
}

Step 2: GET /.well-known/oauth-protected-resource/api/mcp

Per RFC 9207, Claude.ai appends the server's path (/api/mcp) to the well-known URL. This file identifies the auth server.

ts// app/.well-known/oauth-protected-resource/api/mcp/route.ts
export async function GET() {
  const base = getBaseUrl();
  return NextResponse.json({
    resource: `${base}/api/mcp`,
    authorization_servers: [base],
    bearer_methods_supported: ['header'],
    scopes_supported: ['brand:read', 'brand:write'],
  });
}

Step 3: GET /.well-known/oauth-authorization-server/api/mcp

Claude.ai fetches auth server metadata, again path-suffixed.

ts// app/.well-known/oauth-authorization-server/api/mcp/route.ts
export async function GET() {
  const base = getBaseUrl();
  return NextResponse.json({
    issuer: base,
    authorization_endpoint: `${base}/api/mcp/oauth/authorize`,
    token_endpoint: `${base}/api/mcp/oauth/callback`,
    registration_endpoint: `${base}/api/mcp/oauth/register`,  // required for Claude.ai
    response_types_supported: ['code'],
    grant_types_supported: ['authorization_code', 'refresh_token'],
    code_challenge_methods_supported: ['S256'],
    token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
  });
}

Step 4: POST /api/mcp/oauth/register

Claude.ai requires RFC 7591 Dynamic Client Registration before starting OAuth. It registers itself and gets back a client_id. This is stateless — no DB storage.

ts// app/api/mcp/oauth/register/route.ts
import { randomUUID } from 'crypto';

export async function POST(req: NextRequest) {
  let body: Record<string, unknown> = {};
  try { body = await req.json(); } catch { /* empty body is fine */ }

  const clientId = `mcp_${randomUUID().replace(/-/g, '')}`;

  return NextResponse.json({
    client_id: clientId,
    client_id_issued_at: Math.floor(Date.now() / 1000),
    redirect_uris: body.redirect_uris ?? [],
    grant_types: ['authorization_code', 'refresh_token'],
    response_types: ['code'],
    token_endpoint_auth_method: 'none',       // PKCE — no secret needed
    code_challenge_methods_supported: ['S256'],
    client_name: body.client_name ?? 'MCP Client',
  }, { status: 201 });
}

Part 3 — Shared OAuth flow

Both ChatGPT and Claude share the same authorize → login → complete → token exchange flow. The difference is PKCE vs client_secret.

Step 1: /api/mcp/oauth/authorize — redirect to login

ts// app/api/mcp/oauth/authorize/route.ts
export async function GET(req: NextRequest) {
  const { searchParams } = req.nextUrl;
  const clientId = searchParams.get('client_id');
  const redirectUri = searchParams.get('redirect_uri');
  const state = searchParams.get('state');
  const codeChallenge = searchParams.get('code_challenge');       // PKCE (Claude.ai)
  const codeChallengeMethod = searchParams.get('code_challenge_method');

  // ChatGPT uses a known client_id; Claude.ai uses PKCE with a dynamic client_id
  const isGptClient = clientId === process.env.MCP_GPT_CLIENT_ID;
  const isMcpClient = !isGptClient && Boolean(codeChallenge);

  if (!isGptClient && !isMcpClient) {
    return NextResponse.json({ error: 'invalid_client' }, { status: 401 });
  }

  // Pack all OAuth state into a signed cookie so /complete can reconstruct it
  const payload = JSON.stringify({
    redirectUri, state, clientId,
    codeChallenge: codeChallenge ?? null,
    codeChallengeMethod: codeChallengeMethod ?? null,
    exp: Date.now() + 10 * 60 * 1000,   // 10 min TTL
  });
  const encoded = Buffer.from(payload).toString('base64url');
  const sig = createHmac('sha256', process.env.MCP_GPT_CLIENT_SECRET ?? '').update(encoded).digest('hex');

  const res = NextResponse.redirect(`${base}/login?next=/api/mcp/oauth/complete`);
  res.cookies.set('mcp_oauth_state', `${encoded}.${sig}`, {
    httpOnly: true, secure: true, sameSite: 'lax', maxAge: 600,
  });
  return res;
}

Step 2: User logs in at /login

The login page redirects to /api/mcp/oauth/complete after a successful Supabase session is established. No special logic needed — the existing magic-link / password flow handles it.

Step 3: /api/mcp/oauth/complete — issue the code

ts// app/api/mcp/oauth/complete/route.ts
export async function GET(req: NextRequest) {
  const stateCookie = req.cookies.get('mcp_oauth_state')?.value;
  const oauthState = verifyStateCookie(stateCookie);   // HMAC + expiry check
  if (!oauthState) return redirect('/login?error=mcp_state_invalid');

  const supabase = await createSupabaseServerClient();
  const { data: { session } } = await supabase.auth.getSession();
  if (!session) return redirect('/login?next=/api/mcp/oauth/complete');

  // Encode Supabase tokens + PKCE challenge into a signed short-lived code
  const codePayload = JSON.stringify({
    a: session.access_token,
    r: session.refresh_token,
    cc: oauthState.codeChallenge,   // stored for PKCE verification at token exchange
    exp: Date.now() + 5 * 60 * 1000,
  });
  const encodedCode = Buffer.from(codePayload).toString('base64url');
  const code = `${encodedCode}.${signPayload(encodedCode)}`;

  const redirectUrl = new URL(oauthState.redirectUri);
  redirectUrl.searchParams.set('code', code);
  redirectUrl.searchParams.set('state', oauthState.state);

  const res = NextResponse.redirect(redirectUrl.toString());
  res.cookies.delete('mcp_oauth_state');
  return res;
}

Step 4: /api/mcp/oauth/callback — token exchange

ts// app/api/mcp/oauth/callback/route.ts
export async function POST(req: NextRequest) {
  // Accepts both application/x-www-form-urlencoded and application/json
  const body = /* parse body */;
  const { grant_type, code, refresh_token, client_id, client_secret, code_verifier } = body;

  if (grant_type === 'authorization_code') {
    const decoded = verifyCode(code);   // HMAC + expiry check
    if (!decoded) return error('invalid_grant');

    if (decoded.cc) {
      // PKCE flow (Claude.ai) — verify code_verifier against stored challenge
      const digest = createHash('sha256').update(code_verifier).digest('base64url');
      if (digest !== decoded.cc) return error('invalid_grant');
    } else {
      // Client secret flow (ChatGPT)
      if (client_id !== process.env.MCP_GPT_CLIENT_ID ||
          client_secret !== process.env.MCP_GPT_CLIENT_SECRET) {
        return error('invalid_client');
      }
    }

    // Return Supabase JWT directly as the access_token — no DB storage needed
    return NextResponse.json({
      access_token: decoded.a,     // Supabase access_token
      refresh_token: decoded.r,    // Supabase refresh_token
      token_type: 'bearer',
      expires_in: 3600,
    });
  }

  if (grant_type === 'refresh_token') {
    // Delegate to Supabase — it manages rotation
    const { data, error } = await admin.auth.refreshSession({ refresh_token });
    return NextResponse.json({
      access_token: data.session.access_token,
      refresh_token: data.session.refresh_token,
      token_type: 'bearer',
      expires_in: 3600,
    });
  }
}

Key insight: The access_token IS the Supabase JWT. When a tool route calls getUser(), it validates the bearer token directly via admin.auth.getUser(token). No token table, no session store.


Part 4 — MCP JSON-RPC dispatcher

Claude.ai communicates with the MCP server using JSON-RPC 2.0 over HTTP POST.

ts// app/api/mcp/route.ts (POST handler)
export async function POST(req: NextRequest) {
  const rpc = await req.json();
  const { id, method, params = {} } = rpc;

  // Handshake — no auth required
  if (method === 'initialize') {
    return ok(id, {
      protocolVersion: '2024-11-05',
      capabilities: { tools: {} },
      serverInfo: { name: 'Narratr Brand Intelligence', version: '1.0.0' },
    });
  }

  // Tool discovery — no auth required
  if (method === 'tools/list') {
    return ok(id, { tools: TOOLS });  // TOOLS array with name, description, inputSchema
  }

  // Tool execution — auth required (except narratr_ai_readiness_public)
  if (method === 'tools/call') {
    const toolName = params.name;
    const isPublic = PUBLIC_TOOLS.has(toolName);

    if (!isPublic) {
      const user = await getUser();
      if (!user) {
        // Return HTTP 401, NOT a JSON-RPC error — Claude.ai only re-triggers OAuth on HTTP 401
        return new NextResponse(null, {
          status: 401,
          headers: {
            'WWW-Authenticate': `Bearer realm="${base}/api/mcp", resource_metadata="${base}/.well-known/oauth-protected-resource/api/mcp"`,
          },
        });
      }
    }

    // Forward to the tool's REST route, passing the Authorization header through
    const res = await fetch(`${base}${TOOL_ROUTES[toolName]}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Authorization: authHeader },
      body: JSON.stringify(params.arguments ?? {}),
    });
    const result = await res.json();
    return ok(id, { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] });
  }
}

Tools registered

Tool nameRouteAuthnarratr_brand_status/api/mcp/tools/brand-statusRequirednarratr_mi/api/mcp/tools/miRequirednarratr_audit/api/mcp/tools/auditRequirednarratr_ai_readiness/api/mcp/tools/ai-readinessRequirednarratr_ai_visibility/api/mcp/tools/ai-visibilityRequirednarratr_indexnow/api/mcp/tools/indexnowRequirednarratr_ai_readiness_public/api/mcp/tools/ai-readiness-publicNone


Part 5 — Example tool route

ts// app/api/mcp/tools/brand-status/route.ts
export async function POST(req: NextRequest) {
  const user = await getUser();
  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

  const { website_url } = await req.json();
  const brand = await getBrandByUrl(website_url, user.id);
  if (!brand) return NextResponse.json({ error: `No brand found for ${website_url}` }, { status: 404 });

  const [brandRes, miRes, airRes, bwRes] = await Promise.all([
    admin.from('brands').select('name, website_audit_json, website_audit_at').eq('id', brand.id).maybeSingle(),
    admin.from('brand_market_intelligence').select('status, last_refreshed_at').eq('brand_id', brand.id).maybeSingle(),
    admin.from('brand_ai_readiness').select('robots_txt_verified, llms_txt_verified, schema_verified, indexnow_verified').eq('brand_id', brand.id).maybeSingle(),
    admin.from('brandwatch_runs').select('overall_score, status, completed_at').eq('brand_id', brand.id).order('created_at', { ascending: false }).limit(1).maybeSingle(),
  ]);

  const readinessScore =
    (air?.robots_txt_verified ? 2 : 0) +
    (air?.llms_txt_verified ? 3 : 0) +
    (air?.schema_verified ? 3 : 0) +
    (air?.indexnow_verified ? 2 : 0);

  return NextResponse.json({
    brandName, websiteUrl, marketIntelligence, brandAudit, aiReadiness, aiVisibility, pendingWork, tip,
  });
}

Part 6 — How to add Narratr to Claude.ai

  1. Go to https://claude.ai/settings/integrations

  2. Click Add integration

  3. Enter MCP server URL: https://narratr.ai/api/mcp

  4. Claude.ai will auto-discover OAuth metadata and prompt for login

  5. Sign in with your Narratr account — Claude.ai stores the token

To add from the login page without a Narratr account, click Claude in the bottom connector row.


Part 7 — Lessons learned

What was non-obvious building this:

  1. Claude.ai requires RFC 7591 dynamic registration. Without a registration_endpoint in the auth server metadata, Claude.ai shows "Authentication failed" immediately — it won't even start the OAuth flow.

  2. RFC 9207 path-suffixing. Claude.ai appends the server path (/api/mcp) to /.well-known/oauth-protected-resource, producing /.well-known/oauth-protected-resource/api/mcp. A route at /.well-known/oauth-protected-resource alone is not enough.

  3. HTTP 401 vs JSON-RPC error for auth. Returning a JSON-RPC -32001 error from an unauthorized tools/call does not trigger the OAuth flow. Claude.ai only re-triggers OAuth when it receives HTTP 401 with a WWW-Authenticate header.

  4. No token DB needed. The authorization code itself carries the Supabase tokens (HMAC-signed, base64url-encoded). Supabase JWTs are the access tokens — admin.auth.getUser(token) validates them statlessly.

  5. PKCE replaces the client secret. Claude.ai generates a code_verifier locally, sends SHA256(code_verifier) as code_challenge at authorize time, and sends code_verifier at token exchange. The server verifies SHA256(code_verifier) == stored_challenge. No pre-registered secret is needed.

Turn your brand into content like this

Narratr reads your website and generates SEO-optimised blog posts that sound like you.

Try Narratr free →