We exposed all our SvelteKit remote functions (queries + commands) to AI agents via an MCP server — inside the same app #16654
alexbjorlig
started this conversation in
Show and tell
Replies: 1 comment 2 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
We run 21RISK, a multi-tenant risk-management SaaS built on SvelteKit.
Over the last months we've been all-in on remote functions (
query(),command(),form()), and we recently made essentially our whole remote-function surface available to AI agents (Claude, MCP Inspector, etc.) through an MCP server, served by the same SvelteKit app, no separate service. Sharing the setup here since most of it is just SvelteKit primitives used in slightly unusual ways. And maybe this could also bring inspiration for future design ideas.'It's running in production today, and works really well. The summary is written by Claude, but with small edits and fact-checked by me 😎
Built together with @AndreasHald and @oxymc
The core idea
Every remote function that should be callable from the outside gets a thin sibling
.support.tsfile that wraps it with metadata:defineRPCcarries the Zod input schema, a description, required OAuth scopes, discovery visibility (public/hidden/admin), activity-log config for mutations, and OpenAPI examples. Crucially, the support file lazy-loads the actual.remote.tsexport — the remote function stays the single implementation, with all its existing auth checks. The.support.tsnever contains business logic.One gotcha worth knowing: SvelteKit requires that
.remote.tsfiles only export remote functions, so shared schemas live in a.schema.tsfile imported by both sides (also avoids a circular import between.remote.tsand.support.ts).A tiny Vite plugin as the registry
A ~100-line Vite plugin scans our
remote-functions/**directories for*.support.tsfiles onbuildStart(plus a chokidar watcher in dev) and generates a registry of lazy loaders:Create a
.support.tsfile → it's registered. No manual list to maintain. That registry then powers three things: aPOST /api/rpc/[procedure]endpoint (validate with the Zod schema, call the implementation), our public OpenAPI docs, and the MCP server.The MCP server is just a SvelteKit route
src/routes/api/[transport=mcp_transport]/+server.ts, using the official@modelcontextprotocol/sdk:The
WebStandardStreamableHTTPServerTransportspeaks web-standardRequest/Response, so it plugs straight into a SvelteKit endpoint. A param matcher (mcp_transport) keeps the route constrained. Running stateless (no session ids) makes it serverless-friendly.Don't register 400 tools — register 6
Our first instinct was one MCP tool per procedure. That's a terrible idea at our scale (context bloat, and the tool list changes with every deploy). Instead the MCP server exposes a small discovery + execution surface over the registry:
search_functions— keyword/tag search over all registered procedures (name, type, description, tags)get_function_details— full JSON Schema (converted from Zod) + example requests/responses for one procedureexecute_functions— run one or more procedures by name, batched, with per-procedure success/error resultssearch_docs/read_doc— search and read our product documentation (markdown)execute_code— run a JS snippet in an isolated Vercel Sandbox, with a pre-authenticatedapi()helper for ad-hoc scripting against our REST APISo the agent's loop is: search → inspect schema → execute. The server-level MCP
instructionsfield tells clients to orient withwhoAmIandqueryTenantfirst, which works surprisingly well — fresh agents self-onboard.The small OAuth server
MCP clients expect OAuth 2.1 discovery, so we implemented a minimal authorization server — again, just SvelteKit routes:
/.well-known/oauth-authorization-server— server metadata (RFC 8414)/.well-known/oauth-protected-resource— protected-resource metadata, referenced in theWWW-Authenticateheader on 401s so clients can discover auth automatically/oauth/register— dynamic client registration (RFC 7591)/oauth/authorize— authorization code + PKCE (S256 only), rendered with our normal app UI so the user sees exactly which tenants and access level they're granting/oauth/token— code exchange + refresh tokens, public clients (token_endpoint_auth_methods: ['none'])/oauth/revokeThat's the whole thing. Point Claude (or MCP Inspector) at
https://app.example.com/api/mcp, it gets a 401 withresource_metadata, walks the discovery chain, registers itself, and pops our consent screen. No config files, no pre-provisioned client ids.One design decision we're happy with: the OAuth application's access (read vs. full, per tenant) takes precedence over the user's own permissions. So you can grant an AI read-only access to one tenant even if you personally have write access everywhere — and the existing per-function
oauthScope+ the remote function's own auth checks still apply underneath.Why this worked so well with SvelteKit
.support.tsnext to your.remote.tsand it's discoverable by AI on the next build.Happy to answer questions or go deeper on any part (the Zod→JSON Schema conversion, the sandbox, activity logging on commands, etc.). Curious if anyone else is exposing remote functions over MCP — and whether there's appetite for making something like the registry pattern more first-class.
All reactions