@lunora/mcp ships two Model Context Protocol
surfaces.
The deployment server (this package's main entry) exposes a deployed Lunora
app to AI agents. It registers tools for introspecting a deployment
(lunora_list_functions, lunora_list_tables, lunora_get_function_schema) and
invoking its functions (lunora_run_query, lunora_run_mutation,
lunora_run_action), each backed by @lunora/client
over HTTP RPC. It needs an admin token.
The documentation server (@lunora/mcp/docs) exposes the framework's docs,
so an agent writing Lunora code can look up the real API instead of inventing
one. It reads published documentation only, with no credentials and no writes,
which is why it can be hosted unauthenticated; Lunora runs it at
https://lunora.sh/mcp.
Most users never install this package. lunora mcp install wires both servers into whichever editor they use. See AI coding
agents.
pnpm add @lunora/mcpThe server is transport-agnostic. The shipped lunora-mcp binary speaks
JSON-RPC over stdio (the transport MCP clients use when they spawn a
process), or you can build a server with createLunoraMcpServer and connect
any transport yourself.
The lunora-mcp binary
MCP clients spawn the lunora-mcp binary and talk to it over stdio.
Configuration comes from the environment, so the spawn config stays a plain
{ command, env }:
LUNORA_URL(required): base URL of the deployed Worker.LUNORA_ADMIN_TOKEN(optional): bearer token sent on every RPC.LUNORA_MCP_ALLOW_WRITES(optional): set to1/true/yes/onto expose the mutation/action run tools. Default: read-only (writes disabled).LUNORA_MCP_ALLOW_AGENTS(optional): set to1/true/yes/onto expose the agent tools. Default: agents disabled.LUNORA_MCP_AGENTS(optional): a;-separated list ofname:descriptionpairs selecting which agents to expose (see Expose an agent).
{
"mcpServers": {
"lunora": {
"command": "lunora-mcp",
"env": {
"LUNORA_URL": "https://app.example.workers.dev",
"LUNORA_ADMIN_TOKEN": "...",
},
},
},
}The binary exits non-zero if LUNORA_URL is missing or the transport fails to connect, so the spawning client surfaces a startup failure immediately.
Exposed tools
Each tool maps onto a method LunoraClient already provides. TOOL_DEFINITIONS
is the registered tool surface; callTool dispatches a call against a client.
| Tool | Input | What it does |
|---|---|---|
lunora_list_functions | none | Lists the deployment's public functions (queries, mutations, actions) with their kinds. |
lunora_list_tables | none | Lists the deployment's .global() tables with their row counts. |
lunora_get_function_schema | functionPath | Returns one function's argument descriptors and kind, so a caller can build valid args. |
lunora_run_query | functionPath, args?, shardKey? | Runs a query and returns its result. Read-only. |
lunora_run_mutation | functionPath, args?, shardKey? | Runs a mutation and returns its result. Writes data. Requires writes enabled. |
lunora_run_action | functionPath, args?, shardKey? | Runs an action and returns its result. May call external services. Requires writes enabled. |
agent_<name> | prompt, threadKey?, title? | Starts a durable @lunora/agent run and awaits its answer. One tool per exposed agent. Requires agents enabled. |
lunora_agent_status | threadKey | Polls a running agent by threadKey and returns its answer once finished. Requires agents enabled. |
The server is read-only by default: only the introspection tools and lunora_run_query are exposed. lunora_run_mutation and lunora_run_action are
hidden from the advertised tool list and refused at dispatch unless you set LUNORA_MCP_ALLOW_WRITES (or pass allowWrites: true when building a server
programmatically). Grant a least-privilege token scoped to what the agent actually needs, never the deployment's admin token.
The three run-tools share one input schema:
functionPath(required, string): a function reference, e.g."messages:send".args(object): the arguments object passed to the function. Anything that isn't a plain object (including arrays) is coerced to an empty bag.shardKey(string): an optional shard key when the function is.shardBy()-partitioned.
Results are returned as MCP content text: the function's return value JSON,
or the JSON null literal for a void-returning mutation/action. Unknown tools
and thrown errors come back as isError tool results (not rejections), so the
calling model sees the failure as tool output.
Recommended agent flow
An agent discovers the surface before it calls anything:
lunora_list_functions: discover the available paths and their kinds.lunora_get_function_schema: fetch the argument descriptors for one path.lunora_run_query/lunora_run_mutation/lunora_run_action: call the function with a well-formedargsobject.
lunora_get_function_schema returns { path, kind, args }, where kind is
"query", "mutation", or "action" and args is an array of argument
descriptors (name, kind, optional, and optionally element or table).
It returns an isError result if the path doesn't exist.
Building a server programmatically
createLunoraMcpServer returns a transport-agnostic MCP Server. Pass a url
(and optional token), and the tools dispatch against a LunoraClient built
from them:
import { createLunoraMcpServer } from "@lunora/mcp";
const server = createLunoraMcpServer({ url: "https://app.example.workers.dev", token: "..." });
await server.connect(myTransport);For the common stdio case, connectStdio builds the server and connects it over
a StdioServerTransport in one step:
import { connectStdio } from "@lunora/mcp";
await connectStdio({ url: process.env.LUNORA_URL, token: process.env.LUNORA_ADMIN_TOKEN });LunoraMcpServerOptions accepts either a url (with optional token and
fetch) or a pre-built client; the latter is the injection point for tests.
You must supply one or the other; passing neither throws.
Auth and admin gating
The token (sourced from LUNORA_ADMIN_TOKEN for the binary) is set as the
client's auth token and sent as a bearer token on every RPC the tools make.
Gating is enforced by your deployment: the tools call ordinary queries,
mutations, and actions, so whatever auth those functions require applies
unchanged. Run-tools open no WebSocket (every call is plain HTTP RPC), so the
server is safe to run as a short-lived stdio process.
Connecting an MCP client
Any MCP client that can spawn a stdio server works. With the mcpServers config
above, an agent like Claude can call lunora_list_functions to discover the
deployment's surface, then lunora_run_query / lunora_run_mutation /
lunora_run_action to invoke it, passing functionPath (e.g.
"messages:list"), an args object, and an optional shardKey.
Resources and annotations
Both servers implement MCP tools. The documentation server additionally
exposes every page as an MCP resource (lunora-docs:/docs/…,
text/markdown), so a client can list and attach a page on the user's behalf,
before the model knows what to search for.
Every tool carries annotations (readOnlyHint, destructiveHint,
idempotentHint, openWorldHint, title), so a client can badge the read-only
surface and prompt before a write. These are hints for presentation; the
guarantee itself is still enforced at dispatch.
The documentation server
@lunora/mcp/docs is an independent surface with three tools:
| Tool | Description |
|---|---|
lunora_search_docs | Search the docs; returns matching pages and sections with their URLs. |
lunora_get_doc | Return one page in full, as Markdown. |
lunora_list_docs | List every page with its title and description. |
It touches no user data and needs no token, so it is safe to expose publicly.
Nothing in the subpath imports @lunora/client or a Node built-in, so it runs
unchanged on Workers, Netlify/Vercel functions, Deno, and Bun.
The tools read a DocsIndex, and two implementations satisfy that contract. A
docs site wires up its own in-process search index and mounts the server as a
route:
import { createDocsMcpFetchHandler } from "@lunora/mcp/docs";
const handle = createDocsMcpFetchHandler({ index: myDocsIndex });Anything else (the CLI's lunora mcp serve, a script) reads a published site
over its /api/search, /llms.mdx/*, and /llms.txt endpoints:
import { createDocsMcpServer, createRemoteDocsIndex } from "@lunora/mcp/docs";
const server = createDocsMcpServer({ index: createRemoteDocsIndex({ baseUrl: "https://lunora.sh" }) });Because both backends map results through the same helper, a model sees identical hits whichever one answered.
Hosting it safely
createDocsMcpFetchHandler screens each request before the transport sees it,
because this surface is meant to be public and unauthenticated:
- Bodies are capped: 128 KiB by default, overridable with
maxRequestBytes. - JSON-RPC batches are refused. The stateless transport buffers a whole
batch's replies into one response body, so a single small request carrying
thousands of
tools/callmessages would amplify into hundreds of megabytes out, with noinitializeand no session to rate-limit against. A documentation client gains nothing from batching. lunora_search_docsbounds itsquery, andlunora_list_docscaps how many pages it serialises in one call.
Composing a local server
createLocalMcpServer / connectLocalStdio assemble the docs tools, the
deployment tools, and any extra tools a host supplies into one stdio server.
This is what lunora mcp serve runs, and it is why the CLI depends only on this
package rather than on the protocol SDK.
import { connectLocalStdio } from "@lunora/mcp";
await connectLocalStdio({
deployment: () => readMyDevServer(),
docs: { baseUrl: "https://lunora.sh" },
extraTools: myLocalTools,
});deployment accepts a resolver, consulted on every tool call. An editor
spawns its MCP servers when the project opens, usually before a dev server is
running, and keeps them alive across every restart, so a URL captured once at
startup would be absent for the whole first session and stale after the first
restart. The deployment tools are advertised either way (clients cache the tool
list); calling one with nothing running returns an actionable error.
Expose an agent
A deployment's durable @lunora/agent agents can be
fronted as MCP tools. Like writes, this is opt-in and fail-closed: starting
an agent run is a side effect, so the agent tools are hidden from the advertised
list and refused at dispatch unless you enable them. @lunora/mcp takes no
dependency on @lunora/agent; it calls the agent's public agents:agentRun
mutation over HTTP RPC like any other function.
Enable it with two env vars (or the matching createLunoraMcpServer options):
LUNORA_MCP_ALLOW_AGENTS:1/true/yes/onto expose the agent tools.LUNORA_MCP_AGENTS: a;-separated list ofname:descriptionpairs selecting which agents to expose, e.g."support:Support questions;billing:Billing help".LUNORA_MCP_AGENT_TIMEOUT_MS(optional): wall-clock budget a singleagent_<name>call awaits before returning a pending result to poll.
{
"mcpServers": {
"lunora": {
"command": "lunora-mcp",
"env": {
"LUNORA_URL": "https://app.example.workers.dev",
"LUNORA_ADMIN_TOKEN": "...",
"LUNORA_MCP_ALLOW_AGENTS": "1",
"LUNORA_MCP_AGENTS": "support:Support questions;billing:Billing help",
},
},
},
}Each exposed agent gets an agent_<name> tool taking:
prompt(required, string): the task or message for the agent.threadKey(string): reuse to continue a conversation; omit to start a new thread. The tool returns thethreadKeyit used either way.title(string): an optional thread title, applied on the first run only.
The tool starts a durable run and awaits it up to the timeout budget. If the run
outlasts the budget, the tool returns a pending result ({ status: "running", threadKey, runId, hint }) instead of hanging. Feed that threadKey
to the generic lunora_agent_status tool to poll for the final answer once the
run finishes.
Agent runs are owner-scoped to the identity the configured token resolves to. Grant a least-privilege token mapped to a bot identity so each
deployment's threads stay isolated, never the deployment's admin token. If the token resolves to no identity, threads are not owner-isolated.