DEV Community

Anand Rathnas
Anand Rathnas

Posted on Originally published at jo4.io

How to Plug Claude Routines Into Your SaaS With MCP

This article was originally published on Jo4 Blog.

A while back a user asked, "Can I run jo4 from a Claude routine?"

I said "sure, eventually", which is what I always say when something sounds like a weekend and I know damn well it's a long weekend.

It was a long weekend.

But jo4.io now speaks MCP. Claude routines (and any other MCP-aware client) can shorten links, list URLs, pull click stats, update titles, and delete links — all through the same Spring Boot API that powers the dashboard.

Here's what shipped, why it's mostly an OAuth story, and how to wire your own client to it.


TL;DR

Jo4 exposes an MCP server at https://jo4-api.jo4.io/mcp. It's a remote, OAuth-protected MCP server — not a local stdio one. So:

  1. Your client discovers the auth server at /.well-known/oauth-authorization-server.
  2. Your client registers itself dynamically (no human in the loop).
  3. Your client does an OAuth authorization-code + PKCE flow.
  4. Your client gets a bearer token and starts calling MCP tools like jo4_shorten_url.

That's the whole song. The rest of this post is the harmonies.


What I Thought I Was Building vs. What MCP Actually Required

I had a mental model that went "add a few tool annotations to my service classes, point Claude at the URL, ship a tweet."

I had not read the MCP authorization spec. The MCP spec defers to a small constellation of OAuth RFCs:

RFC What it gives you Why MCP needs it
6749 OAuth 2.0 core (auth code, refresh) Standard token issuance
7591 Dynamic Client Registration MCP clients have no human to fill out a portal
7636 PKCE Public clients can't keep secrets
8414 Authorization Server Metadata Clients discover endpoints, not config them
9728 Protected Resource Metadata Resources point at their own auth server

A "remote MCP server" is mostly "a resource server with these five RFCs implemented correctly." The MCP-specific bits (tool registration, transport, JSON-RPC framing) take maybe 20% of the effort. OAuth eats the rest.

If your backend already runs a hand-rolled OAuth server, congratulations — you're 80% of the way there. If it doesn't, you're about to learn a lot about RFC 7591.


Anatomy of the Shipped Code

Roughly the new files:

io/jo4/jo4/mcp/
├── Jo4McpTools.java                    -- @McpTool methods (read + write)
├── Jo4McpTransportConfig.java          -- transport bean override
└── OAuthMcpContextExtractor.java       -- carries auth across reactor threads

io/jo4/jo4/controller/noauth/
├── OAuthDcrController.java             -- POST /oauth/register (RFC 7591)
└── OAuthMetadataController.java        -- GET /.well-known/* (RFC 8414, 9728)

io/jo4/jo4/security/
├── DcrRateLimiter.java                 -- per-IP DCR rate limiting
├── OAuthScopeEnforcementFilter.java    -- read vs. write scope gating
├── RedirectUriValidator.java           -- https/localhost only
└── ResourceIndicatorValidator.java     -- RFC 8707
Enter fullscreen mode Exit fullscreen mode

Plus a couple of Liquibase changesets adding audience to access tokens and DCR fields to clients. About 2,700 net lines, mostly tests. (You will write a lot of tests for OAuth code. Please write a lot of tests for OAuth code.)


The Four RFCs That Bit Me

1. RFC 7591 — Dynamic Client Registration

The thing I most wanted not to implement and absolutely had to.

Picture an MCP client like Claude. It doesn't have a developer to log into your portal and click "Create Client". It needs to register itself, get a client_id, and start an OAuth flow — all in a single uninterrupted dance.

The endpoint is POST /oauth/register. The body is the metadata of the client being created (redirect URIs, grant types, token auth method). The response is the same body plus a server-issued client_id (and client_secret if it's confidential).

Two things tripped me up:

token_endpoint_auth_method: "none" is required for public clients. Native and SPA-style MCP clients can't keep a secret, so they register as public clients with none. Your token endpoint must accept tokenless requests for these. PKCE is what protects them — without code_challenge, the flow must reject.

Open registration means rate limiting. Anyone on the internet can POST to /oauth/register. I added a sliding-window per-IP limiter (DcrRateLimiter) gated behind a config flag. Without it you have a delightful little DDoS surface on day one.

2. RFC 8414 — Authorization Server Metadata

A small controller serving JSON at /.well-known/oauth-authorization-server:

{
  "issuer": "https://jo4-api.jo4.io",
  "authorization_endpoint": "https://jo4-api.jo4.io/oauth/authorize",
  "token_endpoint": "https://jo4-api.jo4.io/oauth/token",
  "registration_endpoint": "https://jo4-api.jo4.io/oauth/register",
  "code_challenge_methods_supported": ["S256"],
  "scopes_supported": ["read", "write"],
  "grant_types_supported": ["authorization_code", "refresh_token"]
}
Enter fullscreen mode Exit fullscreen mode

code_challenge_methods_supported: ["S256"] is non-negotiable for MCP — plain PKCE is forbidden by the MCP authorization spec. List S256. Only S256.

3. RFC 9728 — Protected Resource Metadata

This one I almost skipped because it felt redundant. It's not.

The MCP client has a resource URL (your /mcp endpoint). Before doing anything, it asks the resource: "who issues your tokens?" The answer comes from /.well-known/oauth-protected-resource:

{
  "resource": "https://jo4-api.jo4.io",
  "authorization_servers": ["https://jo4-api.jo4.io"],
  "scopes_supported": ["read", "write"],
  "bearer_methods_supported": ["header"]
}
Enter fullscreen mode Exit fullscreen mode

In our case the resource and the AS are the same Spring app, so the URLs match. When mcp.jo4.io becomes its own subdomain in front of the same backend, only the resource field changes. That's the whole point of having a separate document.

4. RFC 8707 — Resource Indicators

Small but sharp. Tokens issued for the MCP resource carry an aud (audience) claim pinning them to that resource. A token your dashboard issued for browser API calls cannot be replayed at /mcp, and vice versa. A ResourceIndicatorValidator enforces it on both authorize and token endpoints.

If you skip this, a token issued for one purpose works for any purpose. That's fine until the day it isn't.


The MCP-Specific Bit (Finally)

Once OAuth is sorted, the MCP layer is small. We use Spring AI's MCP server starter. The tools look like this:

@McpTool(
    name = "jo4_shorten_url",
    description = "Create a new shortened jo4 link..."
)
public Map<String, Object> shortenUrl(
        McpSyncServerExchange exchange,
        @McpToolParam(description = "Destination URL", required = true) String longUrl,
        @McpToolParam(description = "Optional title", required = false) String title,
        @McpToolParam(description = "Optional custom slug", required = false) String customSlug,
        ...) {
    OAuthTokenAuthentication auth = requireWriteScope(exchange);
    UrlEntity entity = UrlEntity.builder()
        .longUrl(longUrl).title(title).shortUrl(customSlug).build();
    return urlSummary(urlService.createUrl(entity, auth.getUser().getId(),
                                            auth.getUser().getTenantId()));
}
Enter fullscreen mode Exit fullscreen mode

Six tools shipped: jo4_get_url, jo4_list_my_urls, jo4_shorten_url, jo4_update_url, jo4_delete_url, jo4_get_stats. They delegate to the same UrlService the REST controllers use. Tenant isolation is automatic because the OAuth token already carries the user.

The thread-local trap

The non-obvious bit: the Spring AI MCP transport hands tool execution off to a Reactor boundedElastic thread. Spring Security's SecurityContextHolder is a thread-local. By the time your @McpTool method runs, the security context is empty.

The fix is a small McpTransportContextExtractor that snapshots the auth on the servlet thread and stuffs it into MCP's transport context (which does propagate):

@Override
public McpTransportContext extract(ServerRequest request) {
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    if (auth instanceof OAuthTokenAuthentication oauthAuth) {
        return McpTransportContext.create(Map.of(KEY_OAUTH_AUTH, oauthAuth));
    }
    return McpTransportContext.EMPTY;
}
Enter fullscreen mode Exit fullscreen mode

Then in tool methods: exchange.transportContext().get(KEY_OAUTH_AUTH). Took an evening to track down the first time a tool returned null for userId.


How To Connect Your Own Client (The Tutorial Bit)

Say you're writing your own MCP client (or you want to test the flow with curl).

Step 1 — Discover

curl https://jo4-api.jo4.io/.well-known/oauth-protected-resource
curl https://jo4-api.jo4.io/.well-known/oauth-authorization-server
Enter fullscreen mode Exit fullscreen mode

The first tells you which AS issues tokens for the resource. The second tells you that AS's endpoints. A real client chains them.

Step 2 — Register dynamically

curl -X POST https://jo4-api.jo4.io/oauth/register \
  -H 'Content-Type: application/json' \
  -d '{
    "client_name": "my-mcp-client",
    "redirect_uris": ["http://localhost:8765/callback"],
    "grant_types": ["authorization_code", "refresh_token"],
    "response_types": ["code"],
    "token_endpoint_auth_method": "none",
    "application_type": "native",
    "scope": "read write"
  }'
Enter fullscreen mode Exit fullscreen mode

You get back a client_id. Keep it.

localhost redirects are accepted (per the MCP spec's loopback exception). Anything else must be https://. No http:// to non-loopback hosts.

Step 3 — PKCE authorization

Build a verifier and challenge:

VERIFIER=$(openssl rand -base64 32 | tr -d '=+/' | cut -c -43)
CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -sha256 -binary | \
            base64 | tr -d '=+/' | tr '/+' '_-')
Enter fullscreen mode Exit fullscreen mode

Open the user's browser at:

https://jo4-api.jo4.io/oauth/authorize
  ?response_type=code
  &client_id=YOUR_CLIENT_ID
  &redirect_uri=http://localhost:8765/callback
  &scope=read%20write
  &code_challenge=$CHALLENGE
  &code_challenge_method=S256
  &resource=https://jo4-api.jo4.io
  &state=xyz
Enter fullscreen mode Exit fullscreen mode

The user logs in. Your callback receives ?code=.... Exchange it:

curl -X POST https://jo4-api.jo4.io/oauth/token \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d "grant_type=authorization_code" \
  -d "code=THE_CODE" \
  -d "redirect_uri=http://localhost:8765/callback" \
  -d "client_id=YOUR_CLIENT_ID" \
  -d "code_verifier=$VERIFIER" \
  -d "resource=https://jo4-api.jo4.io"
Enter fullscreen mode Exit fullscreen mode

You get an access_token (plus refresh_token if you asked for offline access).

Step 4 — Call the MCP server

The MCP endpoint is https://jo4-api.jo4.io/mcp. Streamable HTTP transport. Standard JSON-RPC 2.0. Use whichever MCP SDK matches your language; from a real MCP client this is one config line:

{
  "mcpServers": {
    "jo4": {
      "url": "https://jo4-api.jo4.io/mcp",
      "transport": "streamable-http"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Most clients with built-in OAuth support will run the discovery + DCR + PKCE dance for you the first time the server is added. You stop seeing OAuth and start seeing tools.

Step 5 — Try a tool

From a Claude routine, after the OAuth handshake, this just works:

"Shorten https://example.com/very-long-marketing-page and call it spring-launch."

Behind the scenes the client invokes jo4_shorten_url with longUrl and customSlug. Jo4 returns the new short URL. The routine continues on its merry way.

For analytics:

"How many clicks did spring-launch get last week, broken down by country?"

That's jo4_get_stats with slug=spring-launch and a 7-day window. Routine reads the breakdown, summarizes, done.


Things I'd Tell My Past Self

Read the MCP authorization spec end-to-end before writing a line of code. It cross-references five RFCs and you need all of them. Skim, then re-read.

Treat DCR as a public endpoint from minute one. Rate limiting and validation aren't day-two concerns. Anyone can POST /oauth/register against your prod server the moment it's live.

Audience-bind your tokens. Tokens minted for one resource leaking into another resource is exactly the class of bug RFC 8707 exists to prevent. Just enforce it.

Test the IDOR path. When tools accept slugs by string, every tool needs an explicit "does this user own this slug" check that returns NOT_FOUND (not FORBIDDEN — never reveal that the slug exists for someone else). I caught this in code review on jo4_get_stats. You don't want to catch it later.

Watch out for thread-locals on async transport. If you read auth from SecurityContextHolder inside a tool method, you'll get null and won't know why. Use the transport context.


What's Next

Right now we ship six tools — read and write for the URL primitive plus stats. Next on the list: bio-page tools (jo4_create_bio_page, jo4_add_bio_link) and webhook-management tools so a routine can subscribe itself to click events.

The OAuth scaffolding is done. New tools are now a one-method-and-a-test affair.


Building an MCP server for your own SaaS? Drop the question that's stuck — happy to compare notes on whichever RFC is currently ruining your evening.

Building jo4.io — a URL shortener with analytics, now reachable from your favorite MCP-aware AI.

Top comments (0)