MCP Servers
Maravilla MCP Servers let your application expose tools that AI agents can call — Claude, ChatGPT, Cursor, and anything else that speaks the Model Context Protocol. You write a small mcp/ folder of typed tool handlers; Maravilla turns it into a fully authenticated MCP server hosted at your app’s own URL. Every tool runs as the logged-in end-user, against the same KV, Database, Storage, and Auth services the rest of your app uses — so an agent can only ever see and do what that user is allowed to.
The headline difference from rolling your own MCP server: Maravilla is the login. Your app already knows how to authenticate people; the runtime reuses that exact identity for agents. An agent connecting to your server logs in with the app’s normal account in one click — no API tokens to generate, copy, or paste.
// mcp/tools.ts
import { defineMcpServer, defineMcpTool } from '@maravilla-labs/platform/mcp';
export const server = defineMcpServer({
name: 'Acme Tools',
version: '1.0.0',
instructions: 'Tools for managing Acme orders and notes.',
});
export const getOrder = defineMcpTool(
{
name: 'get_order',
description: 'Look up an order by id.',
inputSchema: {
type: 'object',
properties: { id: { type: 'string' } },
required: ['id'],
},
scopes: ['orders:read'],
},
async (args, ctx) => {
// ctx.user is the real signed-in person behind the agent.
const order = await ctx.database.findOne('orders', {
_id: args.id,
owner: ctx.user.id,
});
return { content: [{ type: 'text', text: JSON.stringify(order) }] };
},
);
The mcp/ folder
Tools live in an mcp/ folder (or a single mcp.ts) at the root of your app, right next to events/, functions/, and workflows/. The build pipeline discovers every file in it, bundles your handlers, and emits a manifest describing each tool. There is no separate server to deploy — the tools ship with your app and go live the moment it deploys.
Each file exports two kinds of things:
- One server descriptor via
defineMcpServer(...)— the server’s name, version, free-text usage instructions for the model, and any UI templates (more on those below). At most one per app. - Any number of tools via
defineMcpTool(spec, handler)— the tools agents can call.
defineMcpTool(spec, handler)
The spec is what the agent sees when it lists your tools:
| Field | Description |
|---|---|
name | The tool name surfaced to the agent (e.g. get_order). |
description | What the tool does — the model reads this to decide when to call it. |
inputSchema | A JSON Schema for the arguments. Sent verbatim so the model knows how to call you. |
scopes | The permissions a caller must hold to use this tool (see Scopes). |
public | Optional. true makes the tool callable without a login (see Public tools). Defaults to false. |
ui | Optionally links the tool to a UI template (see UI templates). |
The handler receives the validated args and a ctx carrying the full platform — ctx.kv, ctx.database, ctx.storage, ctx.auth, ctx.queue, ctx.push — plus the caller’s identity:
ctx.user— the signed-in person behind the agent:{ id, email, groups, scopes }, ornullfor an agent acting without a user.ctx.client— the agent (OAuth client) that connected, when known.ctx.tenant,ctx.env,ctx.traceId— the usual per-request context.
Because the platform services on ctx run as ctx.user, your existing authorization rules apply automatically. A tool that does ctx.database.find('orders', { owner: ctx.user.id }) simply cannot return another user’s orders — the same guarantee your web UI already relies on.
Scopes
Scopes are how you decide which tools an agent is allowed to call. A tool lists the scopes it requires; a caller must hold all of them. If they don’t, the tool returns an insufficient_scope error to the model instead of running — the rest of your tools keep working.
export const addNote = defineMcpTool(
{
name: 'add_note',
description: 'Create a note for the signed-in user.',
inputSchema: {
type: 'object',
properties: { text: { type: 'string' } },
required: ['text'],
},
scopes: ['notes:write'], // caller must hold notes:write
},
async (args, ctx) => {
const id = crypto.randomUUID();
await ctx.database.insertOne('notes', {
_id: id,
owner: ctx.user.id,
text: args.text,
created_at: Date.now(),
});
return { content: [{ type: 'text', text: `Created note ${id}` }] };
},
);
You declare the full set of scopes your server offers in the mcp config block. When an agent logs in, it’s shown exactly these scopes and asked to consent — the same “this app wants to…” screen people already expect. Granting notes:read but not notes:write lets the agent read notes but never create them.
Maravilla is the Authorization Server
This is the part you’d normally spend a week building. Your Maravilla app is a complete OAuth 2.1 Authorization Server: agents discover it, register themselves, and log in using your app’s own accounts — no extra identity provider, no token-minting UI, nothing to paste.
When an agent connects to your server, the flow is fully automatic:
- The agent discovers your server’s login endpoints.
- It registers itself (Dynamic Client Registration) — no manual app setup.
- A browser window opens to your app’s normal sign-in page. The user logs in the way they always do and approves the requested scopes.
- The agent receives a token and starts calling tools as that user.
For the person using the agent, this is one click: “Connect” → log in → “Allow”. They never see, generate, or handle a token. Logging out of your app, or revoking the agent, cuts the agent off immediately.
Because the agent authenticates as a real user, everything downstream — authorization rules, per-user data scoping, audit trails — works without any MCP-specific code. The agent is just another way for that user to act.
Headless API keys
Interactive login is perfect for an agent a person is driving. For non-interactive agents — a background job, a CI task, a scheduled assistant with no one at the keyboard — Maravilla issues headless API keys instead.
A signed-in user mints a key from your app’s Settings → API Keys, choosing which scopes it carries (always a subset of what that user can do). The key is shown once; only a hash is stored. The agent presents it as a bearer token and is treated as that user, with exactly the granted scopes:
Authorization: Bearer mk_xxxxxxxxxxxxxxxxxxxxxxxx
Keys can be given an expiry and revoked at any time. Revoking a key — or the user losing the underlying permission — stops the agent on its next call. Use interactive login for human-in-the-loop agents and headless keys for automation; both land on the same identity and the same scope checks.
Public tools
By default every tool requires the caller to be authenticated — even a tool that declares no scopes. Sometimes you want a tool anyone can call without logging in — a health check, a public lookup, a “what can you do?” helper that introduces your server to a model before the user commits to connecting.
You opt in to public access in one of two ways:
- Per tool — set
public: trueon the tool. Only that tool becomes anonymous-callable. - Whole server — set
public: truein themcpconfig block. Every tool that requires no scopes is then callable without a login; scoped tools still demand their scopes.
export const ping = defineMcpTool(
{
name: 'ping',
description: 'Health check — confirms the server is reachable.',
inputSchema: { type: 'object', properties: {} },
public: true, // anonymous-callable; without this it would require a login
},
async () => ({ content: [{ type: 'text', text: 'pong' }] }),
);
Make public tools intentional. A public tool runs with no user behind it (ctx.user is null), so it must never read or write anything owned by a specific person. Keep them to safe, read-only, non-sensitive operations. (Scopes are still enforced even when public: a tool that lists scopes won’t run for an anonymous caller.)
UI templates (mini-apps)
A tool can return more than text. Link it to a UI template and the agent renders an interactive mini-app inline in the chat — an order card, a chart, a flip-through reader. Maravilla follows the official MCP Apps standard, so the same widget renders in both Claude and ChatGPT with no per-client code.
A template is a self-contained single-file HTML widget. You don’t build it by hand — drop the widget source in a folder named after the template (mcp-ui/order-card/, sitting next to mcp/), and maravilla build compiles it to one inlined file automatically (your framework’s Vite plugin + Tailwind, all wired for you). No second Vite config, no build script. A tool links to the template via ui.template and returns the data to render:
export const server = defineMcpServer({
name: 'Acme Tools',
uiTemplates: [{ name: 'order-card' }], // source: mcp-ui/order-card/ — auto-built
});
export const getOrder = defineMcpTool(
{
name: 'get_order',
description: 'Look up an order and show it as a card.',
inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
scopes: ['orders:read'],
ui: { template: 'order-card' }, // render the result as a mini-app
},
async (args, ctx) => {
const order = await ctx.database.findOne('orders', { _id: args.id, owner: ctx.user.id });
return { ui: { template: 'order-card', data: order }, content: [{ type: 'text', text: `Order ${args.id}` }] };
},
);
How the data reaches the widget
The widget never queries your database — your tool does, as the signed-in user, and hands the result to the widget. The flow is one round trip:
- The agent calls
get_order. Your handler runs server-side (asctx.user), reads the order, and returns{ ui: { template: 'order-card', data: order } }. Thedataobject is the payload to render;contentis a plain-text fallback for clients that can’t show UI. - The runtime serves your single-file widget inline as the
ui://order-cardresource and deliversdatato it as the MCP AppsstructuredContent. - Inside the widget,
@modelcontextprotocol/ext-appsreceives it —app.ontoolresult = (p) => render(p.structuredContent)— and you render. (On ChatGPT the same payload also arrives viawindow.openai.toolOutput; the recipe’s helper handles both.)
So the widget stays a dumb renderer of whatever the tool returns — no cookies, no auth in the widget, no extra fetch. The full walkthrough (widget source, the ext-apps client, a local dev loop) is the mcp-ui-mini-app recipe — just ask your coding agent for it (it’s available through the Maravilla MCP server).
Heads up: Claude Desktop caches the widget. After you redeploy a new version, fully quit and reopen Claude (or re-add the connector) to pick it up — otherwise it keeps showing the old one.
Configuration
Turn the server on and declare its scopes in your maravilla.config.ts:
import { defineConfig } from '@maravilla-labs/platform';
export default defineConfig({
mcp: {
enabled: true,
server: { name: 'Acme Tools', version: '1.0.0' },
// Every scope your tools can require, advertised to agents at login.
scopes: ['orders:read', 'notes:read', 'notes:write'],
},
});
| Field | Description |
|---|---|
enabled | Master switch. With false (the default) the MCP endpoint is off entirely. |
server | Server name and version advertised to agents. |
scopes | The full set of scopes agents may be granted at login. |
public | Optional. true makes the whole server anonymous-callable — every no-scope tool runs without a login. Defaults to false; prefer per-tool public unless the entire server is meant to be open. |
publicUrl | Optional. The public URL agents connect to, when it differs from the request host. |
consent | Optional. { remember: true } to persist a user’s grant so they aren’t re-prompted every connection. |
Connecting an agent
Your server lives at <your-app-url>/_mcp. Point any MCP client at it.
Claude
claude mcp add --transport http acme https://app.acme.com/_mcp
The first call opens a browser, you log in to your app and approve the scopes, and Claude can use the tools from then on. Claude Desktop and Cursor follow the same connect-and-authorize flow.
ChatGPT
In ChatGPT, add a connector pointing at https://app.acme.com/_mcp. ChatGPT walks the same login: it discovers your server, registers itself, and opens your app’s sign-in page for the user to approve.
Local development
During maravilla dev the server is available at http://localhost:<port>/_mcp, so you can connect an agent to your machine and iterate on tools before deploying:
claude mcp add --transport http acme-dev http://localhost:5173/_mcp
How it fits together
- You write
mcp/tools.tswith adefineMcpServerand yourdefineMcpTools. - You enable
mcpand list your scopes inmaravilla.config.ts. - The build emits an MCP manifest alongside your app; deploying ships the server.
- An agent connects to
<url>/_mcp, logs in with your app’s account (or presents a headless key), and starts calling tools — as the real user, with their permissions.
Next Steps
- Authorization — the per-user rules your tools inherit automatically
- Database API Reference — query documents from inside a tool
- KV Store and Storage — the rest of the services on
ctx - Auth — the accounts agents log in with