ai newsSa., 31. Okt.
EilmeldungJeder vierte Microsoft-Mitarbeiter kann freiwillig gehen, während der Konzern Milliarden in KI steckt

How we built a remote MCP server with OAuth on Cloudflare Workers (and got it into ChatGPT and Claude)

Dashly is a dashboard you keep up by talking to ChatGPT or Claude. You mention lunch, a run or what you spent, or you send a photo of a meal, a nutrition label, a receipt or the bathroom scale, and the assistant logs it into Dashly over the Model Context Protocol (MCP). Dashly turns those entries i

dev.toSamstag 10:17 · 31.10.15 Min. Lesezeit
Bild: dev.to

Dashly is a dashboard you keep up by talking to ChatGPT or Claude. You mention lunch, a run or what you spent, or you send a photo of a meal, a nutrition label, a receipt or the bathroom scale, and the assistant logs it into Dashly over the Model Context Protocol (MCP). Dashly turns those entries into live tiles against your goals.

All of that goes through one URL, https://dashly.si/mcp. It runs on Cloudflare Workers with D1 as the database, it signs people in with OAuth 2.1, and it is listed in both ChatGPT's app directory and Claude's connector directory. Below is how it's put together and what we learned the hard way. Every snippet is from our repo, trimmed.

The API is a Hono app. The same app also runs on Node with SQLite, so routes and services only ever see a small Platform object ( db, email, rateLimit, config, now, log ), and everything Cloudflare-specific lives in one file. On Workers, Prisma talks to D1 through the D1 adapter:

const db = new WorkerdPrismaClient ({ adapter: new PrismaD1 ( env. DB ) }) as unknown as PrismaClient; Enter fullscreen mode Exit fullscreen mode The public site and the web app are static files in the same Worker's assets. Cloudflare serves those on its own and only runs our code for the paths that need a server. That list matters more than it looks, because MCP clients probe /.well-known/* before they ever touch /mcp:

"assets": { "directory": "../../dist", "binding": "ASSETS", "not_found_handling": "404-page", "run_worker_first": [ "/api/*", "/mcp", "/mcp/*", "/oauth/*", "/.well-known/*", "/app", "/app/*", "!/app/assets/*", "!/app/sw.js" //... ] } Enter fullscreen mode Exit fullscreen mode If /.well-known/* is missing from run_worker_first, the discovery documents fall through to the static site's 404 page and the connector just fails to connect, with nothing useful in any log.

We use Streamable HTTP and keep it stateless. There is one route. requireBearer is the only HTTP gate, and a fresh McpServer is built for every request from the caller's identity:

export function mcpRoutes ( p: Platform ) { const app = new Hono < { Variables: BearerVars } > (); // Before requireBearer, so a browser preflight (no Authorization) is answered, and a 401 carries CORS headers. app. use ( ' / ', publicCors ); app. all ( ' / ', requireBearer ( p ), async ( c ) => { const auth = c. get ( ' auth ' ); const mcp: McpCtx = { p, auth, defer: c. get ( ' defer ' ) }; return getHandler ( p ). fetch ( c. req. raw, { authInfo: { token: '', clientId: auth. clientId?? MCP_PERSONAL_CLIENT_ID, scopes: auth. scopes, extra: { mcp } }, }); }); return app; } Enter fullscreen mode Exit fullscreen mode The handler comes from the official TypeScript SDK ( @modelcontextprotocol/server ). We create it once per platform instance and let it build the server per request:

handler = createMcpHandler (( reqCtx ) => { const mcp = reqCtx. authInfo?. extra?. mcp as McpCtx | undefined; if (! mcp ) throw new Error ( ' MCP context missing ' ); return buildServer ( mcp ); }, { onerror: ( err ) => p. log. warn ({ fn: FN, err }, ' mcp handler error ' ), }); Enter fullscreen mode Exit fullscreen mode Building per request sounds wasteful, but on Workers isolates come and go, so any in-memory session would have to be rebuilt from storage anyway. And because the server is built from the caller's AuthContext, every tool closes over the right user id, and there is no shared state that could leak between people. The SDK version we're on speaks the 2026-07-28 protocol, where each request carries its own protocol envelope instead of relying on an initialize handshake.

Older clients that still send initialize get a stateless answer too.

A remote MCP client starts with no token. It calls /mcp, gets a 401, and has to work out where to sign in from that response alone. So the 401 carries a WWW-Authenticate header that points at the protected resource metadata:

export function unauthorizedChallenge ( config: OAuthConfig ): string { return challengeHeader ({ resource_metadata: protectedResourceMetadataUrl ( config ), scope: config. defaultScopes. join ( ' ' ), }); } Enter fullscreen mode Exit fullscreen mode The client fetches that document (RFC 9728), which names the authorization server, then fetches the authorization server metadata (RFC 8414) for the authorize, token, register and revoke endpoints.

The location of the first document is the part we'd warn anyone about. For a resource at https://dashly.si/mcp, the metadata belongs at https://dashly.si/.well-known/oauth-protected-resource/mcp. The well-known segment goes between the host and the path. A bare /.well-known/oauth-protected-resource describes a resource with no path. We serve both, so a client finds it either way:

export function protectedResourceMetadataPath ( config: OAuthConfig ): string { const { pathname } = new URL ( config. resourceUrl ); const suffix = pathname === ' / '? '': pathname; return ` ${ PROTECTED_RESOURCE_PATH }${ suffix } `; } app. get ( PROTECTED_RESOURCE_BARE_PATH, () => json ( protectedResourceMetadata ( config ))); app. get ( protectedResourceMetadataPath ( config ), () => json ( protectedResourceMetadata ( config ))); app. get ( AUTHORIZATION_SERVER_PATH, () => json ( authorizationServerMetadata ( config ))); Enter fullscreen mode Exit fullscreen mode The authorization server metadata says a few things clients actually rely on. We only support public clients with PKCE, so token_endpoint_auth_methods_supported is ['none'] and code_challenge_methods_supported is ['S256']. We advertise client_id_metadata_document_supported: true and a registration_endpoint, because the two big hosts register in different ways.

There are three kinds of client_id our server accepts. An https URL is a Client ID Metadata Document: we fetch it, and the document is the registration. A dcr_ id is a client that registered itself through dynamic client registration (RFC 7591). Anything else would have to be a client we seeded by hand.

Fetching a URL that an unauthenticated caller chose is an SSRF surface sitting right in front of your login, so the fetch is fenced. We check that the host resolves to a public address, refuse redirects, cap the body size and time out quickly. The document must also claim its own URL as client_id, or anyone could host a document pretending to be someone else:

if ( d. client_id!== clientId ) { throw clientError ( ' Client metadata document does not claim its own URL as client_id. ' ); } Enter fullscreen mode Exit fullscreen mode Workers helped here. The platform refuses connections to private address ranges, so a DNS rebinding trick can't reach anything internal, and workerd only implements redirect: 'follow' and 'manual'. We use 'manual' and treat any non-2xx answer as a refusal.

Every token is opaque and stored as a SHA-256 hash in D1. Validating one is a row lookup, which makes revocation instant. Disconnecting an assistant in Settings ends its access on the next call.

Every token minted from one authorization shares a family id, which is the grant's id. That gives replay detection something to act on. A refresh token is single use, so seeing one twice means a copy is loose:

if ( row. consumedAt ) { await ctx. tokens. revokeFamily ( row. familyId ); ctx. deps. log. warn ({ clientId, userId: row. userId, familyId: row. familyId }, ' refresh token replayed, family revoked ' ); throw reject (); } Enter fullscreen mode Exit fullscreen mode The same happens when an authorization code is redeemed twice. Whoever redeemed it first loses their tokens too.

Some places that list MCP servers want to health-check your server with a static credential and can't run an OAuth flow. Scripts are the same. For those, a signed-in person can create a personal token in the web app. It starts with dsh_, is shown once, and is stored hashed like everything else. requireBearer branches on the prefix and produces the same AuthContext either way:

if ( raw. startsWith ( PERSONAL_TOKEN_PREFIX )) { const found = await personalTokensService. authenticate ( p, raw ); if (! found ) { const reason = ( await personalTokensService. wasIssued ( p, raw ))? TOKEN_REJECT_REASONS. REVOKED: TOKEN_REJECT_REASONS.

UNKNOWN; throw await rejected ( ' Invalid access token ', ' token ', reason ); } auth = { userId: found. userId, via: ' token ', clientId: null, clientName: null, scopes: [... OAUTH_DEFAULT_SCOPES ], tokenId: found. tokenId, familyId: null, source: sourceForClient ( null, found. name ), }; } else { // validateAccessToken(oauth, raw) and the same AuthContext from the OAuth row } Enter fullscreen mode Exit fullscreen mode source ends up on every entry the caller writes. It is derived from what we can verify (the client id's host, a dcr_ id, or token:<name> ), never from the name a client gives itself.

When a person sees "logged from Claude" on an entry, it's because the token came from a client registered under claude.ai.

ChatGPT and Claude call your server from their own backends, so thousands of different people arrive from a handful of IP addresses. A per-IP limit on valid traffic would throttle everyone at once. We limit a valid token per token, and only count unknown tokens against the caller's IP, since those are the guesses:

// Only guesses count per IP: a valid token is never throttled by its IP (many users can share one NAT), and // neither is a token Dashly issued that has since expired or been revoked (a client that has not refreshed yet). Enter fullscreen mode Exit fullscreen mode The token endpoint is keyed the same way, by a hash of the code or refresh token being redeemed, with a coarse per-IP ceiling on top. Keying it on client_id looked natural at first, but every ChatGPT user shares one client_id, and anyone could drain that bucket.

D1 suits this workload well. The thing to design around is that D1 has no interactive transactions. A Prisma $transaction() batch is atomic on SQLite but runs sequentially on D1. So we write multi-step operations to be safe to retry, and we validate everything before writing anything.

log_entries is the clearest case. An assistant often logs a whole meal in one call, and we promise all or nothing. Every entry is parsed and checked against its topic first, and only then do the inserts start:

const details = parsed. flatMap (( e, idx ) => checkAgainstTopic ( topics. get ( e. topicId )!, e, true ). map (( d ) => ({... d, path: at ( idx, d. path, indexed ) }))); if ( details. length ) throw new AppError ( ERROR_CODES. VALIDATION_FAILED, ' one or more entries are invalid; nothing was written ', details ); Enter fullscreen mode Exit fullscreen mode Migrations are plain numbered SQL files that both D1 and the Node SQLite build apply, and the backend test suite runs on both runtimes (workerd through vitest-pool-workers, and Node).

We have 24 tools, and the protocol side turned out to be the easy part. The work was in writing tools that a model uses correctly without a human checking each call.

One canonical unit per topic. A topic stores every value in one unit, and the tool descriptions tell the model to convert before logging. The server instructions say it plainly:

' Every topic has one canonical unit. Always convert the person \' s amount into that unit before you log it (a glass of water is about 250 ml if the unit is ml). ', Enter fullscreen mode Exit fullscreen mode The model is good at "a glass is about 250 ml" and bad at keeping track of which unit it used last Tuesday. Putting the unit in the topic, and returning it from list_topics, keeps the stored history consistent. Changing a unit is its own operation ( update_topic with a unitFactor ) that rescales the history and the goal together.

Goals always have a direction. "Protein 140" is a target. "Calories 2000" is a limit. "Sleep 7 to 9" is a range. A model will happily guess, and a wrong guess turns a good day red. So the goal schema is a discriminated union with no default, and the description tells the model to ask:

export const GoalSchema = z. discriminatedUnion ( ' direction ', [ z. object ({ direction: z. literal ( GOAL_DIRECTIONS. AT_LEAST ), value: z. number (). finite (). positive (),... WINDOW }), z. object ({ direction: z. literal ( GOAL_DIRECTIONS. AT_MOST ), value: z. number (). finite (). nonnegative (),... WINDOW }), z. object ({ direction: z. literal ( GOAL_DIRECTIONS. BETWEEN ), min: z. number (). finite (). nonnegative (), max: z. number (). finite (). positive (),... WINDOW }), ]) Enter fullscreen mode Exit fullscreen mode The matching description ends with "If the person gives a number without saying whether it is a limit or a target, ask them." Assistants do ask, which is what we want.

Things to avoid. Some topics are things a person wants less of: takeaway, impulse buys, or, if you tell Claude about them, headaches. Those topics get polarity: 'avoid'. A day with nothing logged counts as a good day, the streak becomes days without, and the only goal allowed is at_most.

Results that survive any host. Some hosts forward only the content text of a tool result to the model and drop structuredContent. Then the model never sees the ids it needs for the next call. So every result puts a sentence first and the same data as JSON after a blank line:

export function textResult ( text: string, structured: unknown ): CallToolResult { const compact = JSON. stringify ( structured ); const json = compact. length < PRETTY_JSON_MAX_CHARS? JSON. stringify ( structured, null, PRETTY_JSON_INDENT ): compact; return { content: [{ type: ' text ', text: ` ${ text }${ TEXT_PARTS_SEPARATOR }${ json } ` }], structuredContent: JSON. parse ( compact ) as Record < string, unknown >, }; } Enter fullscreen mode Exit fullscreen mode Errors are tool results. A validation failure comes back with isError: true, a [CODE] message line and one line per bad field with its allowed values.

The model reads it, fixes the input and tries again. Only an unknown tool or a malformed request becomes a JSON-RPC protocol error. Scope and subscription checks also run inside each tool through one wrapper, so the model gets a readable explanation instead of an HTTP status it can't act on:

export function guarded < A extends unknown [] > ( p: Platform, auth: AuthContext, toolName: ToolName, handler: ToolHandler < A > ): ToolHandler < A > { return async (... args ) => { try { await requireEntitled ( p, auth ); requireScopes ( auth, SCOPE_BY_TOOL [ toolName ]); return await handler (... args ); } catch ( err ) { if (! ( err instanceof AppError || err instanceof McpAccessError )) p. log. error ({ fn: FN, tool: toolName, err }, ' tool failed ' ); return errorResult ( err, p. config. appUrl ); } }; } Enter fullscreen mode Exit fullscreen mode Scopes, annotations and output schemas each live in one table keyed by tool name. A contract test registers every tool and fails if any table is missing an entry, so adding a tool can't skip a step.

Claude's directory ran an automated policy scan and listed us. ChatGPT's review went deeper, and most of what it asked for is now encoded in the server.

Annotations that match behaviour. Every tool declares all four hints as explicit booleans: readOnlyHint, destructiveHint, openWorldHint and idempotentHint. OpenAI checks them against what a tool can actually do. Our rule now is that openWorldHint is true whenever a write can change what someone else sees. Dashly dashboards can be shown on a paired TV or at a shared link, so nearly every write is open-world:

const READ = hints ( true, false, false, true ); // Adds something that shows on the person's dashboards; calling again adds another. const ADD_SHOWN = hints ( false, false, true, false ); // Replaces or removes something that shows on the person's dashboards; calling again changes nothing more. const CHANGE_SHOWN = hints ( false, true, true, true ); Enter fullscreen mode Exit fullscreen mode Per-client wording. Inside ChatGPT you can't show plans or prices, or link to a page that starts a purchase, and the reviewer flagged some of our example wording. We didn't want to weaken the descriptions every other client reads, so the bearer middleware already knows the source, and a small rewrite table applies only for ChatGPT, to the instructions, every tool description and input schema, and the capabilities resource:

export function wordingFor < T > ( source: string, value: T ): T { return isChatGpt ( source )? chatgptWording ( value ): value; } Enter fullscreen mode Exit fullscreen mode A test holds every rewrite pair to the review's rules and fails when one stops matching anything.

A working test account and test cases. Reviewers sign in with a dedicated account that holds a couple of weeks of sample data, so every tool has something real to work on. The ChatGPT package also carries test cases: a prompt, the tools it should trigger, and what should happen. Directories that health-check the server with a static credential get one dsh_ token each, minted by a small script, so revoking one directory never affects another.

Connecting from ChatGPT. This is the one that took longest. Our server supports metadata documents and DCR, and we expected ChatGPT to use one of them. What finally connected was declaring ChatGPT's own metadata document as a provided client in the package's mcp.json:

"auth": { "type": "oauth", "client": { "mode": "provided", "clientId": "https://chatgpt.com/oauth/client.json", "tokenEndpointAuthMethod": "none" } } Enter fullscreen mode Exit fullscreen mode Since that clientId is an https URL, our server resolves it like any other metadata document. Domain verification was a static file at /.well-known/openai-apps-challenge, which also had to be allowed past the Worker's /.well-known/* route so it reaches the static assets.

A few things we'd do again from day one. Serve the protected resource metadata at the path-inserted location and the bare one. Rate limit by token rather than by IP. Hash tokens and look them up per request so revocation is real. Give the model units, goal directions and error messages it can act on, and assume some hosts will only show it your text.

If you want to see the result from the user's side, Dashly is at dashly.si, and dashly.si/connect has the steps for adding it to ChatGPT, Claude or any MCP client that takes a URL.

Quelle: dev.to (dev.to). Original öffnen