githost-mcp

August 11, 2026 · View on GitHub

Built with Claude Code License: MIT

githost-mcp

Unified local + multi-provider git MCP server with a per-agent audit trail as a first-class feature.

Every tool call is tagged with the caller agent (AGENT_ID), written to a structured JSONL audit log, and write operations emit agent-bus events. Local git operations run entirely through gitpython — no subprocess, no injection risk.

Architecture

Every tool call dispatches to one provider and writes a signed audit entry before returning; write operations additionally emit an agent-bus event.

flowchart LR
    client[MCP client / agent] -->|tool call + AGENT_ID| tool[githost-mcp tool]
    tool --> dispatch{provider}
    dispatch -->|local| localgit[gitpython]
    dispatch -->|GitHub| gh[PyGithub]
    dispatch -->|Gitea| gitea[httpx]
    dispatch -->|GitLab| glab[python-gitlab]
    dispatch -->|Woodpecker| wp[httpx]
    localgit --> audit[audit.py]
    gh --> audit
    gitea --> audit
    glab --> audit
    wp --> audit
    audit -->|HMAC-signed JSONL| logfile[(audit log)]
    tool -.->|write ops| bus[[agent-bus event]]

Path-taking tools resolve separate read and write allowlists before touching the filesystem: ALLOWED_REPO_ROOTS env (break-glass, applies to both lists) → /etc/forge/workspace-policy.yml → the agent manifest → empty, fail closed. First match wins — the policy file, once it loads successfully, is authoritative for that agent even if its own grant is empty. A write op additionally passes through a glob gate when the resolved grant carries write_globs/write_globs_deny.

flowchart TD
    call[path-taking tool call] --> env{ALLOWED_REPO_ROOTS set?}
    env -->|yes| envroots[env roots — same list for read + write]
    env -->|no| policy{workspace-policy.yml present + parses?}
    policy -->|yes| proots[policy read/write roots + write_globs]
    policy -->|no| manifest{manifest roots available?}
    manifest -->|yes| mroots[manifest-declared roots]
    manifest -->|no| deny[deny — fail closed]
    envroots --> validate{path under the resolved read/write root?}
    proots --> validate
    mroots --> validate
    validate -->|no| deny
    validate -->|yes, read op| allow[proceed]
    validate -->|yes, write op| glob{write_globs/write_globs_deny set?}
    glob -->|no| allow
    glob -->|yes, matches allow and not deny| allow
    glob -->|yes, denied by glob| deny

Why githost-mcp?

ToolWhat it coversGap
cyanheads/git-mcp-server (TS)28 local git toolsNo remote providers, no agent attribution, no audit trail
poly-git-mcpGitHub + GitLab + GiteaWraps CLI tools — fragile, no audit, no agent ID
Official GitHub MCPGitHub onlyNo local git, no Gitea/GitLab
Official Gitea MCPGitea onlyNo local git, no GitHub/GitLab
j04n-f/woodpecker-mcpWoodpecker (6 read-only tools)No trigger/cancel — githost already exceeds it

githost-mcp fills the gap: local git + multi-provider remote via native APIs + per-agent structured audit trail. As of the Tier-1 parity release it matches the single-provider servers on PR/MR review + diff, CI trigger/rerun/cancel, full release CRUD, and issues — across all three providers behind one audited server.

Tools (63 total)

Higher-verb-count capabilities (PR/MR review, CI control, issues) are exposed as method-dispatch tools — one tool takes a method argument and routes internally — mirroring how the official GitHub/Gitea servers structure theirs. This adds ~40 operations without the tool count ballooning past what every agent pays for in context. Each method still writes its own per-operation audit entry.

Local Git (12)

git_status, git_diff, git_log, git_show, git_branch, git_checkout, git_add, git_commit, git_push, git_pull, git_tag, git_remote (list/add/remove)

git_remote refuses a URL that embeds credentials rather than redacting it — unlike text on its way out to a caller, a remote URL is written to .git/config, where a token would outlive the call and be reused by every later fetch and push. Only http(s)://, ssh://, git:// and scp-style user@host:path are accepted; ext::/fd:: remote helpers are refused because git runs them as commands on the next fetch. URLs returned by list have any pre-existing userinfo redacted, so a remote added out-of-band cannot leak a token back through this tool.

git_push reports failure explicitly (as of 0.9.0): if any of ERROR / REJECTED / REMOTE_REJECTED / REMOTE_FAILURE is set on the push result — including an empty ref-update ack — it returns {"error": ..., "summary": ..., "flags": [...]} with no pushed key. A caller that only checks for pushed will no longer silently treat a rejected push as a success. flags are decoded to reason names (e.g. ["REJECTED", "ERROR"]), not a raw integer bitmask. summary is the remote's human-readable reason, credential-scrubbed (see Credential isolation below). On success, a missing upstream is set automatically and reported as upstream_set: true.

GitHub (17)

github_create_release, github_get_release, github_list_releases, github_release_update, github_release_delete, github_workflow_list, github_workflow_status, github_actions (run/rerun/rerun_failed/cancel/logs), github_fork, github_pr_list, github_pr_comments, github_pr_create, github_pr_get, github_pr_merge, github_pr_review (get_diff/get_files/get_reviews/submit_review/dismiss_review), github_issue_read (get/list/comments), github_issue_write (create/update/add_comment/close/reopen)

Gitea (14)

gitea_create_release, gitea_get_release, gitea_list_releases, gitea_release_update, gitea_release_delete, gitea_pr_list, gitea_pr_create, gitea_pr_get, gitea_pr_comment, gitea_pr_merge, gitea_pr_review (get_diff/get_files/submit_review/dismiss_review), gitea_actions (list_runs/get_run/list_jobs/get_job_log/dispatch_workflow/rerun_run/rerun_failed_jobs), gitea_issue_read (get/list/comments), gitea_issue_write (create/update/add_comment/close/reopen)

GitLab (13)

gitlab_create_release, gitlab_get_release, gitlab_list_releases, gitlab_release_update, gitlab_release_delete, gitlab_mr_list, gitlab_mr_create, gitlab_mr_get, gitlab_mr_merge, gitlab_mr_review (get_diffs/get_changed_files/approve/unapprove/get_approval_state), gitlab_pipeline (list/get/create/retry/cancel/get_job_log), gitlab_issue_read (get/list/comments), gitlab_issue_write (create/update/add_comment/close/reopen)

Release Orchestration (1)

release — coordinated multi-target release: git tag → GitHub/Gitea/GitLab release → PyPI → npm, with rollback on failure

Registry (2)

pypi_publish, npm_publish

Woodpecker CI (5)

woodpecker_trigger, woodpecker_list_pipelines, woodpecker_get_logs, woodpecker_pipeline_cancel, woodpecker_status

woodpecker_trigger returns the per-repo pipeline number as pipeline_id (as of 0.9.0) — this is the value to pass straight into woodpecker_status, woodpecker_get_logs, and woodpecker_pipeline_cancel, which all resolve pipeline_id as a per-repo number, not Woodpecker's global id. The global id is still returned, as internal_id, for reference only. Previously trigger returned the global id, which the other three tools 404 on — chaining trigger into status/get_logs/cancel never worked prior to this fix.

Audit (1)

audit_log_query — query the JSONL audit log by agent, tool, repo, or time range

Audit Architecture

Every tool call writes a JSONL entry before returning:

{
  "ts": "2026-05-27T09:14:23.000Z",
  "agent_id": "sysadmin",
  "tool": "git_push",
  "provider": "local",
  "repo": "/home/ted/repos/personal/signoz-mcp",
  "params": {"remote": "origin", "branch": "main"},
  "result": "ok",
  "duration_ms": 312,
  "hmac": "a3f8..."
}

Each entry is HMAC-SHA256 signed when AUDIT_SIGNING_KEY is set — with no key the hmac field is simply absent and the entry carries no tamper evidence at all. audit_log_query classifies every returned entry in an integrity field, and reports integrity_summary counts plus signing_key_configured alongside the results:

integritytamper_detectedMeaning
verifiedfalseSigned, and the HMAC matches.
tamperedtrueSigned, but the HMAC does not match — the entry was altered.
unsignednullNo hmac field. Written while the agent had no key; nothing can be confirmed.
unverifiablenullSigned, but this process holds no key to check it against.

tamper_detected is retained for older callers and is null — never false — whenever integrity could not be established. An unsigned entry is not a clean bill of health.

Example — what did the sysadmin agent push last week?

audit_log_query(agent_id="sysadmin", tool="git_push", since="2026-05-20")

Security Model

Repo path allowlist

Config carries separate allowed_read_roots and allowed_write_roots. Read tools (git_status, git_diff, git_log, git_show, git_remote list) validate against the read list; write tools (git_add, git_commit, git_push, git_tag, git_checkout, git_branch create/delete, git_remote add/remove, release) validate against the write list. allowed_repo_roots remains as a deprecated alias of allowed_write_roots for any caller not yet migrated.

Resolution order (first match wins, see Architecture diagram above):

  1. ALLOWED_REPO_ROOTS env — the break-glass override, unchanged: applies the same root list to both read and write.
  2. /etc/forge/workspace-policy.yml (path overridable via WORKSPACE_POLICY_PATH) — a central grant file keyed by agent ID, giving read_roots, write_roots, and optionally write_globs/write_globs_deny per agent. Once this file loads successfully it is authoritative for the requesting agent — an agent with no entry in agents:/explicit_agents: gets zero roots and does not fall through to the manifest. A missing, unreadable, or non-mapping file is the only case that falls through.
  3. The agent manifest's git_backed: true workspace_access entries at AGENT_MANIFEST_PATH — now the third fallback rather than the second.
  4. Empty — fail closed, all operations disabled.

Deployed in production on forge. The policy declares a small set of container roots (e.g. ~/repos/gitea, ~/repos/personal) rather than per-repo paths, so a repo created inside an already-granted root is covered automatically — this is what closed the recurring "new repo, no access" gap class (vikunja #203/#332/#308) that per-repo manifest entries kept reproducing. default_read: all grants every agent listed in agents: read across every declared root regardless of that agent's own write_roots; write_roots and write_globs/write_globs_deny are then set per agent (e.g. writer is scoped to docs/**-style globs within its write roots — see Write glob scoping below). Agents not listed in agents: or explicit_agents: get nothing.

Behavior change (relevant to any deployment still on manifest-only resolution): a manifest access: readonly entry now populates allowed_read_roots (previously it granted no access at all). access: readwrite continues to populate both read and write lists.

When no source yields a root for the requested operation, it is denied — fail closed, not open. A malformed or unreadable manifest or policy file resolves to zero roots rather than raising, so it fails closed the same way an unset ALLOWED_REPO_ROOTS does.

Write glob scoping

A workspace-policy.yml grant can additionally narrow write access to a glob subset of allowed_write_roots via write_globs (allow) and write_globs_deny (deny) — e.g. scoping the writer agent to docs/** within a repo it otherwise has full write roots for. validate_write_globs() (security.py) enforces this in git_add (against the paths passed in) and again in git_commit (against the full staged set, since a commit commits whatever is staged regardless of what a prior git_add call itself validated). The deny list is evaluated after the allow list and wins — an allow pattern like **/*.md can never override a deny entry such as **/AGENT_WORKSPACE.md. An agent with neither write_globs nor write_globs_deny configured is unrestricted within its allowed_write_roots, matching prior behavior.

Patterns are plain fnmatch globs, not path-aware doublestar globs: **/*.md requires a literal / before the filename and will not match a bare top-level README.md — the policy schema accounts for this with separate README*/CHANGELOG*-style entries for root-level files. Paths are normalized with os.path.normpath() before matching, and any path whose normalized form is absolute or still starts with .. is denied outright, independent of glob match — closing a traversal shape (docs/../src/exploit.py) that would otherwise textually match a docs/** allow glob. A rejection raises WriteGlobDenied, logged as a distinct denied:write_glob audit-trail result rather than the generic error:ValueError other validation failures get.

As a fail-closed backstop, if a resolved grant carries write_globs/write_globs_deny but the running code has no enforcement path for it, writes are denied entirely rather than silently becoming unrestricted across the full allowed_write_roots. Enforcement now exists (_GLOB_ENFORCEMENT_IMPLEMENTED = True in security.py), so this backstop is currently dormant — it exists to prevent a future revert of the enforcement code from silently widening a glob-scoped agent's grant again.

Per-agent committer identity

GIT_AGENT_NAME and GIT_AGENT_EMAIL set the git author/committer on commits to repos you control. Defaults to {AGENT_ID}-agent / {AGENT_ID}@forge when not explicitly set. Values are sanitized (newlines and null bytes stripped) to prevent git header injection. Those commits also append agent-id: {AGENT_ID} as a trailer.

Public identity on third-party repos

An agent identity is useful on repos you control and is a disclosure on ones you don't: it names your internal agents in permanent public git history, where an external maintainer has no use for it. git_commit therefore picks its identity from the repo's remotes.

A repo is forge-controlled when every remote is either owned by an account listed in FORGE_OWNED_OWNERS or hosted on the configured GITEA_URL host (any org). Those commits get the agent identity and the agent-id: trailer. If any remote is third-party, the commit gets GIT_PUBLIC_NAME/GIT_PUBLIC_EMAIL and no trailer.

Detection is by remote rather than by a flag the caller passes, because the caller forgetting the flag is precisely how the existing contamination happened.

It deliberately does not read the owner of origin alone. Which remote is origin is an artifact of how the clone was made — for a fork checkout, origin=upstream, fork=ours and origin=ours, upstream=theirs are both common — so an origin-only rule answers the same situation two different ways, and gets the fork-under-your-own-account case wrong in the leaking direction.

SituationIdentity
No remotesagent — nothing to publish to
All remotes on the Gitea hostagent + agent-id: trailer
GitHub repo under a forge-owned account, not a fork of a repo you don't ownagent + trailer
GitHub repo under a forge-owned account that is a fork of a repo you don't ownpublic, no trailer
Fork provenance could not be determinedpublic, no trailer
Any third-party remotepublic, no trailer
A remote URL that cannot be parsedrefused
A local filesystem path remoteagent — a path on your own disk

Why the remotes alone are not enough

TadMSTR/githost-mcp (a project of yours) and TadMSTR/claudecodeui (your fork of someone else's project) are byte-identical from the remotes alone. Clone the fork directly and never add an upstream remote — a normal thing to do, and a cross-repo PR can be opened from the API without one — and remote-parsing alone says "forge-controlled", writing the agent identity into a third-party PR.

So when the remotes say forge-controlled and a remote is a GitHub repo under a forge-owned account, git_commit consults GitHub's own record of whether that repo is a fork, and of what. The result is cached in the repo's .git/config under githost-mcp.upstream-provenance, so this costs one API call per repo, not one per commit. Repos on the Gitea host, and repos already resolved as third-party from their remotes, never trigger a lookup.

If the lookup cannot be completed — no GITHUB_TOKEN, no network, rate limited — the commit resolves to the public identity, and the result is not cached, so a transient failure does not pin the answer for the repo's lifetime. That direction is deliberate: the cost is a missing agent-id: trailer on an internal repo, against leaking the agent identity into permanent public history. Since the audit log records the real acting agent in both modes, what is lost is a convenience, not accountability.

Two refusals, both deliberate. An unparseable remote is refused rather than guessed at: defaulting to the agent identity leaks it, defaulting to the public identity breaks attribution on internal repos, and an error the caller can resolve with an explicit identity= argument beats either. A resolved public identity that still looks like an agent identity (*@forge, *-agent) is also refused — a repo-local user.email is enough to produce one, and it would be the same leak under a different label.

GIT_PUBLIC_NAME/GIT_PUBLIC_EMAIL fall back to the repo's own git config (user.name/user.email) when unset.

This changes the commit object only. Every audit entry records the real acting agent in both modes — write_audit_entry reads the process-wide agent ID and never consults identity resolution. Inverting that would turn a disclosure fix into an accountability hole.

git_commit(identity=...) overrides the detection in either direction: auto (default), agent, or public.

Query limits

git_log caps the limit parameter at 200 entries regardless of the requested value, preventing excessive history traversal.

No subprocess git

All local git operations use gitpython (Python library), not subprocess. This eliminates command injection risk via crafted repo_path or branch values.

Credential isolation

Token values never appear in:

  • JSONL audit entries (credential filter applied before write)
  • structlog output (processor filter bound to logger)
  • tool return values (scrubbed before return)
  • exception messages (caught at provider layer and re-raised without token value)

As of 0.9.0, every caller-facing error return across all 27 sites (git_local.py, release.py, woodpecker.py, registry.py, gitea.py/github.py/gitlab.py) is scrubbed via security.scrub()redact_url_credentials(mask_credentials(text)) — rather than mask_credentials() alone. mask_credentials() only replaces githost-mcp's own configured token values, so a credential a human embedded in a remote by hand (https://user:token@host/...) previously survived it. redact_url_credentials() strips the userinfo component from any scheme-qualified URL by shape, independent of whether githost-mcp knows the token. scp-style remotes (git@github.com:owner/repo.git) have no scheme and are left readable — that's the form every forge remote actually uses. This closes the gap where git_push's new summary field (the remote's raw rejection text) could otherwise have surfaced a credential verbatim.

Each provider has its own env vars — a compromised GitHub token does not expose Gitea or GitLab credentials.

HMAC tamper-evidence

AUDIT_SIGNING_KEY is a server-side secret set in the launcher, per agent. When it is set, each JSONL entry includes hmac: HMAC-SHA256(canonical_json, key). This is symmetric (same key signs and verifies) — it proves the file wasn't edited after write, not that the agent identity is genuine. Agent identity proof is the scoped-mcp layer's job.

The key is not enforced at startup: an agent launched without one starts normally, logs an audit_signing_key_unset warning naming itself, and writes unsigned entries from then on. Those entries report as unsigned from audit_log_query (see Audit Architecture) rather than as verified, and stay identifiable as unsigned after a key is later added — the absence of an hmac is a property of the entry, not of the current config. Refusing to start without a key is a deployment policy choice and is deliberately not made here; it would take an agent offline for a missing secret rather than degrade visibly.

HTTP transport surface

TRANSPORT=http (see Deploy) opens a local network listener where stdio mode has none. Two controls are mandatory together, not either/or:

  • Loopback-only bind, fail closed. main() refuses to start if HTTP_HOST resolves to anything other than 127.0.0.1 / localhost / ::1, unless GITHOST_MCP_ALLOW_NONLOOPBACK=1 is set explicitly. There is no default that silently exposes the port beyond the host.
  • Bearer token auth. When GITHOST_MCP_AUTH_TOKEN is set, FastMCP's built-in StaticTokenVerifier rejects any request without a matching Authorization: Bearer <token> header (401). scoped-mcp's manifest headers block supplies it — see the Configuration block in the build plan. The token is included in the credential filter (audit.py / security.py), so it's never written to logs or the audit trail — provided it's at least 16 characters; the scrub only redacts tokens over 4 characters, so main() separately hard-fails on a shorter token rather than silently accepting one the filter can't reliably catch.

githost-mcp will not ship TRANSPORT=http with a reachable port and no token configured, or with a token under 16 characters — both are config errors, not a supported deploy shape. In stdio mode (the default), neither control is relevant: there's no listening port to protect.

Environment Variables

Required

AGENT_ID=dev                     # agent attribution — set per launcher
AUDIT_SIGNING_KEY=<32-byte-hex>  # generate: python3 -c "import secrets; print(secrets.token_hex(32))"
ALLOWED_REPO_ROOTS=/home/user/repos/personal,/home/user/repos/work  # enforced on ALL tools (read + write)

ALLOWED_REPO_ROOTS is not strictly required if AGENT_MANIFEST_PATH resolves to a manifest with git_backed: true workspace_access entries (see AGENT_MANIFEST_PATH below) — but one of the two must yield at least one root, or every path-taking tool is denied.

AGENT_MANIFEST_PATH=/home/user/.claude/manifests/dev-agent.yml  # optional — allowlist fallback
# Default: ~/.claude/manifests/{AGENT_ID}-agent.yml (only when AGENT_ID is set to a real identity)
# On forge, ecosystem.config.js overrides this per-process to
# /etc/forge/manifests/<agent>-agent.yml — see Deploy > Manifest allowlist path.

WORKSPACE_POLICY_PATH=/etc/forge/workspace-policy.yml  # optional — checked ahead of the manifest
# Default: /etc/forge/workspace-policy.yml. See Security Model > Repo path allowlist for
# the full env > policy > manifest > empty resolution order. Deployed in production on
# forge — see that section for the container-root grant model.

Agent Identity (optional)

GIT_AGENT_NAME=dev-agent         # git author/committer name (default: {AGENT_ID}-agent)
GIT_AGENT_EMAIL=dev@forge        # git author/committer email (default: {AGENT_ID}@forge)

# Identity used instead, on any repo that has a third-party remote. Falls back to the
# repo's own git config user.name/user.email when unset. See Security Model >
# Public identity on third-party repos.
GIT_PUBLIC_NAME=YourAccount
GIT_PUBLIC_EMAIL=12345+YourAccount@users.noreply.github.com

# Accounts/orgs you control, comma-separated (default: TadMSTR). Repos whose remotes
# all sit under one of these — or on the GITEA_URL host — keep the agent identity.
FORGE_OWNED_OWNERS=YourAccount,YourOrg

GitHub

GITHUB_TOKEN=<PAT with repo scope>
GITHUB_OWNER=YourOrg

Gitea

GITEA_URL=https://gitea.example.com
GITEA_TOKEN=<PAT>
GITEA_OWNER=youruser

GitLab

GITLAB_URL=https://gitlab.com
GITLAB_TOKEN=<PAT>

Registry

PYPI_TOKEN=<API token>
NPM_TOKEN=<automation token>

Logging (always on)

LOG_FILE=/opt/appdata/githost-mcp/logs/githost-mcp.log
AUDIT_LOG_FILE=/opt/appdata/githost-mcp/audit/githost.jsonl

# Audit JSONL rotation
AUDIT_LOG_MAX_BYTES=10485760     # default 10 MB; 0 disables rotation
AUDIT_LOG_BACKUP_COUNT=5         # default 5; .jsonl.1 is newest

# Application log rotation — defaults to the AUDIT_LOG_* values above
LOG_MAX_BYTES=10485760
LOG_BACKUP_COUNT=5

Both files rotate by rename (.1 newest); entries are never truncated or rewritten. Audit HMACs are per-entry rather than a chain, so a rotated entry verifies exactly as it did before the rename, and audit_log_query searches the rotated backups as well as the live file — its sources_searched field reports which files a given result actually covered.

Observability (all opt-in)

# OTEL (SigNoz, Honeycomb, Grafana Tempo, Jaeger, Datadog — same env var)
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317

# Loki
LOKI_URL=http://localhost:3100

# Prometheus scrape endpoint
METRICS_PORT=9185

# NATS
NATS_URL=nats://localhost:4222

METRICS_PORT is re-enabled on the forge PM2 deploy (0.9.0+), loopback-only. start_http_server() in observability.py now binds addr=127.0.0.1 explicitly, via a hardcoded observability.METRICS_BIND_ADDR (not itself configurable — no deployment wants a LAN-reachable metrics endpoint, and an env knob is how the previous 0.0.0.0 bind regressed). ecosystem.config.js sets one port per agent, 9620-9625, mirroring the 8620-8625 HTTP block. Verify with ss -tlnp showing 127.0.0.1 — a successful curl to localhost alone doesn't distinguish a loopback bind from a 0.0.0.0 one.

Transport (optional — default stdio)

TRANSPORT=stdio          # or "http" — see Deploy below
HTTP_HOST=127.0.0.1      # http mode only; must be loopback unless overridden below
HTTP_PORT=8620           # http mode only
GITHOST_MCP_ALLOW_NONLOOPBACK=   # set to "1" to bind a non-loopback HTTP_HOST (not recommended)
GITHOST_MCP_AUTH_TOKEN=  # required whenever TRANSPORT=http; must be >= 16 chars

Installation

pip install githost-mcp

# With observability extras
pip install "githost-mcp[observability]"

Launcher pattern (scoped-mcp subprocess)

#!/bin/bash
# run-githost-mcp-dev.sh
export AGENT_ID="dev"
export ALLOWED_REPO_ROOTS="/home/ted/repos/personal,/home/ted/repos/work"
export AUDIT_SIGNING_KEY="$(cat /run/secrets/githost_audit_key)"
export GITHUB_TOKEN="$(cat /run/secrets/github_token)"
export GITEA_TOKEN="$(cat /run/secrets/gitea_token)"
export LOG_FILE="/opt/appdata/githost-mcp/logs/githost-mcp.log"
export AUDIT_LOG_FILE="/opt/appdata/githost-mcp/audit/githost.jsonl"
exec /opt/agents/dev/venv/bin/python3 -m githost_mcp.server

This is TRANSPORT=stdio (the default): scoped-mcp spawns a fresh subprocess per call and tears it down afterward. Simple, but Prometheus counters, OTEL spans, and Loki pushes rarely survive that short a lifetime — they reset or drop every call.

Deploy

Transport is selected by TRANSPORT (stdio default, or http) so both models are supported by the same codebase — no fork, no rewrite to move between them.

stdio (default)http
Process lifetimeOne per scoped-mcp call, recycled every turnLong-lived, one PM2 service per agent
ObservabilityRotating file log + audit JSONL only — Prometheus/OTEL/Loki/NATS rarely survive teardownAll of it actually works — metrics accumulate, spans flush, NATS stays connected
RestartN/A — recycled automaticallypm2 restart githost-mcp-<agent>, independent of scoped-mcp
Network surfaceNoneLocal HTTP listener — must be loopback-bound + token-authed (see Security Model)

Per-agent processes, not one shared process. Each agent gets its own OS process (own AGENT_ID, own tokens, own ALLOWED_REPO_ROOTS), so a compromised process can't see another agent's credentials and AGENT_ID can't be spoofed via a request header. This is a deliberate security tradeoff over a single shared process with per-request identity — see the build plan's "Option A vs Option B" rationale if that tradeoff ever needs revisiting.

Running as PM2 services

ecosystem.config.js in this repo builds one app per agent from a single AGENT_ID -> {httpPort, metricsPort} map, reusing the same per-agent secrets files (~/.secrets/githost-mcp-<agent>.env) and shared tokens (~/.secrets/forge.env) the stdio launchers already read — no separate secret plumbing to maintain.

pm2 start ecosystem.config.js
pm2 save

Each service comes up with TRANSPORT=http, HTTP_HOST=127.0.0.1, its own HTTP_PORT / METRICS_PORT, and GITHOST_MCP_AUTH_TOKEN sourced from ~/.secrets/forge.env. Point scoped-mcp's manifest at the corresponding http://127.0.0.1:<port>/mcp/ URL with the token in an Authorization: Bearer header (requires type: http on the manifest block — a bare {url, headers} entry is silently skipped).

Manifest allowlist path

When ALLOWED_REPO_ROOTS is unset for an agent, AGENT_MANIFEST_PATH (default ~/.claude/manifests/{AGENT_ID}-agent.yml) is the allowlist's only other source. On forge, ecosystem.config.js overrides that default to /etc/forge/manifests/<agent>-agent.yml for every agent process — a root-owned, 0644 copy published from origin/main by host-forge-scripts/scripts/agent-manifests-deploy.sh, not a symlink into a live git working tree. The target directory matters as much as the file mode: directory write permission governs rename/unlink regardless of who owns the file inside it, so a root-owned file under a ted-writable parent (e.g. /opt/appdata) isn't actually protected — anyone who can write the directory can swap the file out from under its own permissions. /etc is root-owned end to end, which is why the deployed copy lives there instead.

Deployers must create and populate /etc/forge/manifests before unsetting ALLOWED_REPO_ROOTS for any agent. config.py does not fall back further if the target file is missing or unreadable — the allowlist resolves empty (fail closed), and the agent loses all repo access, rather than silently reusing the old default path.