- Validate integrity during incident response and release checks.
### 1.4 Authentication
## Rotation
-`auth.py` (static API keys) is dead at runtime: `MCP_API_KEYS` is never
compared against anything. The key-management scripts manage keys the server
ignores.
-`oauth.py` validates bearer tokens as Gitea OAuth tokens (JWT via JWKS, else
`/login/oauth/userinfo`).
-`oauth_flow.py` proxies the authorization code flow (PKCE S256, HMAC-signed
state) and runs a small DCR registry on disk. It does not check the
`redirect_uri` against the registered client's own redirect URIs.
- Every call runs on a single service PAT (`GITEA_TOKEN`); per-user permissions
are emulated with a collaborator-permission probe (`server.py`) and a
resource-type gate (`authz.py`). The user's own Gitea token is never used for
API calls.
The server appends to a single audit file and does not rotate it in process — rotating
mid-stream would break the `prev_hash`/`entry_hash` chain. Manage growth externally with
`logrotate` using `copytruncate` so the open file handle keeps appending:
### 1.5 Supporting modules
```
/var/log/aegis-mcp/audit.log {
weekly
rotate 12
compress
missingok
notifempty
copytruncate
}
```
| Module | Role | Fate under full access |
|---|---|---|
| `audit.py` | Hash-chained JSONL audit log | Keep. About 38 client methods log only a "pending" entry; the new dispatcher logs one complete event per call. |
| `response_limits.py` | Item and character caps | Keep. |
| `request_context.py`, `errors.py`, `logging_utils.py`, `cache.py` | Context vars, `ToolError`, JSON logging, TTL cache | Keep or fold into the new core. |
| `policy.py` | YAML allow/deny plus the write gate | Retire (ADR 0006). Replaced by `READ_ONLY`, `TOOLSETS`, `DISABLED_TOOLS`. |
| `authz.py` | Fail-closed emulation of per-user rights on a service PAT | Retire. Per-user mode now uses the user's own Gitea token, so Gitea enforces permissions itself. |
| `auth.py`, `oauth.py`, `oauth_flow.py` | Dead API keys, Gitea token validation, hand-rolled OAuth proxy | Replace with the SDK auth stack (ADR 0004). |
| `server.py`, `server_entry.py`, `mcp_protocol.py`, `registry.py`, `tools/*` | FastAPI adapter, custom protocol, 44 hand-written tools | Replace with the generated catalog and the SDK server (ADR 0002, 0003). |
| `automation.py` | Webhook ingest, placeholder jobs (45% covered) | Remove; out of scope for an API bridge. |
review), conventional commit on `feat/full-api-coverage`, PR into `dev`.
Dependency order: **B1** first; **B2, B3, B6, B7** in parallel; **B4**; then
**B5, B8, B9, B10, B11** in parallel; then the **security phase (S1-S3)**.
| Chunk | Scope | Acceptance criteria |
|---|---|---|
| B1 Foundation | `uv` + `uv.lock`, `mcp>=2.3,<3`, ruff format (black removed), ruff `S` rules, new contract in `AGENTS.md`/`CLAUDE.md`, ADRs, delete legacy modules and their tests, interface skeletons (`catalog/model.py`, `identity.py`, new `config.py`). | `uv run pytest` green on the remaining modules, lint clean, no import of removed modules anywhere. |
| B2 Catalog | `scripts/generate_catalog.py` builds `catalog/tools.json` from the pinned swagger: names, domains, JSON input schemas (`additionalProperties: false`, owner/repo patterns), annotations, content types. Loader in `catalog/__init__.py`. `--check` mode. | All 537 operations present, names unique, match `^gitea_[a-z0-9_]+$`, at most 64 chars; schemas validate as Draft 2020-12; unit tests for naming, schema building, ref resolution. |
| B3 Executor | `gitea/executor.py`: path templating with encoding, query/JSON/multipart (base64 files), Sudo header, pagination metadata (`Link`, `X-Total-Count`), text and binary responses, error mapping with sanitized messages, response limits, secret masking, untrusted-content envelope with identity. | `pytest-httpx` unit tests for each branch, tokens never present in any error or result. |
| B4 Server core + transports | Low-level SDK `Server` built from the catalog: `list_tools` with `TOOLSETS`/`READ_ONLY`/`DISABLED_TOOLS`, `call_tool` with jsonschema validation, audit, executor, `is_error` results. stdio entry, streamable HTTP app with static bearer verifier, `/health`, bind fail-closed, `gitea_api_request` raw tool. | In-memory client tests for list/call/validation/errors; HTTP tests for 401 without/with bad token, refusal to start on a non-loopback bind without auth. |
| B5 Per-user OAuth | `OAuthAuthorizationServerProvider` proxying Gitea (DCR on, PKCE to Gitea), `/oauth/callback`, encrypted token store (file, 0600), refresh handling, combined verifier (static bearer = admin identity, OAuth token = user identity), identity in result metadata. | Unit tests with mocked Gitea OAuth endpoints: full code flow, refresh, expiry, revoke, store permissions and encryption, tokens never logged. |
| B6 Setup command | `aegis-gitea-mcp setup`: reachability, admin and token-scope check (`GET /user`, `GET /token`), OAuth app registration via `POST /user/applications/oauth2`, writes `.env` from `.env.example` (0600), prints client configs and a compose snippet, never echoes secrets. | Unit tests with mocked Gitea; integration test against the CI instance (in B8). |
| B7 Integration harness | Pytest fixtures that start `gitea/gitea:28.1.0` (digest-pinned) with unique container/network names and guaranteed cleanup, seed an admin user and a full-scope token, plus fake-data seed helpers. Marker `integration`, skipped unless enabled. | Harness starts, seeds, tears down on this host and in CI; no bind mounts, no prune. |
| B8 Integration suite | Scenario tests (org, repo, issue, PR, review, merge, release, secret, variable, webhook, cleanup) and a sweep that calls every tool once with fixture-derived arguments, asserting either success or a documented expected status. Per-user mode via minted user tokens. Setup command against the live instance. | Every registered tool exercised; write tools have happy and failure paths; non-admin user gets Gitea's own 403 on admin tools; identity visible. |
| B9 MCP smoke tests | Real SDK clients over stdio (subprocess) and streamable HTTP (uvicorn, and the built image) listing tools and calling a read and a write tool. | Green locally and in CI. |
| B10 CI | `.gitea/workflows/`: gitleaks, lint, test-unit, test-integration, test-mcp, build-and-push (SHA tags, digest output), docs-check, security; strict per-run cleanup, no prune. Dockerfile on `uv`, non-root, no secrets in layers. | All eight jobs green on the PR. |
| B11 Docs | README rewrite (honest scope, setup with time estimate, stdio and HTTP client configs for Claude, Claude Code, Mistral, local tools, token scopes, untrusted-content notice), generated tool inventory `docs/tools.md` with endpoint mapping, `scripts/check_docs.py` drift check, deployment and reverse-proxy guide. | Docs check green; no stale claims left from section 1.9. |
| S1 Threat model | `docs/security.md`: assets, trust boundaries, threats, mitigations. | Reviewed by the checker. |
| S2 Fixes and negative tests | Security test suite: auth negatives, bind refusal, token redaction in logs/errors/results, secrets never echoed, malformed input rejected, base URL validation. | All green. |
| S3 Scans | `pip-audit`, ruff `S`/bandit, gitleaks over history; findings fixed or waived with reasons in `docs/security.md`. | Scans clean or waived. |
## 4. Risks and open questions
Owner decisions taken before execution (2026-10-14):
- **Security contract reversed.** All capabilities on by default; the owner
restricts via configuration and token choice (ADR 0001).
- **Framework.** Rebuild on the official SDK (ADR 0002).
- **Commit signing.** Disabled by the owner on `dev`; commits are unsigned.
- **Tool count.** One tool per swagger operation (537 plus the raw request
tool), all on by default, with a toolset filter (ADR 0003).
Open risks:
1.**Tool-list size.** 538 tools is a large `tools/list` payload. Claude Code
handles it with tool search; claude.ai and Mistral Le Chat may degrade or
truncate. Mitigation: `TOOLSETS`, `READ_ONLY` and `DISABLED_TOOLS`; the README
gives a recommended slim profile for Le Chat. Needs owner testing against
the real clients.
2.**"FastMCP" no longer exists.** In `mcp` 2.x FastMCP was renamed
`MCPServer`, and it cannot register tools from raw JSON schemas. We use the
SDK's low-level `Server`, which is the same package and transport stack
(ADR 0002).
3.**Gitea has no dynamic client registration.** Per-user OAuth therefore needs
our server to be the OAuth authorization server for MCP clients and a Gitea
OAuth client upstream. The server holds per-user Gitea tokens, which makes
the token store a high-value asset (encrypted at rest, ADR 0004).
4.**Gitea scope fallback.** Gitea grants full access for unknown scope
strings. The proxy sends a fixed, validated scope list.
5.**Literal "every endpoint gets an integration test".** Some operations need
state that is costly to build in CI: Actions runs need a registered runner
executing a job, push mirrors and migrations need a reachable remote,
package endpoints need an uploaded package, unadopted repositories need files
on the Gitea volume. The sweep exercises every tool; where a full happy path
is not reproducible in CI, the test asserts the documented Gitea response
(for example 404 for a missing artifact) and the README marks it.
6.**Shared CI runner.** Integration and MCP jobs need Docker from the job
container. Per-run unique names and `always()` cleanup; the existing
`docker image prune` / `builder prune` steps are removed.
7.**Breaking change.** All tool names change (`gitea_<domain>_<action>`), the
FastAPI server, YAML policy and API keys are removed. Version goes to 1.0.0.
8.**Admin coverage limits.** The API has no queue management or instance
configuration write endpoints, and no instance-wide OAuth app endpoint; setup
registers the OAuth app as a user application owned by the admin.
# Research: rebuilding AegisGitea-MCP on the official MCP Python SDK
Date of research: 2026-10-14. All facts below were read from fetched sources (PyPI JSON, SDK source tarball at tag, gitea-mcp clone, Gitea docs repo, live Gitea endpoints, MCP spec repo, Claude/Mistral docs). Base URLs:
- SDK docs: https://py.sdk.modelcontextprotocol.io/ (sources under `SDK/docs/`)
---
## 0. Headline findings (read first)
1. Latest stable `mcp` is 2.3.0 (2026-10-02), `requires_python >=3.10`. In 2.x, FastMCP no longer exists: `mcp.server.fastmcp` raises `ModuleNotFoundError` on import. It was renamed to `MCPServer` (`from mcp.server import MCPServer` / `mcp.server.mcpserver`). Transport settings (host, port, path, stateless_http, json_response) moved from the constructor to `run()` / `streamable_http_app()`.
2. v1.x is still maintained (1.30.0 released 2026-09-07). It has the old `FastMCP` API. The SDK's own advice for libraries that are not ready to move is to pin `mcp>=1.28,<2` (`SDK/docs/whats-new.md`).
3. For about 540 OpenAPI-derived tools, use the low-level `mcp.server.lowlevel.Server` (`mcp.server.Server`). It takes raw `Tool(input_schema=...)` dicts and has `streamable_http_app(auth=..., token_verifier=..., auth_server_provider=..., custom_starlette_routes=...)` with the same auth wiring as MCPServer. `MCPServer.add_tool()` only derives a schema from a Python signature and cannot take a raw inputSchema.
4. In v2 the low-level server does NOT validate tool arguments against `input_schema` ("advertised but never applied"). We have to do jsonschema validation ourselves. `jsonschema` is already an `mcp` dependency.
5. Gitea as an OAuth AS has no RFC 8414 `/.well-known/oauth-authorization-server` (404, verified live on git.hiddenden.cafe and gitea.com) and no `registration_endpoint` (no DCR). It does serve OIDC discovery, PKCE (`plain`, `S256`), `authorization_code` + `refresh_token`, and `/login/oauth/introspect`.
6. The official gitea-mcp (v1.8.1) does NOT proxy. It acts as a pure resource server: its PRM names Gitea as the authorization server. Users paste a pre-registered public Gitea client ID into Claude's "Advanced settings". This works with Claude because MCP 2025-11-25+ allows OIDC discovery. It does not work for DCR-only clients such as Mistral Le Chat, whose docs list OAuth only "with dynamic client registration". Our planned proxy AS (SDK `OAuthAuthorizationServerProvider` with DCR on) covers both.
7. Security gotcha: Gitea falls back to FULL access when an OAuth client requests any scope that is neither OIDC nor a known granular scope. Typos in scope strings therefore widen access (Gitea docs, and gitea-mcp's own comment in `pkg/oauth/oauth.go`).
- The wire types now live in the separate package `mcp-types` (`import mcp_types`), re-exported as `mcp.types`. Python attribute names are snake_case (`tool.input_schema`, `result.is_error`); the JSON wire format stays camelCase. `MCPModel` has `populate_by_name=True` with a camelCase alias generator (`SDK/src/mcp-types/mcp_types/_types.py`).
- v2 behaviour that affects us (`SDK/docs/whats-new.md`, `SDK/docs/migration.md`):
- The HTTP client is `httpx2`. TLS uses the OS trust store via truststore instead of certifi. Minimal containers need `SSL_CERT_FILE` / `SSL_CERT_DIR`.
-`MCP_*` env vars and `.env` are no longer read by MCPServer.
- Streamable HTTP request bodies are capped at 4 MiB by default (`max_request_body_size`).
- The streamable HTTP lifespan runs once at startup, not per session.
- Sync `def` handlers run on a worker thread.
- Server results are validated against the protocol schema. A Tool whose `input_schema` lacks `"type": "object"` fails `tools/list`.
- OpenTelemetry middleware is on by default and costs nothing without an exporter.
- SDK v2 speaks protocol 2026-07-28 and also serves 2024-11-05 through 2025-11-25 clients from the same app (`SDK/src/mcp-types/mcp_types/version.py`: `KNOWN_PROTOCOL_VERSIONS = ("2024-11-05","2025-03-26","2025-06-18","2025-11-25","2026-07-28")`).
---
## 2. SDK internals (v2.3.0, with v1.30.0 notes)
### 2a. Constructing and running
**MCPServer (formerly FastMCP)**, from `SDK/src/mcp/server/mcpserver/server.py`:
- Passing `auth` with neither `auth_server_provider` nor `token_verifier` raises `ValueError`. Passing both also raises. Passing either without `auth` raises. When only `auth_server_provider` is given, MCPServer wraps it in `ProviderTokenVerifier(provider)`.
- `mount_path` was removed in v2. To serve under a prefix, mount the ASGI app instead (`SDK/docs/run/asgi.md`).
- DNS-rebinding protection is automatic when `host` is `127.0.0.1`, `localhost` or `::1` and `transport_security` is None. The allowed hosts/origins are then loopback only. Behind a reverse proxy with a public Host header, pass `TransportSecuritySettings(...)` explicitly (`SDK/src/mcp/server/lowlevel/server.py`, `streamable_http_app`).
- `stateless_http` only affects 2025-era clients. 2026-07-28 traffic has no `Mcp-Session-Id` at all (`SDK/docs/whats-new.md`).
**v1.30.0 FastMCP, for comparison** (`v1.30.0/src/mcp/server/fastmcp/server.py`): `FastMCP(name=None, instructions=None, website_url=None, icons=None, auth_server_provider=None, token_verifier=None, event_store=None, retry_interval=None, *, tools=None, debug=False, log_level="INFO", host="127.0.0.1", port=8000, mount_path="/", sse_path="/sse", message_path="/messages/", streamable_http_path="/mcp", json_response=False, stateless_http=False, max_request_body_size=..., session_idle_timeout=..., max_sessions=..., ..., auth=None, transport_security=None)`. It has `run(transport, mount_path=None)` and a no-arg `streamable_http_app()`. `custom_route` and `add_tool` have the same signatures as in v2.
**Low-level Server (v2)** (`SDK/src/mcp/server/lowlevel/server.py`). Handlers are passed as keyword-only constructor arguments; the v1 decorators are gone:
- Use `add_request_handler(method, params_type, handler)` for custom methods (it cannot override `initialize`). `server.middleware` is a list of `async (ctx, call_next)` callables, marked provisional (`SDK/docs/advanced/middleware.md`).
- HTTP: `server.streamable_http_app(*, streamable_http_path="/mcp", json_response=False, stateless_http=False, event_store=None, retry_interval=None, max_request_body_size=..., session_idle_timeout=..., max_sessions=..., transport_security=None, host="127.0.0.1", auth: AuthSettings | None = None, token_verifier: TokenVerifier | None = None, auth_server_provider: OAuthAuthorizationServerProvider | None = None, custom_starlette_routes: list[Route] | None = None, debug=False) -> Starlette`. Its lifespan runs `session_manager.run()`, so run it with uvicorn directly. If you mount it inside another Starlette or FastAPI app, enter that lifespan yourself.
- Behaviour of the low-level `streamable_http_app` (read from source):
- If `token_verifier` is set, it installs `AuthenticationMiddleware(BearerAuthBackend(token_verifier, resource_server_url=auth.resource_server_url if auth.validate_token_resource else None))` plus `AuthContextMiddleware`, and wraps the `/mcp` route in `RequireAuthMiddleware(app, required_scopes, resource_metadata_url)`.
- If `auth_server_provider` is set, it adds `create_auth_routes(...)`.
- If `auth.resource_server_url` is set, it adds `create_protected_resource_routes(...)` (RFC 9728).
- Unlike MCPServer, the low-level path does NOT auto-create `ProviderTokenVerifier` and does NOT enforce the provider/verifier mutual exclusion. With an AS provider you must pass `token_verifier=ProviderTokenVerifier(provider)`, or your own combined verifier, or `/mcp` stays unauthenticated. The upside: one custom verifier can accept the static bearer token OR provider-issued tokens while the provider mounts the AS routes.
The `authorize()` docstring describes exactly the third-party proxy pattern. The MCP server redirects the user to the upstream provider. A separate route of ours handles the upstream callback, mints our own authorization code (at least 128 bits of entropy, 160 recommended), and redirects to `params.redirect_uri`.
**What the SDK does for us** (`handlers/*.py`):
- `/authorize` accepts only `code_challenge_method="S256"` and takes the RFC 8707 `resource` parameter.
- `/token` verifies the PKCE `code_verifier` (SHA256, base64url), checks code expiry, and requires the redirect_uri to match the one used at /authorize. Client auth supports `client_secret_post`, `client_secret_basic` and `none` (public clients).
- DCR defaults `token_endpoint_auth_method` to `client_secret_post` and generates a secret unless the method is `none`. It rejects `private_key_jwt`.
- Registered redirect URIs are matched exactly (`OAuthClientInformationFull.validate_redirect_uri`, `SDK/src/mcp/shared/auth.py`).
**Routes mounted by `create_auth_routes`** (`routes.py`), at the root of `issuer_url`:
| Route | Methods | Notes |
|---|---|---|
| `/.well-known/oauth-authorization-server` | GET, OPTIONS | CORS `*` |
| `/authorize` | GET, POST | no CORS |
| `/token` | POST, OPTIONS | |
| `/register` | POST, OPTIONS | only if `client_registration_options.enabled` |
| `/revoke` | POST, OPTIONS | only if `revocation_options.enabled` |
- `token_endpoint_auth_methods_supported=["client_secret_post","client_secret_basic"]`. It does not list `"none"` and does not advertise CIMD, so Claude will pick DCR.
**Protected resource metadata** (`create_protected_resource_routes`): served at `/.well-known/oauth-protected-resource{path of resource_server_url}`, e.g. `/.well-known/oauth-protected-resource/mcp`. It contains `resource`, `authorization_servers=[issuer_url]`, `scopes_supported=required_scopes`, `bearer_methods_supported=["header"]`. The SDK only mounts the path-suffixed URL. Claude probes `/.well-known/oauth-protected-resource/<path>` first and then the bare path, and gitea-mcp also serves the bare path ("clients probe the bare path too"). Add the bare path as a custom route if you want both.
**401 handling** (`RequireAuthMiddleware`): returns `401` with `WWW-Authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="<PRM url>"`, or `403 insufficient_scope` when a required scope is missing. It does NOT emit a `scope=` parameter. Claude uses that parameter to choose which scopes to request; without it, Claude falls back to PRM `scopes_supported` (see section 6). Add `scope=` with your own wrapper if needed.
**Identity inside a tool**:
```python
from mcp.server.auth.middleware.auth_context import get_access_token
token = get_access_token() # AccessToken | None
```
It is a per-HTTP-request contextvar set by `AuthContextMiddleware`. It is always `None` over stdio and the in-memory client (`SDK/docs/run/authorization.md`, `SDK/examples/stories/bearer_auth/README.md`). It is not on `Context`; a future release may namespace it as `ctx.transport.auth`. A low-level handler example is `SDK/examples/stories/bearer_auth/server_lowlevel.py`.
**Static bearer token**: implement a `TokenVerifier` that compares in constant time and returns `AccessToken(token=..., client_id="static", scopes=[...])`. Pass it with `AuthSettings(issuer_url=..., resource_server_url=None or URL, required_scopes=...)`. Pattern: `SDK/examples/stories/bearer_auth/server.py`.
**SDK docs stance**: `SDK/docs/run/authorization.md` says `auth_server_provider=` "predates the AS/RS separation ... New servers should not reach for it." It still ships and is unchanged in v2 (`SDK/docs/migration.md` "Unchanged auth surfaces"). Since Gitea has no DCR and Le Chat needs DCR, an embedded AS proxy is still justified for us.
**`examples/servers/simple-auth` in v2.3.0**: it NO LONGER proxies GitHub. Only stale docstrings in `__init__.py` and `__main__.py` still say "GitHub OAuth". It now contains:
- `auth_server.py`: a standalone AS built from `create_auth_routes(...)`, plus `/login` (GET form) and `/login/callback` (POST) routes, plus an RFC 7662 `/introspect` endpoint.
- `server.py`: an RS using `MCPServer(token_verifier=IntrospectionTokenVerifier(...), auth=AuthSettings(issuer_url=AS, resource_server_url="http://localhost:8001/mcp", required_scopes=["user"], validate_token_resource=True))`.
- `legacy_as_server.py`: a combined AS+RS via `MCPServer(auth_server_provider=..., auth=AuthSettings(issuer_url=server_url, client_registration_options=ClientRegistrationOptions(enabled=True, valid_scopes=[...], default_scopes=[...]), required_scopes=[...], resource_server_url=None))`, with the callback handled by `@app.custom_route("/login/callback", methods=["POST"])`.
The callback logic in `simple_auth_provider.py` is the template for a Gitea proxy:
1. `authorize()` stores `state -> {redirect_uri, code_challenge, redirect_uri_provided_explicitly, client_id, resource}` and returns the login URL.
2. The callback looks up the state, authenticates the user, creates `AuthorizationCode(code="mcp_"+token_hex(16), ..., expires_at=time.time()+300, subject=username)`, and returns `RedirectResponse(construct_redirect_uri(redirect_uri, code=new_code, state=state), 302)`.
3. `exchange_authorization_code()` mints an opaque access token, stores it, and deletes the code.
For Gitea, replace step 2 with: redirect to Gitea `/login/oauth/authorize` with our own state and PKCE; on our `/oauth/callback`, POST to Gitea `/login/oauth/access_token`; store the Gitea access and refresh tokens keyed by the MCP tokens we mint.
The co-hosted pattern (AS + PRM + bearer-gated `/mcp` in one app, `issuer_url="http://127.0.0.1:8000"`, `resource_server_url=".../mcp"`, `validate_token_resource=True`, DCR on) is shown in `SDK/examples/stories/oauth/server.py`, `server_lowlevel.py` and `_shared/auth.py`.
### 2c. Registering about 540 tools with explicit JSON schemas
- `MCPServer.add_tool(fn, name=None, title=None, description=None, annotations: ToolAnnotations | None = None, icons=None, meta=None, structured_output=None)` (same signature in v1.30). The schema always comes from `func_metadata(fn).arg_model.model_json_schema()` (`SDK/src/mcp/server/mcpserver/tools/base.py`, `Tool.from_function`). There is no `input_schema` parameter.
- The only way around that with MCPServer is to build internal `Tool` pydantic objects with a custom `parameters` and `fn_metadata`, which is fragile and private. Not recommended.
server = Server("aegis-gitea", on_list_tools=list_tools, on_call_tool=call_tool,
get_tool_input_schema=lambda n: TOOLS[n].input_schema if n in TOOLS else None)
```
Reasons:
- Raw schemas are accepted.
- One `call_tool` dispatch point is a natural place for policy, audit and WRITE_MODE checks.
- Handlers have the same shape on stdio and HTTP.
- `streamable_http_app(auth=..., token_verifier=..., auth_server_provider=..., custom_starlette_routes=[Route("/health", ...)])` gives the same auth wiring MCPServer has.
v2 error semantics:
- An exception raised in a low-level handler becomes a sanitized JSON-RPC `-32603` that the model never sees.
- For failures the model should read, return `CallToolResult(is_error=True, ...)`.
- Raise `MCPError` only for protocol errors.
`get_tool_input_schema` is optional and only used for 2026-07-28 `Mcp-Param-*` header validation.
Practical note on 540 tools: Claude Code uses tool search by default, so large tool lists are tolerated (`ENABLE_TOOL_SEARCH`; https://code.claude.com/docs/en/mcp). The official gitea-mcp instead consolidates into 59 tools, using a `method` enum, read-only mode and scope filters (section 3). Offer toolset filtering either way.
v1.30 alternative: low-level `Server` with decorators `@server.list_tools()` / `@server.call_tool()` returning `list[Tool]` (camelCase `inputSchema`). v1 jsonschema-validates arguments against `inputSchema` before calling the handler (`SDK/docs/whats-new.md` v1 annotations).
### 2d. Client side for tests (v2)
- **High-level `Client`**: `from mcp import Client`. `Client(server: Server | MCPServer | Transport | StdioServerParameters | str, *, mode="auto", raise_exceptions=..., read_timeout_seconds: float, sampling_callback=..., elicitation_callback=..., ...)`. A URL string means streamable HTTP, `StdioServerParameters` means a subprocess, and a server object means in memory (`SDK/src/mcp/client/client.py`, `SDK/docs/client/transports.md`).
- **In memory** (replaces `create_connected_server_and_client_session`, which was removed in v2; `SDK/docs/migration.md`):
```python
async with Client(server, raise_exceptions=True) as c:
result = await c.call_tool("add", {"a": 1, "b": 2})
```
This skips HTTP, so there is no auth and `get_access_token()` is None. `create_client_server_memory_streams()` is still in `mcp.shared.memory` for low-level tests.
- **stdio**: `from mcp.client.stdio import stdio_client, StdioServerParameters`. Signature: `stdio_client(server: StdioServerParameters, errlog: TextIO = sys.stderr)`, where `StdioServerParameters(command, args=[], env=None (merged over an allow-listed default env), cwd=None, encoding="utf-8", encoding_error_handler="strict")`. Typical use: `Client(StdioServerParameters(...))` or `Client(stdio_client(params, errlog=f))`.
- **Streamable HTTP**: `streamablehttp_client` was removed in v2. Use `from mcp.client.streamable_http import streamable_http_client`, signature `streamable_http_client(url, *, http_client: httpx2.AsyncClient | None = None, terminate_on_close=True, max_sse_event_size=...)`. It has no `headers=` argument; headers go on the httpx2 client (`SDK/docs_src/client_transports/tutorial003.py`):
```python
async with httpx2.AsyncClient(headers={"Authorization": "Bearer ..."}, timeout=httpx2.Timeout(30.0, read=300.0)) as hc:
async with Client(streamable_http_client("http://127.0.0.1:8000/mcp", http_client=hc)) as client:
await client.list_tools()
```
For in-process ASGI tests, use an httpx2 client with an ASGI transport and pass `transport_security` that disables DNS-rebinding checks. This is what the SDK stories do (`NO_DNS_REBIND` in `SDK/examples/stories/_hosting.py`). To see 401/403 and `WWW-Authenticate` you have to test at the HTTP level (curl or httpx2); `Client` cannot observe them.
- **`ClientSession`** is still available via `client.session` or `mcp.client.session.ClientSession`. It now runs on `JSONRPCDispatcher`, and list methods take `cursor=`.
## 3. Official gitea-mcp (gitea.com/gitea/gitea-mcp)
Source: https://gitea.com/gitea/gitea-mcp, HEAD e65532fb (2026-10-12), latest tag v1.8.1. Written in Go, on `github.com/modelcontextprotocol/go-sdk v1.8.0` and `gitea.dev/sdk v1.3.0`. Files: `README.md`, `operation/*`, `cmd/cmd.go`, `pkg/oauth/oauth.go`, `pkg/annotation/annotation.go`.
**Tools: 59 in total.** Names are verified against the `*ToolName` constants in `operation/`. "Methods" lists the values of the tool's `method` enum.
| Domain | Tools (methods) |
|---|---|
| version | `get_gitea_mcp_server_version` |
| user | `get_me`, `get_user_orgs`, `create_organization` |
- Read-only mode: `-r` / `--read-only` / `GITEA_READONLY` hides Write tools. Toolset filtering: `-S` / `--scope` (`GITEA_SCOPES`) by the README's Scope column, and `-O` / `--tools` (`GITEA_TOOLS`) by name. Setting both loads the union.
- Annotations: read tools get `ToolAnnotations{Title, ReadOnlyHint: true}`; write tools get `DestructiveHint: true` (`pkg/annotation/annotation.go`).
- No Sudo support: grep for "sudo" across the repo finds nothing.
- Transports: `-t stdio|http`. HTTP is always stateless: POST-only `/mcp`, no `Mcp-Session-Id`, no SSE resumability. `/healthz` is the health probe. Origins are validated. Protocol support goes up to 2026-07-28 and only the `tools` capability is advertised.
- Credentials per HTTP request: `Authorization: Bearer <token>` or `Authorization: token <token>` is passed straight through to Gitea, which the README calls "credential passthrough rather than MCP OAuth". Credentials Gitea rejects get a 401.
- It serves PRM at `/.well-known/oauth-protected-resource` (both the path-suffixed and bare paths) with `authorization_servers=[GiteaURL]`, `scopes_supported` = `write:` (or `read:` with `-r`) + `repository, issue, user, organization, notification, package, misc`, and `bearer_methods_supported=["header"]`.
- The 401 challenge is `Bearer resource_metadata="...", scope="..."`. Code comment: "Claude honours this scope parameter in preference to the metadata."
- It enforces the Host header: `/mcp` only accepts the public host or loopback.
- It does no proxying and no DCR. The README says to register a Gitea OAuth2 app manually as a non-confidential client (PKCE), with redirect URIs `https://claude.ai/api/mcp/auth_callback` and `http://127.0.0.1/callback` (for Claude Code; Gitea matches loopback IPs on any port). Users enter the client ID under Advanced settings.
- Pagination: tools take `page` / `per_page`, capped by Gitea's `[api].MAX_RESPONSE_ITEMS` (default 50).
- No `registration_endpoint`, no `token_endpoint_auth_methods_supported`, no `authorization_response_iss_parameter_supported`.
**Grants**: only Authorization Code, plus PKCE and OIDC. The docs say: "At the moment Gitea only supports the Authorization Code Grant". Refresh tokens are issued alongside.
**Client types**: confidential and public (RFC 6749). For public clients, "a redirect URI of a loopback IP address such as `http://127.0.0.1/` allows any port. Avoid using `localhost`". The public client flow uses PKCE without a secret. Redirect schemes are http and https unless `[oauth2].CUSTOM_SCHEMES` is set.
**Scopes**:
- With only OIDC scopes (`openid`, `email`, `profile`, `groups`) the app gets FULL access to the user's resources (the default).
- Since v1.23, any PAT granular scope restricts access: `read:`/`write:` × `activitypub, admin, issue, misc, notification, organization, package, repository, user`. `public-only` limits to public resources.
- "If third party adds any scope other than the OIDC ones ... or the ones found already in Personal Access Token the scope will fallback to full access". Validate scope strings strictly.
- "If the third party changes its requested scopes in future requests, the entire flow will fail, requiring re-authorization."
- Consent is remembered. `skip_secondary_authorization` on the app skips the consent screen.
**Token lifetimes** (`[oauth2]` section of the cheat sheet):
- `ACCESS_TOKEN_EXPIRATION_TIME=3600` (seconds)
- `REFRESH_TOKEN_EXPIRATION_TIME=730` (hours)
- `INVALIDATE_REFRESH_TOKENS=false` ("Check if refresh token has already been used"), so refresh-token rotation/one-time use is off by default
- `JWT_SIGNING_ALGORITHM=RS256`
- `MAX_TOKEN_LENGTH=32767`
- The example token response has `token_type: bearer`, `expires_in: 3600`, `refresh_token`.
**Creating an OAuth2 app via API** (verified against `/home/latte/Documents/Projecten/hiddenden/AegisGitea-MCP/spec/gitea-swagger.v1.json`, which is byte-identical (same sha256) to the live https://git.hiddenden.cafe/swagger.v1.json, API version 28.1.0):
- `POST /user/applications/oauth2` (operationId `userCreateOAuth2Application`), body `CreateOAuth2ApplicationOptions`:
- `name: string`
- `redirect_uris: string[]`
- `confidential_client: boolean`
- `skip_secondary_authorization: boolean`
- No required fields are declared.
- The response `OAuth2Application` has `id, name, client_id, client_secret, confidential_client, redirect_uris, skip_secondary_authorization, created`.
- Also `GET /user/applications/oauth2` (page, limit), `GET|DELETE /user/applications/oauth2/{id}`, and `PATCH /user/applications/oauth2/{id}` (same body; "this includes regenerating the client secret").
- There is no admin-scoped OAuth2 app endpoint in the swagger. Instance-wide apps are UI-only (`/admin/applications/`).
---
## 5. Gitea API usage
Source: https://docs.gitea.com/development/api-usage/ (markdown: https://gitea.com/gitea/docs/raw/branch/main/docs/development/api-usage.md), plus swagger `securityDefinitions`.
**Auth methods**:
- Basic auth
- `Authorization: token <t>`
- OAuth tokens via `Authorization: bearer <t>`
- `token=` / `access_token=` query parameters. Swagger marks these "deprecated for removal in Gitea 1.23", so never use them.
- HTTP signatures with SSH keys
- 2FA with basic auth requires the `X-Gitea-OTP` header.
**Sudo**: "admin users to sudo API requests as another user. Simply add either a `sudo=` parameter or `Sudo:` request header with the username". Swagger lists `SudoHeader` (header `Sudo`) and `SudoParam` (query `sudo`), described as "Admin privileges are required."
**Pagination**: `page` and `limit` parameters. Responses carry a `Link` header (`rel="next"`, `rel="last"`, ...) when there is more than one page, and `x-total-count`. `MAX_RESPONSE_ITEMS` defaults to 50.
**Token scopes**: `activitypub, admin, issue, misc, notification, organization, package, repository, user`, each as `read:` or `write:` (write implies read), plus the special `all` (read and write everything). `misc` is reserved. `write:package` is currently the same as `read:package`. There is no no-scope token: "A scope is required even for public repositories."
**Creating a token**: `POST /users/{username}/tokens` (operationId `userCreateToken`). It "requires you to authenticate using `BasicAuth` and a password". Body `CreateAccessTokenOption {name: string (required), scopes: string[]}`. Returns 201 `AccessToken {id, name, sha1, token_last_eight, scopes, created_at, last_used_at}`. `sha1` is the token and is shown only once. Related endpoints: `GET /users/{username}/tokens`, `DELETE /users/{username}/tokens/{token}`.
**Useful extras from swagger**:
- `GET /token` (`getCurrentToken`) returns `CurrentAccessToken {id, name, scopes, user, created_at, last_used_at}`. This lets us read the effective scopes of a PAT.
- `DELETE /token` (`deleteCurrentToken`).
- The spec has 342 paths and **537 operations** (GET 261, POST 119, DELETE 92, PATCH 38, PUT 27; by prefix: repos 294, user 78, orgs 67, admin 33, users 18, teams 12, ...).
---
## 6. MCP authorization spec versions and client requirements (brief)
- https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization (raw files at github.com/modelcontextprotocol/modelcontextprotocol under `docs/specification/<ver>/basic/authorization*`)
The latest spec is **2026-07-28**; `draft` also exists.
How the versions differ:
- **2025-03-26**: the MCP server is the AS. AS metadata is RFC 8414, with fallback default paths `/authorize`, `/token`, `/register`. DCR is SHOULD.
- **2025-06-18**: MCP servers MUST implement RFC 9728 PRM; clients MUST use it. The AS MUST provide RFC 8414. DCR is SHOULD.
- **2025-11-25**:
- AS discovery may be RFC 8414 OR OIDC Discovery, and clients MUST support both.
- CIMD (Client ID Metadata Documents) is SHOULD; DCR is MAY.
- Registration priority: pre-registered, then CIMD (if `client_id_metadata_document_supported`), then DCR, then prompt the user.
- RFC 8707 `resource` is mandatory for clients.
- Servers SHOULD send `scope` in `WWW-Authenticate`.
- **2026-07-28**: same structure, but DCR "is deprecated and retained for backwards compatibility". RFC 9207 `iss` in the authorization response is SHOULD for the AS (expected to become MUST), and clients MUST validate it when it is advertised.
**What SDK v2.3.0 implements on the server**:
- RS side: RFC 9728 PRM, a `WWW-Authenticate` header with `resource_metadata`, and an optional RFC 8707 audience check (`validate_token_resource`).
- Not implemented on the server: CIMD (the client side does support CIMD), `iss` in the authorization response, and the `scope` parameter in the 401 challenge.
**Claude** (claude.ai web/desktop/mobile/Cowork and Claude Code). Sources: https://claude.com/docs/connectors/building/authentication and https://code.claude.com/docs/en/mcp
- Auth types: OAuth with DCR (default), OAuth with CIMD (default), Anthropic-held creds (by request), credentials entered at connection time (by request), `static_headers` (beta, limited orgs), and none.
- Custom connectors can use a user-entered OAuth client ID, with an optional secret ("If a user leaves an optional client secret blank, Claude uses the client ID as a public client").
- Discovery:
- A 401 is required; `WWW-Authenticate` on a 200 is ignored.
- PRM `resource` must equal the URL exactly as the user enters it.
- Only the first `authorization_servers` entry is used.
- The AS must serve RFC 8414 or OIDC discovery and be reachable from Anthropic egress `160.79.104.0/21`.
- PKCE S256 is always sent. The AS should advertise `code_challenge_methods_supported: ["S256"]`.
- Scopes come from the `scope=` in the 401 challenge, otherwise from PRM `scopes_supported`. Claude appends `offline_access` if the AS metadata lists it.
- CIMD is chosen only if the AS metadata has `client_id_metadata_document_supported: true` AND `"none"` in `token_endpoint_auth_methods_supported`; otherwise Claude uses DCR.
- Claude Code: loopback on an ephemeral port, `http://localhost:<port>/callback` (it declares both the `localhost` and `127.0.0.1` forms; servers must match both port-agnostically). v2.1.229 briefly sent the 127.0.0.1 form; v2.1.231 restored `localhost`.
- Token endpoint:
- must accept `application/x-www-form-urlencoded`
- return `invalid_grant` for dead refresh tokens
- rotate refresh tokens for public clients (DCR and CIMD clients are public)
- 10 s timeout for discovery, registration and token; 30 s for refresh
- Claude refreshes on 401 and up to 5 minutes before expiry
- Our embedded AS gets DCR from Claude because SDK metadata lacks `none` and CIMD.
- SDK redirect matching is exact. That is fine for DCR, because each client registers its exact redirect URI.
- The Gitea app for our proxy only needs our own callback URL, e.g. `https://<mcp-host>/oauth/callback`.
**Mistral Le Chat custom connectors** (https://docs.mistral.ai/le-chat/knowledge-integrations/connectors/mcp-connectors):
- Auth is auto-detected: no auth, "HTTP Bearer Token / Basic Auth", or "OAuth 2.1 (with dynamic client registration)".
- Requires HTTPS with a valid certificate, Streamable HTTP, and a 401 with `WWW-Authenticate` for OAuth servers.
- The redirect URI is not documented. The Studio/API connector path takes an OAuth client ID and secret instead.
- So for Le Chat with per-user OAuth, the server must offer DCR. Pointing Le Chat directly at Gitea, as the official gitea-mcp does, would not work. The static bearer token path works.
---
## 7. Suggested architecture (from the above)
1. Pin `mcp>=2.3,<3`. Build on the low-level `Server` with a generated registry of `types.Tool` objects carrying raw OpenAPI-derived `input_schema` and `ToolAnnotations` from the HTTP method. Validate arguments ourselves with `jsonschema`. One `on_call_tool` does policy, then WRITE_MODE, then audit, then the Gitea call, then limits, then sanitization.
2. stdio: `stdio_server()` plus `server.run(...)` with a PAT owner identity. `get_access_token()` is None there.
- `token_verifier=CombinedVerifier(static_token, provider)`, which is required explicitly on the low-level path
- `custom_starlette_routes=[/health, /oauth/callback (Gitea redirect target), bare /.well-known/oauth-protected-resource]`
- `transport_security=TransportSecuritySettings(...)` for the public host
- wrap the 401 response to add `scope=` if desired
4. `GiteaProxyProvider.authorize()`:
- store the client's state, code_challenge, redirect_uri and resource under our own random state
- redirect to `{GITEA}/login/oauth/authorize?client_id=<our confidential app>&redirect_uri=<our /oauth/callback>&response_type=code&state=<ours>&scope=<strict granular list>`, plus our own PKCE S256 to Gitea
- the `/oauth/callback` route exchanges the code at `{GITEA}/login/oauth/access_token`, mints our code (`subject` = Gitea user from `/login/oauth/userinfo` or `GET /api/v1/user`), and redirects to the client's `redirect_uri`
- `exchange_authorization_code` returns opaque MCP tokens mapped to the Gitea access and refresh tokens
- `exchange_refresh_token` refreshes upstream when needed (Gitea access tokens last 1 h, refresh tokens 730 h) and rotates our refresh token
5. Create the upstream Gitea app via `POST /user/applications/oauth2 {name, redirect_uris:[".../oauth/callback"], confidential_client:true, skip_secondary_authorization:false}`.
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.