Metadata-Version: 2.5
Name: foxxe-mcp
Version: 0.5.4
Summary: Shared MCP plumbing for the FoxxeLabs server fleet: one SDK pin, one bootstrap, thin re-export.
Project-URL: Homepage, https://github.com/todd427/foxxe-mcp
Author: Todd McCaffrey
License: MIT
License-File: LICENSE
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27.0
Requires-Dist: mcp<2,>=1.29.1
Requires-Dist: pydantic>=2.0
Requires-Dist: starlette>=0.36.0
Requires-Dist: uvicorn>=0.27.0
Provides-Extra: dev
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Provides-Extra: fastmcp
Requires-Dist: fastmcp<3,>=2.14; extra == 'fastmcp'
Description-Content-Type: text/markdown

# foxxe-mcp

Shared MCP plumbing for the FoxxeLabs server fleet. One SDK pin, one bootstrap, thin re-export of the SDK server class — so a spec revision is one commit here, not eleven latent crash-loops.

See [docs/PRD-foxxe-mcp.md](docs/PRD-foxxe-mcp.md) for why. See [docs/migration.md](docs/migration.md) for how to convert a server.

**Status:** v0.1.0 — library built, fleet unmigrated.

## Two shapes

The fleet splits evenly, and the library supports both as first-class.

**1. The MCP app *is* the app** (ainm, git-mcp) — use `serve()` / `build_app()`.

**2. MCP is mounted inside a parent app** (tomhas's FastAPI dashboard, mnemos's
Starlette API) — use `mounted_app()`, keep your own lifespan and `/health`:

```python
from foxxe_mcp import MCPServer, mounted_app, version_payload

mcp = MCPServer("tomhas")

@asynccontextmanager
async def lifespan(app):
    async with mcp.session_manager.run():   # same name on both SDK majors
        yield

app = FastAPI(lifespan=lifespan)
app.get("/version")(lambda: version_payload("tomhas"))
app.mount("/", mounted_app(mcp, allowed_host=EXTERNAL_HOST))   # last: routes above win
```

`mounted_app()` carries the fix for Starlette stripping the leading `/` under
`Mount("/")` — plumbing mnemos and tomhas had each written by hand.

## The shape

```python
from foxxe_mcp import MCPServer, serve

mcp = MCPServer("tomhas")

@mcp.tool()
def status() -> str:
    """Report the current run state."""
    ...

if __name__ == "__main__":
    serve(mcp)
```

`MCPServer` is the SDK's own class, re-exported unwrapped. The tool decorator is
the SDK's, reached through the instance — foxxe-mcp never wraps the registration
API (PRD NG2), so every SDK feature is available the day it ships.

`serve()` owns what eleven servers currently hand-roll: transport, bind address,
allowed host, `/health`, `/version`, logging, graceful shutdown.

## Why the name is `MCPServer` on an SDK that calls it `FastMCP`

v0.1 pins `mcp>=1.29.1,<2`, where the class is `FastMCP`. The export is named
`MCPServer` anyway — that is the mcp 2.x name, and exporting it now means the
eventual pin flip changes this library and no server code at all.

That is a claim, so it is tested: `scripts/verify_sdk2.py` runs the same library
against `mcp==2.1.1` and checks that `MCPServer` resolves to the renamed class,
that a tool still registers, and that `/health`, `/version` and `/mcp` all still
answer. Both majors, one codebase.

```
$ python scripts/verify_sdk2.py
foxxe-mcp 0.1.0 against mcp 2.1.1
  PASS  SDK major detected as 2 — mcp.server.mcpserver
  PASS  MCPServer resolves to the renamed class
  ...
All checks passed — the v1.0 pin flip is a one-line change.
```

## The bound is enforced twice

Once at install time, in `pyproject.toml`, and again at boot, in `check_sdk()`.
The second one is not redundant. PRD finding B: rialu declares no `mcp` at all,
only an unbounded `fastmcp`, and `fastmcp` 3.x pulls `mcp>=2` transitively — a
declaration that binds only at install time cannot catch what a resolver did to
a *transitive* dependency. `serve()` calls `check_sdk()` before the first tool
call, so a wrong resolution fails at boot, next to its cause.

`mcp` out of range is fatal. `fastmcp` out of range warns, and is fatal under
`FOXXE_MCP_STRICT_SDK=1`.

## Install

```
foxxe-mcp==0.5.0
```

Add the `fastmcp` extra only if the server actually uses the third-party
`fastmcp` package: `foxxe-mcp[fastmcp]==0.5.0`. Never declare `mcp` or
`fastmcp` directly in a server again — that is the whole point.

Published to PyPI rather than installed as a git dependency, which is a
deliberate amendment to PRD §7. The git-by-tag plan died on contact with the
first real deploy: this repo is private, so `pip install git+https://...`
inside a Docker build has no credentials and fails with exit 128. The
alternatives were a build-time PAT threaded through eleven Dockerfiles, or
making the repo public — which would publish `fleet.toml` and the internal
audit along with it. PyPI keeps the repo private, publishes only the package,
and makes every Dockerfile a plain `pip install` again. NG3 rules out a
*private* index; this is the public one.

## Fleet operations

```
foxxe-herd status              # poll every server's /version — who is on what
foxxe-herd redeploy            # dry run
foxxe-herd redeploy --yes      # fly deploy every migrated server
```

`fleet.toml` maps server name to Fly app name (verified against `flyer
apps_list`, not guessed — the `-foxxelabs` suffix is inconsistent) and records
which servers are migrated and which are still unbounded.

## Environment

| Variable | Default | Meaning |
|---|---|---|
| `PORT` | `8080` | Port to bind |
| `BIND_HOST` | `0.0.0.0` | Bind address (Fly requires all interfaces) |
| `ALLOWED_HOST` | `$FLY_APP_NAME.fly.dev` | Public hostname for DNS-rebinding protection — **not** the bind address |
| `MCP_TRANSPORT` | `http` | `http` or `stdio` |
| `LOG_LEVEL` | `INFO` | Root log level |
| `LOG_FORMAT` | `json` | `text` for human-readable |
| `MCP_BEARER_TOKENS` | unset | Comma-separated; presence enables bearer auth |
| `FOXXE_MCP_STRICT_SDK` | unset | `1` makes an out-of-range `fastmcp` fatal |
| `FOXXE_MCP_DNS_REBINDING` | unset | `1` re-enables Host/Origin checking (off by default — the edge pins the Host) |
| `TAISCE_SERVICE_TOKEN` | unset | Service token for `taisce_fetch()` |
| `SENTINEL_URL` | unset | Presence enables `sentinel_push()` |

## Development

```
python -m venv .venv && .venv/bin/pip install -e '.[dev]'
.venv/bin/python -m pytest
```
