mcp-tool-server
This server provides a FastMCP-compliant API with three utility tools and a REST health endpoint:
analyze_text: Computes word count, character count (including whitespace), sentence count (split on
.,!,?), average word length, and estimated reading time (based on 200 words per minute).fetch_url_metadata: Fetches an HTTP/HTTPS URL and returns the final URL (after redirects), HTTP status code,
Content-Type,Content-Length, and response time in milliseconds. HTTP error codes (4xx/5xx) are returned as data, not errors; only transport failures (DNS, timeout, connection refused) raise errors. Supports a configurable timeout (default: 10 seconds).convert_temperature: Converts temperature values between Celsius, Fahrenheit, and Kelvin, rejecting values below absolute zero.
/health REST endpoint: Always accessible without authentication; returns server status, name, version, and environment.
Additional capabilities include opt-in API key authentication for the MCP endpoint (supporting multiple bearer tokens), configurable transport (HTTP default or stdio), environment-based configuration (host, port, log level, API keys), Docker deployment, CI/CD via GitHub Actions, and integration with MCP clients or programmatic use via the FastMCP client library.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@mcp-tool-serverCheck the response time and headers for https://example.com"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
mcp-tool-server
A production-shaped MCP tool server built with FastMCP 3, FastAPI, and Pydantic v2.
Three example tools, a REST health endpoint on the same port, opt-in API-key auth, 100% test coverage, CI that publishes a real image on every merge, and ready-to-use deployment configs for Fly.io, Cloud Run, Kubernetes, and Railway — built in nine incremental, independently-verified phases.
Contents
Related MCP server: simple-mcp-server
Why this exists
This is a reference/portfolio implementation, not a business application — the three tools exist to demonstrate patterns (sync vs. async execution, external I/O, structured validation, error translation, tool metadata) rather than to solve one specific problem. A few decisions worth knowing before you read the code:
Tools are decoupled from the MCP server instance. Each tool in
app/tools/builds a standaloneToolviaFunctionTool.from_function(...)instead of decorating an existingmcpobject.create_server()wires them in at construction time. This avoids a circular import betweenapp.serverandapp.tools, and makes every tool callable and unit-testable without any MCP machinery involved.Business logic doesn't know MCP exists.
app/services/has zero FastMCP imports and raises plainValueError. Translation toToolErrorhappens once, at theapp/tools/boundary.Dependencies were added when first used, not upfront. FastAPI wasn't added until the health endpoint needed it;
httpxanduvicornlikewise. Nothing sits unused in the lockfile.Auth is opt-in, not bolted on as an afterthought.
app/security/holds one job: verify a bearer token. It's aTokenVerifierFastMCP already knows how to consume, not a custom middleware reinventing that.A tool that fetches arbitrary URLs, called by an AI agent, is an SSRF vector by default.
fetch_url_metadataresolves the hostname and checks the actual IP before ever connecting — a caller (or a model prompt-injected into calling it) pointing it at169.254.169.254(cloud instance metadata) or an internal10.x/192.168.xaddress gets refused, not a credential leak. Scoped deliberately: this closes the direct case, not DNS rebinding, which needs transport-layer enforcement rather than a pre-request check like this one.Two ASGI apps, one port — see Architecture below.
Architecture
flowchart TD
A["Client (MCP client, curl, browser)"] --> B
subgraph B["Transport Layer"]
direction LR
B1["server.py\nstdio / FastMCP"]
B2["asgi.py\nFastAPI + /health + /mcp mount"]
end
B --> C
subgraph C["Adapter Layer"]
direction LR
C1["tools/\nMCP tool wrappers"]
C2["api/\nREST routes"]
end
C --> D["services/\npure business logic, no FastMCP import"]
D --> E["models/\nPydantic schemas"]
C -.uses.-> E
G["security/\napi-key verification"] -.guards.-> B
F["config/ + utils/\nsettings and logging"] -.injected into.-> B
F -.injected into.-> C
F -.injected into.-> G
classDef transport fill:#eef2ff,stroke:#4338ca,color:#1e1b4b
classDef adapter fill:#fff7ed,stroke:#c2410c,color:#431407
classDef core fill:#ecfdf5,stroke:#047857,color:#022c22
classDef cross fill:#f8fafc,stroke:#64748b,color:#0f172a,stroke-dasharray: 3 3
classDef security fill:#fef2f2,stroke:#b91c1c,color:#450a0a
class B1,B2 transport
class C1,C2 adapter
class D,E core
class F cross
class G securityDependency direction is one-way: transport → adapters → services → models.
Nothing in services/ or models/ imports anything above it — that's what
keeps business logic testable without spinning up any MCP or HTTP machinery.
.github/workflows/ # CI: lint + type-check + test, secret scan, Docker build/smoke-test, GHCR publish
fly.toml # Fly.io deployment config
railway.json # Railway deployment config
deploy/cloudrun/ # Cloud Run declarative service spec
docs/ # Deeper guides not in the main README (Claude API integration)
examples/ # Runnable integration examples, referenced from docs/
k8s/ # Kubernetes manifests (Deployment, Service, HPA, ConfigMap, Secret template)
assets/ # README charts (PNG, generated from real data -- see scripts/)
scripts/ # generate_charts.py: regenerates assets/*.png; not part of the app
app/
├── server.py # create_server(): builds the FastMCP instance + tool registry
├── asgi.py # create_asgi_app(mcp, settings): mounts FastMCP into FastAPI for the http transport
├── config/ # Environment-driven settings (Pydantic v2 / pydantic-settings)
├── utils/ # Logging and other cross-cutting helpers
├── security/ # Opt-in API-key bearer-token verification
├── api/ # Plain REST routes (currently just /health, never auth-gated)
├── tools/ # MCP tool adapters: schema, metadata, ValueError -> ToolError translation
├── services/ # Pure business logic. No FastMCP import, anywhere.
└── models/ # Pydantic schemas shared by tools/services/api
tests/ # 69 tests, 100% line coverage (unit + integration, no real network calls)Request lifecycle
The one genuine gotcha in this codebase: mcp.http_app() returns a
Starlette app whose session manager only starts if its lifespan is
explicitly handed to the parent FastAPI app. Skip that and /health
works fine while every tool call fails with a task-group error — confirmed
the hard way during Phase 3 (see app/asgi.py). The diagram below is what
it looks like once that's wired correctly:
sequenceDiagram
participant C as Client
participant F as FastAPI (asgi.py)
participant Auth as Auth (security/)
participant M as FastMCP session manager
participant T as Tool adapter
participant S as Service
C->>F: POST /mcp (tools/call)
F->>Auth: check Authorization header
alt auth disabled or token valid
Auth-->>F: ok
F->>M: routed via mounted app
M->>T: invoke tool function
T->>S: delegate to service
alt success
S-->>T: return value
T-->>M: Pydantic model
M-->>F: structured result
F-->>C: 200 + JSON-RPC result
else invalid input
S--)T: raise ValueError
T--)M: raise ToolError
M-->>F: JSON-RPC error
F-->>C: JSON-RPC error (is_error=true)
end
else missing or invalid token
Auth-->>F: reject
F-->>C: 401 Unauthorized
end/health is simpler — it never touches the MCP session manager, or the auth
check, at all; it's a plain FastAPI route that reads settings via dependency
injection (see Testing for a real bug that caught).
Authentication
Off by default, on by setting one environment variable:
MCP_API_KEYS=key-for-client-a,key-for-client-bEmpty (default): no auth. Anyone who can reach the port can call any tool. Fine for local exploration; not fine for anything reachable outside your own machine.
Set: bearer-token auth on
/mcp, enforced by FastMCP itself.app/security/api_key_auth.pyimplementsTokenVerifier— the resource-server pattern FastMCP already understands, not a hand-rolled middleware — checking each token against the configured set withhmac.compare_digest(constant-time, so a valid key can't be inferred faster via response-timing side channels)./healthis never gated, on purpose — infrastructure checking liveness (Docker'sHEALTHCHECK, a load balancer, a k8s probe) shouldn't need credentials just to know the process is up.Multiple keys, not one shared secret — supports rotation: issue a new key, roll it out, then remove the old one from
MCP_API_KEYSwithout any downtime.
This is deliberately a pre-shared-key scheme, appropriate for a small
number of known/trusted callers. fastmcp.server.auth.providers bundles
real OAuth/OIDC integrations (Auth0, WorkOS, GitHub, and others) for when
per-user identity or third-party client registration is actually needed —
reach for one of those rather than extending ApiKeyVerifier into
something it isn't.
from fastmcp import Client
async with Client("http://localhost:8000/mcp", auth="key-for-client-a") as client:
await client.call_tool("analyze_text", {"text": "Hello!"})Keeping keys out of logs and the repo, specifically:
Every configured/submitted token is logged, if at all, only through
mask_token()— a short prefix and a length ("a-re...(34 chars)"), enforced by tests that assert the real value never appears incaplogoutput for either a successful or failed check (tests/test_auth.py). Before this,verify_tokenlogged nothing at all about auth attempts — a real observability gap, not a deliberate safety choice, closed alongside the masking rather than left as a TODO.Confirmed directly (not assumed) that neither FastMCP's own 401 response body/headers nor its internal log line echo back a submitted token or a configured key — checked with a real request carrying a token designed to be obviously identifiable if it leaked.
.github/workflows/ci.ymlrunsgitleaksagainst full git history on every push and PR, andpublishwon't run if it finds anything — installed as the plain open-source binary directly rather than via the officialgitleaks-actionwrapper, which requires a paid license for organization (not personal) accounts as of v3. Verified locally against this exact repo (clean) and against a planted fake credential in an isolated scratch repo (correctly caught, exit code 1) before being wired into CI.No real key has ever existed in this repository —
.env.exampleships the variable empty,k8s/secret.example.yamlis obvious placeholder text and is deliberately excluded fromkustomization.yaml, and every value that looks like a key in tests or docs ("secret-key","key-for-client-a", ...) is a fixture, confirmed by runninggitleaksitself against the repo, not just by eyeballing it.
Tools
Tool | Style | Tags | Description |
| sync |
| Word/character/sentence counts and an estimated reading time. |
| async |
| Status code, headers, and response time for an http/https URL. Refuses hosts that resolve to a private/internal address (SSRF protection — see Why this exists). Only transport failures raise — a 404 is valid data. |
| sync |
| Convert between celsius, fahrenheit, and kelvin; rejects values below absolute zero. |
Each carries MCP tool annotations (readOnlyHint, idempotentHint /
openWorldHint) so clients can reason about side effects before calling them.
flowchart LR
subgraph analyze["analyze_text - sync"]
direction TB
A1["text: str"] --> A2["TextAnalysisResult\nword_count: int\ncharacter_count: int\nsentence_count: int\naverage_word_length: float\nestimated_reading_time_seconds: float"]
end
subgraph convert["convert_temperature - sync"]
direction TB
C1["value: float\nfrom_unit: celsius|fahrenheit|kelvin\nto_unit: celsius|fahrenheit|kelvin"] --> C2["ConversionResult\ninput_value: float\ninput_unit: str\noutput_value: float\noutput_unit: str"]
end
subgraph fetch["fetch_url_metadata - async"]
direction TB
F1["url: str\ntimeout_seconds: float = 10.0"] --> F2["UrlMetadata\nurl: str\nstatus_code: int\ncontent_type: str?\ncontent_length_bytes: int?\nresponse_time_ms: float"]
end
classDef input fill:#eef2ff,stroke:#4338ca,color:#1e1b4b
classDef output fill:#ecfdf5,stroke:#047857,color:#022c22
class A1,C1,F1 input
class A2,C2,F2 outputEvery field above is taken directly from the actual Pydantic models in
app/models/ — not summarized or approximated — so this diagram is safe
to treat as the real contract each tool returns, not just an impression
of it.
Quick start
uv sync
cp .env.example .env
uv run python -m app.serverThis starts the http transport on http://0.0.0.0:8000, serving both
/health and the MCP endpoint at /mcp. Set MCP_TRANSPORT=stdio in .env
instead if a client (e.g. Claude Desktop) will spawn this process directly.
Using with Claude Desktop
stdio is the transport an actual MCP client uses to spawn this process
directly (as opposed to connecting to an already-running http server) —
Settings → Developer → Edit Config opens claude_desktop_config.json
(~/Library/Application Support/Claude/ on macOS, %APPDATA%\Claude\ on
Windows). Add:
{
"mcpServers": {
"mcp-tool-server": {
"command": "uv",
"args": [
"run", "--directory", "/absolute/path/to/mcp-tool-server",
"python", "-m", "app.server"
],
"env": { "MCP_TRANSPORT": "stdio" }
}
}
}--directory matters, not just style — Claude Desktop spawns command
directly with no guaranteed working directory, and without it Settings'
relative .env lookup (and uv's own project discovery) would silently
fail depending on wherever Claude Desktop happens to run from. Use an
absolute path; fully quit and restart Claude Desktop (not just close the
window) after saving; a hammer/tools icon in the chat input confirms it
connected.
This transport surfaced a real bug no amount of http-transport testing
ever would have. configure_logging sent every log line — this app's
own, plus every third-party logger sharing the root logger, FastMCP's
included — to stdout. Under http that's invisible; stdout is just a
stream Docker or a terminal captures. Under stdio, stdout is the
JSON-RPC channel a client parses message-by-message, and every log line
between two real protocol messages is itself invalid JSON. A real client
connection confirmed the actual symptom directly: Failed to parse JSONRPC message from server on essentially every line, from fastmcp's own
client — which happened to keep working anyway, by skipping unparseable
lines and waiting for the next valid one. That's not something to depend
on; a stricter client has no obligation to be that forgiving. Fixed by
sending logs to stderr instead (app/utils/logging.py) — required for
stdio to work at all, and a no-op for every other target here, since
Docker/Kubernetes/Fly/Cloud Run/Railway all capture stderr right alongside
stdout. Verified before and after with a real spawned subprocess speaking
real stdio (not a mock): broken, then a single clean line of output with
zero log noise on stdout, confirmed by literally counting the lines.
Calling this from your own code against the Claude API (rather than
through the Claude Desktop app) is a related but different integration —
covered separately in
docs/claude-api-integration.md, with
two runnable examples in examples/.
Docker
cp .env.example .env
docker compose up --buildflowchart LR
subgraph Builder["Builder stage - python:3.12-slim"]
direction TB
U["uv binary, pinned 0.11.7"] --> L["uv sync --frozen --no-install-project"]
L --> Src["copy app/ source"]
Src --> Sync2["uv sync --frozen (installs project)"]
end
Sync2 -->|copy .venv + app/ only| Runtime
subgraph Runtime["Runtime stage - python:3.12-slim"]
direction TB
Venv[".venv"] --> User["non-root user"]
User --> Health["HEALTHCHECK -> /health"]
Health --> Cmd["CMD python -m app.server"]
endDependencies are synced in the builder stage from the lockfile before any
application code is copied in, so rebuilds only reinstall packages when
uv.lock actually changes. The runtime stage carries only the built virtual
environment and app/ — no uv, no lockfile, no tests, no dev dependencies.
# without compose
docker build -t mcp-tool-server .
docker run --rm -p 8000:8000 --env-file .env -e MCP_HOST=0.0.0.0 mcp-tool-serverNote: this Dockerfile follows the standard multi-stage
uvpattern and was reviewed carefully, but this sandbox had no Docker daemon available to actually rundocker buildagainst — unlike the rest of this project, it wasn't executed end-to-end here. Thedockerjob in.github/workflows/ci.ymlbuilds and smoke-tests the image on every push, which is where this actually gets verified — check that it's green before relying on the image.
CI/CD
Four jobs. test, secret-scan, and docker run on every push and PR to
main; publish only runs on push to main, and only if the other three
all pass:
Job | What it runs |
|
|
|
|
|
|
| Only on push to |
astral-sh/setup-uv and actions/checkout are pinned to a specific commit
SHA rather than a mutable tag (@v9.0.0 as a comment for readability, but
the SHA is what actually runs) — a floating tag can be repointed by whoever
controls the action's repo; a SHA can't. Standard supply-chain hardening
for anything that runs arbitrary code in CI.
Deployment
This is a stateless, single-process HTTP service with every setting externalized to environment variables — about as close to "deploy anywhere that runs a container" as an app gets. Four concrete targets are checked in rather than just described:
flowchart LR
Dev["git push main"] --> CI
subgraph CI["CI - .github/workflows/ci.yml"]
direction TB
T["test: ruff + mypy + pytest"] --> SS["secret-scan: gitleaks"]
SS --> D["docker: build + smoke test"]
D --> P["publish: build + push"]
end
P --> GHCR["ghcr.io/OWNER/mcp-tool-server"]
GHCR --> Fly["Fly.io\nfly.toml"]
GHCR --> CloudRun["Cloud Run\ndeploy/cloudrun/service.yaml"]
GHCR --> K8s["Kubernetes\nk8s/"]
Dev -.Dockerfile, no GHCR needed.-> Railway["Railway\nrailway.json"]
classDef ci fill:#eef2ff,stroke:#4338ca,color:#1e1b4b
classDef registry fill:#fff7ed,stroke:#c2410c,color:#431407
classDef target fill:#ecfdf5,stroke:#047857,color:#022c22
class T,SS,D,P ci
class GHCR registry
class Fly,CloudRun,K8s,Railway targetThe publish job in CI builds and pushes to GHCR on every merge to main
(after test and docker both pass, not before) — that's what makes the
manifests below reference a real, pullable image instead of a hypothetical
one. Replace OWNER in each file with the actual GitHub owner/repo.
Target | File | Notes |
Fly.io | Fastest path to a live URL. | |
Cloud Run | Declarative Knative spec; | |
Kubernetes |
| |
Railway | Detects the Dockerfile directly — connect the repo and it deploys, no GHCR step needed. |
k8s/ is the one target with enough moving pieces (five separate
manifest files) that a table row doesn't really show how they fit
together:
flowchart TB
Kustomization["kustomization.yaml"] -.applies.-> Deployment
Kustomization -.applies.-> Service
Kustomization -.applies.-> ConfigMap
Kustomization -.applies.-> HPA
HPA["HorizontalPodAutoscaler\n2-10 replicas at 70% CPU"] -->|scales| Deployment["Deployment\n2 replicas default\nnon-root, read-only rootfs"]
ConfigMap["ConfigMap\nmcp-tool-server-config"] -->|envFrom| Deployment
SecretTemplate["Secret - manual, not in kustomization\nmcp-tool-server-secrets"] -.optional true.-> Deployment
Service["Service - ClusterIP\nport 80 to 8000"] -->|routes to| Deployment
Deployment -->|readiness and liveness| Health["GET /health"]
classDef control fill:#eef2ff,stroke:#4338ca,color:#1e1b4b
classDef config fill:#f8fafc,stroke:#64748b,color:#0f172a,stroke-dasharray: 3 3
classDef workload fill:#ecfdf5,stroke:#047857,color:#022c22
classDef secret fill:#fef2f2,stroke:#b91c1c,color:#450a0a
class Kustomization,HPA,Service control
class ConfigMap config
class Deployment,Health workload
class SecretTemplate secretThe dashed line into Deployment is deliberate, not a rendering
artifact: the Secret is the one resource kustomization.yaml does not
apply (see the table row above), and the Deployment references it with
optional: true, so the whole stack is fully functional with that edge
simply absent — the visual equivalent of auth being opt-in everywhere
else in this project.
Railway specifically needed a real code fix, not just a config file.
Fly/Cloud Run/Kubernetes all let you pick a fixed port and configure the
platform to route to it. Railway instead assigns a port dynamically and
injects it as a bare PORT env var with no way to rename it — and this
app only read MCP_PORT. Without a fix, Railway would've considered the
deploy healthy at the container level while every request 404'd at the
edge, since traffic would arrive on the port Railway picked while the
app listened on 8000 regardless. Fixed in app/config/settings.py with
validation_alias=AliasChoices("MCP_PORT", "PORT") — MCP_PORT still
wins if both are set, so this is purely additive for every other target.
One second-order bug surfaced while fixing the first: the Dockerfile baked
MCP_PORT=8000 in as an image-level default, which — since MCP_PORT
takes precedence — would have silently shadowed Railway's real port on
every deploy regardless of the fix above. Removed; app/config/settings.py
already defaults to 8000 on its own with no env var set at all, so the
Dockerfile default was pure redundancy that happened to also be actively
wrong for this one target. Verified with a real boot under simulated
Railway conditions (PORT set, MCP_PORT deliberately absent) — the app
came up on the assigned port and, just as importantly, confirmed not
listening on 8000 at all.
Secrets, consistently: MCP_API_KEYS is never written into any of
these files. Fly uses fly secrets set; Cloud Run uses Secret Manager via
--set-secrets (a commented secretKeyRef block shows where); Kubernetes
uses a separately-created Secret that the Deployment references with
optional: true; Railway uses its dashboard/CLI Variables (railway variables set) — so the app runs identically with or without it,
matching Authentication's "empty means disabled"
default.
On graceful shutdown: uvicorn drains in-flight requests on SIGTERM by
default; this was exercised (not just assumed) throughout development via
timeout N uv run python -m app.server, which sends SIGTERM and waits —
every such run exited cleanly on its own well within the timeout, with no
forced SIGKILL needed. The Kubernetes manifest's preStop hook and
terminationGracePeriodSeconds: 30 build in margin around that same path
for rolling updates specifically.
Same caveat as the Dockerfile itself: these four configs were written carefully and cross-checked against each platform's current documentation (both Fly's and Railway's schemas have changed before), and validated for syntax — but none were applied against a real Fly app, GCP project, or cluster from this sandbox. Treat first deploys accordingly.
Configuration
All variables are optional and prefixed MCP_; see .env.example for the
full, commented list. The ones worth knowing about:
Variable | Default | Notes |
|
|
|
|
| Only used for the |
|
|
|
|
| Standard library log level name |
| (empty) | Comma-separated bearer tokens; empty disables auth entirely — see Authentication |
|
| FastMCP pings PyPI on startup by default; set |
Example usage
Health check:
curl http://localhost:8000/health
# {"status":"ok","name":"mcp-tool-server","version":"0.1.0","environment":"development"}Calling a tool — the MCP endpoint is a stateful, session-based protocol
(not plain REST; see Request lifecycle), so the
practical way to call it is fastmcp's own client rather than raw curl:
import asyncio
from fastmcp import Client
async def main():
async with Client("http://localhost:8000/mcp") as client:
result = await client.call_tool(
"convert_temperature",
{"value": 100, "from_unit": "celsius", "to_unit": "fahrenheit"},
)
print(result.data) # output_value=212.0 ...
asyncio.run(main())Or in-memory, against the server object directly (no network at all — this is exactly what the test suite does):
from fastmcp import Client
from app.server import mcp
async with Client(mcp) as client:
tools = await client.list_tools()Testing
uv run pytest # 69 tests, coverage report on by default (see pyproject.toml)
uv run ruff check .
uv run mypy appThe same three commands run in CI on every push — see CI/CD.
Real milestones, not a smoothed curve — Phases 5-7 (Docker, docs, CI/CD)
genuinely added no new Python tests, and the chart shows that flat instead
of hiding it. The two jumps are Phase 4 (closing coverage gaps found by
--cov-report=term-missing) and Phase 8 (auth plus the masked-logging
hardening that came out of testing it). The final point is a refine pass
over the finished project — see below.
Coverage is 100% across all 275 statements in app/, and pyproject.toml
sets fail_under = 100 so that claim is enforced, not just true today by
coincidence: uv run pytest exits non-zero the moment coverage drops below
100%, verified with a positive control (a deliberately uncovered function,
confirmed to fail the build, then removed) rather than taken on faith. That
number is a byproduct of testing real behavior (every tool's error path,
the settings validation, the main() transport dispatch, the FastAPI
lifespan wiring), not a target chased for its own sake — the last few
percentage points came directly from pytest --cov-report=term-missing
pointing at genuine gaps, including two real bugs it caught:
create_asgi_app(settings)accepted asettingsargument that the health route was silently ignoring in favor of the global cached singleton (FastAPI'sDepends(get_settings)doesn't know about asettingsvalue constructed elsewhere unless you override the dependency) — fixed with a dependency override inapp/asgi.py.Testing auth by connecting
fastmcp.Clientdirectly to the in-memoryFastMCPinstance silently passed with no token required, regardless of configuration — because that transport bypasses HTTP (and therefore headers) entirely, not because auth was broken. It also revealedcreate_asgi_appwas closing over a module-level app built from default settings at import time, so passing different auth config into it did nothing. Both are fixed:create_asgi_app(mcp, settings)now takes the server instance explicitly, and auth tests go through real HTTP viaTestClientwith an actualAuthorizationheader (tests/test_auth.py).A later refine pass over the (by then "finished") project found two more, of a different kind — not missing tests, but dead or misleading surface area that had accumulated:
is_productionwas defined, tested, and never actually used anywhere, andMCP_TRANSPORTaccepted"sse"as a value that silently did nothing (never forwarded tomcp.http_app()). Fixed by wiringis_productionto actually disable FastAPI's/docs,/redoc, and/openapi.jsonin production, and by removing"sse"rather than leaving an option on that doesn't work — offering a config value that's silently a no-op is worse than not offering it. Also caught, in the same pass: a Dockerfile comment describing theMCP_PORT/PORTfallback precedence backwards from what the code directly below it actually did.A further pass, reading
fetch_url_metadataspecifically with a security lens rather than a correctness one, found the SSRF gap described above. Fixing it exposed a second issue in the fix itself: the obvious implementation does real DNS resolution before ever touching the (correctly mocked) HTTP client, which would have silently made every existingMockTransport-based test in this file dependent on real DNS working from wherever the suite happened to run — exactly what mocking the transport was supposed to prevent, just one layer down. Fixed by injecting the resolver the same way the HTTP client already was, then verified the fix against the actual attack it closes: a real MCP tool call to169.254.169.254(cloud metadata) through the full stack, blocked; a real call to a genuine public site, unaffected.
Bar colors in the first chart match the architecture diagram above —
green for the core layer (services/models), orange for adapters
(tools/api), indigo for transport, red for security — so the two visuals
read as one system rather than two unrelated ones. services carries the
most weight (79 statements) for the same reason it's the layer this
project cares most about testing in isolation: it's where the actual
business logic lives, deliberately kept free of any FastMCP import (see
Why this exists). test_web_service.py is the largest
test file — 17 cases from 11 test functions, since the SSRF check is
verified with a @pytest.mark.parametrize matrix across seven different
blocked-address ranges rather than one test per range copy-pasted; by raw
function count test_auth.py (15) is still larger, which tracks with why
auth got its own dedicated section — it's the other
piece of this codebase with real security consequences if it's wrong.
Both charts are generated from real, freshly-measured data (a live
pytest --cov run, grep -c "def test_" across tests/*.py, and the
exact milestone numbers observed during this project's own build — see
the growth chart above) by scripts/generate_charts.py,
not hand-drawn. Re-run it after the test suite changes enough to make the
numbers stale.
Network-dependent tests (test_web_service.py) use httpx.MockTransport —
no real HTTP calls, no flakiness, no dependency on network access in
whatever environment runs the suite.
Roadmap
Phase 1 — Architecture & project initialization
Phase 2 — Example tools + tool metadata
Phase 3 — FastAPI mounting + health endpoint
Phase 4 — Full unit test suite
Phase 5 — Docker + docker-compose
Phase 6 — Full documentation pass
Phase 7 — CI/CD (GitHub Actions: lint/type/test + Docker build & smoke test)
Phase 8 — Opt-in API-key authentication for the MCP endpoint (+ masked audit logging,
gitleaksin CI)Phase 9 — Deployability: GHCR image publish + Fly.io / Cloud Run / Kubernetes / Railway configs
Refine pass — no new features; closed gaps a fresh critical read found in the "finished" project: dead
is_productionproperty (now wired to gate/docsin production), aMCP_TRANSPORTvalue that silently did nothing, a backwards precedence comment in the Dockerfile,fail_under = 100so the coverage claim is enforced rather than just currently true, an SSRF gap infetch_url_metadata(arbitrary-URL tools called by AI agents are a real attack surface) closed with DNS-resolution-based address checking, and application logging corrupting thestdioJSON-RPC channel — invisible under everyhttp-based test in this whole build, found only by actually connecting a real client over realstdio(see Using with Claude Desktop)
License
MIT — see LICENSE.
Available Tools
3 toolsanalyze_textARead-onlyIdempotent
Compute word, character, and sentence counts plus an estimated reading time.
| Name | Required | Description | Default |
|---|---|---|---|
| text | Yes | The text to analyze. Must be non-empty. |
Output Schema
| Name | Required | Description |
|---|---|---|
| word_count | Yes | Number of whitespace-separated words. |
| sentence_count | Yes | Number of sentences, split on '.', '!', and '?'. |
| character_count | Yes | Total number of characters, including whitespace. |
| average_word_length | Yes | Mean number of characters per word. |
| estimated_reading_time_seconds | Yes | Estimated reading time at 200 words per minute. |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already declare readOnlyHint and idempotentHint, covering the safety profile. The description adds behavioral context by specifying the computed outputs (word, character, sentence counts, reading time), which is beyond the annotations and helps the agent anticipate results. It does not introduce any undisclosed side effects or contradictions.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is a single, front-loaded sentence that immediately states the action and outputs. It contains no fluff or repetition, earning the maximum score for conciseness and structure.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Given the tool's simplicity (one parameter, high schema coverage), the existence of an output schema, and read-only/idempotent annotations, the description fully equips the agent to select and invoke the tool correctly. No additional context is needed.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 100% for the single parameter 'text', which includes the meaning and a non-empty constraint. The description does not add further parameter details, but the schema fully captures the semantics, so the baseline of 3 is appropriate.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description uses a specific verb 'Compute' and lists exact resources (word, character, and sentence counts, plus estimated reading time), making the tool's purpose unmistakable. It clearly differentiates from sibling tools like fetch_url_metadata and convert_temperature, which cover unrelated domains.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description conveys clear context: this tool is for computing text analytics metrics. It does not explicitly discuss when not to use it or compare with alternatives, but the sibling tools are unrelated and the purpose is self-evident, so the guidance is sufficient for a straightforward tool.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
convert_temperatureARead-onlyIdempotent
Convert a temperature between celsius, fahrenheit, and kelvin.
| Name | Required | Description | Default |
|---|---|---|---|
| value | Yes | The temperature value to convert. | |
| to_unit | Yes | Unit to convert to. | |
| from_unit | Yes | Unit of `value`. |
Output Schema
| Name | Required | Description |
|---|---|---|
| input_unit | Yes | |
| input_value | Yes | |
| output_unit | Yes | |
| output_value | Yes | `input_value` converted to `output_unit`. |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already declare readOnlyHint and idempotentHint, so the safety profile is covered. The description confirms the operation but adds no extra behavioral context (e.g., edge cases or return format), which is acceptable given the simplicity and annotation coverage.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is a single, focused sentence that front-loads the action and all necessary details. No wasted words or redundant information.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
For a simple, self-contained conversion tool with a complete schema and output schema available, the description sufficiently conveys the tool's purpose and scope. No additional context is needed for an agent to select and invoke it correctly.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
The input schema has 100% coverage with clear descriptions for all three parameters, including enums for from_unit and to_unit. The tool description adds no additional parameter semantics beyond what the schema already provides, so the baseline of 3 applies.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description uses a specific verb 'Convert' and clearly identifies the resource (temperature) and the units involved (celsius, fahrenheit, kelvin). This fully distinguishes it from sibling tools like analyze_text and fetch_url_metadata.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description clearly indicates this tool is for temperature conversions, providing clear context for when to use it. There are no explicit alternatives or exclusions, but the purpose is unambiguous given the sibling tools are unrelated.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
fetch_url_metadataARead-only
Fetch a URL and report its status code, content type/length, and response time.
Does not raise on HTTP error status codes (4xx/5xx) -- those come back as data, since a 404 is a valid answer to "what does this URL return?". Only transport-level failures (timeout, DNS, connection refused, bad scheme) raise.
| Name | Required | Description | Default |
|---|---|---|---|
| url | Yes | The http/https URL to check. | |
| timeout_seconds | No | Request timeout in seconds. |
Output Schema
| Name | Required | Description |
|---|---|---|
| url | Yes | The final URL after following redirects. |
| status_code | Yes | HTTP status code returned by the server. |
| content_type | No | Value of the Content-Type header, if present. |
| response_time_ms | Yes | Round-trip time for the request, in milliseconds. |
| content_length_bytes | No | Value of the Content-Length header, if present. |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations include readOnlyHint=true and openWorldHint=true, but the description adds crucial behavioral detail beyond that: it explicitly states that HTTP error status codes do not raise exceptions, while transport-level failures do. This is valuable, non-obvious behavior that informs the agent's error handling.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is two short paragraphs. The first sentence states the core purpose immediately, and the second paragraph explains error-handling behavior without any fluff or redundancy. Every sentence earns its place.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
This is a simple tool with only two well-documented parameters and an output schema. The description covers the main purpose and the critical edge case of HTTP error handling, making it complete enough for an agent to select and invoke the tool correctly.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
The schema already covers both parameters with clear descriptions (URL and timeout_seconds), and schema_description_coverage is 100%. The description does not add meaningful parameter semantics beyond what the schema provides, so the baseline score of 3 is appropriate.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description opens with 'Fetch a URL and report its status code, content type/length, and response time,' which is a specific verb+resource pair. It clearly distinguishes this tool from siblings like analyze_text and convert_temperature, which serve entirely different purposes.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description gives clear context for when to use the tool (checking URL metadata) and explains behavior on HTTP errors versus transport failures, effectively setting expectations. However, it does not explicitly mention alternatives or exclusion criteria, falling just short of a 5.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
3 tool updates
v0.1.0- First observed
analyze_text - First observed
convert_temperature - First observed
fetch_url_metadata
TDQS
Scored across 3 tools
Each tool operates on a completely different domain: text statistics, URL metadata, and temperature conversion. There is zero overlap, so an agent can easily distinguish between them.
All three tools follow a consistent verb_noun pattern (analyze_text, fetch_url_metadata, convert_temperature) using snake_case. The naming is uniform and predictable.
With exactly three tools, the set falls squarely within the typical well-scoped range (3-15). Each tool earns its place, providing a distinct and non-redundant utility.
Each tool individually covers its stated purpose comprehensively (text counts and reading time, HTTP metadata with non-error handling, and temperature conversion across all units). However, the overall server lacks a cohesive domain and could reasonably include additional common utilities, leaving a minor sense of incompleteness.
Maintenance
Related MCP Connectors
MCP server for progressive tool usage at any scale (see https://klavis.ai)
Nifty's MCP server — exposes tasks, projects, messages, and files as tools for AI agents.
Related MCP Servers
- FlicenseAqualityDmaintenanceA lightweight MCP server providing utility tools for math, text processing, data conversion, and URL fetching. It supports both STDIO and SSE communication modes for seamless integration with Claude Desktop and remote AI agents.51-
- AlicenseNot gradedqualityDmaintenanceA simple MCP server offering three utility tools: UUID generation, temperature conversion, and text statistics.11MIT
- FlicenseNot gradedqualityDmaintenanceA lightweight MCP server providing tools for adding integers, getting current time, and fetching weather forecasts via wttr.in.-
- FlicenseNot gradedqualityDmaintenanceA simple MCP server that provides basic utility tools for text manipulation, file operations, and calculations, intended to be connected to Claude AI desktop app.-