Model Context Protocol
The Model Context Protocol lets any agent host call tools a server offers. The RisingWave MCP server offers more than a hundred of them over a database whose views are seconds behind reality. This guide explains what the server exposes, how to scope it so an agent can only read what it should, how to make views discoverable, and how to connect Claude Desktop, Cursor, VS Code and your own Python agent.
Last reviewed 2026-11-09. Covers risingwave-mcp 0.1 and RisingWave 2.6 and later.
Companion guide: Real-Time Context for AI Agents →
The Protocol
Before MCP, every agent that needed data got a hand-written tool: a function in the agent's code, a description for the model, a deployment when the query changed. The Model Context Protocol moves that boundary. A host (Claude Desktop, Cursor, VS Code, or your own agent process) connects to a server, asks it to list its tools with JSON schemas, gives those definitions to the model, and executes whatever calls the model makes. The server is a separate program with its own credentials, and any host can use it.
For a database, that means the question shifts from "which queries do I wrap as tools?" to "which data should this agent be able to read, and how does it find out what is there?" The RisingWave MCP server answers the first half with a query tool that accepts SELECT and WITH statements only, plus schema tools for discovery, and leaves the permission boundary to the database user it connects as. The second half, discoverability, is yours to design, and the reference implementation below shows one way.
What a streaming database adds is that the tool results are current. A materialized view in RisingWave is maintained incrementally as Kafka events and CDC changes arrive, so a tool call that reads it returns the state of a shipment, an account or a hub as of a second ago. Against a warehouse the same call returns the last load; against an application database it returns raw rows the model must join itself.
| Concern | Hand-written tools in the agent | RisingWave MCP server |
|---|---|---|
| How the model learns what data exists | You write a tool and a description per query | The host lists tools; the agent reads the view catalog |
| Adding a new context object | New code, new deploy of the agent | New view plus one catalog row; nothing redeployed |
| Sharing with Claude Desktop, Cursor, VS Code | Not possible without an MCP wrapper | A few lines of JSON in the host's config |
| Operating the pipeline from the same chat | Separate tooling | Jobs, backfill, fragments and explain tools in the same server |
| Permission boundary | Whatever the application enforces | A database user, enforced inside RisingWave |
| Token cost of the tool list | Only your tools | 100+ tools unless the host or your agent allowlists |
The Server
risingwave-mcp is an open-source Python server built on FastMCP. It connects to RisingWave with a PostgreSQL connection string and registers more than 100 tools. When run from source it speaks stdio by default; the Docker image listens on port 8000 for streamable HTTP at /mcp, and SSE is available for hosts that still need it. Three environment variables choose the transport, listen address and port; the rest configure the connection.
The tools fall into groups, and the groups map cleanly onto who should hold them. The table lists the ones you will meet first. The complete list is in the repository README.
| Group | Examples | Who should have it |
|---|---|---|
| Query | run_select_query (SELECT and WITH only), table_row_count, get_table_stats | Every agent |
| Schema | list_materialized_views, describe_materialized_view, show_create_materialized_view, list_sources, list_sinks, list_indexes | Every agent, for discovery |
| Docs | search_docs, get_doc_section, list_doc_sections over bundled RisingWave documentation | Agents that write SQL |
| Explain | explain_query, explain_analyze, explain_distsql | Engineering assistants |
| DDL and DML | create_materialized_view, execute_ddl_statement, insert_multiple_rows, drop_table | Platform users only; blocked for agents by GRANT |
| Streaming and cluster | show_jobs, get_backfill_progress, list_fragments, get_actor_distribution, show_cluster | On-call and platform assistants |
| Storage and catalog | get_table_storage_stats, get_compaction_group_summary, query_system_catalog | Operators |
Two details matter for safety. First, run_select_query checks that the statement begins with SELECT or WITH and refuses anything else before it reaches the database. Second, the server has no read-only mode of its own: the DDL and DML tools are always registered. The boundary that holds is the database user in the connection string, which is why the setup below starts with GRANT rather than with the server.
Reference Implementation
The scenario is a parcel network. Scan events stream from hubs through Kafka; shipments and hub service levels live in PostgreSQL. An operations agent answers "where is this shipment and will it be late?" from inside Claude Desktop or a Slack bot, and a platform assistant helps engineers inspect the pipeline. Both use the same server binary with different database users. Everything runs on a single-node RisingWave started with the Docker command from the companion guide.
Each view answers one question the agent will be asked. The latest-scan view uses the group Top-N pattern; the status view joins it with the shipment and the hub's dwell-time allowance to produce an on_track flag the model does not have to compute. The catalog table is the discovery mechanism: because COMMENT ON applies to tables and columns rather than materialized views, a plain-language row per view is the clearest thing to show a model.
-- Scenario: a logistics operations agent answering "where is shipment X and will it be late?"
CREATE SOURCE scan_events (
shipment_id VARCHAR, hub VARCHAR, scan_type VARCHAR,
next_hub VARCHAR, scanned_at TIMESTAMPTZ,
WATERMARK FOR scanned_at AS scanned_at - INTERVAL '30 seconds'
) WITH (connector = 'kafka', topic = 'scan_events',
properties.bootstrap.server = 'broker:9092')
FORMAT PLAIN ENCODE JSON;
CREATE SOURCE ops_pg WITH (
connector = 'postgres-cdc', hostname = 'pg', port = '5432',
username = 'rw', password = '...', database.name = 'ops', schema.name = 'public'
);
CREATE TABLE shipments (shipment_id VARCHAR PRIMARY KEY, customer_id BIGINT,
origin VARCHAR, destination VARCHAR, promised_by TIMESTAMPTZ,
service VARCHAR)
FROM ops_pg TABLE 'public.shipments';
CREATE TABLE hub_sla (hub VARCHAR PRIMARY KEY, max_dwell INTERVAL)
FROM ops_pg TABLE 'public.hub_sla';
-- One view per question the agent will be asked.
CREATE MATERIALIZED VIEW shipment_latest AS
SELECT shipment_id, hub, scan_type, next_hub, scanned_at
FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY shipment_id ORDER BY scanned_at DESC) AS rn
FROM scan_events) t
WHERE rn = 1;
CREATE MATERIALIZED VIEW shipment_status AS
SELECT s.shipment_id, s.customer_id, s.origin, s.destination, s.service, s.promised_by,
l.hub AS last_hub, l.scan_type AS last_scan, l.next_hub, l.scanned_at AS last_scan_at,
(l.scan_type <> 'delivered' AND l.scanned_at + h.max_dwell < s.promised_by) AS on_track
FROM shipments s
LEFT JOIN shipment_latest l ON l.shipment_id = s.shipment_id
LEFT JOIN hub_sla h ON h.hub = l.hub;
CREATE MATERIALIZED VIEW hub_load_15m AS
SELECT hub, window_start, window_end,
COUNT(*) FILTER (WHERE scan_type = 'arrived') AS arrivals,
COUNT(*) FILTER (WHERE scan_type = 'departed') AS departures
FROM HOP(scan_events, scanned_at, INTERVAL '1 minute', INTERVAL '15 minutes')
GROUP BY hub, window_start, window_end;
CREATE INDEX shipment_status_by_id ON shipment_status (shipment_id);
CREATE INDEX shipment_status_by_customer ON shipment_status (customer_id);
-- The catalog is how the agent discovers what to query. Plain language, one row per view.
CREATE TABLE agent_catalog (
view_name VARCHAR PRIMARY KEY, purpose VARCHAR, lookup_key VARCHAR, freshness VARCHAR
);
INSERT INTO agent_catalog VALUES
('shipment_status', 'Current location, last scan and on-track flag for one shipment',
'shipment_id or customer_id', 'Within a few seconds of the last scan'),
('hub_load_15m', 'Arrivals and departures per hub in sliding 15-minute windows',
'hub; use the row with the latest window_end', 'Window advances every minute'),
('shipment_latest', 'Raw latest scan per shipment; prefer shipment_status',
'shipment_id', 'Within a few seconds');GRANT on a materialized view supports SELECT, which is exactly what an agent should hold. With this user, every DDL and DML tool in the server fails inside RisingWave with a permission error, and run_select_query can only read these three relations. The platform assistant gets broader read access; neither gets CREATE.
-- One user per agent. The MCP server has DDL and DML tools; this is what makes them inert.
CREATE USER ops_agent WITH PASSWORD 'change-me';
GRANT SELECT ON MATERIALIZED VIEW shipment_status TO ops_agent;
GRANT SELECT ON MATERIALIZED VIEW hub_load_15m TO ops_agent;
GRANT SELECT ON TABLE agent_catalog TO ops_agent;
-- A second agent, a platform assistant for engineers, gets to read the catalog and explain plans.
CREATE USER platform_assistant WITH PASSWORD 'change-me-too';
GRANT SELECT ON ALL MATERIALIZED VIEWS IN SCHEMA public TO platform_assistant;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO platform_assistant;
-- Verify before handing out the connection string: as ops_agent, a write is refused.
-- psql -h localhost -p 4566 -U ops_agent -d dev -c "DELETE FROM shipments"
-- ERROR: permission denied for table shipmentsTwo ways to run it, for two kinds of client. Desktop hosts launch the server themselves over stdio, so the connection string lives in the user's host configuration. A shared agent service connects to one Docker container over streamable HTTP. Run one container per database user; the process is stateless, so this costs nothing but a port.
# Option A: from source, stdio. This is what desktop hosts launch.
git clone https://github.com/risingwavelabs/risingwave-mcp.git
cd risingwave-mcp && pip install -r requirements.txt # Python 3.10+
export RISINGWAVE_CONNECTION_STR="postgresql://ops_agent:change-me@localhost:4566/dev"
export MCP_TRANSPORT=stdio
python src/main.py
# Option B: Docker, streamable HTTP on :8000/mcp. One server, many clients.
docker run -p 8000:8000 \
-e RISINGWAVE_CONNECTION_STR="postgresql://ops_agent:change-me@host.docker.internal:4566/dev" \
-e MCP_TRANSPORT=streamable-http \
risingwavelabs/risingwave-mcp-server:0.1.0
# Individual parameters work too: RISINGWAVE_HOST, RISINGWAVE_PORT, RISINGWAVE_USER,
# RISINGWAVE_PASSWORD, RISINGWAVE_DATABASE, RISINGWAVE_SSLMODE. MCP_HOST and MCP_PORT set the listener.Each host has a JSON file where MCP servers are declared. Claude Desktop and VS Code launch a stdio subprocess; Cursor can do the same or connect to the HTTP endpoint. Once connected, the host lists the server's tools and most let the user switch individual tools on and off, which is where a desktop user applies the allowlist that the Python agent below applies in code.
// Claude Desktop: claude_desktop_config.json
{
"mcpServers": {
"risingwave-ops": {
"command": "python",
"args": ["/opt/risingwave-mcp/src/main.py"],
"env": {
"RISINGWAVE_CONNECTION_STR": "postgresql://ops_agent:change-me@localhost:4566/dev",
"MCP_TRANSPORT": "stdio"
}
}
}
}
// Cursor: .cursor/mcp.json (same shape; or point at a shared HTTP server)
{
"mcpServers": {
"risingwave-ops": { "url": "http://rw-mcp.internal:8000/mcp" }
}
}
// VS Code: .vscode/mcp.json
{
"servers": {
"risingwave-ops": {
"type": "stdio",
"command": "python",
"args": ["/opt/risingwave-mcp/src/main.py"],
"env": { "RISINGWAVE_CONNECTION_STR": "postgresql://ops_agent:change-me@localhost:4566/dev" }
}
}
}The MCP Python SDK connects to the HTTP endpoint with one class. The script lists the tools, reads the catalog, runs an indexed lookup and then shows what happens when a model tries a DELETE through the query tool: the server refuses it before RisingWave sees it, and the user's grants would refuse it after.
# pip install "mcp[cli]"
import asyncio
from mcp import Client
ALLOWED = {"run_select_query", "list_materialized_views",
"describe_materialized_view", "search_docs"}
async def main():
async with Client("http://localhost:8000/mcp") as mcp:
tools = (await mcp.list_tools()).tools
print(len(tools), "tools offered;", len([t for t in tools if t.name in ALLOWED]), "allowed")
# Discovery: the catalog tells the model which view answers which question.
cat = await mcp.call_tool("run_select_query",
{"query": "SELECT view_name, purpose, lookup_key FROM agent_catalog"})
print(cat.content[0].text)
# A context read: one indexed lookup on a view that is seconds old.
res = await mcp.call_tool("run_select_query", {
"query": "SELECT to_jsonb(s) FROM shipment_status s WHERE shipment_id = 'SHP-88213'"})
if res.is_error:
raise RuntimeError(res.content[0].text)
print(res.content[0].text)
# Anything that is not a SELECT is refused by the server before it reaches RisingWave.
bad = await mcp.call_tool("run_select_query", {"query": "DELETE FROM shipments"})
print(bad.content[0].text) # Error: Only SELECT/WITH queries are allowed ...
asyncio.run(main())Bridging MCP tools to a model is a short loop: filter the tool list, hand the definitions to the model, execute the calls it makes, feed the results back until it stops asking. The allowlist keeps two tools in the prompt instead of a hundred. The system prompt tells the model to read the catalog first and run one SELECT, which is the whole discipline of this page in two sentences.
# An agent loop that bridges MCP tools to the Claude API with an allowlist.
import asyncio, json, anthropic
from mcp import Client
ALLOWED = {"run_select_query", "describe_materialized_view"}
SYSTEM = """You are a logistics operations agent. Data lives in RisingWave views that are a few
seconds behind reality. First read agent_catalog to see which view answers the question, then
run one SELECT against it. Never guess a shipment's status; if the view has no row, say so."""
async def run(question: str) -> str:
llm = anthropic.Anthropic()
async with Client("http://localhost:8000/mcp") as mcp:
tools = [{"name": t.name, "description": t.description, "input_schema": t.input_schema}
for t in (await mcp.list_tools()).tools if t.name in ALLOWED]
messages = [{"role": "user", "content": question}]
while True:
resp = llm.messages.create(model="claude-sonnet-5-5", max_tokens=1024,
system=SYSTEM, tools=tools, messages=messages)
messages.append({"role": "assistant", "content": resp.content})
if resp.stop_reason != "tool_use":
return "".join(b.text for b in resp.content if b.type == "text")
results = []
for block in resp.content:
if block.type != "tool_use":
continue
out = await mcp.call_tool(block.name, block.input)
results.append({"type": "tool_result", "tool_use_id": block.id,
"content": out.content[0].text, "is_error": out.is_error})
messages.append({"role": "user", "content": results})
print(asyncio.run(run("Where is SHP-88213 and will it make its promised delivery?")))The platform assistant, or an engineer in psql, watches what the agent asks for. Lookups the agent repeats deserve an index; EXPLAIN shows whether they get one. SHOW JOBS and the table statistics catalog say whether the views are keeping up and how much state each holds. The same checks are available as MCP tools to the platform user, so an engineer can ask the assistant "why is shipment_status slow?" and get an answer grounded in the plan.
-- What is the agent running right now? Sessions and their current statements.
SHOW PROCESSLIST;
-- Lookups the agent repeats deserve an index; check the plan it gets.
EXPLAIN SELECT to_jsonb(s) FROM shipment_status s WHERE customer_id = 4711;
-- Is the view keeping up? Streaming jobs and their progress.
SHOW JOBS;
-- State size per view, to catch a context view that grew without bound.
SELECT * FROM rw_catalog.rw_table_stats ORDER BY total_key_count DESC;Sources for the above: the risingwave-mcp repository, the Model Context Protocol specification, the MCP Python SDK and the RisingWave GRANT and Top-N by group documentation.
Alternatives
MCP servers exist for most data systems now, and hand-written tools remain an option. The columns that separate them are how current the answers are, how much work the model has to do to get an answer, where the permission boundary sits, and how many pieces you run.
| Approach | Freshness | What the model does | Boundary | Pieces |
|---|---|---|---|---|
| RisingWave MCP server over materialized views | Seconds; views update incrementally | Reads a catalog, runs one SELECT per question | Database user per agent; server refuses non-SELECT in the query tool | RisingWave, one MCP server process |
| Generic PostgreSQL MCP server on the application database | Current, but raw OLTP rows | Must compose multi-table joins and aggregates itself | Database user; production database takes the load | Postgres, MCP server, read replica in practice |
| Warehouse MCP server (Snowflake, BigQuery, Databricks) | Minutes to hours, per load schedule | Text-to-SQL over wide schemas | Warehouse role; cost per query | Warehouse, loader, MCP server |
| Hand-written MCP tools, one per query (FastMCP or the TypeScript SDK) | Whatever the backing store provides | Sees only curated tools with good descriptions | Application code | Your server, its deployment, a backing database |
| Kafka or Confluent MCP servers (topics, schemas, Flink statements) | Raw events, current | Consumes messages; must aggregate and join itself | Kafka ACLs | Kafka, schema registry, MCP server; no queryable state |
| REST APIs wrapped as tools | Depends on the API's own caching | One tool per endpoint; no ad hoc questions | API auth | Each API, a wrapper per API |
These combine. A common production shape is the RisingWave server for live reads, a hand-written server for the actions the agent may take, and the host's own tools for files and the web. MCP is what lets them sit side by side in one agent without any of them knowing about the others.
Design Decisions
Views. A table is a fact source; a view is an answer. Give the agent the catalog and the views that answer its questions, and keep the raw tables for the platform user. The agent's SELECTs become one-row lookups, which are cheap, correct and easy to index. When an agent needs a question nobody anticipated, add a view, not a GRANT on the raw tables.
The server is stateless apart from its connection string, so run one process per database user: ops-agent server, platform-assistant server, each with its own credentials. A single shared server would have to carry the broadest permissions any client needs, which defeats the purpose of per-agent users. Processes are cheap; privileges are not.
stdio when the host runs on the same machine as the credentials, which is the desktop case: Claude Desktop, Cursor or VS Code launch the server and the connection string stays in the user's config. Streamable HTTP when the server is shared or the agent runs as a service: one Docker container behind your usual network controls, clients connect to /mcp. SSE exists for older hosts; do not build anything new on it.
As few as answer its job. The server offers more than 100 tools, and every tool definition costs prompt tokens on every turn and widens the space of wrong calls. Desktop hosts let users toggle tools; in your own agent, allowlist run_select_query plus two or three discovery tools. Add explain and streaming tools only for agents whose job is operating the pipeline.
From a catalog table you maintain, because RisingWave's COMMENT ON covers tables and columns, not materialized views, and because a sentence of purpose and a lookup key is worth more to a model than a column list. The agent reads agent_catalog first; describe_materialized_view fills in the types when it needs them. Keep the catalog honest when views change.
Not the production agent. Views are the contract between the data team and the agents; changing them is a deployment. Give DDL privileges to a platform user that engineers (or an engineering assistant under their supervision) use, and let the MCP server's create_materialized_view and explain tools shine there. Production agents hold SELECT.
When Not to Use This
Run RisingWave Cloud or a single Docker container, start the MCP server, connect a host.
Start Free