docs: document static dual-namespace Gitea MCP deployment model (#143) #158
@@ -0,0 +1,156 @@
|
|||||||
|
# Static Dual-Namespace Gitea MCP Deployment
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document (tracked as issue #143) records the deployment model accepted
|
||||||
|
in issue #139: run the
|
||||||
|
Gitea MCP server as **two static, per-role namespaces** — one authoring, one
|
||||||
|
reviewing — instead of switching profiles inside a running server or routing
|
||||||
|
through a dispatcher. It explains what to configure, why this model was
|
||||||
|
chosen, and what to expect from MCP clients.
|
||||||
|
|
||||||
|
This is the deployment companion to
|
||||||
|
[`gitea-execution-profiles.md`](gitea-execution-profiles.md) (the profile
|
||||||
|
*model*) and [`llm-workflow-runbooks.md`](llm-workflow-runbooks.md) (the
|
||||||
|
workflows run on top of it).
|
||||||
|
|
||||||
|
## The model
|
||||||
|
|
||||||
|
Run two independent MCP server instances of the same `gitea-mcp` code, each
|
||||||
|
launched with exactly one static execution profile:
|
||||||
|
|
||||||
|
| Namespace (MCP server name) | Profile (role) | Typical use |
|
||||||
|
|-----------------------------|----------------|-------------|
|
||||||
|
| `gitea-author` | an author profile | implement issues, push branches, open PRs, comment |
|
||||||
|
| `gitea-reviewer` | a reviewer profile | review, approve/request changes, merge |
|
||||||
|
|
||||||
|
Properties:
|
||||||
|
|
||||||
|
- **One process, one credential.** Each namespace authenticates as exactly
|
||||||
|
one Gitea identity for its entire lifetime. A session connected to
|
||||||
|
`gitea-author` can never approve or merge; a session connected to
|
||||||
|
`gitea-reviewer` cannot push branches or commit unless explicitly
|
||||||
|
configured.
|
||||||
|
- **`runtime_switching_supported: false`.** The running server never changes
|
||||||
|
identity. Choosing a role means choosing which namespace to connect to,
|
||||||
|
not asking the server to become someone else.
|
||||||
|
- **Roles are profiles, not LLMs.** Per the profile model, the LLM is not
|
||||||
|
the role — the profile is. The same LLM session may author under one
|
||||||
|
namespace and (in a *separate* session) review under the other.
|
||||||
|
|
||||||
|
## Rejected alternatives — and why
|
||||||
|
|
||||||
|
Both alternatives below were considered in the #139 discussion and are
|
||||||
|
**rejected for now**; dynamic in-process profile switching is
|
||||||
|
**not enabled in this deployment model**. (The runtime *can* support it
|
||||||
|
behind an explicit `allow_runtime_switching: true` config opt-in — see
|
||||||
|
[`gitea-execution-profiles.md`](gitea-execution-profiles.md) — but this
|
||||||
|
model deliberately leaves it off, so namespaces report
|
||||||
|
`runtime_switching_supported: false`.)
|
||||||
|
|
||||||
|
- **Dynamic profile switching** (one server, `gitea_activate_profile`-style
|
||||||
|
role changes at runtime): rejected because a single process would hold, or
|
||||||
|
be able to obtain, both credentials; "which identity am I?" becomes
|
||||||
|
mutable state that injected instructions could target; and audit
|
||||||
|
attribution blurs when one process acts as multiple identities.
|
||||||
|
- **Dispatcher / router front door** (one entry point that forwards each
|
||||||
|
call to a role-appropriate backend): rejected because it concentrates
|
||||||
|
every credential behind one surface and re-creates the same escalation
|
||||||
|
problem with extra moving parts.
|
||||||
|
|
||||||
|
Why the static dual-namespace model wins:
|
||||||
|
|
||||||
|
- **Clearer audit.** Every audit record from a namespace maps to one
|
||||||
|
identity and one `audit_label`; there is no in-process identity history
|
||||||
|
to reconstruct.
|
||||||
|
- **Less credential concentration.** No process ever holds more than one
|
||||||
|
token. Compromise or prompt-injection of one session bounds the blast
|
||||||
|
radius to that role's allowed operations.
|
||||||
|
- **Simpler two-party review boundary.** Author and reviewer are different
|
||||||
|
authenticated identities in different processes; self-review/self-merge
|
||||||
|
checks stay structural, not behavioral. Note that namespaces alone do not
|
||||||
|
provide two-party review — one agent driving both namespaces in one
|
||||||
|
session still defeats it. Keep authoring and reviewing in separate
|
||||||
|
sessions.
|
||||||
|
- **Safer fail-closed behavior.** Each server validates its single profile
|
||||||
|
at startup and on every gated call; anything unknown, ambiguous, or
|
||||||
|
unresolved refuses. There is no "switch succeeded but half-applied"
|
||||||
|
state to reason about.
|
||||||
|
|
||||||
|
## Client setup
|
||||||
|
|
||||||
|
Each namespace is the same server binary launched with its own environment.
|
||||||
|
Configuration is by *reference only*: environment variables name a config
|
||||||
|
file and a profile entry; tokens stay in the operator's keychain/secret
|
||||||
|
store and never appear in client config, tool output, or this document.
|
||||||
|
|
||||||
|
Conceptual client registration (names and variables only — adapt the launch
|
||||||
|
syntax to the client):
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"gitea-author": {
|
||||||
|
"command": "<path-to>/venv/bin/python3",
|
||||||
|
"args": ["<path-to>/mcp_server.py"],
|
||||||
|
"env": {
|
||||||
|
"GITEA_MCP_CONFIG": "<path-to-profiles.json>",
|
||||||
|
"GITEA_MCP_PROFILE": "<author-profile-name>"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gitea-reviewer": {
|
||||||
|
"command": "<path-to>/venv/bin/python3",
|
||||||
|
"args": ["<path-to>/mcp_server.py"],
|
||||||
|
"env": {
|
||||||
|
"GITEA_MCP_CONFIG": "<path-to-profiles.json>",
|
||||||
|
"GITEA_MCP_PROFILE": "<reviewer-profile-name>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `GITEA_MCP_CONFIG` — path to the operator-owned profiles config (see
|
||||||
|
[`gitea-execution-profiles.md`](gitea-execution-profiles.md)). The file is
|
||||||
|
operator-owned; LLM sessions must never rewrite it.
|
||||||
|
- `GITEA_MCP_PROFILE` — the profile entry this namespace runs as. Exactly
|
||||||
|
one per namespace; never both.
|
||||||
|
- Verify after connecting: call `gitea_whoami` / `gitea_get_runtime_context`
|
||||||
|
and confirm the authenticated identity and allowed operations match the
|
||||||
|
namespace's role before doing any work.
|
||||||
|
|
||||||
|
### "Auth unsupported" in some clients is normal
|
||||||
|
|
||||||
|
Some MCP clients display an "Auth unsupported" (or similar) status for
|
||||||
|
custom/local stdio servers. That message refers to the client↔server MCP
|
||||||
|
authentication handshake, which local servers do not use — it does **not**
|
||||||
|
mean Gitea authentication failed. Gitea credentials are resolved by the
|
||||||
|
server itself from the configured profile. Trust `gitea_whoami`, not the
|
||||||
|
client's connection badge.
|
||||||
|
|
||||||
|
## Reconnect / reload after changes
|
||||||
|
|
||||||
|
The server reads its code and profile config **once, at process start**. A
|
||||||
|
long-running namespace does not see later changes, so after any of:
|
||||||
|
|
||||||
|
- editing the profiles config (e.g. granting/removing an operation),
|
||||||
|
- merging server code that changes operation gating or tool surfaces,
|
||||||
|
- rotating the credential a profile references,
|
||||||
|
|
||||||
|
the operator must **reload** the affected namespace — restart the server or
|
||||||
|
use the client's MCP reconnect action (e.g. `/mcp` in Claude Code) — before
|
||||||
|
the change takes effect. Symptoms of a stale namespace include gated calls
|
||||||
|
failing closed with operation-normalization errors even though the live
|
||||||
|
config is correct. Fail-closed is the intended behavior here: a stale
|
||||||
|
server refuses rather than guesses. Reconnect and re-verify with
|
||||||
|
`gitea_whoami`.
|
||||||
|
|
||||||
|
## Related documents
|
||||||
|
|
||||||
|
- [`gitea-execution-profiles.md`](gitea-execution-profiles.md) — the profile
|
||||||
|
model, reference profiles (`gitea-author`, `gitea-reviewer`), operation
|
||||||
|
naming, and safety rules.
|
||||||
|
- [`llm-workflow-runbooks.md`](llm-workflow-runbooks.md) — the author and
|
||||||
|
reviewer workflows run on top of these namespaces.
|
||||||
|
- [`safety-model.md`](safety-model.md) — fail-closed and gating principles.
|
||||||
|
- Issue #139 — the discussion and decision this document records.
|
||||||
@@ -265,6 +265,9 @@ For security-sensitive or high-risk tasks, the preferred safety model uses separ
|
|||||||
- `gitea-author`: Exposes tools configured with author permissions; cannot perform approvals or merges.
|
- `gitea-author`: Exposes tools configured with author permissions; cannot perform approvals or merges.
|
||||||
- `gitea-reviewer`: Exposes tools configured with reviewer permissions; used for PR reviews and merges.
|
- `gitea-reviewer`: Exposes tools configured with reviewer permissions; used for PR reviews and merges.
|
||||||
This layout maintains physical separation of credentials and prevents privilege escalation within a single session.
|
This layout maintains physical separation of credentials and prevents privilege escalation within a single session.
|
||||||
|
This is the model accepted in #139; deployment details, rationale, and client
|
||||||
|
setup live in
|
||||||
|
[`gitea-dual-namespace-deployment.md`](gitea-dual-namespace-deployment.md).
|
||||||
|
|
||||||
### 3. Verification Post-Switching
|
### 3. Verification Post-Switching
|
||||||
When dynamic profile switching is enabled and a profile is activated via `gitea_activate_profile`, the session MUST immediately:
|
When dynamic profile switching is enabled and a profile is activated via `gitea_activate_profile`, the session MUST immediately:
|
||||||
|
|||||||
@@ -485,6 +485,7 @@ scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md --push
|
|||||||
|
|
||||||
- [`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable cross-project LLM workflow skill.
|
- [`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable cross-project LLM workflow skill.
|
||||||
- [`gitea-execution-profiles.md`](gitea-execution-profiles.md) — the profile model.
|
- [`gitea-execution-profiles.md`](gitea-execution-profiles.md) — the profile model.
|
||||||
|
- [`gitea-dual-namespace-deployment.md`](gitea-dual-namespace-deployment.md) — static author/reviewer namespace deployment (#139 decision).
|
||||||
- [`llm-agent-sha.md`](llm-agent-sha.md) — opaque agent attribution metadata (never an eligibility input).
|
- [`llm-agent-sha.md`](llm-agent-sha.md) — opaque agent attribution metadata (never an eligibility input).
|
||||||
- [`safety-model.md`](safety-model.md) — trust boundaries and audit logging.
|
- [`safety-model.md`](safety-model.md) — trust boundaries and audit logging.
|
||||||
- [`tool-boundaries.md`](tool-boundaries.md) — per-tool allowed operations.
|
- [`tool-boundaries.md`](tool-boundaries.md) — per-tool allowed operations.
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Docs checks for the static dual-namespace deployment model (#143).
|
||||||
|
|
||||||
|
Issue #139's accepted decision: run two static, per-role Gitea MCP
|
||||||
|
namespaces (`gitea-author`, `gitea-reviewer`) instead of dynamic profile
|
||||||
|
switching or a dispatcher. These tests keep the deployment doc present,
|
||||||
|
accurate to that decision, cross-linked from the operator docs, and free
|
||||||
|
of secret material or raw service URLs.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
DOC = REPO_ROOT / "docs" / "gitea-dual-namespace-deployment.md"
|
||||||
|
|
||||||
|
|
||||||
|
def _doc_text():
|
||||||
|
assert DOC.is_file(), "missing docs/gitea-dual-namespace-deployment.md"
|
||||||
|
return DOC.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_doc_exists_with_title_and_content():
|
||||||
|
text = _doc_text()
|
||||||
|
assert text.lstrip().startswith("#"), "doc lacks a title"
|
||||||
|
assert len(text.strip()) > 1000, "doc is a stub"
|
||||||
|
assert "#139" in text, "doc does not reference the #139 decision"
|
||||||
|
|
||||||
|
|
||||||
|
def test_doc_names_both_role_namespaces():
|
||||||
|
text = _doc_text()
|
||||||
|
assert "gitea-author" in text
|
||||||
|
assert "gitea-reviewer" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_doc_rejects_dynamic_switching_and_dispatcher():
|
||||||
|
text = _doc_text().lower()
|
||||||
|
assert "dynamic" in text and "switching" in text
|
||||||
|
assert "dispatcher" in text
|
||||||
|
for marker in ("rejected", "not enabled"):
|
||||||
|
assert marker in text, f"doc does not state {marker!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_doc_explains_why():
|
||||||
|
text = _doc_text().lower()
|
||||||
|
for reason in ("audit", "credential", "two-party", "fail-closed"):
|
||||||
|
assert reason in text, f"doc does not explain the {reason!r} rationale"
|
||||||
|
|
||||||
|
|
||||||
|
def test_doc_gives_client_setup_examples_without_secret_values():
|
||||||
|
text = _doc_text()
|
||||||
|
assert "GITEA_MCP_PROFILE" in text, "no profile env var in setup examples"
|
||||||
|
assert "GITEA_MCP_CONFIG" in text, "no config env var in setup examples"
|
||||||
|
# References/names only — never values, hosts, or key material.
|
||||||
|
for marker in ("https://", "http://", "ghp_", "Authorization:",
|
||||||
|
"BEGIN PRIVATE KEY"):
|
||||||
|
assert marker not in text, f"doc contains {marker!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_doc_covers_auth_unsupported_and_reload():
|
||||||
|
text = _doc_text()
|
||||||
|
assert "Auth unsupported" in text, (
|
||||||
|
"doc does not explain the 'Auth unsupported' client message")
|
||||||
|
lower = text.lower()
|
||||||
|
assert "reconnect" in lower and "reload" in lower, (
|
||||||
|
"doc does not cover reconnect/reload after profile/config changes")
|
||||||
|
|
||||||
|
|
||||||
|
def test_operator_docs_link_deployment_doc():
|
||||||
|
for name in ("llm-workflow-runbooks.md", "gitea-execution-profiles.md"):
|
||||||
|
text = (REPO_ROOT / "docs" / name).read_text(encoding="utf-8")
|
||||||
|
assert "gitea-dual-namespace-deployment.md" in text, (
|
||||||
|
f"docs/{name} does not link the deployment doc")
|
||||||
Reference in New Issue
Block a user