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
⚠ Flagged1 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.
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/opson the same Next.js + Vercel project I was already shipping. 10 read-only tools that wrap Supabase / Stripe / Sentry / Vercel / Resend, plus one whitelistedsend_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:
- Custom connectors don't auto-flow into routines — each routine needs explicit
mcp_connections. The default Gmail connector that auto-attaches has nosend_email(onlycreate_draft), so without a custom MCP your routine can't actually deliver mail. vercel env addfrom CLI v53+ stores values as Sensitive type — which AI routines cannot read. You have to use the REST API to store them asencryptedinstead.- The
connector_uuidyou need to attach a connector to a routine isn't exposed anywhere in the UI or API — you read it fromclaude.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:
- They didn't run when the laptop was off. Closing the lid Friday night meant no Monday morning report.
- The headless
claude -pinvocation 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. - There was no observability. A failed run logged to a
.logfile 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/connectorsdid not flow into routines automatically. - The Gmail connector only has
create_draft, notsend_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 204tools/list→ return your tool definitionstools/call→ dispatch to the per-tool handler, return{content: [{type:'text', text: JSON.stringify(result)}], isError: false}GETon 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.
- Open
https://claude.ai/customize/connectors - Click "Add custom connector"
- Name: short alphanumeric (becomes the prefix in tool names —
mcp__YourName__tool). Dashes allowed; dots and spaces are not. - URL:
https://yourdomain.com/api/mcp/ops - Click Save → redirects to your
/authorizepage - (Log in if not already) → click Approve
- 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:
- 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 — onlymcp__Gmail__*(12 tools) will show without explicit MCP attach.
- Gmail connector has no
send_email— onlycreate_draft. Routines that "send" via Gmail produce drafts you have to open and send manually. Always include your ownsend_summary_emailtool.
vercel env addCLI stores Sensitive type. Sensitive env vars are not exposed to routine runtime. Use the REST API withtype: "encrypted".
sources: []for non-GitHub-connected projects. Even one-shot manualruntriggers a GitHub auth check. Empty sources skips it.
- Tool prefix is
mcp__{name from mcp_connections}__{tool_name}. If the connector is namedProject-Ops, your tools becomemcp__Project-Ops__health_check. Test the exact prefix in your routine prompt by referencing it explicitly.
- 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.
- 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.
- Sentry
statsPeriodfor 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.
- Author: MankhongGarden
- Source: MankhongGarden/anthropic-cloud-routines-custom-mcp
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.