jcwalker3andClaude Opus 4.8 c162608175 feat(transport): add transport-neutral MCP bind seam
Closes #931.

The transport was a literal passed once at the bottom of the module,
bind_native_mcp_transport(transport="stdio"), and every guard that asks
"is this a trusted native session" resolves that question through the
value bound there. The string was a constant in the authorization chain
rather than configuration, so a remote transport had no way to be
expressed and no way to be told apart from an untrusted offline import.

mcp_transport_config becomes the single source of truth: it owns the
permitted set, the default, and the resolution of deployment
configuration (GITEA_MCP_TRANSPORT) into a validated identifier. Unset
configuration still yields stdio, so existing deployments are unchanged.
streamable-http is accepted so the bind is pluggable; standing up its
listener remains #938. sse is deliberately not registered.

bind_native_mcp_transport now resolves from configuration when no
transport argument is given, validates against that one permitted set
before writing the runtime record, and pins the result. bound_transport
is the shared accessor every transport-aware guard reads; it reports the
pinned value and never the environment, so a post-bind GITEA_MCP_TRANSPORT
change cannot move what a guard observes -- the rule already applied to
the session-state root under #695 AC2. Rebinding the same identifier is
idempotent; rebinding a different one is refused, so two guards can never
disagree within one process. assert_transport_bound fails closed before
mcp.run, so an absent or invalid bind stops the server instead of serving
tools over a transport no guard can name.

The bound identifier is now recorded in durable provenance:
mutation_provenance_fields gains bound_transport, which reaches the
decision lock through mcp_session_state.save_state and the audit records
that already spread those fields. The pre-existing transport field keeps
its trust-class values, so no durable record changes shape.
assess_transport_for_auth_mint reports the bound transport through the
same accessor; its verdict is unchanged.

#956's anchor fixture and threat model are re-anchored for the lines this
change shifts, and the two boundary claims that #931 makes false -- the
"literal stdio bind" in B1 and the stdio contract at mcp_server.py:4 --
are restated. Without this the #956 guard fails, which is what it is for.

Non-goals, untouched: the remote listener (#938), per-request principal
resolution (#932), client-managed provenance policy (#934), TLS and
remote client authentication, role capability sets, repository-binding
semantics.

Tests: tests/test_issue_931_transport_bind_seam.py, 42 tests, covering
default bind, explicit stdio, the accepted non-stdio identifier, an
unregistered identifier, no bind at all, one-value agreement across
guards, post-bind environment tampering, the durable decision-lock
record, the rebind contract, and the #695 protections that must not
regress.

Full suite from the branches/ worktree: 28 failed, 5843 passed, 6
skipped, 1047 subtests. The pinned base 9b80e75c reports 28 failed, 5801
passed, 6 skipped, 1047 subtests. The failing test IDs are identical sets
-- no failure added, none fixed; the +42 passed are this PR's new module.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-28 09:59:01 -05:00

Gitea Tools

A collection of Python scripts and an MCP server to automate interactions with Gitea instances.

Supported Instances

Remote Host Org / Repo
dadeschools gitea.dadeschools.net Contractor / Timesheet
prgs gitea.prgs.cc Scaled-Tech-Consulting / Timesheet

Authentication

Authentication is configured via environment variables or a local .env file in the repository root (uses python-dotenv).

Create a .env file in the project root:

# Option A: Gitea Personal Access Tokens (Recommended)
GITEA_TOKEN_DADESCHOOLS="your_token_here"
GITEA_TOKEN_PRGS="your_token_here"

# Option B: Gitea Username & Password (fallback)
GITEA_USER_DADESCHOOLS="username"
GITEA_PASS_DADESCHOOLS="password"
GITEA_USER_PRGS="username"
GITEA_PASS_PRGS="password"

# Optional: Fallback to macOS Keychain (via git credential fill)
# GITEA_USE_KEYCHAIN=1

The Gitea-Tools MCP server exposes all functionality as structured tool calls. Any MCP-compatible agent (Antigravity, Claude Code, etc.) can call these tools natively.

Available Tools

Tool Description
gitea_create_issue Create an issue with title, body, remote
gitea_create_pr Open a pull request with title, head, base
gitea_edit_pr Edit details of an existing pull request
gitea_list_prs List pull requests with state/remote
gitea_view_pr Get full details of a single pull request
gitea_merge_pr Gated merge: merge/squash/rebase only after identity+profile+eligibility gates pass, explicit confirmation="MERGE PR <n>", optional head-SHA and changed-files pinning (no self-merge, no force)
gitea_review_pr Legacy wrapper for gitea_submit_pr_review (merging disabled)
gitea_delete_branch Delete a remote branch
gitea_close_issue Close an issue by number
gitea_list_issues List issues with state/label filters
gitea_view_issue Get full details of a single issue
gitea_whoami Read-only: identify the authenticated Gitea account (safe metadata only)
gitea_get_profile Read-only: describe the active runtime execution profile (safe metadata only)
gitea_check_pr_eligibility Read-only: check if the current identity/profile may review/approve/request_changes/merge a PR
gitea_assess_conflict_fix_classification Read-only: classify conflict-fix need from a live PR head re-fetch before creating a conflict-fix worktree
gitea_submit_pr_review Gated review mutation: comment/approve/request_changes, only after identity+profile+eligibility gates pass (no merge, no self-approval)
gitea_mark_issue Claim/release an issue (start/done)
gitea_list_labels List all available labels in a repository
gitea_create_label Create a new label with custom color
gitea_set_issue_labels Replace all labels on an issue
gitea_get_file Retrieve file content and SHA metadata
gitea_commit_files Commit changes to multiple files atomically
gitea_mirror_refs Mirror branches + tags between instances

Setup

1. Install dependencies

cd /Users/jasonwalker/Development/Gitea-Tools
python3 -m venv venv          # skip if venv already exists
source venv/bin/activate
pip install "mcp[cli]"

2. Configure your AI client

The MCP server uses stdio transport — each client starts it as a subprocess. Add the config below to your client, then restart it.

Antigravity (Google)

Add to ~/.gemini/antigravity-ide/mcp_config.json inside "mcpServers":

"gitea-tools": {
  "command": "/Users/jasonwalker/Development/Gitea-Tools/venv/bin/python3",
  "args": ["/Users/jasonwalker/Development/Gitea-Tools/mcp_server.py"],
  "env": {}
}

Restart Antigravity to load the server. Tools appear as lazy-loaded MCP tools (call via call_mcp_tool with ServerName: "gitea-tools").

Claude Code (Anthropic)

Add to ~/.claude.json (global) or .mcp.json in the project root:

{
  "mcpServers": {
    "gitea-tools": {
      "command": "/Users/jasonwalker/Development/Gitea-Tools/venv/bin/python3",
      "args": ["/Users/jasonwalker/Development/Gitea-Tools/mcp_server.py"]
    }
  }
}

Restart Claude Code. Tools appear as mcp__gitea-tools__gitea_create_issue, etc.

Any MCP-compatible client

The server is a standard MCP stdio server. Point your client at:

  • Command: /Users/jasonwalker/Development/Gitea-Tools/venv/bin/python3
  • Args: ["/Users/jasonwalker/Development/Gitea-Tools/mcp_server.py"]
  • Transport: stdio

No environment variables needed — auth is handled via macOS keychain.

Runtime profiles (multiple env-configured entries)

The same server can run as separate MCP entries, each authenticating as its own Gitea token and carrying its own profile name. This keeps roles task-scoped: the profile is the role, not the LLM. Point each entry at a different gitignored env file.

{
  "mcpServers": {
    "gitea-tools-reviewer": {
      "command": "/Users/jasonwalker/Development/Gitea-Tools/venv/bin/python3",
      "args": ["/Users/jasonwalker/Development/Gitea-Tools/mcp_server.py"],
      "env": {
        "GITEA_PROFILE_NAME": "gitea-reviewer",
        "GITEA_ALLOWED_OPERATIONS": "read,review,approve"
      }
    },
    "gitea-tools-merger": {
      "command": "/Users/jasonwalker/Development/Gitea-Tools/venv/bin/python3",
      "args": ["/Users/jasonwalker/Development/Gitea-Tools/mcp_server.py"],
      "env": {
        "GITEA_PROFILE_NAME": "gitea-merger",
        "GITEA_ALLOWED_OPERATIONS": "read,merge"
      }
    }
  }
}

Recognized environment fields (see .env.example for placeholders):

Variable Purpose
GITEA_TOKEN API token for this runtime. Read only by the auth layer; never returned, logged, or committed.
GITEA_PROFILE_NAME Non-secret label for the running profile (e.g. gitea-reviewer). Surfaced by gitea_whoami.
GITEA_ALLOWED_OPERATIONS Optional, comma-separated operation categories (descriptive metadata only for now).
GITEA_FORBIDDEN_OPERATIONS Optional, comma-separated categories this profile must not perform (descriptive).
GITEA_AUDIT_LABEL Optional short label for this runtime, for audit purposes.
GITEA_TOKEN_SOURCE Optional name of the token source (e.g. an env var name). A name only — never the token value.
GITEA_BASE_URL Optional informational base URL.
GITEA_AUDIT_LOG Optional path to an audit log file. When set, mutating actions append one redacted JSON record each (profile + authenticated user + outcome). Unset ⇒ auditing off (no records, no extra API calls).
GITEA_MCP_CONFIG Optional path to a JSON file defining multiple named runtime profiles. Unset ⇒ pure env behaviour.
GITEA_MCP_PROFILE Name of the profile (from GITEA_MCP_CONFIG) to activate for this runtime.

External MCP Control Plane servers

Jenkins and GlitchTip are separate MCP trust boundaries, not tools inside this Gitea MCP runtime. Register them as jenkins-mcp and glitchtip-mcp in the client that will use them, then reconnect or reload the client and verify the expected tools are visible before claiming readiness. See docs/mcp-client-registration.md.

Notes:

  • This provides one token + one profile per process. It does not implement multi-token switching inside a single runtime, nor any approve/merge/eligibility gating — those are later roadmap items (#14#18).
  • Profile name and allowed operations are metadata only; the token value is never part of any tool output. gitea_whoami returns the profile name, and gitea_get_profile returns the full non-secret profile metadata so a workflow can inspect which runtime it is talking to before deciding to act.
  • See docs/gitea-execution-profiles.md for the full profile model, and docs/llm-workflow-runbooks.md for the task-scoped, profile-based runbooks (create/review/merge/close, thin launchers, migration, fail-closed rules).
  • For the portable version of this workflow (issue-first, isolated worktrees, no self-review/merge, profile safety, cleanup, fail-closed) that can be copied into any project, see the reusable skill skills/llm-project-workflow/SKILL.md.
  • Audit logging (#18): mutating actions emit a durable, redacted JSON audit record — timestamp, action, result (allowed/blocked/failed/succeeded), profile name + audit label, authenticated username, target repo/issue/PR, branch and head SHA where applicable — when GITEA_AUDIT_LOG is set. Auditing is off by default and never adds API calls or breaks the action when off. See gitea_audit.py.

Canonical runtime profiles (#19). Define every Gitea profile once, in a canonical JSON file, and keep each LLM launcher (Claude / Gemini / Codex) a thin pointer at it — no duplicated GITEA_USER_* / GITEA_PASS_* blocks and no raw tokens in client configs. See gitea-mcp.example.json, loaded by gitea_config.py.

Canonical profile file (e.g. ~/.config/gitea-tools/profiles.json):

{
  "version": 1,
  "profiles": {
    "prgs": {
      "base_url": "https://gitea.prgs.cc",
      "username": "jcwalker3",
      "auth": { "type": "keychain", "id": "prgs-gitea-token" },
      "default_owner": "Scaled-Tech-Consulting",
      "execution_profile": "personal-prgs"
    },
    "mdcps": {
      "base_url": "https://gitea.dadeschools.net",
      "username": "913443",
      "auth": { "type": "env", "name": "GITEA_TOKEN_MDCPS" },
      "execution_profile": "mdcps"
    },
    "mdcps-reviewer": {
      "base_url": "https://gitea.dadeschools.net",
      "username": "913443",
      "auth": { "type": "keychain", "id": "mdcps.gitea.reviewer.token" },
      "execution_profile": "mdcps-reviewer"
    }
  }
}

Thin LLM launcher (Claude / Gemini / Codex) — only two env vars, no secrets:

"gitea-tools": {
  "command": "/Users/jasonwalker/Development/Gitea-Tools/venv/bin/python3",
  "args": ["/Users/jasonwalker/Development/Gitea-Tools/mcp_server.py"],
  "env": {
    "GITEA_MCP_CONFIG": "/Users/jasonwalker/.config/gitea-tools/profiles.json",
    "GITEA_MCP_PROFILE": "prgs"
  }
}
  • Secrets by reference only: a profile's auth names where the token lives — { "type": "keychain", "id": "..." } (macOS keychain) or { "type": "env", "name": "..." } (env var). Inline token/password keys are rejected. The value is resolved on demand and never stored in, returned by, or logged as profile metadata.
  • Precedence: explicit process env vars (GITEA_PROFILE_NAME, GITEA_BASE_URL, GITEA_TOKEN, …) override the JSON profile; the JSON profile only fills what the environment leaves unset.
  • Backwards compatible / fail-safe: with GITEA_MCP_CONFIG unset, behaviour is exactly the legacy env-only mode. A missing file, invalid JSON, unsupported version, unknown/unset selected profile, or unresolvable secret reference raises a clear startup error that never prints file contents, tokens, or passwords. Parsing makes no network calls.

Migrating from duplicated GITEA_PASS_* blocks. Move each instance's credentials into one canonical profile entry (referencing a keychain id or env var for the secret), then delete the GITEA_USER_* / GITEA_PASS_* / GITEA_SITE_* blocks from every LLM mcp_config.json, leaving only GITEA_MCP_CONFIG + GITEA_MCP_PROFILE. Existing env-only setups keep working unchanged until migrated.

Interactive setup — no hand-editing JSON. Run the menu to create/edit/ validate profiles, store a token in the macOS keychain (never echoed or written to any config), test a profile's authentication, print the authenticated user, check reviewer eligibility for a PR, and generate ready-to-paste launcher snippets for Claude / Gemini / Codex:

./scripts/gitea-config-menu

The generated launcher snippets contain only command, args, GITEA_MCP_CONFIG, and GITEA_MCP_PROFILE — never a token or password.

Portable LLM workflow skill

Reusable LLM operating rules are packaged as a portable skill at skills/llm-project-workflow/SKILL.md. It documents issue-first work, isolated branch worktrees, no self-review or self-merge, profile safety, fail-closed behavior, merge cleanup, and recovery patterns. Copy the skills/llm-project-workflow/ directory into other projects that should use the same workflow.

Codex / non-MCP tools

OpenAI Codex and other tools that don't support MCP can use the CLI scripts directly. See the CLI Scripts section below.

# Example: Codex can shell out to the scripts
python3 /Users/jasonwalker/Development/Gitea-Tools/create_issue.py \
  --remote prgs --title "Bug report" --body "Details here"

CLI Scripts

The MCP tools can also be used as standalone CLI scripts:

Script Description
create_issue.py Create an issue (--remote, --title, --body, --body-file)
create_pr.py Open a Pull Request (--remote, --title, --head, --base)
edit_pr.py Edit a Pull Request (--title, --body, --body-file, etc.)
review_pr.py Review/sign-off on a pull request (--merge is disabled — fails closed; merge only via gated gitea_merge_pr)
close_issue.py Close a specific issue
mark_issue.py Claim/release an issue via status:in-progress label
manage_labels.py Create label set and apply label mappings (--dry to preview)
mirror_refs.sh Mirror branches + tags between dadeschools ⇄ prgs

Quick Examples

# Create an issue
./create_issue.py --title "Fix PDF output" --body "Blank on Safari"

# Create an issue on the prgs instance
./create_issue.py --remote prgs --title "Add tests" --body-file description.md

# Create a PR
./create_pr.py --title "feat: add validation" --head feat/validation --body "Closes #12"

# Edit a PR's description or title
./edit_pr.py 155 --body "Updated description wording"

# Review and approve a PR (review only — CLI merge is disabled; use the
# gated gitea_merge_pr MCP workflow to merge)
./review_pr.py --pr-number 12 --event APPROVE --body "Approved"

# Close issue #5
./close_issue.py 5

# Claim an issue before working on it
./mark_issue.py 10 start

# Release when done
./mark_issue.py 10 done

# Mirror refs (dry-run by default)
./mirror_refs.sh

# Actually push the refs
./mirror_refs.sh --apply

Use --help on any Python script or shell script for full usage details.

Architecture

gitea_auth.py    ← shared auth & API helpers (get_credentials, api_request)
mcp_server.py    ← MCP server (FastMCP, stdio transport)
create_issue.py  ← CLI: create issues
create_pr.py     ← CLI: create PRs
edit_pr.py       ← CLI: edit PRs
review_pr.py     ← CLI: review PRs
manage_labels.py ← CLI: label management
close_issue.py   ← CLI: close issues
mark_issue.py    ← CLI: claim/release issues
mirror_refs.sh   ← CLI: ref mirroring

Tests

# Run with the venv (includes MCP SDK)
source venv/bin/activate
python3 -m pytest tests/ -v
Test file Covers
test_mcp_server.py All 7 MCP tools: create, list, view, close, mark, PR, mirror
test_create_issue.py CLI arg parsing, remote resolution, payload, auth, errors
test_create_pr.py CLI arg parsing, remote resolution, payload, auth, errors
test_credentials.py get_credentials(), get_auth_header(), repo_api_url()
test_manage_labels.py Label create/skip, dry run, mapping, constant validation
test_python_cli.py close_issue.py + mark_issue.py CLI validation
test_mirror_refs.py Flags, safety defaults, local integration tests

(Core suites — the table is non-exhaustive; see tests/ for the full set.)

All tests mock network and keychain access — no real API calls are made.

For how to write tests — mocking the API/auth safely, testing profile and self-review/self-merge gates, no-secret regression expectations, and unit vs. integration guidance — see docs/developer-testing-guidelines.md.

Troubleshooting

macOS: com.apple.provenance blocks Python execution (#3)

On macOS Sequoia and later, files written by an agent/IDE terminal receive the com.apple.provenance extended attribute, and macOS blocks Python.app from executing such files. Symptoms: newly created/restored .py files fail to run (e.g. create_issue.py "vanishing" or refusing to execute), while shell scripts and files created before the session are unaffected. This is a macOS security feature, not a bug in this project's code.

Workarounds (run from a terminal with Full Disk Access, e.g. Terminal.app — not the IDE terminal, or the removal itself may be blocked):

# Preferred: strip only com.apple.provenance under the repo (dry-run first)
./scripts/clear-provenance --dry-run
./scripts/clear-provenance

# Or a single file
./scripts/clear-provenance /path/to/file.py

# Manual equivalents
xattr -r -d com.apple.provenance /Users/jasonwalker/Development/Gitea-Tools/
xattr -cr /Users/jasonwalker/Development/Gitea-Tools/   # clears ALL xattrs

Alternatively, grant Full Disk Access to the terminal app in System Settings → Privacy & Security. scripts/clear-provenance removes only com.apple.provenance (leaving other extended attributes intact) and supports --dry-run.

S
Description
Python and Bash scripts to automate interactions with Gitea instances (issues, PRs, labels).
Readme
56 MiB
Languages
Python 99.3%
Shell 0.7%