Skip to content

Add MCP server (beacon-mcp): expose tables to Claude over streamable HTTP - #323

Merged
robinskil merged 9 commits into
mainfrom
features/mcp-server
Jul 1, 2026
Merged

robinskil merged 9 commits into
mainfrom
features/mcp-server

Conversation

@robinskil

@robinskil robinskil commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

What

Adds an in-tree MCP server so MCP clients (e.g. Claude) can discover and query beacon's tables over the Model Context Protocol. New beacon-mcp crate, mounted as a streamable-HTTP route (/mcp) in beacon-api using the official rmcp SDK (pinned =1.8.0).

Stacked on #304 (typed table extensions) — this branch contains those commits plus origin/main merged in (which brought Authentication & RBAC, #321). Merge #304 first and this PR's diff reduces to just the MCP work, or review together.

How it works

The MCP tool surface is generated from data, not hard-coded:

  • Generic tools: list_tables, describe_table, run_sql (read-only SELECT → JSON rows).
  • Per-table tools: every table whose mcp extension has enabled: true becomes its own tool, generated from the extension metadata — tool_nameTool.name, titleTool.title, descriptionTool.description, exposed_columns constrain the generated inputSchema, and preset names become an enum the model can pick (expanded to the stored filters at query time).

This is the payoff of the table-extensions work: curators add or shape MCP tools via SET EXTENSION / the admin REST API, with no code changes.

Auth — MCP is just another user, and strictly read-only

  • /mcp rides the same resolve_identity middleware as the client API: each request authenticates via Authorization (resolving to that user's roles), the anonymous principal when enabled, or a role-less identity otherwise. Per-user read RBAC applies.
  • Every tool call executes with is_super_user cleared, so the query planner rejects all DDL/DML for any caller (defense-in-depth) — MCP is read-only by design. Verified live: SELECT returns rows; CREATE TABLE is rejected.
  • Gated by BEACON_MCP_ENABLED (default on; set false to disable mounting).

Standards & validation

  • The mcp descriptor maps onto the MCP Tool standard, and every generated tool carries annotations.readOnlyHint: true.
  • tool_name is validated to MCP/Anthropic rules (1–64 chars [A-Za-z0-9_-]) so a bad name can't break a client's tools/list; generated defaults are sanitized.
  • Extension payloads parse strictly: typed PresetOp enum (= != < <= > >= between in) and serde(deny_unknown_fields) on all extension structs — unknown keys/operators are rejected with clear errors, not silently dropped.
  • Result rows are capped (1000) to keep tool output bounded.

Files

  • New beacon-mcp crate: server.rs (rmcp ServerHandler: dynamic list_tools/call_tool), catalog.rs (tool generation + preset→SQL), result.rs (query→JSON).
  • beacon-api/src/axum/router.rs: gated /mcp mount + resolve_identity layer.
  • docs/mcp.md: connection + behavior.

Tests

beacon-mcp unit tests (tool generation, preset/SQL rendering, escaping), beacon-core extension tests (strict parsing, schema + tool_name validation), and an end-to-end runtime test. Full workspace builds; affected-crate tests pass. Smoke-tested the live /mcp handshake → tools/listtools/call for both read (allowed) and write (rejected) under authenticated and unauthenticated requests.

Tables can now carry consumer-facing metadata, decoupled from their storage
definition: an MCP descriptor (how a downstream MCP server should surface the
table) and named query presets (predefined filter sets). Extensions are typed
and validated against the live table schema.

Storage is a `tables://<name>/extensions.json` sidecar, separate from
`table.json`, so extensions apply uniformly to every table type, can be edited
without rebuilding the provider, survive provider re-registration (MV refresh,
Iceberg alter), and are removed automatically on DROP TABLE.

Manage via SQL:
  SET EXTENSION '<kind>' FOR <table> TO '<json>'
  DROP EXTENSION '<kind>' FOR <table>
  SHOW EXTENSIONS FOR <table>

or REST: public GET /api/table-extensions; admin PUT/DELETE
/api/admin/table-extensions/{name}. All OpenAPI-documented.

Validation rejects unknown columns, unsupported operators, malformed
between/in values, and duplicate preset names.

Tests: extension validation + parser unit tests, a persistence round-trip and
cleanup test, and an end-to-end runtime test (CREATE TABLE -> SET/SHOW/DROP
EXTENSION -> validation rejection).
…le HTTP

Introduces a `beacon-mcp` crate that turns beacon into a Model Context Protocol
server, mounted at `/mcp` in beacon-api via rmcp's streamable-HTTP transport.
MCP clients (e.g. Claude) can discover tables and run read-only queries.

Tools are generated from the runtime:
- generic: `list_tables`, `describe_table`, `run_sql` (SELECT-only)
- one tool per table whose `mcp` table extension is enabled, with inputs derived
  from the extension metadata (exposed_columns -> `select`, presets -> a `preset`
  enum that expands to the stored filters), built on the table-extensions feature.

Execution runs through Runtime::run_query as a non-super-user (read-only), with
results serialized to JSON and capped to 1000 rows. Identifiers are quoted and
preset values rendered as SQL literals.

Pinned rmcp =1.8.0 (2.0.0 was one day old at time of writing).

Tests: catalog/SQL-builder unit tests (preset expansion, column-exposure
enforcement, value escaping). Verified end to end against a running server:
initialize -> tools/list -> tools/call run_sql returns JSON rows.

Docs: docs/mcp.md (tools, exposing a table, connecting Claude).
# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	beacon-api/Cargo.toml
#	beacon-api/src/axum/admin/mod.rs
#	beacon-api/src/axum/client/mod.rs
#	beacon-api/src/axum/client/tables.rs
#	beacon-core/src/lib.rs
#	beacon-core/src/parser/beacon_parser.rs
#	beacon-core/src/parser/statement.rs
#	beacon-core/src/runtime.rs
#	beacon-core/src/statement_plan/mod.rs
#	beacon-core/src/statement_plan/physical.rs
#	beacon-core/src/statement_plan/query_planner.rs
MCP tool execution now always runs with is_super_user cleared, so the query
planner rejects DDL/DML for any caller (defense-in-depth, independent of how the
identity was resolved). The caller's roles are preserved so per-user read grants
still apply. Docs updated.
Preset/MCP extension payloads now parse against a strict structure:
- PresetFilter.op is a typed PresetOp enum (= != < <= > >= between in),
  serialized with the symbolic spelling; any other operator is rejected at
  parse time with a clear error instead of only at schema-validation.
- All extension structs use serde(deny_unknown_fields), so typos/extra keys
  are rejected rather than silently dropped.

This guarantees stored extension JSON conforms to a known shape that consumers
can reliably parse. Updated tests + API re-export.
- Validate mcp tool_name against MCP/Anthropic rules (1-64 chars [A-Za-z0-9_-])
  so a bad name can't break a client's tools/list; sanitize generated defaults.
- Add optional `title` (maps to Tool.title) and emit annotations.readOnlyHint
  on every generated tool, matching the MCP Tool descriptor.
- McpExtension already parses strictly (deny_unknown_fields from prior change).
- Tests + docs updated.
@codecov

codecov Bot commented Jun 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 69.58119% with 414 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.53%. Comparing base (75d9eb6) to head (98fa84b).

Files with missing lines Patch % Lines
beacon-mcp/src/catalog.rs 49.38% 205 Missing ⚠️
beacon-mcp/src/result.rs 0.00% 41 Missing ⚠️
beacon-mcp/src/server.rs 0.00% 36 Missing ⚠️
beacon-core/src/extensions.rs 88.48% 35 Missing ⚠️
...-data-lake/src/table_runtime/schema_persistence.rs 77.04% 28 Missing ⚠️
beacon-core/src/runtime.rs 84.44% 14 Missing ⚠️
beacon-core/src/statement_plan/physical.rs 84.26% 14 Missing ⚠️
beacon-api/src/axum/admin/extensions.rs 0.00% 11 Missing ⚠️
beacon-core/src/parser/statement.rs 50.00% 11 Missing ⚠️
beacon-core/src/statement_plan/logical.rs 88.60% 9 Missing ⚠️
... and 3 more
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #323      +/-   ##
==========================================
- Coverage   74.71%   74.53%   -0.19%     
==========================================
  Files         284      290       +6     
  Lines       37089    38448    +1359     
==========================================
+ Hits        27712    28656     +944     
- Misses       9377     9792     +415     
Files with missing lines Coverage Δ
beacon-api/src/axum/admin/mod.rs 87.87% <100.00%> (+0.37%) ⬆️
beacon-api/src/axum/client/mod.rs 95.12% <100.00%> (+0.12%) ⬆️
beacon-api/src/axum/router.rs 76.41% <100.00%> (+1.96%) ⬆️
beacon-core/src/api.rs 69.51% <ø> (ø)
beacon-core/src/statement_plan/mod.rs 99.35% <100.00%> (+0.10%) ⬆️
beacon-core/src/statement_plan/query_planner.rs 84.21% <ø> (ø)
beacon-data-lake/src/lib.rs 95.65% <ø> (ø)
beacon-mcp/src/lib.rs 87.50% <87.50%> (ø)
beacon-core/src/parser/beacon_parser.rs 95.06% <96.29%> (+0.21%) ⬆️
beacon-api/src/axum/client/tables.rs 0.00% <0.00%> (ø)
... and 10 more

... and 2 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@robinskil robinskil self-assigned this Jul 1, 2026
robinskil added 3 commits July 1, 2026 09:56
exposed_columns entries may now be either a bare name or {name, description},
so curators can describe what each column means. Descriptions are folded into
the generated tool's 'select' help and returned by describe_table; the table's
own meaning continues via 'description' -> Tool.description. New ExposedColumn/
ColumnDoc types (strict-parsed), validation by column name, tests + docs.
describe_table and each per-table tool now present a merged per-column view —
name + data_type + nullable + description — scoped to exposed_columns (in order)
when set, or all columns otherwise. Descriptions come from the extension, with a
fallback to the Arrow field's description/comment metadata. The select parameter
help lists each column as 'name (type): meaning'. Adds resolve_columns() + test.
- docs/mcp.md: config/env vars, agent authentication (create user, header
  examples for Claude Code / Desktop / SDKs), Tool-standard mapping table, quick
  check.
- docs/mcp-architecture.md: request lifecycle, tool generation, identity flow +
  read-only enforcement, tool-call->SQL, result handling, extension points.
@robinskil
robinskil merged commit 475e1b5 into main Jul 1, 2026
4 of 5 checks passed
@robinskil robinskil mentioned this pull request Aug 17, 2026
63 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant