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.
scopeson 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 templateraisin://{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:
datais 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 callerdelete_nodeover 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.instructionsis 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.operationsfailsinitializeand 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_pricecan refuse a negative number;update_nodecannot. - Shared code is a sibling library. Put helpers in a function directory with
enabled: falsenext to your tools and import them with one../, an explicit.jsextension, and a single-lineimport … 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
scopeslist 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 withdefineMcpTool, 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.