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:

FieldDescription
nameThe tool name surfaced to the agent (e.g. get_order).
descriptionWhat the tool does — the model reads this to decide when to call it.
inputSchemaA JSON Schema for the arguments. Sent verbatim so the model knows how to call you.
scopesThe permissions a caller must hold to use this tool (see Scopes).
publicOptional. true makes the tool callable without a login (see Public tools). Defaults to false.
uiOptionally 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 }, or null for 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:

  1. The agent discovers your server’s login endpoints.
  2. It registers itself (Dynamic Client Registration) — no manual app setup.
  3. 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.
  4. 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: true on the tool. Only that tool becomes anonymous-callable.
  • Whole server — set public: true in the mcp config 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:

  1. The agent calls get_order. Your handler runs server-side (as ctx.user), reads the order, and returns { ui: { template: 'order-card', data: order } }. The data object is the payload to render; content is a plain-text fallback for clients that can’t show UI.
  2. The runtime serves your single-file widget inline as the ui://order-card resource and delivers data to it as the MCP Apps structuredContent.
  3. Inside the widget, @modelcontextprotocol/ext-apps receives it — app.ontoolresult = (p) => render(p.structuredContent) — and you render. (On ChatGPT the same payload also arrives via window.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'],
  },
});
FieldDescription
enabledMaster switch. With false (the default) the MCP endpoint is off entirely.
serverServer name and version advertised to agents.
scopesThe full set of scopes agents may be granted at login.
publicOptional. 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.
publicUrlOptional. The public URL agents connect to, when it differs from the request host.
consentOptional. { 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

  1. You write mcp/tools.ts with a defineMcpServer and your defineMcpTools.
  2. You enable mcp and list your scopes in maravilla.config.ts.
  3. The build emits an MCP manifest alongside your app; deploying ships the server.
  4. 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