Using
MCP server
applies to v0.5.1LuraDB ships an MCP server. It is opt-in, set up in the [mcp] section of luradb.toml, and listens on its own port, 3100 by default, at the path /mcp. Its tools list and describe domains, read keys and documents, search, run SQL queries and read health and metrics; further tools that write, and tools that create structure, are separate opt-ins. Every call runs with the permissions of the API key that signed in.
What MCP is
Section titled “What MCP is”The Model Context Protocol (MCP) is an open protocol with which a program calls the tools of another program and reads its resources. The program that calls is the client: an assistant, an agent, a script or any program that speaks MCP. The program that offers tools is the server, here LuraDB. This page describes what LuraDB offers to any client, and nothing on it depends on a particular one; MCP with Claude is a client guide that builds on it.
LuraDB offers two of the things MCP defines: tools, which a client calls with arguments and which return a result, and resources, which a client reads by address. The messages are JSON-RPC 2.0, sent over HTTP.
Turning it on
Section titled “Turning it on”The listener is off until you enable it:
[mcp]enabled = trueAfter a restart, the log names the listener:
INFO luradb::server: MCP listening on http://127.0.0.1:3100/mcp| Setting | Default | What it does |
|---|---|---|
mcp.enabled |
false |
Starts the listener. |
mcp.port |
3100 |
The port, on server.bind_address. It must differ from the REST ports. |
mcp.read_only |
true |
Offers only the tools that read. With false, the tools that write are offered too. |
mcp.allow_ddl |
false |
With mcp.read_only false, also offers the tools that create domains, tables, views and indexes. |
mcp.allowed_origins |
[] |
The origins a browser-based client may use. |
mcp.oauth_enabled |
false |
Lets clients sign in with OAuth. |
mcp.public_url |
empty | The address clients reach the listener at; needed for OAuth. |
The listener has its own port, so you can expose, firewall and proxy it separately from the REST API. It runs on a thread of its own and takes no frontend worker, see Multicore. With server.tls_enabled on, the MCP listener serves HTTPS only, with the same certificate. With auth.enabled off, every client that reaches the port has full access and the server warns at start: mcp.enabled is true and auth.enabled is false — any client that reaches the MCP port has full access. Keep authentication on, and put TLS in front of the listener before you reach it from beyond the host.
The endpoint speaks Streamable HTTP
Section titled “The endpoint speaks Streamable HTTP”The transport is the MCP Streamable HTTP transport: the client sends each JSON-RPC message in a POST to /mcp, and the answer comes back as an event stream with the message in a data: line. The Accept header has to list both application/json and text/event-stream; with only one, or none, the answer is 406. The first message is initialize; its answer carries the header Mcp-Session-Id, which the client sends on every later request. A request without a session answers 400 Bad Request: Session ID is required. The client and the server agree on a protocol version in initialize: for 2024-11-05 and 2025-06-18 the server answers with the same version, for a newer one it answers with the latest it knows, 2025-11-25. The header MCP-Protocol-Version is optional on this server. After initialize the client confirms with the notification notifications/initialized, which is answered 202. DELETE /mcp with the session id ends the session.
A walk-through with curl. The function sends one message and prints the JSON-RPC answer:
mcp() { curl -s -X POST http://127.0.0.1:3100/mcp \ -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" -H "MCP-Protocol-Version: 2025-06-18" \ -H "Mcp-Session-Id: $SID" -d "$1" | sed -n 's/^data: \({.*\)/\1/p'}
SID=$(curl -si -X POST http://127.0.0.1:3100/mcp \ -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' \ | grep -i '^mcp-session-id' | awk '{print $2}' | tr -d '\r')mcp '{"jsonrpc":"2.0","method":"notifications/initialized"}'mcp '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"whoami","arguments":{}}}'The answer to initialize names the server, its capabilities and a short instruction for the client; the call of whoami answers:
{ "jsonrpc": "2.0", "id": 1, "result": { "protocolVersion": "2025-06-18", "capabilities": { "resources": {}, "tools": {} }, "serverInfo": { "name": "luradb", "version": "0.5.1" }, "instructions": "LuraDB: key-value, JSON document and relational (LuraSQL) domains. Start with list_domains, then describe the domain, its tables or indexes. Every call runs with the permissions of the caller's API key." } }{ "jsonrpc": "2.0", "id": 2, "result": { "content": [{ "type": "text", "text": "{\"name\":\"admin\",\"role\":\"Admin\"}" }], "structuredContent": { "name": "admin", "role": "Admin" }, "isError": false } }The server offers tools and resources; it has no prompts. A client that is a program of your own can use any MCP library for these steps.
Signing in
Section titled “Signing in”With auth.enabled on, every request to /mcp carries Authorization: Bearer <token>. The token is an API key or, with OAuth on, an access token. Without a token the answer is 401 with the header WWW-Authenticate: Bearer; with a token that is unknown, removed or rotated it is 401 with WWW-Authenticate: Bearer error="invalid_token". The check is repeated for every call, so a key that is rotated or removed stops working in the middle of a session.
With an API key
Section titled “With an API key”The client sends the key as a Bearer token. This is the simplest setup, for every client that lets you set a header, and it needs nothing on the server beyond mcp.enabled. Give the client a key of its own that holds the permissions it needs and no more, see Security.
With OAuth
Section titled “With OAuth”Some clients can sign in only through OAuth and offer no field for a key. With mcp.oauth_enabled on, LuraDB is its own authorization server: the user signs in once, on a LuraDB page, with an API key, and the client receives tokens that are limited to MCP. The key never reaches the client. OAuth needs auth.enabled and mcp.public_url, and the server refuses to start without them:
[mcp]enabled = trueoauth_enabled = truepublic_url = "https://db.example.com:3100"The client signs in without ever holding the API key
Section titled “The client signs in without ever holding the API key”The endpoints, all on the MCP port:
| Path | Purpose |
|---|---|
/.well-known/oauth-protected-resource |
Metadata of the protected resource (RFC 9728): the resource <public_url>/mcp and its authorization server. |
/.well-known/oauth-authorization-server |
Metadata of the authorization server (RFC 8414): the other endpoints. |
/oauth/register |
Dynamic client registration (RFC 7591). |
/oauth/authorize |
The sign-in page. |
/oauth/token |
Exchanges a code or a refresh token for tokens. |
/oauth/revoke |
Revokes a token. |
A request to /mcp without a token points to the metadata in its 401:
HTTP/1.1 401 Unauthorizedwww-authenticate: Bearer resource_metadata="http://127.0.0.1:3100/.well-known/oauth-protected-resource"{ "authorization_servers": ["http://127.0.0.1:3100"], "bearer_methods_supported": ["header"], "resource": "http://127.0.0.1:3100/mcp", "scopes_supported": []}The client registers itself. Only public clients register (token_endpoint_auth_method is none), with a client_name of up to 100 characters and one to ten redirect URIs of up to 2048 characters that are https:// or http:// on localhost, 127.0.0.1 or ::1:
curl -s -X POST http://127.0.0.1:3100/oauth/register \ -H "Content-Type: application/json" \ -d '{"client_name": "Example client", "redirect_uris": ["http://127.0.0.1:8765/callback"], "token_endpoint_auth_method": "none", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"]}'{ "client_id": "f6917829e4d5c654181775afe8add032d4d00ea2f81f59128abda86f6d21871e", "client_id_issued_at": 1793746034, "client_name": "Example client", "grant_types": ["authorization_code", "refresh_token"], "redirect_uris": ["http://127.0.0.1:8765/callback"], "response_types": ["code"], "token_endpoint_auth_method": "none"}Then the client sends the user’s browser to /oauth/authorize with response_type=code, its client_id, a registered redirect_uri, a PKCE challenge (code_challenge with code_challenge_method=S256, the only method), a state and resource set to <public_url>/mcp. The page the user sees is plain HTML:
Sign in to LuraDB
Example client asks for access to this LuraDB with the permissions of your API key;it will be sent back to 127.0.0.1:8765.
API key [ ••••••••••••• ][ Allow access ] [ Deny ]With a key that is not valid, the page comes back with The API key is not valid. After Allow access the browser is redirected to the registered address with a one-time code that lives 60 seconds, and the state of the client. After Deny it is redirected with error=access_denied. The form accepts a request only from the origin of mcp.public_url, and five failed sign-ins from one sender within 15 minutes block that sender with 429. The client then trades the code and the verifier of its challenge for tokens:
curl -s -X POST http://127.0.0.1:3100/oauth/token \ -d grant_type=authorization_code -d code=$CODE -d code_verifier=$VERIFIER \ -d client_id=$CLIENT_ID -d redirect_uri=http://127.0.0.1:8765/callback \ -d resource=http://127.0.0.1:3100/mcp{ "access_token": "lura_at_21d61aeda2c6466b22fda00897a16a9d0cc40a96eb9a07e7a8ca5a07d7f10f94", "expires_in": 3600, "refresh_token": "lura_rt_eb0ac81f0b4c8d9c1612917a5711f57c3c7644d8018008a292628890bb0bdb74", "token_type": "Bearer"}The access token is valid for 3600 seconds, the refresh token for 30 days. Each use of a refresh token returns a new pair; using a code or a refresh token a second time answers invalid_grant and revokes the whole grant. The access token works on the MCP port only: the REST API answers 401 to it. A token carries no rights of its own. It stands for the account and the API key of the sign-in, so it has the rights of that account, and it stops working when the account is deleted, its key is rotated, or mcp.public_url changes. The limits and lifetimes are collected on Limits and defaults.
Behind a reverse proxy under a path
Section titled “Behind a reverse proxy under a path”Clients reach LuraDB under the name in mcp.public_url. When a proxy publishes the listener under a path, for example mcp.public_url set to "https://db.example.com/luradb", every address in the metadata gets that path, and the listener itself still serves its endpoints at its root. The proxy therefore has to strip the prefix /luradb from the requests it forwards, except for two metadata paths, which it must pass on unchanged, with /luradb still in them:
| Request to the proxy | Forwarded to the listener as |
|---|---|
/luradb/mcp, /luradb/oauth/… |
/mcp, /oauth/… |
/.well-known/oauth-authorization-server/luradb |
the same path |
/.well-known/oauth-protected-resource/luradb/mcp |
the same path |
A client looks for the metadata below /.well-known/ with the path appended, as RFC 8414 and RFC 9728 define. The proxy should also forward X-Real-IP or X-Forwarded-For, and the proxy must be listed in proxy.trusted_proxies, so that the limits of the sign-in page count the real senders. Use https outside tests: the sign-in page takes an API key and the tokens are bearer tokens.
Browser-based clients
Section titled “Browser-based clients”Programs and desktop clients send no Origin header and are not affected by mcp.allowed_origins. A web application that talks to /mcp from the browser has to be listed there, exactly as scheme://host[:port]; a request with another Origin answers 403 Forbidden: Origin is not allowed. The listener sends no CORS headers; a proxy has to add them for a page of another origin.
The server has a catalog of 26 tools, and offers part of it, depending on mcp.read_only and mcp.allow_ddl. A tool adds no behavior of its own: each call becomes the REST requests that do the work, run through the same layers with the caller’s Authorization header. The counts, measured on v0.5.1:
| Settings | Tools offered |
|---|---|
mcp.read_only true (the default) |
17: the tools that read. mcp.allow_ddl has no effect, and the start logs mcp.allow_ddl has no effect while mcp.read_only is true. |
mcp.read_only false |
23: plus the six that write. |
mcp.read_only false, mcp.allow_ddl true |
26: plus the three that create structure. |
metrics and create_domain use routes for admins, so they are listed only for an admin key. With a user key, the server offers two fewer: 15, 21 or 24.
The tools that read:
| Tool | What it does | Needs |
|---|---|---|
whoami |
Name and role of the key. | Any valid key. |
list_domains |
The domains of all engines, or of one with engine; each entry names its engine, and engines that are switched off are named under unavailable_engines. |
Any valid key; it lists the domains the key has a permission on. |
describe_domain |
Details of one domain: engine and name. |
read on the domain. |
metrics |
The server metrics. | An admin key. |
list_tables, describe_table |
Tables of a relational domain; columns, indexes and references of one table. | read on the relational domain. |
list_views |
Views of a relational domain, with their SQL. | read on the relational domain. |
list_json_indexes |
The indexes of a JSON domain. | read on the JSON domain. |
health |
The health report of the server and its shards. | Any valid key. |
kv_get |
The value of a key as {"value": …}. |
read on the key-value domain. |
kv_scan, kv_count |
Keys, with prefix, contains, limit, offset; the number of keys. |
read on the key-value domain. |
json_get, json_list, json_search, json_count |
A document; a page of documents, or only their keys; documents matching a filter on indexed fields; the number of documents. |
read on the JSON domain. |
sql_query |
Runs one SELECT, with params and expand. Every other statement is refused. |
read on the relational domain. |
The tools that write, offered with mcp.read_only at false:
| Tool | What it does | Needs |
|---|---|---|
kv_put |
Sets the value of a key, with optional ttl, and replaces any value. |
write on the key-value domain. |
kv_delete, kv_set_null |
Deletes a key; sets it to the null state. | write on the key-value domain. |
json_put, json_delete |
Creates or replaces a document; deletes one. | write on the JSON domain. |
sql_execute |
Runs one INSERT, UPDATE or DELETE. |
write on the relational domain. |
The tools that create structure, offered with mcp.read_only at false and mcp.allow_ddl at true:
| Tool | What it does | Needs |
|---|---|---|
create_domain |
Creates a domain of the engine kv, json or rel. |
An admin key. |
create_json_index |
Creates an index on a field of a JSON domain, with its type. | write on the JSON domain. |
sql_ddl |
Runs one CREATE TABLE, CREATE INDEX, CREATE VIEW or ALTER TABLE with ADD COLUMN, RENAME COLUMN or RENAME TO. |
ddl on the relational domain. |
No tool deletes a domain, a table, a view or an index, and none drops a column: sql_ddl refuses DROP TABLE, DROP INDEX, DROP VIEW and ALTER TABLE … DROP COLUMN before the statement reaches the SQL route, whatever the permission of the key, and each SQL tool refuses the statements of the others. There are no tools for users, permissions, backups, bulk loads, re-indexing or watches; those stay with the REST API.
The tools carry annotations that a client can use for its own confirmation dialogs: the tools that read are marked readOnlyHint: true; the tools that write, and sql_ddl, which can rename, are marked as not read-only and destructive; create_domain and create_json_index are marked as not read-only and not destructive.
A tool call and its result
Section titled “A tool call and its result”A call names the tool and gives its arguments. A result carries the REST answer twice, as text in content and as an object in structuredContent, and isError says whether the call failed. A list that the REST API answers as an array comes back as {"result": [...]}:
mcp '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"kv_get","arguments":{"domain":"sessions","key":"s-4f2a"}}}'mcp '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"sql_query","arguments":{"domain":"orders","sql":"SELECT id, customer_id, total FROM purchases WHERE status = ?","params":["open"],"expand":["customer_id"]}}}'{ "jsonrpc": "2.0", "id": 3, "result": { "content": [{ "type": "text", "text": "{\"value\":\"{\\\"user\\\": \\\"jane\\\"}\"}" }], "structuredContent": { "value": "{\"user\": \"jane\"}" }, "isError": false } }{ "jsonrpc": "2.0", "id": 4, "result": { "content": [{ "type": "text", "text": "{\"columns\":[…],\"expanded\":{\"customer_id\":[{\"email\":\"omar@example.com\",\"id\":2,\"name\":\"Omar Haddad\",\"town\":\"Tunis\",\"vip\":false}]},\"limit_applied\":false,\"row_count\":1,\"rows\":[[3,2,120.0]]}" }], "structuredContent": { "columns": ["…"], "expanded": { "customer_id": [{ "email": "omar@example.com", "id": 2, "name": "Omar Haddad", "town": "Tunis", "vip": false }] }, "limit_applied": false, "row_count": 1, "rows": [[3, 2, 120.0]] }, "isError": false } }kv_get returns the value exactly as stored, in value. A key in the null state returns {"value": null}, and a value that is not valid UTF-8 comes back base64url-encoded with "encoding": "base64url". The results of writes carry the status of the REST answer, for example {"status": 200} for kv_put and {"status": 204} for kv_delete.
Resources
Section titled “Resources”Besides the tools the server offers two resources, read-only and returned as JSON:
| Address | Content |
|---|---|
luradb://domains |
The domains of all engines the key may access, as list_domains returns them. |
luradb://{engine}/{name}/schema |
The schema of a domain: tables and views of a relational domain, indexes of a JSON domain, domain for a key-value domain. engine is kv, json or rel. |
A client lists them with resources/list and resources/templates/list and reads one with resources/read:
mcp '{"jsonrpc":"2.0","id":5,"method":"resources/read","params":{"uri":"luradb://json/orders/schema"}}'mcp '{"jsonrpc":"2.0","id":6,"method":"resources/read","params":{"uri":"luradb://rel/nope/schema"}}'{ "jsonrpc": "2.0", "id": 5, "result": { "contents": [{ "uri": "luradb://json/orders/schema", "mimeType": "application/json", "text": "{\"indexes\":[{\"created_at\":1793745837,\"field\":\"status\",\"type\":\"string\"},{\"created_at\":1793745837,\"field\":\"total\",\"type\":\"number\"},{\"created_at\":1793745837,\"field\":\"shipping.city\",\"type\":\"string\"},{\"created_at\":1793745837,\"field\":\"customer\",\"type\":\"string\"}]}" }] } }{ "jsonrpc": "2.0", "id": 6, "error": { "code": -32002, "message": "HTTP 404 Not Found: domain 'nope' not found" } }The permissions of the key apply
Section titled “The permissions of the key apply”MCP never gives more than the same key gives over REST. The server checks the credential and then runs the tool’s REST requests in the same layers as any request, so a permission, a rate limit, a size limit or a refusal of the resource guard answers as it does over REST. With a user that may read sessions and write the JSON and relational domain orders:
| Call | Result |
|---|---|
kv_get in sessions |
The value. |
kv_put in sessions |
Error: HTTP 403 Forbidden: Forbidden. The level is read. |
kv_get in default |
Error: HTTP 403 Forbidden: Forbidden. No permission on the domain. |
json_put in orders |
The document. |
create_json_index on orders |
The index; creating an index needs write on the JSON domain. |
sql_ddl in orders |
Error: HTTP 403 Forbidden: Forbidden. ddl is needed, the user has write. |
create_domain, metrics |
Not listed for the user; a direct call answers HTTP 403 Forbidden: Forbidden. |
The switches narrow what MCP offers; they do not widen the rights of a key. mcp.read_only true guarantees that no MCP tool can change data, whatever key the client holds. A tool sends no If-Match or If-None-Match, so a write to a domain created with require_conditional_writes is refused with 428, see Consistency and conditional writes. While writes are blocked for lack of disk space or memory, the write tools answer 507, see Resource guard. Run the clients that explore your data with mcp.read_only true, and switch to false only for clients you trust with writes.
Limits and errors
Section titled “Limits and errors”A request body to /mcp can be up to 4 MiB; a larger one answers 413 once the credential is accepted. The result of a tool or a resource can be up to 1 MiB; a larger result comes back as a tool error that tells the client to reduce the limit or narrow the request.
There are two kinds of failure. A tool that fails returns a result with isError: true and one line of text: the REST status and message as HTTP 404 Not Found: …, or a message of the tool itself. A request the protocol cannot serve returns a JSON-RPC error.
| Case | What the client gets |
|---|---|
| The REST route refuses: no right, missing key or domain, a conflict | Tool error: HTTP 403 Forbidden: Forbidden, HTTP 404 Not Found: 404 Not Found: key 'nope' not found |
| A missing or mistyped argument | Tool error: missing argument 'key', argument 'limit' must be of type integer |
| A statement for the wrong tool or a drop | Tool error: this statement does not belong to this tool; use sql_execute, dropping a table, an index or a view is not available over MCP, dropping a column is not available over MCP |
| A SQL syntax error | Tool error: HTTP 400 Bad Request: syntax error at position 7: … |
| A result over 1 MiB | Tool error: the result is larger than 1 MiB, and the text tells the client to reduce the limit or narrow the request. |
| A tool that does not exist, or is not offered with the current switches | JSON-RPC error -32602: tool 'create_domain' is not available |
| A method the server does not know | JSON-RPC error -32601 |
| A resource that does not exist | JSON-RPC error -32002: HTTP 404 Not Found: domain 'nope' not found |
| No session, or no valid credential | 400 Session ID is required; 401 with WWW-Authenticate |
Connecting a client
Section titled “Connecting a client”A client needs the URL of the endpoint, a credential and a network path to the port.
- The URL is
http://host:3100/mcp, orhttps://…when TLS is on or a proxy ends it. With OAuth, the host and path ofmcp.public_urlfollowed by/mcp. - The header, for a client that can set one:
Authorization: Bearer <API key>, on every request. - OAuth, for a client that cannot: no header and no key. The client sends the first request without a token, follows the
resource_metadataaddress of the401, registers, and sends the user to the sign-in page as in the drawing above; the user types the key, and the client stores the tokens and renews them with the refresh token. When a token is refused, the client signs in again. - A network path. A client that runs on the user’s computer can use
http://127.0.0.1:3100/mcp. A client that connects from the servers of a service provider cannot reach a private address: it needs a publichttps://URL, which means a reverse proxy with a certificate in front of the listener, andmcp.public_urlset to the address of the proxy.
Step-by-step setups for particular clients are separate pages, starting with MCP with Claude.