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.
46 KiB
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)
- Latest stable
mcpis 2.3.0 (2026-10-02),requires_python >=3.10. In 2.x, FastMCP no longer exists:mcp.server.fastmcpraisesModuleNotFoundErroron import. It was renamed toMCPServer(from mcp.server import MCPServer/mcp.server.mcpserver). Transport settings (host, port, path, stateless_http, json_response) moved from the constructor torun()/streamable_http_app(). - v1.x is still maintained (1.30.0 released 2026-09-07). It has the old
FastMCPAPI. The SDK's own advice for libraries that are not ready to move is to pinmcp>=1.28,<2(SDK/docs/whats-new.md). - For about 540 OpenAPI-derived tools, use the low-level
mcp.server.lowlevel.Server(mcp.server.Server). It takes rawTool(input_schema=...)dicts and hasstreamable_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. - 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.jsonschemais already anmcpdependency. - 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 noregistration_endpoint(no DCR). It does serve OIDC discovery, PKCE (plain,S256),authorization_code+refresh_token, and/login/oauth/introspect. - 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
OAuthAuthorizationServerProviderwith DCR on) covers both. - 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(nothttpx!),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, extrascli,rich. - The wire types now live in the separate package
mcp-types(import mcp_types), re-exported asmcp.types. Python attribute names are snake_case (tool.input_schema,result.is_error); the JSON wire format stays camelCase.MCPModelhaspopulate_by_name=Truewith 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 needSSL_CERT_FILE/SSL_CERT_DIR. MCP_*env vars and.envare 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
defhandlers run on a worker thread. - Server results are validated against the protocol schema. A Tool whose
input_schemalacks"type": "object"failstools/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")).
- The HTTP client is
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
authwith neitherauth_server_providernortoken_verifierraisesValueError. Passing both also raises. Passing either withoutauthraises. When onlyauth_server_provideris given, MCPServer wraps it inProviderTokenVerifier(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-levelServer.streamable_http_app(...), passingauth,token_verifier,auth_server_provider,custom_starlette_routesanddebug. - Custom routes. These are NOT behind auth (docstring: "Routes using this decorator will not require authorization"):
Signature:
@mcp.custom_route("/health", methods=["GET"]) async def health(request: Request) -> Response: return JSONResponse({"status": "ok"})custom_route(self, path: str, methods: list[str], name: str | None = None, include_in_schema: bool = True). mount_pathwas removed in v2. To serve under a prefix, mount the ASGI app instead (SDK/docs/run/asgi.md).- DNS-rebinding protection is automatic when
hostis127.0.0.1,localhostor::1andtransport_securityis None. The allowed hosts/origins are then loopback only. Behind a reverse proxy with a public Host header, passTransportSecuritySettings(...)explicitly (SDK/src/mcp/server/lowlevel/server.py,streamable_http_app). stateless_httponly affects 2025-era clients. 2026-07-28 traffic has noMcp-Session-Idat 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 overrideinitialize).server.middlewareis a list ofasync (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 runssession_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_verifieris set, it installsAuthenticationMiddleware(BearerAuthBackend(token_verifier, resource_server_url=auth.resource_server_url if auth.validate_token_resource else None))plusAuthContextMiddleware, and wraps the/mcproute inRequireAuthMiddleware(app, required_scopes, resource_metadata_url). - If
auth_server_provideris set, it addscreate_auth_routes(...). - If
auth.resource_server_urlis set, it addscreate_protected_resource_routes(...)(RFC 9728). - Unlike MCPServer, the low-level path does NOT auto-create
ProviderTokenVerifierand does NOT enforce the provider/verifier mutual exclusion. With an AS provider you must passtoken_verifier=ProviderTokenVerifier(provider), or your own combined verifier, or/mcpstays unauthenticated. The upside: one custom verifier can accept the static bearer token OR provider-issued tokens while the provider mounts the AS routes.
- If
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):
/authorizeaccepts onlycode_challenge_method="S256"and takes the RFC 8707resourceparameter./tokenverifies the PKCEcode_verifier(SHA256, base64url), checks code expiry, and requires the redirect_uri to match the one used at /authorize. Client auth supportsclient_secret_post,client_secret_basicandnone(public clients).- DCR defaults
token_endpoint_auth_methodtoclient_secret_postand generates a secret unless the method isnone. It rejectsprivate_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 fromcreate_auth_routes(...), plus/login(GET form) and/login/callback(POST) routes, plus an RFC 7662/introspectendpoint.server.py: an RS usingMCPServer(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 viaMCPServer(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:
authorize()storesstate -> {redirect_uri, code_challenge, redirect_uri_provided_explicitly, client_id, resource}and returns the login URL.- The callback looks up the state, authenticates the user, creates
AuthorizationCode(code="mcp_"+token_hex(16), ..., expires_at=time.time()+300, subject=username), and returnsRedirectResponse(construct_redirect_uri(redirect_uri, code=new_code, state=state), 302). 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 fromfunc_metadata(fn).arg_model.model_json_schema()(SDK/src/mcp/server/mcpserver/tools/base.py,Tool.from_function). There is noinput_schemaparameter.- The only way around that with MCPServer is to build internal
Toolpydantic objects with a customparametersandfn_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 asreadOnlyHintetc.
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_tooldispatch 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
-32603that the model never sees. - For failures the model should read, return
CallToolResult(is_error=True, ...). - Raise
MCPErroronly 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,StdioServerParametersmeans 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):This skips HTTP, so there is no auth andasync with Client(server, raise_exceptions=True) as c: result = await c.call_tool("add", {"a": 1, "b": 2})get_access_token()is None.create_client_server_memory_streams()is still inmcp.shared.memoryfor low-level tests. - stdio:
from mcp.client.stdio import stdio_client, StdioServerParameters. Signature:stdio_client(server: StdioServerParameters, errlog: TextIO = sys.stderr), whereStdioServerParameters(command, args=[], env=None (merged over an allow-listed default env), cwd=None, encoding="utf-8", encoding_error_handler="strict"). Typical use:Client(StdioServerParameters(...))orClient(stdio_client(params, errlog=f)). - Streamable HTTP:
streamablehttp_clientwas removed in v2. Usefrom mcp.client.streamable_http import streamable_http_client, signaturestreamable_http_client(url, *, http_client: httpx2.AsyncClient | None = None, terminate_on_close=True, max_sse_event_size=...). It has noheaders=argument; headers go on the httpx2 client (SDK/docs_src/client_transports/tutorial003.py):For in-process ASGI tests, use an httpx2 client with an ASGI transport and passasync 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()transport_securitythat disables DNS-rebinding checks. This is what the SDK stories do (NO_DNS_REBINDinSDK/examples/stories/_hosting.py). To see 401/403 andWWW-Authenticateyou have to test at the HTTP level (curl or httpx2);Clientcannot observe them. ClientSessionis still available viaclient.sessionormcp.client.session.ClientSession. It now runs onJSONRPCDispatcher, and list methods takecursor=.- 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), orstreamable_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_READONLYhides 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 getDestructiveHint: 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, noMcp-Session-Id, no SSE resumability./healthzis the health probe. Origins are validated. Protocol support goes up to 2026-07-28 and only thetoolscapability is advertised. - Credentials per HTTP request:
Authorization: Bearer <token>orAuthorization: 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) withauthorization_servers=[GiteaURL],scopes_supported=write:(orread:with-r) +repository, issue, user, organization, notification, package, misc, andbearer_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:
/mcponly 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_callbackandhttp://127.0.0.1/callback(for Claude Code; Gitea matches loopback IPs on any port). Users enter the client ID under Advanced settings.
- It serves PRM at
- 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, notoken_endpoint_auth_methods_supported, noauthorization_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-onlylimits 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_authorizationon 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 defaultJWT_SIGNING_ALGORITHM=RS256MAX_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(operationIduserCreateOAuth2Application), bodyCreateOAuth2ApplicationOptions:name: stringredirect_uris: string[]confidential_client: booleanskip_secondary_authorization: boolean- No required fields are declared.
- The response
OAuth2Applicationhasid, 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}, andPATCH /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-OTPheader.
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) returnsCurrentAccessToken {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
resourceis mandatory for clients. - Servers SHOULD send
scopeinWWW-Authenticate.
- 2026-07-28: same structure, but DCR "is deprecated and retained for backwards compatibility". RFC 9207
issin 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-Authenticateheader withresource_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),
issin the authorization response, and thescopeparameter 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-Authenticateon a 200 is ignored. - PRM
resourcemust equal the URL exactly as the user enters it. - Only the first
authorization_serversentry is used. - The AS must serve RFC 8414 or OIDC discovery and be reachable from Anthropic egress
160.79.104.0/21.
- A 401 is required;
- 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 PRMscopes_supported. Claude appendsoffline_accessif the AS metadata lists it. - CIMD is chosen only if the AS metadata has
client_id_metadata_document_supported: trueAND"none"intoken_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 thelocalhostand127.0.0.1forms; servers must match both port-agnostically). v2.1.229 briefly sent the 127.0.0.1 form; v2.1.231 restoredlocalhost.
- hosted apps: exactly
- Token endpoint:
- must accept
application/x-www-form-urlencoded - return
invalid_grantfor 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
- must accept
- 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 redirecthttp://localhost:PORT/callback - tool output warns above 10k tokens;
MAX_MCP_OUTPUT_TOKENSdefaults to 25,000
- static header:
- Implication for us:
- Our embedded AS gets DCR from Claude because SDK metadata lacks
noneand 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.
- Our embedded AS gets DCR from Claude because SDK metadata lacks
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-Authenticatefor 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)
- Pin
mcp>=2.3,<3. Build on the low-levelServerwith a generated registry oftypes.Toolobjects carrying raw OpenAPI-derivedinput_schemaandToolAnnotationsfrom the HTTP method. Validate arguments ourselves withjsonschema. Oneon_call_tooldoes policy, then WRITE_MODE, then audit, then the Gitea call, then limits, then sanitization. - stdio:
stdio_server()plusserver.run(...)with a PAT owner identity.get_access_token()is None there. - 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 pathcustom_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
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/callbackroute exchanges the code at{GITEA}/login/oauth/access_token, mints our code (subject= Gitea user from/login/oauth/userinfoorGET /api/v1/user), and redirects to the client'sredirect_uri exchange_authorization_codereturns opaque MCP tokens mapped to the Gitea access and refresh tokensexchange_refresh_tokenrefreshes upstream when needed (Gitea access tokens last 1 h, refresh tokens 730 h) and rotates our refresh token
- Create the upstream Gitea app via
POST /user/applications/oauth2 {name, redirect_uris:[".../oauth/callback"], confidential_client:true, skip_secondary_authorization:false}.