Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
a38fdc2
fix(security): enforce MCP method auth without a session header; guar…
jrosskopf Aug 30, 2026
599351c
feat(mcp): derive a typed inputSchema from field validators
jrosskopf Aug 30, 2026
4193224
feat(mcp): return tool failures as isError results and attach structu…
jrosskopf Aug 30, 2026
e12d838
refactor(mcp): protocol hygiene + dead-code purge
jrosskopf Aug 30, 2026
969d659
feat(mcp): OAuth discovery (RFC 9728) + 401/403 auth challenges
jrosskopf Aug 30, 2026
ca24124
feat(mcp): cursor pagination, completion wrapper, and URI-templated r…
jrosskopf Aug 30, 2026
d1ffcd2
feat(mcp): dual-era MCP 2026-07-28 support
jrosskopf Aug 30, 2026
0264bfe
feat(mcp): mirrored-header validation for the 2026-07-28 path
jrosskopf Aug 30, 2026
84f84ae
feat(mcp): x-mcp-header parameter mirroring for edge routing
jrosskopf Aug 30, 2026
c66bd2f
feat(mcp): Tasks extension for long-running tool execution
jrosskopf Aug 30, 2026
acf3f3a
docs(mcp): document the 2026-07-28 dual-era model, tasks, and new config
jrosskopf Aug 30, 2026
c238e86
chore: restore posthog-telemetry submodule pointer to main
jrosskopf Aug 30, 2026
a180c75
feat(mcp): learned outputSchema on tools/list; collapse discovery dup…
jrosskopf Aug 31, 2026
869ca8d
feat(mcp): durable Tasks store backed by DuckDB (survives restart)
jrosskopf Aug 31, 2026
ebb2df7
fix(mcp): address Codex review findings on the Tasks/dual-era path
jrosskopf Aug 31, 2026
aeb715f
fix(mcp): restore config-tool discovery; keep config-tool failures as…
jrosskopf Aug 31, 2026
c61bc5d
fix(mcp): address second-round review findings (Codex + local /code-r…
jrosskopf Aug 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -305,14 +305,14 @@ add_library(flapi-lib STATIC
src/sql_parameter_classifier.cpp
src/sql_template_processor.cpp
src/sql_utils.cpp
src/mcp_server.cpp
src/mcp_tool_handler.cpp
src/mcp_tool_rate_limiter.cpp
src/mcp_route_handlers.cpp
src/mcp_session_manager.cpp
src/mcp_error_builder.cpp
src/mcp_request_validator.cpp
src/mcp_client_capabilities.cpp
src/mcp_schema_builder.cpp
src/mcp_header_validation.cpp
src/mcp_task_manager.cpp
src/config_tool_adapter.cpp
src/mcp_content_types.cpp
src/mcp_auth_handler.cpp
Expand Down
157 changes: 156 additions & 1 deletion docs/MCP_REFERENCE.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
# flAPI - MCP Protocol Reference

**Version:** 1.0.0
**Protocol Version:** 2025-11-25
**Protocol Versions:** 2026-07-28 (modern, stateless) and 2024-11-05 … 2025-11-25 (legacy) — flAPI is a **dual-era** server
**flAPI Version:** >= 1.0.0

This document provides a comprehensive reference for flAPI's MCP (Model Context Protocol) implementation, covering protocol details, configuration, and usage.

> **Dual-era at a glance.** flAPI serves the modern **MCP 2026-07-28** stateless
> path and the legacy `initialize` + session path from the same endpoint. A
> request is treated as *modern* when it carries
> `params._meta["io.modelcontextprotocol/protocolVersion"]`; otherwise it takes
> the legacy path, which is unchanged. See [§11](#11-mcp-2026-07-28-dual-era).

---

## Table of Contents
Expand Down Expand Up @@ -1585,6 +1591,155 @@ class AuthenticatedMCPClient(FlapiMCPClient):

---

## 11. MCP 2026-07-28 (dual-era)

flAPI serves the **MCP 2026-07-28** revision alongside the legacy protocol from
the same `/mcp/jsonrpc` endpoint. There is no separate port or path, and legacy
clients are unaffected.

### 11.1 Era selection

A request is **modern** when its `params._meta` contains
`io.modelcontextprotocol/protocolVersion`. Modern requests are stateless and
follow the rules below; every other request is **legacy** and behaves exactly
as documented in §§3–10.

Modern preamble (`params._meta`):

| Key | Required | Notes |
|---|---|---|
| `io.modelcontextprotocol/protocolVersion` | yes | must be one flAPI supports, else `-32022` + `data.supported`, HTTP 400 |
| `io.modelcontextprotocol/clientCapabilities` | yes | missing → `-32602`, HTTP 400; `extensions` object declares e.g. the tasks extension |
| `io.modelcontextprotocol/clientInfo` | no | informational |
| `io.modelcontextprotocol/logLevel` | no | per-request log level |

### 11.2 server/discover

The modern replacement for `initialize`. Publicly reachable (no auth) so a
client can discover how to authenticate. Returns `supportedVersions`,
`capabilities` (including `extensions.io.modelcontextprotocol/tasks`),
`instructions`, and `_meta.serverInfo`, with `ttlMs: 3600000` / `cacheScope: public`.

### 11.3 Result envelope

Every modern result carries `resultType` (`"complete"`, or `"task"` for an async
`tools/call`) and `_meta.io.modelcontextprotocol/serverInfo`. Cacheable results
(`server/discover`, the `*_list` methods, `resources/read`) also carry `ttlMs`
and `cacheScope` (`public` for discovery, `private` for lists).

### 11.4 Mirrored headers

Modern Streamable-HTTP POSTs MUST mirror the request into headers so an edge
proxy can route without parsing the body:

- `MCP-Protocol-Version` — equals the `_meta` protocol version
- `Mcp-Method` — equals the JSON-RPC method
- `Mcp-Name` — `params.name` (tools/call, prompts/get) or `params.uri` (resources/read)

Values may be base64-sentinel encoded (`=?base64?<b64>?=`) for non-ASCII, and
integers compare numerically. A missing required header or a mismatch is
`-32020` HeaderMismatch, HTTP 400. Legacy requests are exempt.

### 11.5 Statelessness and removed methods

On the modern path flAPI never mints or echoes `Mcp-Session-Id` and ignores any
inbound one. `ping` and `logging/setLevel` are removed (`-32601`); `GET`
`/mcp/jsonrpc` returns `405`. The legacy path keeps sessions, `ping`,
`logging/setLevel` and `initialize`.

### 11.6 OAuth discovery (RFC 9728)

When `mcp.auth.type: oidc` is configured, flAPI serves
`GET /.well-known/oauth-protected-resource` advertising the authorization server
(the OIDC issuer). Authentication failures return HTTP `401` with
`WWW-Authenticate: Bearer[ resource_metadata="…"]`; authorization (role) denials
return HTTP `403` with `error="insufficient_scope"`.

```yaml
mcp:
auth:
type: oidc
canonical-resource-uri: https://api.example.com/mcp/jsonrpc # optional; else derived from Host
scopes-supported: [mcp.read, mcp.write] # optional
oidc:
issuer-url: https://accounts.example.com
```

### 11.7 Tasks extension (long-running tools)

A tool can run as a durable task so multi-minute queries do not block the
request connection. A task is used only when the request is modern **and** the
client declared `io.modelcontextprotocol/tasks` in `clientCapabilities.extensions`
**and** the tool opts in; otherwise the call is synchronous (legacy clients
never see a task).

```yaml
mcp-tool:
name: sap_revenue_by_region
description: Revenue by region
async: true # return a taskId immediately
# async-after-ms: 5000 # or: run synchronously, degrade to a task after 5s
```

Flow: `tools/call` returns `resultType: "task"` with `{ task: { taskId, status,
pollIntervalMs, ttlMs } }`. The client polls `tasks/get` (`{taskId}`) until
`status` is `completed` (the tool result is under `result`), `failed` (`error`),
or `cancelled`. `tasks/cancel` (`{taskId}`) requests cancellation. A `taskId` is
scoped to its creating principal — `tasks/get`/`tasks/cancel` re-check ownership.
A task result stops being returned once it is past `ttlMs`.

> **Cancellation is cooperative.** `tasks/cancel` cancels a task that has not
> started yet and marks a running one for cancellation, but it cannot interrupt a
> SQL statement already executing — that query runs to completion (and, for a
> write tool, may commit) before the task is marked `cancelled`. Interrupting an
> in-flight query (via `duckdb_interrupt`) is a planned follow-up. `server/discover`
> and the `tasks/*` methods are modern-only; a legacy request sees `-32601`.

```yaml
mcp:
tasks:
workers: 2 # concurrent background workers
queue-depth: 32 # max queued tasks before backpressure
default-ttl-ms: 3600000 # terminal-task retention
poll-interval-ms: 1000 # suggested client poll interval
```

> **Durability:** tasks are persisted to a `flapi_mcp_tasks` table in the
> configured DuckDB and recovered on startup, so they survive a restart when
> `duckdb.db_path` is a **file** (with an in-memory database the table is
> recreated empty each start). A task left `working` by a crash is recovered as
> `failed` (its query did not survive the process).

### 11.8 x-mcp-header (parameter mirroring)

A request field can be annotated so the client mirrors it into an
`Mcp-Param-<name>` header for edge routing/rate-limiting:

```yaml
request:
- field-name: tenant
field-in: query
mcp-header: Tenant # emitted as "x-mcp-header": "Tenant" in the tool schema
```

Header names must be valid tokens, unique per endpoint (case-insensitive), and
must never mirror a secret (the config load fails otherwise) — header values are
visible to every intermediary.

### 11.9 Other conformance notes

- **Pagination:** `mcp.page-size` (default 0 = off) enables opaque `cursor` /
`nextCursor` on the list methods; a cursor minted before a config reload is
rejected with `-32602`.
- **Notifications:** a JSON-RPC request with no `id` gets HTTP 202 and no body.
- **`listChanged`** is advertised as `false` (flAPI has no notification transport).
- **`completion/complete`** results are wrapped under a `completion` key.
- **Resource templates:** `mcp-resource.uri-template` (e.g. `flapi://customers/{id}`)
exposes a resource in `resources/templates/list` and binds path variables on
`resources/read`.

---

## Appendix A: Complete Configuration Example

```yaml
Expand Down
74 changes: 73 additions & 1 deletion src/config_manager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,20 @@ void ConfigManager::parseMCPConfig() {
mcp_config.enabled = safeGet<bool>(mcp, "enabled", "mcp.enabled", true);
mcp_config.port = safeGet<int>(mcp, "port", "mcp.port", 8081);
mcp_config.strict_descriptions = safeGet<bool>(mcp, "strict-descriptions", "mcp.strict-descriptions", false);
mcp_config.page_size = safeGet<int>(mcp, "page-size", "mcp.page-size", 0);
if (mcp["tasks"]) {
auto tasks = mcp["tasks"];
mcp_config.tasks_workers = safeGet<int>(tasks, "workers", "mcp.tasks.workers", 2);
mcp_config.tasks_queue_depth = safeGet<int>(tasks, "queue-depth", "mcp.tasks.queue-depth", 32);
mcp_config.tasks_default_ttl_ms = safeGet<int>(tasks, "default-ttl-ms", "mcp.tasks.default-ttl-ms", 3600000);
// A non-positive TTL would disable expiry entirely and let the task store
// grow without bound; clamp to the default so tasks are always reaped.
if (mcp_config.tasks_default_ttl_ms <= 0) {
CROW_LOG_WARNING << "mcp.tasks.default-ttl-ms must be positive; using 3600000";
mcp_config.tasks_default_ttl_ms = 3600000;
}
mcp_config.tasks_poll_interval_ms = safeGet<int>(tasks, "poll-interval-ms", "mcp.tasks.poll-interval-ms", 1000);
}

CROW_LOG_DEBUG << "MCP Enabled: " << (mcp_config.enabled ? "true" : "false");
CROW_LOG_DEBUG << "MCP Port: " << mcp_config.port;
Expand All @@ -360,6 +374,16 @@ void ConfigManager::parseMCPConfig() {
mcp_config.auth.enabled = safeGet<bool>(auth, "enabled", "mcp.auth.enabled", false);
mcp_config.auth.type = safeGet<std::string>(auth, "type", "mcp.auth.type", "bearer");

// RFC 9728 protected-resource metadata (optional; derived at runtime
// from the request host when canonical-resource-uri is unset).
mcp_config.auth.canonical_resource_uri =
safeGet<std::string>(auth, "canonical-resource-uri", "mcp.auth.canonical-resource-uri", "");
if (auth["scopes-supported"]) {
for (const auto& scope : auth["scopes-supported"]) {
mcp_config.auth.scopes_supported.push_back(scope.as<std::string>());
}
}

CROW_LOG_DEBUG << "MCP Auth Enabled: " << (mcp_config.auth.enabled ? "true" : "false");
CROW_LOG_DEBUG << "MCP Auth Type: " << mcp_config.auth.type;

Expand Down Expand Up @@ -566,11 +590,59 @@ void ConfigManager::parseEndpointRequestFields(const YAML::Node& endpoint_config
}
}

field.mcp_header = safeGet<std::string>(req, "mcp-header", "request.mcp-header", "");

parseEndpointValidators(req, field);

endpoint.request_fields.push_back(field);
}
}

validateMcpHeaderAnnotations(endpoint);
}

void ConfigManager::validateMcpHeaderAnnotations(const EndpointConfig& endpoint) const {
// Enforce the x-mcp-header constraints at config load: a bad annotation
// makes clients hide the tool, so fail loudly. Header names must be valid
// RFC 9110 tokens, unique case-insensitively, and never mirror a secret.
static const std::vector<std::string> kSecretMarkers = {
"token", "secret", "password", "passwd", "pwd", "key", "credential", "auth"};
std::set<std::string> seen_lower;
for (const auto& field : endpoint.request_fields) {
if (field.mcp_header.empty()) {
continue;
}
const std::string& h = field.mcp_header;
// RFC 9110 token characters (a reasonable subset).
for (char c : h) {
if (!(std::isalnum(static_cast<unsigned char>(c)) || c == '-' || c == '_')) {
throw std::runtime_error("mcp-header '" + h + "' on field '" + field.fieldName +
"' contains an invalid character; use letters, digits, '-' or '_'.");
}
}
std::string lower;
for (char c : h) {
lower += static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
}
if (!seen_lower.insert(lower).second) {
throw std::runtime_error("Duplicate mcp-header '" + h +
"' (case-insensitive) in endpoint request fields.");
}
// A parameter is a secret risk if EITHER the header name OR the mirrored
// field's own name looks like a credential — mirroring a field called
// `api_key` into a header named `Tenant` still exposes the secret.
std::string field_lower;
for (char c : field.fieldName) {
field_lower += static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
}
for (const auto& marker : kSecretMarkers) {
if (lower.find(marker) != std::string::npos || field_lower.find(marker) != std::string::npos) {
throw std::runtime_error("mcp-header on field '" + field.fieldName +
"' (header '" + h + "') looks like a secret; header values are visible to "
"every intermediary and must never mirror credentials.");
}
}
}
}

void ConfigManager::parseEndpointValidators(const YAML::Node& req, RequestFieldConfig& field) {
Expand Down
27 changes: 27 additions & 0 deletions src/endpoint_config_parser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,11 @@ void EndpointConfigParser::parseMcpToolFields(
tool_info.rate_limit.interval = config_manager_->safeGet<int>(rl, "interval", "mcp-tool.rate-limit.interval", 60);
}

// MCP 2026-07-28 Tasks: async / async-after (a duration in ms, or a plain
// integer of milliseconds).
tool_info.async = config_manager_->safeGet<bool>(mcp_tool_node, "async", "mcp-tool.async", false);
tool_info.async_after_ms = config_manager_->safeGet<int>(mcp_tool_node, "async-after-ms", "mcp-tool.async-after-ms", 0);

config.mcp_tool = tool_info;
}

Expand All @@ -231,6 +236,18 @@ void EndpointConfigParser::parseMcpResourceFields(
resource_info.name = config_manager_->safeGet<std::string>(mcp_resource_node, "name", "mcp-resource.name");
resource_info.description = config_manager_->safeGet<std::string>(mcp_resource_node, "description", "mcp-resource.description");
resource_info.mime_type = config_manager_->safeGet<std::string>(mcp_resource_node, "mime-type", "mcp-resource.mime-type", "application/json");
resource_info.uri_template = config_manager_->safeGet<std::string>(mcp_resource_node, "uri-template", "mcp-resource.uri-template", "");

// Per-resource RBAC (mirrors mcp-tool.allowed-roles). Absent → nullopt
// (deny-by-default under mcp.auth.enabled, transparent under demo mode).
if (mcp_resource_node["allowed-roles"].IsDefined()) {
std::vector<std::string> roles;
for (const auto& role_node : mcp_resource_node["allowed-roles"]) {
roles.push_back(role_node.as<std::string>());
}
resource_info.allowed_roles = std::move(roles);
}

config.mcp_resource = resource_info;
}

Expand Down Expand Up @@ -294,6 +311,16 @@ void EndpointConfigParser::parseMcpPromptFields(
}
}

// Per-prompt RBAC (mirrors mcp-tool.allowed-roles). Absent → nullopt
// (deny-by-default under mcp.auth.enabled, transparent under demo mode).
if (mcp_prompt_node["allowed-roles"].IsDefined()) {
std::vector<std::string> roles;
for (const auto& role_node : mcp_prompt_node["allowed-roles"]) {
roles.push_back(role_node.as<std::string>());
}
prompt_info.allowed_roles = std::move(roles);
}

config.mcp_prompt = prompt_info;
} catch (const std::exception& e) {
result.success = false;
Expand Down
Loading
Loading