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

46 KiB
Raw Permalink Blame History

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:


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:

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"):
    @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:

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:
    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):

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):

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):

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:

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.

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):
    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):
    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:

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:

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}.