Docs / Build your own

Your own MCP server

The Studio and Events connectors that appear at the bottom of Settings are not hand-written servers. Each is one content node in the mcp workspace that names the tools it exposes, and each tool is a server function you already know how to write. RaisinDB does the rest: the protocol, the login, row-level security, resources, and the delivery of interactive widgets to Claude and ChatGPT. This page builds a third one the same way.

What you get without writing a server

Declare a raisin:McpServer node and the engine serves it at POST /mcp/<repository>/main/<slug> with:

  • The protocol: initialize, tools/list, tools/call, resources/*, capability negotiation, every MCP revision back to 2024. Nothing to host.
  • Login: on the cloud, OAuth 2.1 with dynamic client registration — the person adds the URL in Claude or ChatGPT and signs in as their own Studio identity. On localhost, a bearer token. No second identity provider, no token minting UI.
  • Security you already have: every call runs as the signed-in person, under the same row-level security and the same roles as the Studio UI. scopes on the server and per tool only narrow which tools are visible; data access is never wider than the caller.
  • A data tier for free: ten generic operations (query_nodes, get_node, search_nodes, list_children, create_node, update_node, delete_node, move_node, reorder_node, list_workspaces) over the workspaces you list, with zero code.
  • Resources: one browsable root per exposed workspace (raisin://stories/) plus a template raisin://{workspace}/{+path}, so a host can pin a page as context without a tool call.
  • Schema from your functions: a tool’s description, input schema and output schema come from the bound function. Declare them once.
  • Widgets: a tool can render as an interactive mini-app in the chat, following the MCP Apps standard, from a single HTML asset you ship in the package.

1. The server node

One directory under content/mcp/, whose name must equal the node’s name — the installer derives the path from it, and a mismatch fails the whole package install.

# content/mcp/Catalogue/.node.yaml
node_type: raisin:McpServer
properties:
  name: Catalogue                    # == directory name, stored at /Catalogue
  slug: catalogue                    # the URL: /mcp/studio/main/catalogue
  version: 1.0.0
  public: false                      # callers must sign in
  scopes: [studio_admin]             # who sees this server at all
  instructions: >
    You manage Acme's product catalogue. Read with list_products and get_product;
    change data only through set_price and set_stock, never with update_node.
  data:
    workspaces: [products, assets]   # the generic tier, and resources, cover these
    operations: [query_nodes, get_node, search_nodes, list_children, list_workspaces]
  tools:
    - function: /lib/acme/mcp-list-products
      name: list_products
    - function: /lib/acme/mcp-get-product
      name: get_product
    - function: /lib/acme/mcp-set-price
      name: set_price
      scopes: [catalogue_manager]    # narrower than the server

Three decisions live here:

  • data is one policy for the whole server. Every workspace listed gets every operation listed. That is why Events is a second server rather than more tools on Studio: exposing the ticketing workspace there would hand every caller delete_node over orders. Put what needs typed access in its own server with a read-only generic tier, or no generic tier at all, and reach it only through your tools.
  • instructions is the model’s briefing. It is where you teach the domain model and steer the model between your typed tools and the generic ones. The Studio server’s runs to a hundred lines; it is the single most effective place to improve an agent’s behaviour.
  • List only operations the engine knows. An unknown name in data.operations fails initialize and takes the entire server offline, not just one tool.

2. A tool is a function

Every tool binds to a raisin:Function by its full path. The function’s description becomes the tool description, its input_schema the arguments, and its output_schema makes the result structured content the host and a widget can read.

# content/functions/lib/acme/mcp-list-products/.node.yaml
node_type: raisin:Function
properties:
  name: mcp-list-products            # == directory name
  title: List products (MCP)
  description: >-
    List products with price and stock, newest first. Exposed as `list_products`.
    Call this before set_price so the path you change is one that exists.
  enabled: true
  language: javascript
  execution_mode: sync
  entry_file: index.js:handler
  input_schema:
    type: object
    additionalProperties: false
    properties:
      query: { type: string, description: Filter by name; empty lists everything. }
      limit: { type: number, default: 50 }
  output_schema:
    type: object
    properties:
      products: { type: array }
      count: { type: number }
      next_step: { type: string }
  resource_limits: { timeout_ms: 20000 }
// content/functions/lib/acme/mcp-list-products/index.js
async function handler(input) {
  const q = (input.query ?? '').trim();
  const rows = await raisin.sql.query(
    `SELECT path, properties FROM 'products' WHERE node_type = 'acme:Product'
       ${q ? "AND properties->>'title'::String ILIKE $1" : ''}
     ORDER BY properties->>'created_at'::String DESC LIMIT ${Number(input.limit ?? 50)}`,
    q ? [`%${q}%`] : [],
  );
  const products = rows.map((r) => ({ path: r.path, title: r.properties.title, price: r.properties.price, stock: r.properties.stock }));
  return { products, count: products.length, next_step: 'Call get_product with a path for details, set_price to change a price.' };
}

Rules that come from experience with the shipped servers:

  • Write descriptions in the imperative and name the tool. The model decides from the description alone; “call this before set_price” is doing real work.
  • Read back after a write and verify. A write that the server partly rejected still returns success at the SQL layer. The Studio tools re-read the node and fail naming the keys that did not land; do the same in yours.
  • Prefer typed writes over update_node. The generic tool cannot set an archetype and cannot validate your invariants. set_price can refuse a negative number; update_node cannot.
  • Shared code is a sibling library. Put helpers in a function directory with enabled: false next to your tools and import them with one ../, an explicit .js extension, and a single-line import … from. Deeper paths do not resolve, and a failed import is silent.
  • With an output_schema, the function’s logs are dropped. Test tools against the real endpoint and read the real error text.

Register every function in manifest.yaml, then claim both roots in .raisin-sync.yaml, or your later edits to tools[] silently never land:

filters:
  - { root: /functions, mode: replace }
  - { root: /mcp, mode: replace }      # tools[] must stay in lockstep with the functions

3. A widget: MCP Apps

A tool that returns structured content can render it as a mini-app inside the chat. The Studio server does this for show_stories, preview_page, show_brand, show_translations and review_publish: a card view of a page, a brand kit, a publish review with a button. Same widget in Claude and ChatGPT, no per-client code.

Three pieces:

The asset. A single self-contained HTML file — all script and style inlined — shipped as an asset in the package. Studio’s widgets are Svelte apps in packages/mcp-widgets/widgets/<name>/, built with vite-plugin-singlefile into content/assets/mcp-widgets/<name>/index.html. Any framework works as long as the output is one file.

The declaration. Declare the widget once on the server, then point tools at it:

  ui_resources:
    - id: catalogue-panel
      workspace: assets
      entry: /mcp-widgets/catalogue/index.html
      name: Catalogue panel
      prefersBorder: true
      csp:
        connectDomains: []            # the server's own origin is always allowed
        resourceDomains: []
  tools:
    - function: /lib/acme/mcp-list-products
      name: show_products             # the same function, bound twice: once as data, once as a view
      ui: { resource: catalogue-panel }

The engine mints the ui:// resource, serves the file, advertises the binding on the tool’s _meta.ui.resourceUri, and builds the host’s content-security policy from the csp block. You never write a ui:// string.

The widget code. The tool’s structured output arrives in the widget through @raisindb/mcp-ui-client; a kind field in every tool’s output_schema lets one widget serve several views:

import { onToolResult, callTool, updateModelContext } from '@raisindb/mcp-ui-client';

onToolResult((r) => { if (r.kind === 'products') render(r.products); });
button.onclick = async () => {
  await callTool('set_price', { path, price });        // through the host, as the signed-in user
  updateModelContext(`Price of ${path} set to ${price}.`);
};

Two rules the shipped widgets learned the hard way. Never hardcode the server origin: read window.__RAISIN_SERVER_ORIGIN__, which the engine injects, then fall back to the page origin. And end the description of a widget-rendering tool with a sentence telling the model the user already sees the result, so it does not narrate it again.

What hosts do with it today: Claude and ChatGPT render the widget; ChatGPT also honours frameDomains, which is how Studio’s preview_page can show the real site in an iframe with inline editing, while Claude currently refuses framed sites regardless of the declaration and the widget says so. Hosts cache widget files hard; restart the client after you redeploy one.

4. Deploy, verify, connect

raisindb deploy ./acme-package --repo studio --install         # functions + the server node
cd acme-package && raisindb sync . --push --force --yes --repo studio   # function bodies and tools[] edits

Ask the server itself what it exposes before trusting a client, because clients fetch the tool list once when they connect and cache it:

curl -s -X POST http://localhost:8080/mcp/studio/main/catalogue \
  -H "Authorization: Bearer $TOKEN" -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

Then connect: the URL appears in Studio’s Settings once the server is installed (see the MCP connector). In Claude or ChatGPT add it as a custom connector and sign in; in Claude Code or any headless client pass a bearer token of a user who holds the server’s scopes. Test as a real user, not with the operator’s superadmin token — that token bypasses row-level security and exercises a path production never takes.

What to build with it

  • A domain connector: your records (products, members, bookings) with typed reads and guarded writes, so an assistant can answer and act without ever touching raw nodes.
  • A narrow server for sensitive data: no generic tier, three tools, a scopes list of one role — the Events server’s ticketing pattern.
  • A view: a widget over data the assistant fetched, with one or two actions that call back through the host.
  • The app side: a Maravilla application can also ship an MCP server from an mcp/ folder with defineMcpTool, running as the app’s own signed-in user against its own database. Use that for the application’s data and the content-declared server for Studio’s; an assistant can hold both connectors at once.

Pilot documentation. Studio is in a partner pilot. Screens and YAML shown here are current as of the version noted on each page; tell your Maravilla contact when something does not match what you see.