Files
Latte 8b7bb7205c docs: phase 0 audit, chunk plan, ADRs and pinned Gitea 28.1.0 swagger
Inventory and gap analysis of the 0.2.0 codebase against the full Gitea API
and the official gitea-mcp toolset, the B1..B11 + security chunk plan, ADRs
0001-0006 for the full-access rebuild, SDK/OAuth research notes, and the
instance swagger pinned for tool generation. The audit-logging doc moves to
docs/audit-logging.md.
2026-10-14 15:34:48 +02:00

505 lines
46 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 v2.3.0 source: https://github.com/modelcontextprotocol/python-sdk/tree/v2.3.0 (abbreviated `SDK/` below, e.g. `SDK/src/mcp/server/lowlevel/server.py` = https://github.com/modelcontextprotocol/python-sdk/blob/v2.3.0/src/mcp/server/lowlevel/server.py)
- SDK v1.30.0 source (last 1.x): https://github.com/modelcontextprotocol/python-sdk/tree/v1.30.0
- 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`).
---
## 1. `mcp` on PyPI
Source: https://pypi.org/pypi/mcp/json
- `info.version`: **2.3.0**, upload 2026-10-02. `requires_python`: **>=3.10**.
- Recent releases: 1.30.0 (2026-09-07, 1.x maintenance), 2.0.0 (2026-07-28), 2.0.1, 2.1.0, 2.1.1, 2.2.0, 2.3.0.
- 2.3.0 `requires_dist` (abridged): `anyio>=4.9`, `httpx2>=2.10.0` (not `httpx`!), `jsonschema>=4.20.0`, `mcp-types==2.3.0`, `opentelemetry-api>=1.28.0`, `pydantic>=2.12.0`, `pyjwt[crypto]>=2.10.1`, `python-multipart>=0.0.9`, `sse-starlette>=3.0.0`, `starlette>=0.27`, `uvicorn>=0.31.1`, extras `cli`, `rich`.
- 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`:
```python
class MCPServer(Generic[LifespanResultT]):
def __init__(self, name: str | None = None, title=None, description=None, instructions=None,
website_url=None, icons=None, version: str = "",
auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any] | None = None,
token_verifier: TokenVerifier | None = None, *,
tools: list[Tool] | None = None, resources=None, extensions=None, debug=False,
log_level="INFO", warn_on_duplicate_resources=True, warn_on_duplicate_tools=True,
warn_on_duplicate_prompts=True, dependencies=None, lifespan=None,
auth: AuthSettings | None = None, resource_security=..., request_state_security=None,
cache_hints=None, subscriptions=None, middleware=None)
```
- 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)`.
- `run(transport="stdio")`. Streamable HTTP overload:
`run("streamable-http", *, host="127.0.0.1", port=8000, streamable_http_path="/mcp", json_response=False, stateless_http=False, event_store=None, retry_interval=None, max_request_body_size=DEFAULT_MAX_REQUEST_BODY_SIZE, session_idle_timeout=..., max_sessions=..., transport_security=None)`.
- ASGI app: `mcp.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") -> Starlette`. It delegates to the low-level `Server.streamable_http_app(...)`, passing `auth`, `token_verifier`, `auth_server_provider`, `custom_starlette_routes` and `debug`.
- Custom routes. These are NOT behind auth (docstring: "Routes using this decorator will not require authorization"):
```python
@mcp.custom_route("/health", methods=["GET"])
async def health(request: Request) -> Response:
return JSONResponse({"status": "ok"})
```
Signature: `custom_route(self, path: str, methods: list[str], name: str | None = None, include_in_schema: bool = True)`.
- `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:
```python
Server(name: str, *, version="", title=None, description=None, instructions=None, website_url=None,
icons=None, cache_hints=None, lifespan=..., get_tool_input_schema=None,
on_list_tools: Callable[[ServerRequestContext, PaginatedRequestParams | None], Awaitable[ListToolsResult]] | None = None,
on_call_tool: Callable[[ServerRequestContext, CallToolRequestParams], Awaitable[CallToolResult | InputRequiredResult]] | None = None,
on_list_resources=..., on_read_resource=..., on_list_prompts=..., on_get_prompt=..., on_completion=..., ...)
```
- 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`).
- stdio:
```python
from mcp.server.stdio import stdio_server
async with stdio_server() as (read, write):
await server.run(read, write, server.create_initialization_options())
```
- 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.
### 2b. Auth
All from `SDK/src/mcp/server/auth/`.
**`TokenVerifier`** (`provider.py`):
```python
class TokenVerifier(Protocol):
async def verify_token(self, token: str) -> AccessToken | None: ...
```
**`AccessToken`**: `token: str, client_id: str, scopes: list[str], expires_at: int | None = None, resource: str | None = None, subject: str | None = None, claims: dict[str, Any] | None = None`. `BearerAuthBackend` rejects a token when `expires_at < now`, and also when `resource_server_url` is enforced and `resource` does not match.
**`AuthSettings`** (`settings.py`):
```python
class AuthSettings(BaseModel):
issuer_url: AnyHttpUrl # required
service_documentation_url: AnyHttpUrl | None = None
client_registration_options: ClientRegistrationOptions | None = None # enabled=False, client_secret_expiry_seconds, valid_scopes, default_scopes
revocation_options: RevocationOptions | None = None # enabled=False
required_scopes: list[str] | None = None
identity_assertion_enabled: bool = False # SEP-990
resource_server_url: AnyHttpUrl | None = Field(...) # required field, may be None
validate_token_resource: bool | None = None # warns if None while resource_server_url set; default True in 3.0
```
`issuer_url` must be HTTPS unless it is localhost or 127.0.0.1 (`routes.validate_issuer_url`).
**`OAuthAuthorizationServerProvider[AuthorizationCodeT, RefreshTokenT, AccessTokenT]`** (Protocol):
```python
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None
async def register_client(self, client_info: OAuthClientInformationFull) -> None # may raise RegistrationError
async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str # returns redirect URL; may raise AuthorizeError
async def load_authorization_code(self, client: OAuthClientInformationFull, authorization_code: str) -> AuthorizationCodeT | None
async def exchange_authorization_code(self, client: OAuthClientInformationFull, authorization_code: AuthorizationCodeT) -> OAuthToken # may raise TokenError
async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshTokenT | None
async def exchange_refresh_token(self, client: OAuthClientInformationFull, refresh_token: RefreshTokenT, scopes: list[str]) -> OAuthToken
async def load_access_token(self, token: str) -> AccessTokenT | None
async def revoke_token(self, token: AccessTokenT | RefreshTokenT) -> None
async def exchange_identity_assertion(self, client, params: IdentityAssertionParams) -> OAuthToken # default raises unsupported_grant_type
```
Supporting models:
- `AuthorizationParams(state: str | None, scopes: list[str] | None, code_challenge: str, redirect_uri: AnyUrl, redirect_uri_provided_explicitly: bool, resource: str | None = None)`
- `AuthorizationCode(code, scopes, expires_at: float, client_id, code_challenge, redirect_uri, redirect_uri_provided_explicitly, resource=None, subject=None)`
- `RefreshToken(token, client_id, scopes, expires_at: int | None, resource=None, subject=None)`
- Errors: `RegistrationError`, `AuthorizeError`, `TokenError`
- Helper: `construct_redirect_uri(base, **params)`
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` |
The AS metadata advertises:
- `response_types_supported=["code"]`
- `grant_types_supported=["authorization_code","refresh_token"]`
- `code_challenge_methods_supported=["S256"]`
- `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.
- `scopes_supported=client_registration_options.valid_scopes`
**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.
- Low-level `Tool` (mcp_types): `Tool(name, title=None, description=None, input_schema: dict (root "type":"object" required), output_schema=None, icons=None, annotations: ToolAnnotations | None, _meta)`.
- `ToolAnnotations(title=None, read_only_hint=None, destructive_hint=None, idempotent_hint=None, open_world_hint=None)`. Spec defaults when omitted: readOnly false, destructive true, idempotent false, openWorld true. They serialize as `readOnlyHint` etc.
**Recommendation**: use the low-level `Server` for both transports, built from one registry.
```python
import mcp.types as types
from mcp import MCPError
from mcp.server import Server, ServerRequestContext
TOOLS: dict[str, types.Tool] = {
op.tool_name: types.Tool(
name=op.tool_name,
description=op.summary,
input_schema=op.json_schema, # generated from OpenAPI, must have "type": "object"
annotations=types.ToolAnnotations(
title=op.summary,
read_only_hint=op.method == "GET",
destructive_hint=op.method == "DELETE", # or True for PUT/PATCH that overwrite
idempotent_hint=op.method in ("GET", "PUT", "DELETE"),
open_world_hint=False,
),
)
for op in operations
}
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> types.ListToolsResult:
return types.ListToolsResult(tools=filter_by_policy(TOOLS.values())) # optionally paginate via params.cursor / next_cursor
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult:
tool = TOOLS.get(params.name)
if tool is None:
raise MCPError(types.INVALID_PARAMS, f"Unknown tool: {params.name}")
args = params.arguments or {} # may be None in v2
validator.validate(args, tool.input_schema) # v2 does NOT validate for you
... # policy, audit, Gitea call, limits, sanitization
return types.CallToolResult(content=[types.TextContent(type="text", text=out)], is_error=False)
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=`.
- **v1.30 equivalents**:
- `mcp.shared.memory.create_connected_server_and_client_session(server, read_timeout_seconds=None, ..., raise_exceptions=False)`
- `mcp.client.streamable_http.streamablehttp_client(url, headers=None, timeout=30, sse_read_timeout=300, terminate_on_close=True, httpx_client_factory=..., auth=None)` (deprecated alias), or `streamable_http_client(url, *, http_client=None, terminate_on_close=True)`, both yielding `(read, write, get_session_id)`
- `ClientSession(read, write)` + `await session.initialize()`
---
## 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` |
| search | `search_users`, `search_org_teams`, `search_repos`, `search_issues` |
| notification | `notification_read` (list, get); `notification_write` (mark_read, mark_all_read) |
| label | `label_read` (list_repo_labels, get_repo_label, list_org_labels); `label_write` (create/edit/delete for repo and org labels) |
| milestone | `milestone_read` (get, list); `milestone_write` (create, update, edit, delete) |
| wiki | `wiki_read` (list, get, get_revisions); `wiki_write` (create, update, delete) |
| timetracking | `timetracking_read` (list_issue_times, list_repo_times, get_my_stopwatches, get_my_times); `timetracking_write` (start_stopwatch, stop_stopwatch, delete_stopwatch, add_time, delete_time) |
| packages | `package_read` (list, list_versions, get); `package_write` (delete) |
| project | `project_read` (list, get, list_columns, list_column_issues); `project_write` (create, update, delete, create_column, update_column, delete_column, add_issue, remove_issue) |
| issue | `list_issues`; `attachment_read` (list, get, download); `issue_read` (get, get_comments, get_comment, get_labels, get_blocked_by, get_blocking); `issue_write` (create, update, add_comment, edit_comment, add_labels, remove_label, replace_labels, clear_labels, add_dependency, remove_dependency) |
| pull_request | `list_pull_requests`; `pull_request_read` (get, get_diff, get_files, get_status, get_reviews, get_review, get_review_comments, get_comments); `pull_request_write` (create, update, close, reopen, merge, update_branch, add_reviewers, remove_reviewers); `pull_request_review_write` (create, submit, delete, dismiss, reply_comment, resolve_thread, unresolve_thread) |
| actions | `actions_config_read` (list/get repo and org secrets and variables); `actions_config_write` (upsert/delete secrets, create/update/delete variables, repo and org); `actions_run_read` (list_workflows, get_workflow, list_runs, get_run, list_jobs, list_run_jobs, get_job, get_job_log_preview, download_job_log, list_artifacts, list_run_artifacts, get_artifact, download_artifact); `actions_run_write` (dispatch_workflow, cancel_run, rerun_run) |
| repository | `create_repo`, `fork_repo`, `list_my_repos`, `list_org_repos`, `get_repository_tree` |
| file | `get_file_contents`, `get_dir_contents`, `create_or_update_file`, `delete_file` |
| branch | `create_branch`, `delete_branch`, `list_branches`, `rename_branch` |
| tag | `create_tag`, `delete_tag`, `get_tag`, `list_tags` |
| commit | `list_commits`, `get_commit` |
| release | `create_release`, `delete_release`, `get_release`, `get_latest_release`, `list_releases` |
**Notable features**:
- 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.
- OAuth mode (`-oauth -oauth-public-url https://mcp.example.com -H https://gitea.example.com`):
- 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).
---
## 4. Gitea OAuth2 provider
Sources:
- https://docs.gitea.com/development/oauth2-provider/ (markdown: https://gitea.com/gitea/docs/raw/branch/main/docs/development/oauth2-provider.md)
- config cheat sheet: https://gitea.com/gitea/docs/raw/branch/main/docs/administration/config-cheat-sheet.md
- live https://git.hiddenden.cafe/.well-known/openid-configuration (Gitea 28.1.0)
**Endpoints**:
| Endpoint | Path |
|---|---|
| OIDC discovery | `/.well-known/openid-configuration` |
| Authorization | `/login/oauth/authorize` |
| Token | `/login/oauth/access_token` (accepts JSON and form-urlencoded) |
| Introspection | `/login/oauth/introspect` |
| UserInfo | `/login/oauth/userinfo` |
| JWKS | `/login/oauth/keys` |
`/.well-known/oauth-authorization-server` returns **404** on both git.hiddenden.cafe and gitea.com.
**Live discovery on git.hiddenden.cafe**:
- `issuer: https://git.hiddenden.cafe` (no trailing slash)
- `code_challenge_methods_supported: ["plain","S256"]`
- `grant_types_supported: ["authorization_code","refresh_token"]`
- `response_types_supported: ["code","id_token"]`
- `scopes_supported: ["openid","profile","email","groups"]`. Granular scopes are not listed, but they are accepted.
- `id_token_signing_alg_values_supported: ["RS256"]`
- 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)
Spec sources:
- https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization
- https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization
- https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
- 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`).
- Embedded AS: RFC 8414 metadata, DCR, PKCE S256 only, refresh, revocation, and SEP-990 identity assertion.
- 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.
- Redirect URIs:
- hosted apps: exactly `https://claude.ai/api/mcp/auth_callback`
- 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
- Claude Code CLI:
- static header: `claude mcp add --transport http <name> <url> --header "Authorization: Bearer <token>"`
- OAuth via `/mcp` (DCR or CIMD); with neither it errors "Incompatible auth server: does not support dynamic client registration"
- pre-registered client: `--client-id <id> --client-secret --callback-port <port>`, with redirect `http://localhost:PORT/callback`
- tool output warns above 10k tokens; `MAX_MCP_OUTPUT_TOKENS` defaults to 25,000
- Implication for us:
- 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.
3. HTTP: `server.streamable_http_app(...)` with:
- `auth=AuthSettings(issuer_url=PUBLIC_BASE_URL, resource_server_url=PUBLIC_BASE_URL + "/mcp", required_scopes=[...], client_registration_options=ClientRegistrationOptions(enabled=True, valid_scopes=..., default_scopes=...), revocation_options=RevocationOptions(enabled=True), validate_token_resource=True)`
- `auth_server_provider=GiteaProxyProvider(...)`
- `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}`.