# MCP Server Overview

> Expose tools, resources, and prompts to AI agents via the Model Context Protocol over HTTP.

Source: https://rws8.tech/docs/mcp/overview/ · Last updated 2026-10-30

The Model Context Protocol (MCP) is a JSON-RPC 2.0 standard that lets AI agents (Claude Desktop, Cursor, custom agents) call server-defined **tools**, read **resources**, and retrieve **prompt templates** over plain HTTP. `rust-web-server` ships a first-class `McpServer` that implements the MCP specification up to 2026-07-28 with no external dependencies, and speaks each version from 2024-11-05 on to the clients that ask for it, on one endpoint — see [MCP 2026-07-28](#mcp-2026-07-28) and [Protocol version negotiation](#protocol-version-negotiation) below.

## Creating an MCP server

```rust
use rust_web_server::server::Server;
use rust_web_server::mcp::{McpServer, McpContent, PromptMessage};

let mcp = McpServer::new("my-server", "1.0")
    .tool(
        "echo",
        "Echo text back to the caller",
        r#"{"type":"object","properties":{"text":{"type":"string"}},"required":["text"]}"#,
        |args| {
            let text = rust_web_server::mcp::extract_arg(args, "text")
                .unwrap_or_else(|| "(nothing)".to_string());
            Ok(McpContent::text(text))
        },
    )
    .resource(
        "docs://{topic}",
        "Documentation",
        "Return documentation for a topic",
        |uri| Ok(McpContent::text(format!("Docs for: {uri}"))),
    )
    .prompt(
        "summarize",
        "Summarize the given text",
        |args| {
            let text = rust_web_server::mcp::extract_arg(args, "text")
                .unwrap_or_else(|| "some text".to_string());
            Ok(vec![PromptMessage::user(format!("Please summarize: {text}"))])
        },
    );

// Pass directly to the server — McpServer implements Application.
// let (listener, pool) = Server::setup().unwrap();
// Server::run(listener, pool, mcp);
```

## Attaching MCP to an existing app

If you already have routes, state, or middleware, use `.wrap()` so that non-MCP requests fall through to your existing `Application`:

```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::mcp::{McpContent};

let server = App::new()
    .mcp("my-server", "1.0")
    .tool("ping", "Ping the server", "{}", |_| Ok(McpContent::text("pong")))
    .wrap(App::new()); // non-MCP requests handled by the built-in App
```

:::note[app.mcp() shorthand]
`App::new().mcp(name, version)` is equivalent to `McpServer::new(name, version)` with the built-in `App` automatically wired as the fallback. Both forms pass directly to `Server::run`.
:::

`AppWithState` and `WithMiddleware` have the same `.mcp()` method, so MCP can sit on top of your own routes — the dispatch chain is `McpServer` → your routes → the built-in controllers:

```rust
use rust_web_server::app::App;
use rust_web_server::mcp::McpContent;
use rust_web_server::request::Request;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use rust_web_server::router::PathParams;
use rust_web_server::routes;
use rust_web_server::server::{ConnectionInfo, Server};

#[derive(Clone)]
struct State { version: &'static str }

fn get_version(_req: &Request, _p: &PathParams, _c: &ConnectionInfo, _s: &State) -> Response {
    let mut r = Response::new();
    r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
    r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
    r
}

let http = routes! {
    App::with_state(State { version: "1.0.0" }),
    GET "/api/version" => get_version,
};

let mut mcp = http.mcp("my-app", "1.0.0");
if let Ok(token) = std::env::var("MCP_TOKEN") {
    mcp = mcp.require_bearer(token);
}
let app = mcp.tool(
    "server_version",
    "Return the running server version",
    r#"{"type":"object"}"#,
    |_| Ok(McpContent::json(r#"{"version":"1.0.0"}"#)),
);

let (listener, pool) = Server::setup().unwrap();
Server::run(listener, pool, app);
```

## MCP endpoint

All JSON-RPC 2.0 messages travel over `POST /mcp`. The endpoint handles the full MCP lifecycle:

| JSON-RPC method      | Purpose                                      |
|----------------------|----------------------------------------------|
| `initialize`         | Capability negotiation and server info       |
| `ping`               | Liveness check                               |
| `tools/list`         | List all registered tools                    |
| `tools/call`         | Invoke a tool by name                        |
| `resources/list`     | List all registered resources                |
| `resources/read`     | Read a resource by URI                       |
| `prompts/list`       | List all registered prompt templates         |
| `prompts/get`        | Retrieve a rendered prompt by name           |
| `resources/subscribe` / `resources/unsubscribe` | Watch a resource URI — see [Resource subscriptions](#resource-subscriptions) |
| `logging/setLevel`   | Set the minimum pushed log level — see [Logging](#logging) |
| `completion/complete` | Argument autocompletion — see [Argument autocompletion](#argument-autocompletion) |

`OPTIONS /mcp` is handled for CORS preflight, and `GET /mcp` opens an SSE stream for server → client push — see [SSE streaming transport](#sse-streaming-transport) below. All other HTTP methods return `405 Method Not Allowed`.

Override the default path with `.at("/custom-path")` if needed.

## Batch requests

`POST /mcp` also accepts a top-level JSON array instead of a single object — a JSON-RPC 2.0 batch request, letting a client send several calls in one HTTP round trip. Batching was removed from MCP in 2025-06-18, so batches are accepted only from clients on 2025-03-26 or earlier (by session, or `MCP-Protocol-Version`; no header and no session counts as 2025-03-26); others get a `-32600` error:

```json
// Request:
[{"jsonrpc":"2.0","method":"tools/list","id":1},
 {"jsonrpc":"2.0","method":"ping","id":2}]

// Response — one entry per element, in order:
[{"jsonrpc":"2.0","result":{"tools":[...]},"id":1},
 {"jsonrpc":"2.0","result":{},"id":2}]
```

Each element is dispatched through the same method table as a standalone request, and each one's success or error is independent — one element failing (e.g. an unknown method) doesn't affect the others or fail the batch as a whole.

Elements with no `id` (notifications) contribute no entry to the response array, exactly like a standalone notification produces no response body. A batch made up entirely of notifications returns `202 Accepted` with an empty body. An empty array (`[]`) is itself invalid per the JSON-RPC spec — it gets back a single `Invalid Request` error object rather than an empty `[]`.

:::note[initialize inside a batch]
If a batch includes a successful `initialize`, the *first* one mints a session and attaches `Mcp-Session-Id` to the overall response, same as a standalone `initialize` would. Sending more than one `initialize` in a single batch is unusual and only the first is honored for session purposes — one HTTP response can only carry one session id.
:::

## Pagination

`tools/list`, `resources/list`, and `prompts/list` return every registered item in one response by default. For a server with a lot of tools or resources, call `.page_size(n)` when building the server to cap each response to `n` items and enable cursor-based pagination:

```rust
use rust_web_server::mcp::McpServer;

let server = McpServer::new("my-server", "1.0").page_size(50);
```

A response with more items remaining includes `"nextCursor"` — an opaque string the client echoes back as `params.cursor` on its next call to get the next page:

```json
// First call — no cursor:
{"method":"tools/list","params":{}}
// → {"result":{"tools":[...50 items...],"nextCursor":"NTA="}}

// Next call — cursor from the previous response:
{"method":"tools/list","params":{"cursor":"NTA="}}
// → {"result":{"tools":[...remaining items...]}}  — no nextCursor once exhausted
```

Claude Desktop and other MCP clients already send `cursor` back automatically once a `nextCursor` appears in a response — no extra client-side wiring is needed.

:::note[The cursor is opaque]
The cursor is base64 of a decimal offset, but treat it as an opaque token — don't construct or parse it yourself. A malformed or tampered cursor gets a JSON-RPC `INVALID_PARAMS` (`-32602`) error rather than silently resetting to the first page. An offset past the end of the list returns an empty page with no `nextCursor`, not an error.
:::

## SSE streaming transport

The MCP Streamable HTTP spec defines a second transport alongside `POST /mcp`: a client that sends `GET /mcp` instead gets back a `text/event-stream` response that stays open indefinitely, for server → client push (log messages, progress updates, list-changed notifications, and anything else you want to push proactively).

Call `.notify(method, params_json)` from anywhere in your code — a background thread, a webhook handler, another tool's own handler — to push a JSON-RPC notification to every client currently connected to the SSE stream:

```rust
use rust_web_server::mcp::McpServer;

let server = McpServer::new("my-server", "1.0");

// Elsewhere, e.g. after a background job finishes:
server.notify("notifications/message", Some(r#"{"level":"info","data":"job finished"}"#));
```

`params_json`, if given, must already be valid JSON (usually an object) — it's spliced into the notification verbatim, not escaped or re-serialized. `method` alone (no `id`) matches how the JSON-RPC spec defines a notification: fire-and-forget, no response expected.

```json
// What a connected client sees on the SSE stream after the call above:
data: {"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info","data":"job finished"}}
```

:::note[Backpressure and disconnection]
`.notify()` never blocks the calling thread. Each connected client has a bounded 32-frame buffer; a client that isn't reading fast enough (buffer full) is dropped from the broadcast list exactly like a disconnected one — one slow or stuck client can never stall notifications to everyone else. Idle connections receive a `: keep-alive` SSE comment every 15 seconds, both so intermediate proxies don't time out a silent connection and because it forces a write attempt that reveals a dead peer.
:::

The stream works over HTTP/1.1, HTTP/2 and HTTP/3 alike: each delivers
events as they are produced (tested against real HTTP/2 and HTTP/3 clients).

## Logging

:::caution[Deprecated by MCP 2026-07-28]
The logging capability (`logging/setLevel`, `notifications/message`, `.log()`) is deprecated in the [2026-07-28 MCP specification](https://blog.modelcontextprotocol.io/posts/2026-07-28/) (SEP-2577). It keeps working with clients that use it, for at least twelve months; new servers should log through their own logging instead.
:::

The spec lets a client request a minimum log level and receive server log messages pushed as `notifications/message` events over the SSE stream — useful during development, since an MCP client like Claude Desktop can show server diagnostics inline instead of you tailing a separate log file.

Call `.logging_enabled()` when building the server to advertise the `logging` capability in `initialize`, then call `.log(level, logger, data_json)` from anywhere in your code to push an entry:

```rust
use rust_web_server::mcp::{LogLevel, McpServer};

let server = McpServer::new("my-server", "1.0").logging_enabled();

// Elsewhere in your code, e.g. inside a tool handler or a background thread:
server.log(LogLevel::Warning, Some("database"), r#""connection pool exhausted""#);
```

`LogLevel` is the MCP spec's eight RFC 5424 syslog severities, from most to least severe: `Debug`, `Info`, `Notice`, `Warning`, `Error`, `Critical`, `Alert`, `Emergency`. A connected client can narrow which levels it wants delivered by calling `logging/setLevel`:

```json
{"method":"logging/setLevel","params":{"level":"warning"}}
```

After that call, only `.log()` calls at `Warning` or more severe are pushed to that client — `Debug`, `Info`, and `Notice` calls are silently filtered. Before any client calls `logging/setLevel`, the default minimum is `Debug` — the least restrictive level, so nothing is filtered until a client asks for less noise.

`logger` (optional) identifies the log's source — a module name, a subsystem, whatever's meaningful in your app — and appears as the `logger` field on the pushed notification. `data_json` must already be valid JSON (an object, a string, a number — the spec allows any type) and is spliced in verbatim.

:::note[`.logging_enabled()` doesn't gate functionality]
Calling `.logging_enabled()` only changes what's *advertised* in `initialize`'s `capabilities` — `.log()` and `logging/setLevel` both work whether or not it was called. A spec-honest client simply wouldn't send `logging/setLevel` in the first place without seeing the capability advertised, so pairing them is expected but not enforced.
:::

`.log()` is built directly on [`.notify()`](#sse-streaming-transport) and inherits its behavior: it never blocks the calling thread, and a client whose event buffer fills up is dropped from the broadcast list exactly like a disconnected one.

## Dynamic registration

`.tool()`, `.resource()`, and `.prompt()` are consuming builders — call them once while constructing the server, before it starts serving requests. For tools/resources/prompts that only become known later (a plugin discovered at startup, a database connection, a hot-reloaded config file), use the `&self` equivalents instead, callable at any time from any thread holding a clone of the server:

```rust
use rust_web_server::mcp::{McpContent, McpServer};

let server = McpServer::new("my-server", "1.0");

// Later, from any thread holding a clone of `server`:
server.register_tool("refresh_cache", "Reload the in-memory cache", "{}", |_args| {
    Ok(McpContent::text("cache refreshed"))
});

let existed = server.remove_tool("refresh_cache"); // -> true
```

The matching pairs are `.register_tool(...)`/`.remove_tool(name)`, `.register_resource(...)`/`.remove_resource(uri_template)`, and `.register_prompt(...)`/`.remove_prompt(name)` — each `remove_*` returns `bool` (whether something was actually found and removed).

This works because tools/resources/prompts are stored behind `Arc<RwLock<Vec<_>>>` rather than a plain list, so every clone of `McpServer` — each connection gets one — shares the same live data. A tool handler is looked up and its `Arc` cloned out from under a brief read-lock before being called, so a slow-running tool never blocks a concurrent registration on another thread.

Every registration or removal that actually changes something pushes the corresponding notification to every `GET /mcp` SSE client:

```json
{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}
```

(Similarly `notifications/resources/list_changed` and `notifications/prompts/list_changed` — none of these carry a `params` field, per spec.) A removal that finds nothing (an unknown name) pushes no notification.

`initialize` now advertises `"listChanged":true` for tools, resources, and prompts unconditionally — dynamic registration is always available, unlike logging which needs `.logging_enabled()`. `resources.subscribe` is advertised as `true` too — see [Resource subscriptions](#resource-subscriptions).

:::note[No dynamic equivalent for every builder variant]
`.register_tool()` and `.register_prompt()` only cover the plain shapes — matching `.tool()` and `.prompt()`. There's no dynamic equivalent of `.tool_with_context()`, `.tool_annotated()`, or `.prompt_with_args()`. To change a dynamically-added tool's annotations or a prompt's argument definitions, remove it and register it again under the same name.
:::

## Argument autocompletion

Clients like Cursor and VS Code call `completion/complete` to offer autocomplete suggestions while the user fills in a tool or prompt argument, instead of leaving it a plain text box. Register a provider with `.completion(ref_type, ref_name, handler)`:

```rust
use rust_web_server::mcp::McpServer;

let server = McpServer::new("my-server", "1.0")
    .completion("tool", "deploy", |arg_name, partial| {
        match arg_name {
            "region" => Ok(vec!["us-east-1", "eu-west-1", "ap-southeast-1"]
                .into_iter()
                .filter(|r| r.starts_with(partial))
                .map(String::from)
                .collect()),
            _ => Ok(vec![]),
        }
    });
```

`ref_type` is `"tool"` or `"prompt"` — matched against the request's `ref.type` (`"ref/tool"`/`"ref/prompt"` on the wire) with the `"ref/"` prefix stripped. `ref_name` is the tool or prompt name this applies to. The handler receives the argument's name and whatever partial value the user has typed so far, and returns candidate completion strings.

A `completion/complete` request with no matching registration — an unrecognized `ref`/name, or an argument name the handler doesn't branch on — gets back an empty `values` array rather than an error; completion is a best-effort hint, not something every tool or prompt is required to support.

```json
{"completion":{"values":["us-east-1"],"hasMore":false,"total":1}}
```

:::note[Large result sets]
A handler returning more than 100 values gets truncated to the first 100, with `hasMore:true` and the untruncated `total` reported — matching the spec's guidance that servers shouldn't return huge completion lists in one response.
:::

:::note[`initialize` advertises this automatically]
`"completions":{}` appears in `initialize`'s capabilities as soon as at least one `.completion()` has been registered — there's no separate opt-in flag to call, unlike `.logging_enabled()`. A server with no completions registered simply doesn't advertise the capability, since `completion/complete` would just return empty results for everything anyway.
:::

There's no dynamic (`&self`) equivalent of `.completion()` — unlike tools/resources/prompts, completion providers are registered only via the consuming builder, before the server starts serving requests.

## Resource subscriptions

A client can subscribe to a specific resource URI and receive `notifications/resources/updated` whenever it changes — the mechanism behind live-updating resource panels in clients like Claude Desktop (watching a config file, a dashboard resource, or anything else that changes outside of a direct `tools/call`).

Call `.notify_resource_updated(uri)` from wherever your application actually changes the underlying data — a file watcher, a webhook handler, a poll loop:

```rust
use rust_web_server::mcp::McpServer;

let server = McpServer::new("my-server", "1.0");

// Elsewhere, e.g. after reloading a watched config file:
server.notify_resource_updated("config://main");
```

Unlike every other notification this server pushes (`.notify()`, `.log()`, `list_changed`), which broadcast to every connected `GET /mcp` SSE client, `notify_resource_updated` is **targeted**: only sessions that called `resources/subscribe` for that exact URI receive it. This is why `resources/subscribe` and `resources/unsubscribe` both require an `Mcp-Session-Id` header (from a prior `initialize` call) — without one there's no way to later match a subscription back to a specific `GET /mcp` SSE connection.

```json
// A client subscribes:
{"method":"resources/subscribe","params":{"uri":"config://main"}}

// Later, on that same session's GET /mcp SSE stream:
data: {"jsonrpc":"2.0","method":"notifications/resources/updated","params":{"uri":"config://main"}}
```

`resources/unsubscribe` removes the subscription; once a URI has zero subscribers, its bookkeeping entry is dropped entirely rather than left as an empty list.

:::caution[A client that disconnects without unsubscribing leaves a stale entry]
Nothing proactively notices a `GET /mcp` connection closing and removes its subscriptions — the same "no eviction without an explicit call" tradeoff already accepted for session storage elsewhere in this server. The stale session id sits harmlessly in the subscriber list for that URI (it just won't have a live SSE connection to actually deliver to) until an explicit `resources/unsubscribe` removes it.
:::

`initialize`'s `resources` capability now advertises `"subscribe":true` unconditionally, alongside the already-unconditional `listChanged:true`.

## MCP 2026-07-28

MCP 2026-07-28 is stateless: no `initialize`, no session. Every request
carries its protocol version and the client's capabilities in
`params._meta`, mirrored in HTTP headers. The same `/mcp` endpoint serves
it beside the session-based versions (since 18.5.0) — a request whose
`_meta` names a protocol version, or whose `MCP-Protocol-Version` header
isn't one of the earlier versions, is served statelessly; `initialize` and
the requests of a session work as before. Nothing changes in how you
register tools, resources and prompts.

On the 2026-07-28 path:

- **Validation.** Missing `_meta` fields are `-32602`; an
  `MCP-Protocol-Version`, `Mcp-Method` or `Mcp-Name` header that is missing
  or disagrees with the body is `-32020` (`Mcp-Name` may be
  `=?base64?…?=`); an unknown version is `-32022` with
  `data: {"supported": [...], "requested": "..."}` — all `400`.
- **Discovery.** `server/discover` returns every supported version
  (2026-07-28 and the four earlier ones), the capabilities and
  `_meta.io.modelcontextprotocol/serverInfo`. `initialize`, `ping`,
  `logging/setLevel`, `resources/subscribe`/`unsubscribe` and unknown
  methods are `404` with `-32601`. `GET` and `DELETE` belong to sessions.
- **Results** carry `resultType` and `serverInfo` in `_meta`; discovery,
  the lists and `resources/read` also `ttlMs` and `cacheScope`, by default
  `0` and `"private"` — set them with `.cache_hints(ttl_ms, public)` when
  the lists are the same for every caller. A missing resource is `-32602`
  with `data.uri`.
- **Input from the client** — elicitation, sampling, roots — is asked for
  by returning `McpContent::input_required(...)` from the tool (or
  `PromptReply::InputRequired` from a prompt), not by sending requests:
  see [Asking the client for input](/mcp/tools/#asking-the-client-for-input-mcp-2026-07-28).
  A request for a capability the client didn't declare is refused with
  `-32021` and `data.requiredCapabilities`.
- **`subscriptions/listen`** replaces the `GET` stream: the response is an
  SSE stream that starts with `notifications/subscriptions/acknowledged`
  and then carries only the types the client asked for
  (`toolsListChanged`, `promptsListChanged`, `resourcesListChanged`,
  `resourceSubscriptions`), each tagged with
  `_meta.io.modelcontextprotocol/subscriptionId`. `register_tool`,
  `notify_resource_updated` and the rest feed both kinds of stream.
- **Long-running tools** can answer with a task (the Tasks extension,
  `io.modelcontextprotocol/tasks`, advertised in `server/discover`):
  `ctx.start_task(...)` — see [Long-running work: tasks](/mcp/tools/#long-running-work-tasks-mcp-tasks-extension);
  `tasks/get`, `tasks/update` and `tasks/cancel` (with `Mcp-Name` = the
  task id) serve it.
- **`Mcp-Param-*` headers.** For a tool whose `inputSchema` marks
  parameters with `"x-mcp-header": "Name"`, `tools/call` must carry
  `Mcp-Param-Name` with that argument's value (plain or
  `=?base64?…?=`, canonical base64 only) whenever the argument is set, else
  `400 -32020`.
- **Request-scoped notifications** go on the request's own SSE response:
  progress (`ctx.report_progress`) when `_meta.progressToken` is set, log
  messages (`ctx.log(level, data)`) only when `_meta` sets
  `io.modelcontextprotocol/logLevel`.

The official conformance suite runs in CI for both eras
(`.github/workflows/mcp-conformance.yml`, suite 0.2.0-alpha.12): for
2026-07-28 every check passes — the required scenarios and those run
beside them (the Tasks extension, `Mcp-Param-*` validation, JSON Schema
2020-12); for 2025-11-25, 69 of 72 checks, the three failures being
server-initiated elicitation, which rws doesn't implement for that version.

## Protocol version negotiation

The server speaks MCP 2024-11-05, 2025-03-26, 2025-06-18 and 2025-11-25
through `initialize` (and 2026-07-28 without it, above).
`initialize` confirms the client's `params.protocolVersion` when it is one of
those, and answers anything else — older, newer, malformed or missing — with
the newest, 2025-11-25; the client then decides whether it can continue:

```json
// A client on 2025-06-18 gets 2025-06-18:
{"method": "initialize", "params": {"protocolVersion": "2025-06-18", "clientInfo": {"name": "my-client", "version": "1.0"}}}
{"result": {"protocolVersion": "2025-06-18", "capabilities": {...}, "serverInfo": {...}}}
```

The session remembers the version. After `initialize`, clients on 2025-06-18
and later send `MCP-Protocol-Version` on every request; a version the server
doesn't speak gets `400`, and a request without the header is taken as
2025-03-26 (as the transport specifies).

`params.clientInfo` (if the client sends it) is logged to stderr at `initialize` time and recorded under a freshly minted session id, returned to the client via an `Mcp-Session-Id` response header — see [Per-request context](/mcp/tools/#per-request-context) for how a `.tool_with_context()` handler gets it back on later requests in the same session.

## Sessions

`initialize` mints a session and returns its id in `Mcp-Session-Id`; the
client sends it on later requests. Sessions are bounded (since 18.0.0):

- A session idle for an hour is evicted — `.session_ttl(Duration)` changes
  that. Any use keeps it alive.
- At most 10 000 sessions are kept (`.max_sessions(n)`); a new one past that
  evicts the least recently used.
- `DELETE /mcp` with the session's `Mcp-Session-Id` ends it (`204`).
- A request carrying a session id the server doesn't know — never issued,
  ended or evicted — gets `404`, the transport's signal to send `initialize`
  again. An `initialize` carrying a stale id is accepted.
- An ended or evicted session's resource subscriptions and `GET /mcp` streams
  go with it.
- At most 1000 `GET /mcp` streams are open at once (`.max_sse_clients(n)`);
  another gets `503` with `Retry-After`.

```rust
use std::time::Duration;
use rust_web_server::mcp::McpServer;

let mcp = McpServer::new("my-server", "1.0")
    .session_ttl(Duration::from_secs(15 * 60))
    .max_sessions(1000)
    .max_sse_clients(100);
```

## Built-in rws tools

The `rws` binary serves `/mcp` with 20 built-in tools when `MCP_TOKEN` is set (required as a Bearer token; without it there is no `/mcp`), not in config-driven proxy mode. See [Operate rws from an AI client](/mcp/operate/) for setup and playbooks:

| Tool name             | Description                              |
|-----------------------|------------------------------------------|
| `server_config`       | Active `RWS_CONFIG_*` values, secrets hidden |
| `feature_flags`       | List compiled feature flags              |
| `server_metrics`      | Live request/error/connection counters   |
| `list_routes`         | Registered HTTP routes                   |
| `cache_stats`         | Response cache hit/miss and entry counts |
| `cache_clear`         | Evict all response cache entries         |
| `rate_limit_config`   | Current rate limit settings              |
| `check_rate_limit`    | Check remaining quota for a client IP   |
| `cors_config`         | Active CORS rules                        |
| `list_static_files`   | Files served from the static root        |
| `reload_config`       | Trigger a hot config reload              |
| `maintenance_status`  | Whether maintenance mode is on           |
| `set_maintenance`     | Turn maintenance mode on/off             |
| `block_ip` / `unblock_ip` | Add/remove an IP on the runtime blocklist |
| `list_blocked_ips`    | Currently blocked IPs                    |
| `feature_list`        | Runtime feature flags and their state    |
| `feature_set`         | Enable/disable a runtime feature flag    |
| `recent_requests`     | Last N requests (default 20, max 100)    |
| `recent_errors`       | Last N 4xx/5xx requests (default 20, max 100) |

## Connecting Claude Desktop

Add the server to `~/Library/Application Support/Claude/claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "my-server": {
      "url": "http://localhost:7878/mcp"
    }
  }
}
```

With bearer token authentication:

```json
{
  "mcpServers": {
    "my-server": {
      "url": "http://localhost:7878/mcp",
      "headers": {
        "Authorization": "Bearer your-token-here"
      }
    }
  }
}
```

## Connecting Cursor

In Cursor settings under **MCP Servers**, add:

```json
{
  "my-server": {
    "url": "http://localhost:7878/mcp"
  }
}
```

Or for HTTPS deployments:

```json
{
  "my-server": {
    "url": "https://api.example.com/mcp",
    "headers": {
      "Authorization": "Bearer your-token-here"
    }
  }
}
```
