From 4ae0d3ec46662dc96400055b436bd8118844c59c Mon Sep 17 00:00:00 2001 From: Robin Kooyman Date: Wed, 1 Jul 2026 14:29:49 +0200 Subject: [PATCH 1/4] MCP: add export_query tool for large Parquet/Arrow/CSV results run_sql inlines rows (capped) for previews; export_query instead returns a fetch recipe -- the exact /api/query request plus a ready-to-run Python snippet -- so a script pulls the Parquet/Arrow/CSV directly from /api/query and the bulk bytes never enter the model's context. Read-only (SELECT/WITH) guard; parquet default, arrow (IPC) and csv supported. Tests + docs (user guide + architecture). --- beacon-mcp/src/catalog.rs | 113 +++++++++++++++++++++++++++++++++++++- docs/mcp-architecture.md | 11 +++- docs/mcp.md | 45 ++++++++++++++- 3 files changed, 166 insertions(+), 3 deletions(-) diff --git a/beacon-mcp/src/catalog.rs b/beacon-mcp/src/catalog.rs index 377b5db3..16620b85 100644 --- a/beacon-mcp/src/catalog.rs +++ b/beacon-mcp/src/catalog.rs @@ -18,7 +18,12 @@ use crate::result::{run_sql_to_json, MAX_ROWS}; /// Build the full tool list: generic tools + per-table tools from extensions. pub async fn build_tools(runtime: &Arc) -> anyhow::Result> { - let mut tools = vec![list_tables_tool(), describe_table_tool(), run_sql_tool()]; + let mut tools = vec![ + list_tables_tool(), + describe_table_tool(), + run_sql_tool(), + export_query_tool(), + ]; for table in runtime.list_tables() { let ext = runtime .get_table_extensions(table.clone()) @@ -48,6 +53,7 @@ pub async fn dispatch( .ok_or_else(|| anyhow::anyhow!("missing required 'sql' argument"))?; run_sql_to_json(runtime, sql.to_string(), identity).await } + "export_query" => export_query_recipe(&args), other => run_table_tool(runtime, other, &args, identity).await, } } @@ -100,6 +106,90 @@ fn run_sql_tool() -> Tool { )) } +fn export_query_tool() -> Tool { + read_only(Tool::new( + "export_query", + "Build a recipe to export a large read-only SELECT as a Parquet/Arrow/CSV file for use \ + in a Python script. Returns the exact /api/query request plus a ready-to-run Python \ + snippet; it does NOT run the query or return rows. Prefer this over run_sql when the \ + result is large.", + object_schema( + json!({ + "sql": { "type": "string", "description": "A read-only SELECT statement to export." }, + "format": { + "type": "string", + "enum": ["parquet", "arrow", "csv"], + "description": "Output file format (default parquet)." + } + }), + &["sql"], + ), + )) +} + +/// Build a "fetch recipe" for exporting a query as a file. MCP tool results are +/// model-context text, so we never stream the (potentially huge) file through the +/// model: instead we return the exact `/api/query` request and a Python snippet +/// the agent can drop into a script, which fetches the Parquet/Arrow/CSV directly. +fn export_query_recipe(args: &Map) -> anyhow::Result { + let sql = args + .get("sql") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("missing required 'sql' argument"))? + .trim(); + // MCP is read-only: only allow SELECT / WITH (CTE) exports. + let head = sql.split_whitespace().next().unwrap_or("").to_ascii_uppercase(); + anyhow::ensure!( + matches!(head.as_str(), "SELECT" | "WITH"), + "export_query only supports read-only SELECT queries" + ); + let format = args.get("format").and_then(Value::as_str).unwrap_or("parquet"); + anyhow::ensure!( + matches!(format, "parquet" | "arrow" | "csv"), + "unsupported format '{format}'; expected one of: parquet, arrow, csv" + ); + + let body = json!({ "sql": sql, "output": { "format": format } }); + let body_py = serde_json::to_string(&body)?; + let (imports, reader) = match format { + "parquet" => ("import io, requests, pandas as pd", "df = pd.read_parquet(io.BytesIO(resp.content))"), + "csv" => ("import io, requests, pandas as pd", "df = pd.read_csv(io.BytesIO(resp.content))"), + "arrow" => ( + "import io, requests, pyarrow.ipc as pa_ipc", + "df = pa_ipc.open_file(io.BytesIO(resp.content)).read_all().to_pandas()", + ), + _ => unreachable!(), + }; + let python = [ + imports.to_string(), + "BEACON_URL = \"http://localhost:5001\" # your beacon host".to_string(), + "AUTH = \"Bearer \" # or \"Basic \"; omit header if anonymous".to_string(), + format!( + "resp = requests.post(f\"{{BEACON_URL}}/api/query\", headers={{\"Authorization\": AUTH}}, json={body_py})" + ), + "resp.raise_for_status()".to_string(), + reader.to_string(), + "print(df.shape)".to_string(), + ] + .join("\n"); + + let recipe = json!({ + "note": "This does not run the query. POST `request.body` to /api/query; the response body IS the file. Send the same Authorization you use for MCP (Basic/Bearer), or omit it for anonymous access.", + "format": format, + "request": { + "method": "POST", + "path": "/api/query", + "headers": { + "Content-Type": "application/json", + "Authorization": "" + }, + "body": body + }, + "python": python + }); + Ok(serde_json::to_string_pretty(&recipe)?) +} + async fn list_tables_json(runtime: &Arc) -> anyhow::Result { let mut out = Vec::new(); for table in runtime.list_tables() { @@ -445,6 +535,27 @@ mod tests { } } + #[test] + fn export_query_recipe_builds_fetch_and_guards_writes() { + let mut args = Map::new(); + args.insert("sql".into(), Value::String("SELECT * FROM obs".into())); + args.insert("format".into(), Value::String("parquet".into())); + let out = export_query_recipe(&args).unwrap(); + assert!(out.contains("/api/query"), "recipe should reference the query endpoint"); + assert!(out.contains("read_parquet"), "parquet snippet should use read_parquet"); + assert!(out.contains("\"format\": \"parquet\"")); + + // WITH (CTE) is allowed; default format is parquet. + let mut cte = Map::new(); + cte.insert("sql".into(), Value::String("WITH x AS (SELECT 1) SELECT * FROM x".into())); + assert!(export_query_recipe(&cte).unwrap().contains("read_parquet")); + + // Non-SELECT is rejected (MCP is read-only). + let mut bad = Map::new(); + bad.insert("sql".into(), Value::String("DELETE FROM obs".into())); + assert!(export_query_recipe(&bad).is_err()); + } + fn field(name: &str, data_type: &str) -> SchemaFieldView { SchemaFieldView { name: name.into(), diff --git a/docs/mcp-architecture.md b/docs/mcp-architecture.md index e1193c6d..77fa0c1f 100644 --- a/docs/mcp-architecture.md +++ b/docs/mcp-architecture.md @@ -95,7 +95,8 @@ call_tool(request, context): dispatch(runtime, request.name, request.arguments, identity): "list_tables" -> catalog listing (JSON) "describe_table" -> merged columns + extensions (JSON) - "run_sql" -> run_query(SELECT, identity) -> JSON rows + "run_sql" -> run_query(SELECT, identity) -> JSON rows (capped preview) + "export_query" -> fetch recipe (no execution): /api/query request + Python snippet -> build_table_sql(...) -> run_query(..., identity) -> JSON rows ``` @@ -133,6 +134,14 @@ Rows are collected from the query stream, capped at 1000, and serialized to JSON (`isError: true`) with the message, rather than failing the JSON-RPC call, so the model can read and react to them. +For **large** results, `export_query` avoids inlining data entirely: it returns a +*recipe* (the `/api/query` request body with `output.format` set, plus a Python +snippet) rather than executing the query. The caller's script POSTs that to +`/api/query`, which streams the Parquet/Arrow/CSV file in one response — so bulk +data flows script↔beacon, never through the model's context. `beacon-mcp` builds +the recipe with a read-only (`SELECT`/`WITH`) guard; the actual query runs under +the script's own credential and beacon's normal read enforcement. + ## Extending it - **New generic tool** — add a builder in `catalog.rs`, list it in `list_tools`, diff --git a/docs/mcp.md b/docs/mcp.md index 9a862999..5ccbad9b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -30,7 +30,11 @@ The tool set is generated dynamically from the runtime on every `tools/list`: `nullable`, `description`), scoped to `exposed_columns` when set or all columns otherwise, plus the raw extensions. Descriptions come from the extension, falling back to the Arrow field's `description`/`comment` metadata. -- **`run_sql`** — run a read-only `SELECT` and get JSON rows. +- **`run_sql`** — run a read-only `SELECT` and get JSON rows (for previews; capped). +- **`export_query`** — for **large** results: returns a *recipe* (the exact + `/api/query` request + a ready-to-run Python snippet) to fetch the result as a + Parquet/Arrow/CSV file. It does **not** run the query or return rows, so the + bytes never enter the model's context. See [Large results](#large-results). - **one tool per table** whose `mcp` extension is enabled. Inputs are derived from the extension: `select` (restricted to `exposed_columns`; its help lists each column as `name (type): meaning`), `preset` (an enum of the table's preset @@ -89,6 +93,45 @@ back or remove with `SHOW EXTENSIONS FOR obs` / `DROP EXTENSION 'mcp' FOR obs`. (`deny_unknown_fields`, typed operators), so typos/extra keys are rejected rather than silently dropped. See the table-extensions docs for the full schema. +## Large results + +`run_sql` inlines rows into the model's context and is capped (1000 rows) — it's +for previews and reasoning, not bulk data. For large exports, `export_query` +returns a **fetch recipe** instead of the data: the model gets a small JSON blob, +and a Python script fetches the file directly from `/api/query` (which streams the +Parquet/Arrow/CSV in one response). The bytes never pass through the model. + +Calling `export_query` with `{"sql": "SELECT ... FROM obs WHERE ...", "format": "parquet"}` +returns something like: + +```json +{ + "note": "This does not run the query. POST `request.body` to /api/query; the response body IS the file.", + "format": "parquet", + "request": { + "method": "POST", "path": "/api/query", + "headers": {"Content-Type": "application/json", "Authorization": ""}, + "body": {"sql": "SELECT ... FROM obs WHERE ...", "output": {"format": "parquet"}} + }, + "python": "import io, requests, pandas as pd\nBEACON_URL = \"http://localhost:5001\"\n..." +} +``` + +The `python` field is runnable as-is (fill in `BEACON_URL`/`AUTH`): + +```python +import io, requests, pandas as pd +resp = requests.post(f"{BEACON_URL}/api/query", + headers={"Authorization": AUTH}, + json={"sql": "SELECT ... FROM obs WHERE ...", "output": {"format": "parquet"}}) +resp.raise_for_status() +df = pd.read_parquet(io.BytesIO(resp.content)) +``` + +Formats: `parquet` (default; typed, columnar), `arrow` (IPC), `csv`. Only +read-only `SELECT`/`WITH` queries are accepted. The query runs when the script +runs, under whatever credential the script sends (same read-only rules as MCP). + ## Authenticating an agent `/mcp` authenticates via the HTTP `Authorization` header (the same From 2400a78721a92ed32ba21dfd7f01394af6d908ca Mon Sep 17 00:00:00 2001 From: Robin Kooyman Date: Wed, 1 Jul 2026 14:33:37 +0200 Subject: [PATCH 2/4] MCP: guard rail steering large run_sql results to export_query run_sql is now explicitly a bounded preview; when a result exceeds the 1000-row cap the response carries truncated=true plus guidance instructing the model not to treat the preview as complete and to call export_query for the full result as a file. Tool description states the contract. Docs updated. --- beacon-mcp/src/catalog.rs | 6 +++++- beacon-mcp/src/result.rs | 9 ++++++++- docs/mcp.md | 19 ++++++++++++++----- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/beacon-mcp/src/catalog.rs b/beacon-mcp/src/catalog.rs index 16620b85..68acbdcf 100644 --- a/beacon-mcp/src/catalog.rs +++ b/beacon-mcp/src/catalog.rs @@ -98,7 +98,11 @@ fn describe_table_tool() -> Tool { fn run_sql_tool() -> Tool { read_only(Tool::new( "run_sql", - "Run a read-only SQL query (SELECT only) against beacon and return JSON rows.", + "Run a read-only SQL query (SELECT only) and return JSON rows. This is a bounded \ + PREVIEW: at most 1000 rows are returned; if the result is larger it is truncated \ + (the response sets \"truncated\": true). For complete or large results — anything you \ + intend to analyze in full or hand to a script — use export_query to fetch a \ + Parquet/Arrow/CSV file instead of run_sql.", object_schema( json!({ "sql": { "type": "string", "description": "A read-only SELECT statement." } }), &["sql"], diff --git a/beacon-mcp/src/result.rs b/beacon-mcp/src/result.rs index 31deab7e..fb72079e 100644 --- a/beacon-mcp/src/result.rs +++ b/beacon-mcp/src/result.rs @@ -42,8 +42,15 @@ pub async fn run_sql_to_json( let rows = batches_to_json(&batches)?; if truncated { + // Guard rail: the result is larger than the preview cap. Return the + // preview but steer the model to `export_query` for the complete data + // rather than letting it treat the truncated rows as the full result. Ok(format!( - "{{\"truncated\":true,\"max_rows\":{MAX_ROWS},\"rows\":{rows}}}" + "{{\"truncated\":true,\"returned_rows\":{MAX_ROWS},\"max_rows\":{MAX_ROWS},\ + \"guidance\":\"This is a truncated preview ({MAX_ROWS} rows) because the result is \ + large. Do NOT treat these rows as complete. To get the full result, call \ + export_query with the same SQL to fetch it as a Parquet/Arrow/CSV file.\",\ + \"rows\":{rows}}}" )) } else { Ok(rows) diff --git a/docs/mcp.md b/docs/mcp.md index 5ccbad9b..e7005a7e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -30,7 +30,10 @@ The tool set is generated dynamically from the runtime on every `tools/list`: `nullable`, `description`), scoped to `exposed_columns` when set or all columns otherwise, plus the raw extensions. Descriptions come from the extension, falling back to the Arrow field's `description`/`comment` metadata. -- **`run_sql`** — run a read-only `SELECT` and get JSON rows (for previews; capped). +- **`run_sql`** — run a read-only `SELECT` and get JSON rows. A bounded **preview**: + capped at 1000 rows; when a result is larger it is truncated and the response + (`"truncated": true` + `guidance`) steers the model to `export_query` for the + complete data — so a partial preview is never mistaken for the full result. - **`export_query`** — for **large** results: returns a *recipe* (the exact `/api/query` request + a ready-to-run Python snippet) to fetch the result as a Parquet/Arrow/CSV file. It does **not** run the query or return rows, so the @@ -96,10 +99,16 @@ than silently dropped. See the table-extensions docs for the full schema. ## Large results `run_sql` inlines rows into the model's context and is capped (1000 rows) — it's -for previews and reasoning, not bulk data. For large exports, `export_query` -returns a **fetch recipe** instead of the data: the model gets a small JSON blob, -and a Python script fetches the file directly from `/api/query` (which streams the -Parquet/Arrow/CSV in one response). The bytes never pass through the model. +for previews and reasoning, not bulk data. **Guard rail:** when a `run_sql` result +exceeds the cap, the response is marked `"truncated": true` and carries `guidance` +telling the model not to treat the preview as complete and to call `export_query` +instead — so large queries are steered to the file path rather than silently +returning partial data. + +For large exports, `export_query` returns a **fetch recipe** instead of the data: +the model gets a small JSON blob, and a Python script fetches the file directly +from `/api/query` (which streams the Parquet/Arrow/CSV in one response). The bytes +never pass through the model. Calling `export_query` with `{"sql": "SELECT ... FROM obs WHERE ...", "format": "parquet"}` returns something like: From fda5191d744c9b412ab65959db24f2120f24305c Mon Sep 17 00:00:00 2001 From: Robin Kooyman Date: Wed, 1 Jul 2026 15:20:51 +0200 Subject: [PATCH 3/4] MCP: advisory free-form guardrails on the mcp extension Adds an optional free-form `guardrails` map to the mcp extension: arbitrary key/value hints that beacon surfaces to the agent (appended to the generated tool's description and returned by describe_table) but does NOT enforce. Admins can attach any keys. Enforcement of output size remains the separate built-in run_sql preview cap. Tests + docs. --- beacon-core/src/extensions.rs | 6 +++++ beacon-mcp/src/catalog.rs | 47 ++++++++++++++++++++++++++++++++++- docs/mcp.md | 20 +++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/beacon-core/src/extensions.rs b/beacon-core/src/extensions.rs index f164456b..b87ce15a 100644 --- a/beacon-core/src/extensions.rs +++ b/beacon-core/src/extensions.rs @@ -107,6 +107,12 @@ pub struct McpExtension { /// describing what the column means. #[serde(default, skip_serializing_if = "Option::is_none")] pub exposed_columns: Option>, + /// Free-form advisory guard rails surfaced to the agent (in the generated + /// tool's description and via `describe_table`). Any key/value pairs are + /// allowed and beacon does **not** enforce them — they are hints for the model + /// (e.g. `{"recommended_row_limit": 10000, "note": "filter by time first"}`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub guardrails: Option>, } /// A column surfaced through the MCP tool — a bare name, or a name plus a diff --git a/beacon-mcp/src/catalog.rs b/beacon-mcp/src/catalog.rs index 68acbdcf..40736521 100644 --- a/beacon-mcp/src/catalog.rs +++ b/beacon-mcp/src/catalog.rs @@ -312,6 +312,28 @@ fn default_tool_name(table: &str) -> String { name } +/// Render a table's advisory guard rails to a compact `key: value; …` string for +/// the tool description. Beacon does not interpret these — they are hints for the +/// agent. Returns `None` when there are no guard rails. +fn guardrails_text(mcp: &McpExtension) -> Option { + let guardrails = mcp.guardrails.as_ref()?; + if guardrails.is_empty() { + return None; + } + let rendered = guardrails + .iter() + .map(|(key, value)| { + let value = value + .as_str() + .map(str::to_string) + .unwrap_or_else(|| value.to_string()); + format!("{key}: {value}") + }) + .collect::>() + .join("; "); + Some(rendered) +} + async fn table_tool( runtime: &Arc, table: &str, @@ -322,10 +344,16 @@ async fn table_tool( .tool_name .clone() .unwrap_or_else(|| default_tool_name(table)); - let description = mcp + let mut description = mcp .description .clone() .unwrap_or_else(|| format!("Query the '{table}' table.")); + // Surface any advisory guard rails to the model as text (beacon does not + // enforce them; they are hints the agent is expected to respect). + if let Some(text) = guardrails_text(mcp) { + description.push_str("\n\nGuard rails (advisory): "); + description.push_str(&text); + } // Merge the table schema (types) with the extension's per-column descriptions, // scoped to `exposed_columns` when set, or all columns otherwise, so the model @@ -595,6 +623,7 @@ mod tests { }), ExposedColumn::Name("lat".into()), ]), + guardrails: None, }; let cols = resolve_columns(&schema, Some(&ext)); assert_eq!(cols.len(), 2); @@ -616,9 +645,25 @@ mod tests { .map(|s| beacon_core::extensions::ExposedColumn::Name(s.to_string())) .collect() }), + guardrails: None, } } + #[test] + fn guardrails_render_as_advisory_text() { + assert_eq!(guardrails_text(&mcp(None)), None); + let mut ext = mcp(None); + let mut g = std::collections::BTreeMap::new(); + g.insert("recommended_row_limit".to_string(), serde_json::json!(10000)); + g.insert("note".to_string(), serde_json::json!("filter by time first")); + ext.guardrails = Some(g); + // Rendered as `key: value` pairs (BTreeMap => deterministic key order). + assert_eq!( + guardrails_text(&ext).as_deref(), + Some("note: filter by time first; recommended_row_limit: 10000") + ); + } + #[test] fn builds_select_with_preset_between() { let p = preset( diff --git a/docs/mcp.md b/docs/mcp.md index e7005a7e..e70e9cf1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -96,6 +96,26 @@ back or remove with `SHOW EXTENSIONS FOR obs` / `DROP EXTENSION 'mcp' FOR obs`. (`deny_unknown_fields`, typed operators), so typos/extra keys are rejected rather than silently dropped. See the table-extensions docs for the full schema. +### Advisory guard rails + +The `mcp` descriptor may carry a free-form `guardrails` map — arbitrary key/value +pairs that beacon surfaces to the agent (appended to the tool's description, and +returned by `describe_table`) but does **not** enforce. Use it to nudge model +behavior: + +```sql +SET EXTENSION 'mcp' FOR obs TO '{ + "enabled": true, + "guardrails": { + "recommended_row_limit": 10000, + "note": "Always filter by time range; use export_query for full extracts." + } +}'; +``` + +Any keys are allowed. These are hints only — enforcement of result size is the +separate, built-in `run_sql` preview cap described under [Large results](#large-results). + ## Large results `run_sql` inlines rows into the model's context and is capped (1000 rows) — it's From 53ecd03d5cf045936505966bc4caee02f0a88d5f Mon Sep 17 00:00:00 2001 From: Robin Kooyman Date: Wed, 1 Jul 2026 15:36:11 +0200 Subject: [PATCH 4/4] docs(site): add MCP Server page to VitePress (1.8.0) + sidebar Adds a versioned, canonical MCP Server guide at docs/docs/1.8.0/mcp.md (config, tools incl. export_query, exposing tables via the mcp/preset extensions, column descriptions, advisory guardrails, large-result export, per-user auth, connecting Claude, and a 'How it works' section) and wires it into the 1.8.0 sidebar between REST API and Connect. Removes the redundant top-level docs/mcp.md and docs/mcp-architecture.md (superseded by the versioned page). --- docs/.vitepress/config.mts | 14 +++ docs/docs/1.8.0/mcp.md | 236 +++++++++++++++++++++++++++++++++++ docs/mcp-architecture.md | 152 ----------------------- docs/mcp.md | 244 ------------------------------------- 4 files changed, 250 insertions(+), 396 deletions(-) create mode 100644 docs/docs/1.8.0/mcp.md delete mode 100644 docs/mcp-architecture.md delete mode 100644 docs/mcp.md diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 09327f20..03c443db 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -569,6 +569,20 @@ export default defineConfig({ }, ] }, + { + text: 'MCP Server', + link: '/docs/1.8.0/mcp', + collapsed: true, + items: [ + { text: 'Enabling & configuration', link: '/docs/1.8.0/mcp#enabling-configuration' }, + { text: 'Tools', link: '/docs/1.8.0/mcp#tools' }, + { text: 'Exposing a table', link: '/docs/1.8.0/mcp#exposing-a-table-to-mcp' }, + { text: 'Large results', link: '/docs/1.8.0/mcp#large-results' }, + { text: 'Authenticating an agent', link: '/docs/1.8.0/mcp#authenticating-an-agent' }, + { text: 'Connecting a client', link: '/docs/1.8.0/mcp#connecting-a-client' }, + { text: 'How it works', link: '/docs/1.8.0/mcp#how-it-works' }, + ] + }, { text: 'Connect', collapsed: true, diff --git a/docs/docs/1.8.0/mcp.md b/docs/docs/1.8.0/mcp.md new file mode 100644 index 00000000..c98179ba --- /dev/null +++ b/docs/docs/1.8.0/mcp.md @@ -0,0 +1,236 @@ +--- +description: Beacon's built-in MCP server lets AI agents (e.g. Claude) discover tables and run read-only queries over the Model Context Protocol — with per-table tools generated from table extensions, per-user auth, and a large-result export path. +--- + +# MCP Server + +Beacon ships a built-in [MCP](https://modelcontextprotocol.io) server so AI agents +(e.g. Claude) can discover your tables and run **read-only** queries over the +Model Context Protocol. It is served over the streamable-HTTP transport at +`POST/GET/DELETE /mcp`, alongside the REST API. + +The tool set is generated from your data: a few generic tools plus one tool per +table you opt in via the table's `mcp` extension. + +## Enabling & configuration + +The endpoint is mounted by default. Relevant environment variables: + +| Variable | Default | Effect | +|---|---|---| +| `BEACON_MCP_ENABLED` | `true` | Mount `/mcp`. Set `false`/`0`/`off` to disable. | +| `BEACON_AUTH_ANONYMOUS_ENABLED` | `true` | Unauthenticated requests resolve to the anonymous principal. | +| `BEACON_AUTH_ENFORCE` | `false` | Apply per-role read grants at query time. | + +With the defaults, `/mcp` is on and open (anonymous, read-only). To lock it down, +set `BEACON_AUTH_ENFORCE=true` and `BEACON_AUTH_ANONYMOUS_ENABLED=false`, then +give each agent a credential (see [Authenticating an agent](#authenticating-an-agent)). + +## Tools + +Generated dynamically on every `tools/list`: + +- **`list_tables`** — registered tables and their MCP exposure status. +- **`describe_table`** — a merged per-column view (`name`, `data_type`, + `nullable`, `description`), scoped to `exposed_columns` when set or all columns + otherwise, plus the table's extensions. +- **`run_sql`** — run a read-only `SELECT` and get JSON rows. A **bounded + preview** (capped at 1000 rows); larger results are truncated and steer you to + `export_query`. +- **`export_query`** — for **large** results: returns a recipe (an `/api/query` + request + a Python snippet) to fetch the result as a Parquet/Arrow/CSV file. See + [Large results](#large-results). +- **one tool per table** whose `mcp` extension is enabled, generated from the + extension: `select` (restricted to the exposed columns), `preset` (an enum of + the table's named filter sets), and `limit`. + +The MCP surface is **strictly read-only**: every tool call runs with super-user +privileges cleared, so the planner rejects any DDL/DML (`CREATE`, `INSERT`, +`UPDATE`, `DELETE`, `SET EXTENSION`, …) regardless of who connects. Each tool +carries `annotations.readOnlyHint: true`. + +## Exposing a table to MCP + +A table becomes an MCP tool when its `mcp` [extension](/docs/1.8.0/data-lake/external-tables) +is enabled. Set it via SQL (`SET EXTENSION`) or the admin REST API. An optional +`preset` extension adds named, predefined filter sets. + +```sql +SET EXTENSION 'mcp' FOR obs TO '{ + "enabled": true, + "tool_name": "query_obs", + "title": "Ocean observations", + "description": "Argo float profiles: temperature and salinity by location, depth and time.", + "exposed_columns": [ + {"name": "lat", "description": "latitude in decimal degrees"}, + {"name": "depth", "description": "measurement depth in meters"}, + "temperature" + ] +}'; + +SET EXTENSION 'preset' FOR obs TO '{ + "presets": [ + {"name": "shallow", "description": "Surface layer", + "filters": [{"column": "depth", "op": "<=", "value": 10}]} + ] +}'; +``` + +Read back or remove with `SHOW EXTENSIONS FOR obs` / `DROP EXTENSION 'mcp' FOR obs`. + +### Fields → the MCP `Tool` standard + +| Extension field | MCP `Tool` | Notes | +|---|---|---| +| `tool_name` | `name` | Validated to 1–64 chars of `[A-Za-z0-9_-]`; the generated default (`query_
`) is sanitized. | +| `title` | `title` | Human-readable label. | +| `description` | `description` | What the **table** means. | +| `exposed_columns` | `inputSchema` | Constrains `select`; per-column meanings feed its help + `describe_table`. | +| — | `annotations.readOnlyHint` | Always `true`. | + +`exposed_columns` entries are a bare name (`"lat"`) or `{"name", "description"}`. +The per-column meanings are folded into the tool's `select` help and returned by +`describe_table`, so the model knows what each field represents. Payloads parse +strictly (unknown keys and invalid operators are rejected). + +### Advisory guard rails + +The `mcp` descriptor may carry a free-form `guardrails` map — arbitrary key/value +pairs that beacon surfaces to the agent (appended to the tool's description and +returned by `describe_table`) but does **not** enforce. Use it to nudge model +behavior: + +```sql +SET EXTENSION 'mcp' FOR obs TO '{ + "enabled": true, + "guardrails": { + "recommended_row_limit": 10000, + "note": "Always filter by time range; use export_query for full extracts." + } +}'; +``` + +Any keys are allowed — these are hints only. Enforcement of result size is the +separate, built-in `run_sql` preview cap (below). + +## Large results + +`run_sql` inlines rows into the model's context and is capped (1000 rows) — for +previews and reasoning, not bulk data. When a result exceeds the cap it is marked +`"truncated": true` with `guidance` steering the model to `export_query`, so a +partial preview is never mistaken for the full result. + +`export_query` returns a **fetch recipe** rather than data: the model gets a small +JSON blob, and a Python script fetches the file directly from `/api/query` (which +streams the Parquet/Arrow/CSV in one response). Calling it with +`{"sql": "SELECT …", "format": "parquet"}` yields a `request` (POST body for +`/api/query`) and a runnable `python` snippet: + +```python +import io, requests, pandas as pd +resp = requests.post(f"{BEACON_URL}/api/query", + headers={"Authorization": AUTH}, + json={"sql": "SELECT … FROM obs WHERE …", "output": {"format": "parquet"}}) +resp.raise_for_status() +df = pd.read_parquet(io.BytesIO(resp.content)) +``` + +Formats: `parquet` (default), `arrow` (IPC), `csv`. Only read-only `SELECT`/`WITH` +queries are accepted; the query runs when the script runs, under whatever +credential the script sends. + +## Authenticating an agent + +`/mcp` authenticates via the HTTP `Authorization` header (the same identity +resolution as the [client API](/docs/1.8.0/security/access-control)): + +- **Basic** — `Authorization: Basic base64(user:pass)` → a beacon user's roles. +- **Bearer** — `Authorization: Bearer ` → an OIDC/OAuth2 JWT. +- **No header** → the anonymous principal (if enabled), else no access. + +MCP is read-only regardless of identity; the identity only decides *which reads* +are allowed (when `BEACON_AUTH_ENFORCE=true`). Create a read-only user for an +agent (as a super-user, via SQL or the admin API): + +```sql +CREATE USER agent WITH PASSWORD 's3cret'; +GRANT SELECT ON obs TO ROLE readers; -- when enforcing +GRANT ROLE readers TO USER agent; +``` + +::: tip +Beacon's built-in super-user is **config-only** (`BEACON_ADMIN_*`) and is not a +client identity, so those admin credentials do **not** authenticate on `/mcp`. +Use a `CREATE USER` account or an OIDC token. +::: + +## Connecting a client + +**Claude Code (CLI):** + +```bash +claude mcp add --transport http beacon https://your-host/mcp \ + --header "Authorization: Basic $(printf 'agent:s3cret' | base64)" +# or: --header "Authorization: Bearer " +``` + +**Claude Desktop** — bridge a static token with `mcp-remote`: + +```json +{ + "mcpServers": { + "beacon": { + "command": "npx", + "args": ["mcp-remote", "https://your-host/mcp", + "--header", "Authorization: Bearer "] + } + } +} +``` + +For an open/anonymous local instance, point straight at the URL: +`{ "mcpServers": { "beacon": { "url": "http://localhost:5001/mcp" } } }`. + +**Programmatic (MCP SDKs)** — set the header on the streamable-HTTP transport: + +```ts +new StreamableHTTPClientTransport(new URL("https://your-host/mcp"), { + requestInit: { headers: { Authorization: "Bearer " } }, +}); +``` + +The transport attaches the header to every request — beacon authenticates per +request, even within a long-lived session. + +### Quick check + +```bash +curl -s -X POST http://127.0.0.1:5001/mcp \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "Authorization: Basic $(printf 'agent:s3cret' | base64)" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"c","version":"0"}}}' +``` + +A `200` with an `initialize` result means the credential was accepted; `401` means +it was rejected. + +## How it works + +The MCP server is a thin protocol adapter in front of beacon's query runtime — it +adds no query engine of its own. Every tool call becomes a normal beacon query, so +MCP inherits the planner, catalog, metrics, and access control. + +- **Transport** — an `rmcp` streamable-HTTP service mounted at `/mcp`, behind the + `BEACON_MCP_ENABLED` flag and the same identity-resolution middleware as the + client API. +- **`tools/list`** — rebuilt per call: the generic tools plus one per enabled + table, generated from the `mcp`/`preset` extensions (so newly-exposed tables + appear without a restart). +- **`tools/call`** — resolves the caller's identity, **clears super-user** (MCP is + read-only), and dispatches: `run_sql`/table tools build a `SELECT` and run it; + `export_query` returns a fetch recipe; `describe_table`/`list_tables` read the + catalog. Per-table tools expand the chosen `preset` into a `WHERE` clause with + safely-rendered values — the model never supplies raw SQL through them. +- **Results** — capped and returned as JSON tool content; errors come back as MCP + tool errors (`isError: true`) so the model can react. diff --git a/docs/mcp-architecture.md b/docs/mcp-architecture.md deleted file mode 100644 index 77fa0c1f..00000000 --- a/docs/mcp-architecture.md +++ /dev/null @@ -1,152 +0,0 @@ -# How the MCP server works - -This explains the internals of beacon's MCP server — the request path, how tools -are generated, how identity and read-only enforcement work, and how a tool call -becomes a query. For usage (enabling, exposing tables, connecting clients) see -[mcp.md](mcp.md). - -## Overview - -The MCP server is a thin **protocol adapter** in front of the existing -`Runtime`. It adds no query engine of its own: every tool call is translated into -a normal `Runtime::run_query`, so MCP inherits beacon's planner, catalog, -metrics, and RBAC. The tool set is not hard-coded — it is generated from the live -catalog and each table's `mcp`/`preset` extension, so curators shape the MCP -surface with SQL/REST, no code changes. - -``` -Claude ──MCP JSON-RPC over streamable HTTP──▶ beacon-api /mcp - │ resolve_identity (per request) - ▼ - beacon-mcp (rmcp ServerHandler) - list_tools / call_tool - │ - ▼ - Runtime ── get_table_extensions / list_table_schema_view - ── run_query(Query, identity[super-user cleared]) -``` - -## Crate layout (`beacon-mcp`) - -| File | Responsibility | -|---|---| -| `server.rs` | The rmcp `ServerHandler`: `get_info`, `list_tools`, `call_tool`, and identity extraction. | -| `catalog.rs` | Tool generation (`resolve_columns`, per-table tools), preset→SQL, argument→query mapping. | -| `result.rs` | Runs a query and serializes the rows to JSON. | -| `lib.rs` | `streamable_http_service(runtime)` — builds the tower service mounted at `/mcp`. | - -It depends on `rmcp` (the official Rust MCP SDK, pinned `=1.8.0`) for the protocol -and transport, and on `beacon-core` for `Runtime`, the extension types, and -`AuthIdentity`. - -## Transport & mounting - -`beacon_mcp::streamable_http_service(runtime)` returns an rmcp -`StreamableHttpService` (a `tower::Service`) wrapping a fresh `BeaconMcpServer` -per session. `beacon-api`'s `router.rs` mounts it: - -``` -route_service("/mcp", beacon_mcp::streamable_http_service(runtime)) - .layer(from_fn_with_state(runtime, resolve_identity)) // same as client API -``` - -behind a `BEACON_MCP_ENABLED` check. Streamable HTTP uses one endpoint for the -whole session (POST for requests, GET for the SSE stream, DELETE to end it); rmcp -tracks sessions with an in-memory `LocalSessionManager`. - -## Request lifecycle - -1. **`initialize`** — client and server negotiate; `get_info` advertises the - `tools` capability and server instructions. -2. **`tools/list`** — `list_tools` builds the current tool set (see below). It is - rebuilt per call, so newly-exposed tables appear without a restart. -3. **`tools/call`** — `call_tool` resolves the caller's identity, dispatches by - tool name, executes, and returns the result as MCP text content (`isError` set - on failure). - -## Tool generation (`list_tools`) - -Two groups are assembled: - -**Generic tools** — always present: `list_tables`, `describe_table`, `run_sql`. - -**Per-table tools** — for each table whose `mcp` extension has `enabled: true`, -one tool is generated from the extension metadata: - -- `name` ← `tool_name` (or a sanitized `query_
`), `title`, `description`. -- `inputSchema` is built from `resolve_columns` + presets: - - `select` — an array whose `enum` is the exposed column names; its description - lists each column as `name (type): meaning`. - - `preset` — an `enum` of the table's preset names (if any). - - `limit` — integer, default 100. -- `annotations.readOnlyHint = true`. - -`resolve_columns` merges the table's Arrow schema (types) with the extension's -per-column descriptions, scoped to `exposed_columns` (in order) when set or all -columns otherwise, with a fallback to the Arrow field's `description`/`comment` -metadata. The same function feeds `describe_table`, so the tool schema and the -description tool agree. - -## Executing a call (`call_tool`) - -``` -call_tool(request, context): - identity = identity_from_context(context) # super-user cleared - dispatch(runtime, request.name, request.arguments, identity): - "list_tables" -> catalog listing (JSON) - "describe_table" -> merged columns + extensions (JSON) - "run_sql" -> run_query(SELECT, identity) -> JSON rows (capped preview) - "export_query" -> fetch recipe (no execution): /api/query request + Python snippet -
-> build_table_sql(...) -> run_query(..., identity) -> JSON rows -``` - -**Per-table tools → SQL.** `build_table_sql` turns the validated arguments into a -`SELECT`: `select` (checked against `exposed_columns`, identifiers quoted), the -chosen `preset` expanded to a `WHERE` from its stored filters, and `limit`. -Preset operators are the typed `PresetOp` enum; values are rendered safely -(scalars/arrays escaped) — the model never supplies raw SQL through these tools. - -## Identity & read-only enforcement - -The `resolve_identity` middleware authenticates each HTTP request (Basic/Bearer → -a user's roles, or the anonymous principal, or an empty identity) and inserts the -`AuthIdentity` into the request extensions. The streamable-HTTP transport injects -the request `http::request::Parts` into the MCP `RequestContext`, so -`identity_from_context` recovers that `AuthIdentity`. - -Before use it **clears `is_super_user`**: - -```rust -let mut identity = /* from request parts, or AuthIdentity::empty() */; -identity.is_super_user = false; // MCP is read-only, always -``` - -This is defense-in-depth: the query planner gates DDL/DML on super-user, so a -non-super identity can only run `SELECT`. The caller's `roles` are preserved, so -when `BEACON_AUTH_ENFORCE=true` per-user read grants still apply. (Beacon's -super-user is config-only and never a client identity, so this only matters if -that ever changes.) - -## Results - -Rows are collected from the query stream, capped at 1000, and serialized to JSON -(`result.rs`) as the tool's text content. Errors are returned as MCP tool errors -(`isError: true`) with the message, rather than failing the JSON-RPC call, so the -model can read and react to them. - -For **large** results, `export_query` avoids inlining data entirely: it returns a -*recipe* (the `/api/query` request body with `output.format` set, plus a Python -snippet) rather than executing the query. The caller's script POSTs that to -`/api/query`, which streams the Parquet/Arrow/CSV file in one response — so bulk -data flows script↔beacon, never through the model's context. `beacon-mcp` builds -the recipe with a read-only (`SELECT`/`WITH`) guard; the actual query runs under -the script's own credential and beacon's normal read enforcement. - -## Extending it - -- **New generic tool** — add a builder in `catalog.rs`, list it in `list_tools`, - and add a `dispatch` arm. -- **Richer per-table inputs** (e.g. server-side filters, sort) — extend the - per-table `inputSchema` and `build_table_sql`. -- **New extension-driven behavior** — add fields to the `mcp` extension in - `beacon-core`'s `extensions` module; they flow here via `get_table_extensions`. diff --git a/docs/mcp.md b/docs/mcp.md deleted file mode 100644 index e70e9cf1..00000000 --- a/docs/mcp.md +++ /dev/null @@ -1,244 +0,0 @@ -# MCP server - -Beacon ships an [MCP](https://modelcontextprotocol.io) server so MCP clients -(e.g. Claude) can discover beacon's tables and run **read-only** queries against -them. It is served over the streamable-HTTP transport at `POST/GET/DELETE /mcp` -by the `beacon-mcp` crate, mounted alongside the REST API. - -For how it works internally, see [mcp-architecture.md](mcp-architecture.md). - -## Enabling & configuration - -The endpoint is mounted by default. Relevant environment variables: - -| Variable | Default | Effect | -|---|---|---| -| `BEACON_MCP_ENABLED` | `true` | Mount `/mcp`. Set `false`/`0`/`off` to disable. | -| `BEACON_AUTH_ANONYMOUS_ENABLED` | `true` | Unauthenticated requests resolve to the anonymous principal. | -| `BEACON_AUTH_ENFORCE` | `false` | Apply per-role read grants at query time. | - -With the defaults, `/mcp` is on and open (anonymous, read-only). To lock it down, -set `BEACON_AUTH_ENFORCE=true` and `BEACON_AUTH_ANONYMOUS_ENABLED=false`, then -issue each agent a credential (below). - -## Tools - -The tool set is generated dynamically from the runtime on every `tools/list`: - -- **`list_tables`** — registered tables and their MCP exposure status. -- **`describe_table`** — a merged per-column view (`name`, `data_type`, - `nullable`, `description`), scoped to `exposed_columns` when set or all columns - otherwise, plus the raw extensions. Descriptions come from the extension, - falling back to the Arrow field's `description`/`comment` metadata. -- **`run_sql`** — run a read-only `SELECT` and get JSON rows. A bounded **preview**: - capped at 1000 rows; when a result is larger it is truncated and the response - (`"truncated": true` + `guidance`) steers the model to `export_query` for the - complete data — so a partial preview is never mistaken for the full result. -- **`export_query`** — for **large** results: returns a *recipe* (the exact - `/api/query` request + a ready-to-run Python snippet) to fetch the result as a - Parquet/Arrow/CSV file. It does **not** run the query or return rows, so the - bytes never enter the model's context. See [Large results](#large-results). -- **one tool per table** whose `mcp` extension is enabled. Inputs are derived from - the extension: `select` (restricted to `exposed_columns`; its help lists each - column as `name (type): meaning`), `preset` (an enum of the table's preset - names, expanded to filters at query time), and `limit`. - -The MCP surface is **strictly read-only**: every tool call executes with -`is_super_user` cleared, so the query planner rejects any DDL/DML (`CREATE`, -`INSERT`, `UPDATE`, `DELETE`, `SET EXTENSION`, …) regardless of who connects — -only `SELECT` runs. The caller's roles are preserved, so per-user read grants -(RBAC) still apply. Every tool carries `annotations.readOnlyHint: true`. Results -are capped (1000 rows) to keep tool output bounded. - -## Exposing a table to MCP - -Tables become MCP tools via the table-extensions surface (SQL or the admin REST -API). The `mcp` extension describes the table; an optional `preset` extension -adds named, predefined filter sets. - -```sql -SET EXTENSION 'mcp' FOR obs TO '{ - "enabled": true, - "tool_name": "query_obs", - "title": "Ocean observations", - "description": "Argo float profiles: temperature and salinity by location, depth and time.", - "exposed_columns": [ - {"name": "lat", "description": "latitude in decimal degrees"}, - {"name": "lon", "description": "longitude in decimal degrees"}, - {"name": "depth", "description": "measurement depth in meters"}, - "temperature" - ] -}'; - -SET EXTENSION 'preset' FOR obs TO '{ - "presets": [ - {"name": "shallow", "description": "Surface layer", - "filters": [{"column": "depth", "op": "<=", "value": 10}]} - ] -}'; -``` - -`query_obs` then appears as an MCP tool with a `preset: "shallow"` option. Read -back or remove with `SHOW EXTENSIONS FOR obs` / `DROP EXTENSION 'mcp' FOR obs`. - -### How the `mcp` fields map to the MCP `Tool` standard - -| Extension field | MCP `Tool` | Notes | -|---|---|---| -| `tool_name` | `name` | Validated to 1–64 chars of `[A-Za-z0-9_-]`; the generated default (`query_
`) is sanitized. | -| `title` | `title` | Human-readable label. | -| `description` | `description` | What the **table** means. | -| `exposed_columns` | `inputSchema` | Constrains `select`; per-column meanings feed its help + `describe_table`. | -| — | `annotations.readOnlyHint` | Always `true`. | - -`exposed_columns` entries are either a bare name (`"lat"`) or -`{"name": ..., "description": ...}`. Payloads parse strictly -(`deny_unknown_fields`, typed operators), so typos/extra keys are rejected rather -than silently dropped. See the table-extensions docs for the full schema. - -### Advisory guard rails - -The `mcp` descriptor may carry a free-form `guardrails` map — arbitrary key/value -pairs that beacon surfaces to the agent (appended to the tool's description, and -returned by `describe_table`) but does **not** enforce. Use it to nudge model -behavior: - -```sql -SET EXTENSION 'mcp' FOR obs TO '{ - "enabled": true, - "guardrails": { - "recommended_row_limit": 10000, - "note": "Always filter by time range; use export_query for full extracts." - } -}'; -``` - -Any keys are allowed. These are hints only — enforcement of result size is the -separate, built-in `run_sql` preview cap described under [Large results](#large-results). - -## Large results - -`run_sql` inlines rows into the model's context and is capped (1000 rows) — it's -for previews and reasoning, not bulk data. **Guard rail:** when a `run_sql` result -exceeds the cap, the response is marked `"truncated": true` and carries `guidance` -telling the model not to treat the preview as complete and to call `export_query` -instead — so large queries are steered to the file path rather than silently -returning partial data. - -For large exports, `export_query` returns a **fetch recipe** instead of the data: -the model gets a small JSON blob, and a Python script fetches the file directly -from `/api/query` (which streams the Parquet/Arrow/CSV in one response). The bytes -never pass through the model. - -Calling `export_query` with `{"sql": "SELECT ... FROM obs WHERE ...", "format": "parquet"}` -returns something like: - -```json -{ - "note": "This does not run the query. POST `request.body` to /api/query; the response body IS the file.", - "format": "parquet", - "request": { - "method": "POST", "path": "/api/query", - "headers": {"Content-Type": "application/json", "Authorization": ""}, - "body": {"sql": "SELECT ... FROM obs WHERE ...", "output": {"format": "parquet"}} - }, - "python": "import io, requests, pandas as pd\nBEACON_URL = \"http://localhost:5001\"\n..." -} -``` - -The `python` field is runnable as-is (fill in `BEACON_URL`/`AUTH`): - -```python -import io, requests, pandas as pd -resp = requests.post(f"{BEACON_URL}/api/query", - headers={"Authorization": AUTH}, - json={"sql": "SELECT ... FROM obs WHERE ...", "output": {"format": "parquet"}}) -resp.raise_for_status() -df = pd.read_parquet(io.BytesIO(resp.content)) -``` - -Formats: `parquet` (default; typed, columnar), `arrow` (IPC), `csv`. Only -read-only `SELECT`/`WITH` queries are accepted. The query runs when the script -runs, under whatever credential the script sends (same read-only rules as MCP). - -## Authenticating an agent - -`/mcp` authenticates via the HTTP `Authorization` header (the same -`resolve_identity` path as the client API): - -- **Basic** — `Authorization: Basic base64(user:pass)` → a beacon user's roles. -- **Bearer** — `Authorization: Bearer ` → an OIDC/OAuth2 JWT. -- **No header** → the anonymous principal (if enabled), else no access. - -MCP is read-only regardless of identity; the identity only decides *which reads* -are allowed (when `BEACON_AUTH_ENFORCE=true`). - -Create a read-only user to hand to an agent (as a super-user, via SQL or the -admin API): - -```sql -CREATE USER agent WITH PASSWORD 's3cret'; --- when enforcing, grant reads and assign a role: -GRANT SELECT ON obs TO ROLE readers; -GRANT ROLE readers TO USER agent; -``` - -> Beacon's built-in super-user is **config-only** (`BEACON_ADMIN_*`) and is not a -> client identity, so those admin credentials do **not** authenticate on `/mcp`. -> Use a `CREATE USER` account or an OIDC token. - -## Connecting a client - -**Claude Code (CLI)** — streamable HTTP with an auth header: - -```bash -claude mcp add --transport http beacon https://your-host/mcp \ - --header "Authorization: Basic $(printf 'agent:s3cret' | base64)" -# or: --header "Authorization: Bearer " -``` - -**Claude Desktop** — for a static token, bridge with `mcp-remote`: - -```json -{ - "mcpServers": { - "beacon": { - "command": "npx", - "args": ["mcp-remote", "https://your-host/mcp", - "--header", "Authorization: Bearer "] - } - } -} -``` - -(For an open/anonymous local instance you can point a client straight at the URL -with no header: `{ "mcpServers": { "beacon": { "url": "http://localhost:5001/mcp" } } }`.) - -**Programmatic (MCP SDKs)** — set the header on the streamable-HTTP transport: - -```ts -new StreamableHTTPClientTransport(new URL("https://your-host/mcp"), { - requestInit: { headers: { Authorization: "Bearer " } }, -}); -``` - -```python -streamablehttp_client("https://your-host/mcp", - headers={"Authorization": "Bearer "}) -``` - -The transport attaches the header to every request, which is what beacon needs — -it authenticates per request, even within a long-lived MCP session. - -## Quick check - -```bash -curl -s -X POST http://127.0.0.1:5001/mcp \ - -H "Content-Type: application/json" \ - -H "Accept: application/json, text/event-stream" \ - -H "Authorization: Basic $(printf 'agent:s3cret' | base64)" \ - -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"c","version":"0"}}}' -``` - -A `200` with an `initialize` result means the credential was accepted; `401` -means it was rejected.