# Anthropic Cloud Routines Custom Mcp

> 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.

- **Type:** MCP server
- **Install:** `agentstack add mcp-mankhonggarden-anthropic-cloud-routines-custom-mcp`
- **Verified:** Pending review
- **Seller:** [MankhongGarden](https://agentstack.voostack.com/s/mankhonggarden)
- **Installs:** 0
- **Category:** [Databases](https://agentstack.voostack.com/c/databases)
- **Latest version:** 0.1.0
- **License:** MIT
- **Upstream author:** [MankhongGarden](https://github.com/MankhongGarden)
- **Source:** https://github.com/MankhongGarden/anthropic-cloud-routines-custom-mcp

## Install

```sh
agentstack add mcp-mankhonggarden-anthropic-cloud-routines-custom-mcp
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## 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:

```ts
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)

```ts
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`

```ts
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`

```tsx
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)

```ts
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:

```bash
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:

```js
// 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`:

```json
{
  "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.

2. **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.

3. **`vercel env add` CLI stores Sensitive type.** Sensitive env vars are not exposed to routine runtime. Use the REST API with `type: "encrypted"`.

4. **`sources: []` for non-GitHub-connected projects.** Even one-shot manual `run` triggers a GitHub auth check. Empty sources skips it.

5. **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.

6. **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.

7. **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.

8. **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.

- **Author:** [MankhongGarden](https://github.com/MankhongGarden)
- **Source:** [MankhongGarden/anthropic-cloud-routines-custom-mcp](https://github.com/MankhongGarden/anthropic-cloud-routines-custom-mcp)
- **License:** MIT

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

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** yes
- **Filesystem access:** no
- **Shell / process execution:** no
- **Environment & secrets:** yes
- **Dynamic code execution:** no

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: flagged — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/mcp-mankhonggarden-anthropic-cloud-routines-custom-mcp
- Seller: https://agentstack.voostack.com/s/mankhonggarden
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
