feat: local stdio package + safe full-API coverage #59

Merged
Latte merged 9 commits from feat/local-package-and-full-coverage into dev 2026-06-27 12:23:32 +00:00
Owner

Summary

Refactors AegisGitea-MCP into a transport-agnostic core + two thin adapters and ships a local stdio package alongside the existing public HTTP/OAuth server, with safe full-API coverage and resource-type-aware authorization. The public HTTP/OAuth server behavior is unchanged.

  • Core / adapter split. registry.py is the single name→handler source of truth consumed by both adapters. Core handlers raise the transport-agnostic errors.ToolError (no more fastapi.HTTPException in the core); the HTTP adapter maps it back to HTTPException. A subprocess boundary test asserts the core imports no fastapi/uvicorn/starlette.
  • Local stdio package. stdio_app.py (official mcp SDK) runs uvx aegis-gitea-mcp — single-user, PAT-owner identity, no OAuth. It resolves the PAT owner (GET /user), pins request context, and runs the same policy + WRITE_MODE + secret sanitization + audit; it skips only the per-user repo probe (the operator is the token owner). Audit log falls back to a per-user state path.
  • Packaging. Core install (httpx/pydantic/mcp/…) + [server] extra (fastapi/uvicorn/PyJWT/python-multipart). Console scripts aegis-gitea-mcp (stdio) and aegis-gitea-mcp-server (guarded HTTP entry). Version → 0.2.0. uv build produces sdist + wheel.

Authorization model (every decision fails closed)

Enforced in service-PAT mode on top of policy + WRITE_MODE (pure-OAuth mode defers to the user's token at Gitea):

Resource type Rule
repository per-user collaborator permission; unparseable repo path → deny
org signed-in user must be a Gitea-verified member; unverifiable → deny
user_owned (/users/{x}, /packages/{owner}) allow only if owner == caller or caller is a member org; else deny
user_self (/user, /notifications) deny — data is the bot's, not the caller's
misc_global reads allow, writes deny
admin default-deny; allowed only with RAW_API_ALLOW_SENSITIVE=true and a verified site admin
unknown / unverifiable / transport error deny, audited

gitea_request adds a deterministic write classifier (a render-only override table can only downgrade provably side-effect-free POSTs to reads, never upgrade — a write can't slip past WRITE_MODE), a known-path gate (unknown prefix → deny), and the pre-existing admin/credential denylist.

Verification

  • ruff + ruff-format + black + mypy(strict) clean.
  • pytest: 346 passed, 1 skipped; coverage 83.70% (threshold 80%, unchanged; baseline was 284 / 84.04%).
  • uv build → sdist + wheel; uvx --from ./dist/<wheel> aegis-gitea-mcp installs 34 core deps (no FastAPI) and runs the console script end-to-end.

Assumptions / deferred

  • Version set to 0.2.0 per the task brief (the app already reported 0.2.0; pyproject was 0.1.0).
  • TODO(authz): list_organizations (/user/orgs) stays denied in service-PAT mode because it returns the bot's orgs (fail-closed, safe). Follow-up: make it user-scoped via /users/{login}/orgs so it can be allowed.
  • structlog retained as a core dep per the brief though currently unused in src.

Notes

  • Branch flow: HEAD → feature → dev → main. This PR targets dev; dev/main are expected to be protected. The publish workflow runs on a v* tag.
  • New .gitea/workflows/publish.yml builds with uv and publishes to the Gitea PyPI registry on tag, gated on lint + tests, using GITEA_PACKAGE_USER / GITEA_PACKAGE_TOKEN secrets (fails loudly if absent; public PyPI intentionally not published).

🤖 Generated with Claude Code

## Summary Refactors AegisGitea-MCP into a **transport-agnostic core + two thin adapters** and ships a **local stdio package** alongside the existing public HTTP/OAuth server, with **safe full-API coverage** and **resource-type-aware authorization**. The public HTTP/OAuth server behavior is unchanged. - **Core / adapter split.** `registry.py` is the single name→handler source of truth consumed by both adapters. Core handlers raise the transport-agnostic `errors.ToolError` (no more `fastapi.HTTPException` in the core); the HTTP adapter maps it back to `HTTPException`. A subprocess boundary test asserts the core imports no `fastapi`/`uvicorn`/`starlette`. - **Local stdio package.** `stdio_app.py` (official `mcp` SDK) runs `uvx aegis-gitea-mcp` — single-user, PAT-owner identity, no OAuth. It resolves the PAT owner (`GET /user`), pins request context, and runs the same policy + `WRITE_MODE` + secret sanitization + audit; it skips only the per-user repo probe (the operator is the token owner). Audit log falls back to a per-user state path. - **Packaging.** Core install (httpx/pydantic/mcp/…) + `[server]` extra (fastapi/uvicorn/PyJWT/python-multipart). Console scripts `aegis-gitea-mcp` (stdio) and `aegis-gitea-mcp-server` (guarded HTTP entry). Version → 0.2.0. `uv build` produces sdist + wheel. ## Authorization model (every decision fails closed) Enforced in service-PAT mode on top of policy + `WRITE_MODE` (pure-OAuth mode defers to the user's token at Gitea): | Resource type | Rule | |---|---| | `repository` | per-user collaborator permission; unparseable repo path → **deny** | | `org` | signed-in user must be a Gitea-verified member; unverifiable → **deny** | | `user_owned` (`/users/{x}`, `/packages/{owner}`) | allow only if owner == caller or caller is a member org; else **deny** | | `user_self` (`/user`, `/notifications`) | **deny** — data is the bot's, not the caller's | | `misc_global` | reads allow, writes **deny** | | `admin` | **default-deny**; allowed only with `RAW_API_ALLOW_SENSITIVE=true` **and** a verified site admin | | `unknown` / unverifiable / transport error | **deny**, audited | `gitea_request` adds a deterministic write classifier (a render-only override table can only *downgrade* provably side-effect-free POSTs to reads, never upgrade — a write can't slip past `WRITE_MODE`), a known-path gate (unknown prefix → **deny**), and the pre-existing admin/credential denylist. ## Verification - ruff + ruff-format + black + mypy(strict) clean. - pytest: 346 passed, 1 skipped; coverage 83.70% (threshold 80%, unchanged; baseline was 284 / 84.04%). - `uv build` → sdist + wheel; `uvx --from ./dist/<wheel> aegis-gitea-mcp` installs 34 core deps (no FastAPI) and runs the console script end-to-end. ## Assumptions / deferred - Version set to **0.2.0** per the task brief (the app already reported 0.2.0; pyproject was 0.1.0). - **TODO(authz):** `list_organizations` (`/user/orgs`) stays **denied** in service-PAT mode because it returns the bot's orgs (fail-closed, safe). Follow-up: make it user-scoped via `/users/{login}/orgs` so it can be allowed. - `structlog` retained as a core dep per the brief though currently unused in `src`. ## Notes - Branch flow: `HEAD → feature → dev → main`. This PR targets **`dev`**; `dev`/`main` are expected to be protected. The publish workflow runs on a `v*` tag. - New `.gitea/workflows/publish.yml` builds with `uv` and publishes to the Gitea PyPI registry on tag, gated on lint + tests, using `GITEA_PACKAGE_USER` / `GITEA_PACKAGE_TOKEN` secrets (fails loudly if absent; public PyPI intentionally not published). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Latte added 9 commits 2026-06-27 11:41:15 +00:00
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduce aegis_gitea_mcp.registry as the single name->handler source of
truth consumed by every transport adapter, moving TOOL_HANDLERS out of the
FastAPI server module. Add aegis_gitea_mcp.errors.ToolError so core handlers
no longer import fastapi.HTTPException; raw_tools now raises ToolError and the
HTTP adapter maps it back to HTTPException, preserving status codes and audit
behavior. Add a subprocess boundary test asserting the core imports without
pulling in fastapi/uvicorn/starlette.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add aegis_gitea_mcp.stdio_app: a single-user, local MCP server over stdio
(official mcp SDK) that serves the same tools from the shared registry,
resolves the PAT owner via GET /user and pins request context to it, and runs
policy + WRITE_MODE + secret sanitization + audit while skipping the per-user
repo probe (the operator is the trusted token owner). Audit log falls back to a
per-user state path when the container default is unwritable.

Packaging: split deps into core (httpx/pydantic/mcp/...) and a [server] extra
(fastapi/uvicorn/PyJWT/python-multipart); add console scripts aegis-gitea-mcp
(stdio) and aegis-gitea-mcp-server (guarded HTTP entry); bump to 0.2.0 and fix
repo URLs. mcp added to requirements for CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a deterministic (method, path) read/write classifier with an explicit
render-only override table that can only downgrade provably side-effect-free
POSTs (markdown/markup) to reads, never the reverse — so a mutating call cannot
slip past the write-mode gate. Add a known-Gitea-prefix gate: gitea_request now
fails closed on any path whose top segment is not a recognized /api/v1 route
instead of passing unknown paths through. Expose raw_relative_segments for the
authorization layer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add aegis_gitea_mcp.authz: classify every dispatched call (typed tools and
gitea_request) by resource type (repository/org/user_self/user_owned/
misc_global/admin/unknown) and enforce a type-specific rule in service-PAT
mode, on top of policy + WRITE_MODE. Every decision fails closed:

- org: signed-in user must be a verified org member (Gitea-checked).
- user_owned: owner must be the caller or a member org of the caller.
- user_self: token-owner-scoped endpoints denied (token is the bot's).
- admin: default-deny; allowed only with RAW_API_ALLOW_SENSITIVE opt-in AND a
  verified site admin.
- misc_global: reads allowed, writes denied.
- unknown / unverifiable: denied and audited.

Wire it into the server's service-PAT dispatch: repository calls keep the
existing per-user collaborator check; non-repo calls (previously blanket-denied)
now go through the resource-type gate, opening the org/user/admin surface
safely. Verification results are cached briefly (fail-closed: positives only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cover the stdio adapter: local-mode env bootstrap (OAuth off, API-key gate off,
per-user audit path), missing-env failure, PAT owner resolution, and dispatch
(unknown tool, write-mode policy denial, and the happy path pinning request
context to the PAT owner via the shared registry). Tidy the boundary-test
assertion so ruff and black agree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reframe the README around two transports and add a local stdio quickstart with
uvx/pip and Claude Desktop / Claude Code wiring. New docs: local-quickstart.md
and packaging.md (uv build/publish). Document resource-type-aware authorization
and classified gitea_request in security.md; stdio env vars + audit-log
fallback in configuration.md; local install in deployment.md; core+adapters in
architecture.md. Add the missing root AGENTS.md contract, update CLAUDE.md with
the core/adapter layout, fail-closed invariants, and the branching flow
(HEAD -> feature -> dev -> main). Update roadmap/todo and .env.example.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ci: build and publish package to Gitea registry on tag
docker / test (push) Successful in 38s
lint / lint (push) Successful in 45s
docker / lint (push) Successful in 44s
test / test (push) Successful in 44s
docker / docker (push) Successful in 40s
docker / lint (pull_request) Successful in 40s
docker / test (pull_request) Successful in 34s
lint / lint (pull_request) Successful in 41s
test / test (pull_request) Successful in 40s
docker / docker (pull_request) Successful in 37s
499bf98d92
Add .gitea/workflows/publish.yml: on a v* tag, gate on the existing lint + test
jobs, then build sdist+wheel with uv and publish to the self-hosted Gitea PyPI
registry using least-privilege Actions secrets (GITEA_PACKAGE_USER /
GITEA_PACKAGE_TOKEN). The job fails loudly when the secrets are absent rather
than publishing anonymously, uploads the built artifacts, and leaves a disabled
public-PyPI stub. Public PyPI is intentionally not published in this pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Latte merged commit cf19a320b0 into dev 2026-06-27 12:23:32 +00:00
Latte deleted branch feat/local-package-and-full-coverage 2026-06-27 12:23:32 +00:00
Sign in to join this conversation.