AgentStack
MCP unreviewed MIT Self-run

Anthropic Cloud Routines Custom Mcp

mcp-mankhonggarden-anthropic-cloud-routines-custom-mcp · by MankhongGarden

Custom MCP server on Next.js + Vercel with OAuth 2.1 + PKCE so Anthropic Cloud Routines can autonomously fetch your vendor APIs and send real emails.

No reviews yet
0 installs
1 views
0.0% view→install

Install

$ agentstack add mcp-mankhonggarden-anthropic-cloud-routines-custom-mcp

Open-source listing — not yet scanned by AgentStack. Follow the source repository for install instructions.

Security review

⚠ Flagged

1 finding(s); flagged for manual review. · v0.1.0 How review works →

  • Prompt-injection patterns
  • Secret / credential exfiltration
  • Dangerous shell & filesystem operations
  • Untrusted network calls
  • Known-malicious package signatures
  • high Destructive filesystem operation.

What it can access

  • Network access Used
  • Filesystem access No
  • Shell / process execution No
  • Environment & secrets Used
  • Dynamic code execution No

From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.

Are you the author of Anthropic Cloud Routines Custom Mcp? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

About

Replacing Local Cron with Anthropic Cloud Routines

A Custom MCP Server on Next.js + Vercel with OAuth 2.1 + PKCE So Claude Can Fetch My Vendor Data Autonomously

TL;DR

  • Replaced 12 Windows Task Scheduler jobs with 12 Anthropic Cloud Routines that fire at the same cron times — except they run on Anthropic's infrastructure, not my laptop.
  • Built a custom MCP server at /api/mcp/ops on the same Next.js + Vercel project I was already shipping. 10 read-only tools that wrap Supabase / Stripe / Sentry / Vercel / Resend, plus one whitelisted send_summary_email.
  • OAuth 2.1 + PKCE with stateless HS256 JWT — no auth-server DB. Three token types: 60s code · 1h access · 90d refresh. ~250 lines of TypeScript total across the OAuth files.
  • Routines run inside the Anthropic Max plan's daily routine quota — verified zero extra-usage charge at console.anthropic.com after a full week of 12 routines firing daily.
  • Three non-obvious findings the public guides don't mention:
  1. Custom connectors don't auto-flow into routines — each routine needs explicit mcp_connections. The default Gmail connector that auto-attaches has no send_email (only create_draft), so without a custom MCP your routine can't actually deliver mail.
  2. vercel env add from CLI v53+ stores values as Sensitive type — which AI routines cannot read. You have to use the REST API to store them as encrypted instead.
  3. The connector_uuid you need to attach a connector to a routine isn't exposed anywhere in the UI or API — you read it from claude.ai's IndexedDB react-query cache.

Why this turned into a thing

I had 12 cron jobs on my laptop. Daily security advisor scans for Supabase. Weekly Stripe reconciliation totals. Sentry error triage email at 09:00. Vercel deployment health pings. Resend deliverability summaries. All scheduled via Windows Task Scheduler, all running claude -p --dangerously-skip-permissions headless and emailing the result.

Three problems:

  1. They didn't run when the laptop was off. Closing the lid Friday night meant no Monday morning report.
  2. The headless claude -p invocation was eating into my Max plan's interactive token budget. A morning of routine runs would already have me hitting rate-limit warnings before I sat down to actually work.
  3. There was no observability. A failed run logged to a .log file on my disk that I never checked. I once discovered the Supabase advisor cron had been silently failing for a week because the PAT expired.

I knew Anthropic had been rolling out a /schedule feature on claude.ai ("CCR routines") — cron jobs that run on their infrastructure. I tried it. Three blockers showed up immediately:

  • It only auto-attaches Gmail + Google Drive connectors. Custom connectors I had added at claude.ai/customize/connectors did not flow into routines automatically.
  • The Gmail connector only has create_draft, not send_email. A routine that "emails me the report" was actually creating a draft I had to manually open and send. Defeats the point.
  • A routine that needs to read live vendor data — Supabase advisors, Stripe payments, Sentry issues — has no way to do that with stock connectors.

So I built a custom MCP server. Here's the pattern.


The architecture in one picture

┌─────────────────────────────┐         ┌─────────────────────────────┐
│ Anthropic CCR routine       │   HTTPS │  /api/mcp/ops on Vercel     │
│ (claude.ai/code/routines)   │ ───────►│  ── OAuth 2.1 + PKCE auth   │
└─────────────────────────────┘         │  ── 10 read-only tools      │
                                        │  ── send_summary_email      │
                                        └──────────┬──────────────────┘
                                                   │
                              ┌────────────────────┼──────────────────┐
                              │                    │                  │
                              ▼                    ▼                  ▼
                         [Supabase]            [Stripe]          [Sentry]
                         [Vercel]              [Resend]

The OAuth dance happens once per connector setup. After that, claude.ai stores access + refresh tokens and routines just use them.


Phase 1 — pick the tools

Resist the temptation to add write tools. The OAuth secret will leak eventually (chat history echo · debug logs · someone shoulder-surfs your screen) and read-only contains the blast radius. The only "write" tool in my server is send_summary_email, and even that is restricted to a hardcoded recipient.

What I shipped:

| Tool | Wraps | Why useful | |---|---|---| | supabase_get_advisors(type) | Supabase Management API /v1/projects/{ref}/advisors/{type} | Daily security + performance scan | | supabase_admin_query(sql) | Supabase Management API /database/query | Arbitrary SELECT (validated · SELECT/WITH only · no semicolons · 5000 char cap) | | stripe_list_payment_intents(filter) | Stripe SDK paymentIntents.list | Per-payment investigation | | stripe_payment_intents_summary(window) | Paginated count + sum | Weekly reconciliation total | | sentry_search_issues(filter) | Sentry API /issues/ | Daily error triage | | vercel_recent_deployments(limit) | Vercel API /v6/deployments | Cron health + deploy state | | resend_list_emails(limit) | Resend API /emails | Weekly deliverability trend | | resend_get_email(id) | Resend API /emails/{id} | Single message status | | health_check() | All-vendor ping | Setup verification + alerting | | send_summary_email(subject, body) | Resend send, founder-whitelisted | The only writable tool |

send_summary_email is the trick that makes CCR routines actually useful. The recipient is baked as a constant in the server, not a parameter:

const FOUNDER_RECIPIENT = "founder@example.com";
const SUMMARY_FROM = "Project Ops ";
// hardcoded · ignore any `to` param even if accidentally exposed

A leaked secret can spam exactly one inbox. That's a containable blast.


Phase 2 — the MCP route

Next.js App Router. One file at src/app/api/mcp/ops/route.ts. JSON-RPC 2.0 over plain POST. No SSE needed for routine workflows — they're request/response.

Methods to handle:

  • initialize → return server info + capabilities: { tools: {} }
  • notifications/initialized → return 204
  • tools/list → return your tool definitions
  • tools/call → dispatch to the per-tool handler, return {content: [{type:'text', text: JSON.stringify(result)}], isError: false}
  • GET on the same URL → return server name + tool list (browser smoke test)

The auth gate (two paths in one function)

export async function authenticate(req: Request) {
  const authHeader = req.headers.get("authorization");
  if (!authHeader?.startsWith("Bearer ")) {
    return unauthorized();
  }
  const token = authHeader.slice("Bearer ".length);

  // Path 1: JWT access token (what claude.ai sends after OAuth)
  try {
    const { payload } = await jwtVerify(token, OAUTH_KEY, { issuer: ISSUER, audience: RESOURCE });
    return { ok: true, subject: payload.sub };
  } catch {}

  // Path 2: Static Bearer (legacy · curl/PowerShell smoke tests)
  if (token === process.env.MCP_OPS_SECRET) {
    return { ok: true, subject: "static" };
  }

  return unauthorized();
}

function unauthorized() {
  return new Response("unauthorized", {
    status: 401,
    headers: {
      "WWW-Authenticate": `Bearer resource_metadata="${ISSUER}/.well-known/oauth-protected-resource"`,
    },
  });
}

The WWW-Authenticate header is what tells claude.ai where to discover the OAuth authorization server (RFC 9728). Without it, the custom connector setup fails with a generic "auth required" error.

Read-only enforcement on supabase_admin_query

const READONLY_SQL = /^\s*(SELECT|WITH)\s/i;
if (!READONLY_SQL.test(sql)) throw new Error("Only SELECT or WITH allowed");
if (sql.includes(";")) throw new Error("No semicolons (no multi-statement)");
if (sql.length > 5000) throw new Error("Query too long");

Multi-statement guard via ; rejection is the cheapest mitigation against "AI thinks it's clever and tries SELECT 1; DROP TABLE users;". Belt-and-suspenders on top of the SELECT prefix check.


Phase 3 — OAuth 2.1 + PKCE

Eight files. Stateless JWT design — no DB. One signing secret. ~250 lines of TypeScript.

src/lib/oauth.ts                                          — JWT sign/verify · PKCE check · allowlists
src/app/.well-known/oauth-authorization-server/route.ts  — RFC 8414 metadata
src/app/.well-known/oauth-protected-resource/route.ts    — RFC 9728 resource metadata
src/app/api/oauth/register/route.ts                       — RFC 7591 dynamic client registration
src/app/authorize/page.tsx                                — consent page (server component)
src/app/authorize/ApproveButtons.tsx                      — Approve/Deny
src/app/api/oauth/approve/route.ts                        — sign code · 302 to claude.ai callback
src/app/api/oauth/token/route.ts                          — authorization_code + refresh_token grants

Token design

Three JWT types, all HS256-signed with the same secret:

| Token | TTL | Embeds | |---|---|---| | Code | 60 seconds | code_challenge + redirect_uri + scope + sub | | Access | 1 hour | client_id + scope + sub | | Refresh | 90 days | family + gen (incremented each refresh) |

Stateless means I don't need a database for OAuth state. The trade-off is that revocation requires rotating the signing secret (which invalidates every issued token at once). For a solo-founder use case that's fine — emergency rotation is rare and acceptable.

Use jose for sign/verify (likely already a transitive dep if you use @supabase/ssr). One env var: OAUTH_SIGNING_SECRET (64 bytes of hex from crypto.getRandomValues).

Founder-only auth gate on /authorize

export default async function AuthorizePage({ searchParams }) {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) {
    redirect(`/login?next=${encodeURIComponent(currentUrl)}`);
  }
  if (user.email !== FOUNDER_EMAIL) {
    return ;
  }
  // ... consent form ...
}

This piggybacks on your existing app login. Anyone who reaches /authorize must be logged in AND be the founder email. Even if someone discovers the URL, they can't proceed past the second check.

redirect_uri allowlist (single highest-leverage defense)

const ALLOWED_REDIRECT_URIS = new Set([
  "https://claude.ai/api/mcp/auth_callback",
  "https://claude.com/api/mcp/auth_callback",
  "https://api.claude.ai/api/mcp/auth_callback",
]);

Even if every other check is wrong, an attacker can't redirect the authorization code to their own server. Hardcoded set, no regex matching, no wildcard.


Phase 4 — env vars (the Sensitive-type trap)

This is where the public guides get it wrong.

If you run vercel env add OAUTH_SIGNING_SECRET production from CLI v53+, the value gets stored as type Sensitive. AI routines cannot read Sensitive env vars — they're only exposed at build time to specific build steps.

You have to use the REST API to store them as type encrypted instead:

curl -X POST \
  "https://api.vercel.com/v10/projects/$PROJECT_ID/env?upsert=true&teamId=$TEAM_ID" \
  -H "Authorization: Bearer $VERCEL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "key": "OAUTH_SIGNING_SECRET",
    "value": "...",
    "type": "encrypted",
    "target": ["production", "preview", "development"]
  }'

The required env vars:

| Var | Purpose | |---|---| | MCP_OPS_SECRET | Legacy Bearer for smoke tests (64-byte hex) | | OAUTH_SIGNING_SECRET | HS256 JWT signing (64-byte hex) | | SUPABASE_ACCESS_TOKEN | Supabase Management API PAT | | VERCEL_TOKEN | Vercel REST API (read your own deployments) | | SENTRY_AUTH_TOKEN | Scopes event:read + project:read + org:read | | RESEND_API_KEY | likely already set in your project | | STRIPE_SECRET_KEY | likely already set | | NEXT_PUBLIC_SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY | likely already set |

Trigger a redeploy after adding (use POST /v13/deployments with the deploymentId of the last READY production deployment).


Phase 5 — register the connector

User-side only. OAuth requires consent, so this can't be automated.

  1. Open https://claude.ai/customize/connectors
  2. Click "Add custom connector"
  3. Name: short alphanumeric (becomes the prefix in tool names — mcp__YourName__tool). Dashes allowed; dots and spaces are not.
  4. URL: https://yourdomain.com/api/mcp/ops
  5. Click Save → redirects to your /authorize page
  6. (Log in if not already) → click Approve
  7. 302 back to claude.ai → token exchange → connector connected

To attach this connector to a routine, you need its connector_uuid — and this is not exposed in any UI or public API. You read it from claude.ai's IndexedDB:

// Run in the claude.ai DevTools console:
// 1. Get org UUID
fetch('/api/organizations', { credentials: 'include' })
  .then(r => r.json())
  .then(o => console.log('org', o?.[0]?.uuid));

// 2. Then DevTools → Application → IndexedDB → keyval-store → keyval
// Search the cached react-query data for your connector name.
// connector_uuid is in the surrounding JSON.

Phase 6 — attach to routines

Once you have the UUID, create or update a routine with mcp_connections:

{
  "mcp_connections": [
    {
      "connector_uuid": "",
      "name": "YourName",
      "url": "https://yourdomain.com/api/mcp/ops"
    }
  ],
  "job_config": {
    "ccr": {
      "session_context": {
        "sources": [],
        "model": "claude-sonnet-4-6"
      },
      "events": [
        {
          "data": {
            "message": {
              "content": "Use mcp__YourName__supabase_get_advisors('security') to check the project. If any findings have level=ERROR, call mcp__YourName__send_summary_email with the details."
            }
          }
        }
      ]
    }
  }
}

Set sources: [] if your project's GitHub repo is not connected. Otherwise the routine fails on manual run with github_repo_access_denied. Empty sources is fine — routines that get all their data from MCP tools never need a repo checkout.


The thirteen gotchas I hit

In order of how much time each one cost me:

  1. Custom connectors don't auto-flow into routines. Each routine needs explicit mcp_connections. Probe a routine with an introspection prompt to see what's actually attached — only mcp__Gmail__* (12 tools) will show without explicit MCP attach.
  1. Gmail connector has no send_email — only create_draft. Routines that "send" via Gmail produce drafts you have to open and send manually. Always include your own send_summary_email tool.
  1. vercel env add CLI stores Sensitive type. Sensitive env vars are not exposed to routine runtime. Use the REST API with type: "encrypted".
  1. sources: [] for non-GitHub-connected projects. Even one-shot manual run triggers a GitHub auth check. Empty sources skips it.
  1. Tool prefix is mcp__{name from mcp_connections}__{tool_name}. If the connector is named Project-Ops, your tools become mcp__Project-Ops__health_check. Test the exact prefix in your routine prompt by referencing it explicitly.
  1. OAuth 2.1 + PKCE is required. Static Bearer auth doesn't appear as an option in the cloud custom connector UI (as of mid-2025+). Don't skip the OAuth setup expecting a simpler auth to be accepted.
  1. Supabase advisors path is path-style. /v1/projects/{ref}/advisors/{security|performance} — NOT ?type=security. Wrong path returns 404 with error text echoing your malformed URL.
  1. Sentry statsPeriod for issues endpoint only accepts '', 24h, 14d. 1h (which seems reasonable) returns 400

Source & license

This open-source MCP server is cataloged on AgentStack and links to its original source — we do not rehost the code.

Install and usage instructions live in the source repository linked above.

Reviews

No reviews yet — be the first.

Versions

  • v0.1.0 Imported from the upstream source.