diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..093e238
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,24 @@
+# Gitea MCP runtime profile — EXAMPLE / PLACEHOLDERS ONLY.
+#
+# Copy to a real, gitignored env file (e.g. .env.reviewer) per runtime profile.
+# The same MCP server code is launched as separate MCP entries, each pointed at
+# a different env file so each process authenticates as ONE token and carries
+# ONE profile name. Do NOT put real tokens in this file.
+#
+# The token is read only by the auth layer; it is never returned, logged, or
+# committed. Profile name and allowed operations are non-secret metadata.
+
+# Base URL of the Gitea instance (informational).
+GITEA_BASE_URL=https://gitea.example.invalid
+
+# The API token for THIS runtime profile. Placeholder only — replace in a real,
+# gitignored env file. Never commit a real token.
+GITEA_TOKEN=replace-with-token
+
+# Human label for the running profile (non-secret metadata).
+# Examples: gitea-author, gitea-reviewer, gitea-merger, gitea-issue-manager.
+GITEA_PROFILE_NAME=gitea-reviewer
+
+# Optional, comma-separated operation categories this profile is intended for
+# (descriptive only in this issue; enforcement is a later roadmap item).
+GITEA_ALLOWED_OPERATIONS=read,review,approve
diff --git a/.gitignore b/.gitignore
index dc5fc3f..ed6386f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,5 +2,6 @@ venv/
__pycache__/
*.pyc
.env*
+!.env.example
.vscode/
graphify-out/
diff --git a/README.md b/README.md
index 4831af7..90e4b7a 100644
--- a/README.md
+++ b/README.md
@@ -123,6 +123,58 @@ The server is a standard MCP stdio server. Point your client at:
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.
+
+```json
+{
+ "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`](.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_BASE_URL` | Optional informational base URL. |
+
+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 (#13–#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 so a
+ workflow can see which runtime it is talking to.
+- See [`docs/gitea-execution-profiles.md`](docs/gitea-execution-profiles.md) for
+ the full profile model.
+
+
Codex / non-MCP tools
diff --git a/gitea_auth.py b/gitea_auth.py
index 9007c3b..2890549 100644
--- a/gitea_auth.py
+++ b/gitea_auth.py
@@ -170,3 +170,35 @@ def api_request(method, url, auth_header, payload=None):
def repo_api_url(host, org, repo):
"""Return the base API URL for a repo: https://host/api/v1/repos/org/repo"""
return f"https://{host}/api/v1/repos/{org}/{repo}"
+
+
+def get_profile():
+ """Return safe runtime *profile* metadata for this MCP process.
+
+ A runtime profile is how the same server code is launched as separate MCP
+ entries (e.g. ``gitea-tools-author`` vs ``gitea-tools-reviewer``): each
+ process is configured with its own token *and* its own profile name via
+ environment variables. This function reads only the non-secret profile
+ metadata:
+
+ - ``GITEA_PROFILE_NAME`` — a human label for the running profile.
+ - ``GITEA_ALLOWED_OPERATIONS`` — optional comma-separated operation
+ categories (descriptive only; not enforced here).
+ - ``GITEA_BASE_URL`` — optional informational base URL.
+
+ It never reads, returns, or logs ``GITEA_TOKEN`` or any credential. The
+ token continues to be resolved separately by ``get_auth_header`` and is
+ never part of this metadata. Callers may surface the result safely.
+
+ Returns:
+ dict with 'profile_name', 'allowed_operations' (list), and 'base_url'.
+ """
+ name = (os.environ.get("GITEA_PROFILE_NAME") or "gitea-default").strip()
+ raw_ops = os.environ.get("GITEA_ALLOWED_OPERATIONS") or ""
+ ops = [o.strip() for o in raw_ops.split(",") if o.strip()]
+ base_url = os.environ.get("GITEA_BASE_URL") or None
+ return {
+ "profile_name": name,
+ "allowed_operations": ops,
+ "base_url": base_url,
+ }
diff --git a/mcp_server.py b/mcp_server.py
index 6712f5f..06274e5 100644
--- a/mcp_server.py
+++ b/mcp_server.py
@@ -36,6 +36,7 @@ from gitea_auth import ( # noqa: E402
get_auth_header,
api_request,
repo_api_url,
+ get_profile,
)
mcp = FastMCP("gitea-tools", instructions=(
@@ -617,7 +618,8 @@ def gitea_whoami(
Returns:
dict with 'authenticated', 'username', 'display_name', 'user_id',
- 'email', 'server', and 'remote'.
+ 'email', 'server', 'remote', and 'profile' (safe runtime profile
+ metadata: profile_name + allowed_operations; never the token).
"""
if remote not in REMOTES:
raise ValueError(f"Unknown remote '{remote}'. Choose from: {list(REMOTES)}")
@@ -631,6 +633,9 @@ def gitea_whoami(
f"Could not determine the authenticated Gitea identity for {h}. "
"Verify the configured token is valid for this instance."
)
+ # Runtime profile metadata is non-secret (name + allowed op categories).
+ # The token is resolved separately and is never included here.
+ profile = get_profile()
return {
"authenticated": True,
"username": data.get("login"),
@@ -639,6 +644,10 @@ def gitea_whoami(
"email": data.get("email") or None,
"server": f"https://{h}",
"remote": remote,
+ "profile": {
+ "profile_name": profile["profile_name"],
+ "allowed_operations": profile["allowed_operations"],
+ },
}
diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py
index 7b7731a..cd62680 100644
--- a/tests/test_mcp_server.py
+++ b/tests/test_mcp_server.py
@@ -28,6 +28,7 @@ from mcp_server import ( # noqa: E402
gitea_commit_files,
gitea_whoami,
)
+from gitea_auth import get_profile # noqa: E402
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
@@ -514,5 +515,60 @@ class TestWhoami(unittest.TestCase):
gitea_whoami(remote="nope")
+# ---------------------------------------------------------------------------
+# Runtime profile (env-configured profile metadata) — issue #19
+# ---------------------------------------------------------------------------
+class TestRuntimeProfile(unittest.TestCase):
+
+ def test_defaults_when_unset(self):
+ with patch.dict(os.environ, {}, clear=True):
+ p = get_profile()
+ self.assertEqual(p["profile_name"], "gitea-default")
+ self.assertEqual(p["allowed_operations"], [])
+ self.assertIsNone(p["base_url"])
+
+ def test_reads_env_metadata(self):
+ env = {
+ "GITEA_PROFILE_NAME": "gitea-reviewer",
+ "GITEA_ALLOWED_OPERATIONS": "read, review , approve",
+ "GITEA_BASE_URL": "https://gitea.example.invalid",
+ }
+ with patch.dict(os.environ, env, clear=True):
+ p = get_profile()
+ self.assertEqual(p["profile_name"], "gitea-reviewer")
+ self.assertEqual(p["allowed_operations"], ["read", "review", "approve"])
+ self.assertEqual(p["base_url"], "https://gitea.example.invalid")
+
+ def test_never_includes_token(self):
+ env = {
+ "GITEA_PROFILE_NAME": "gitea-author",
+ "GITEA_TOKEN": "super-secret-token",
+ }
+ with patch.dict(os.environ, env, clear=True):
+ p = get_profile()
+ blob = repr(p).lower()
+ self.assertNotIn("super-secret-token", blob)
+ self.assertNotIn("token", blob)
+
+ @patch("mcp_server.api_request")
+ @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
+ def test_whoami_surfaces_profile_without_token(self, _auth, mock_api):
+ mock_api.return_value = {"id": 7, "login": "rev"}
+ env = {
+ "GITEA_PROFILE_NAME": "gitea-reviewer",
+ "GITEA_ALLOWED_OPERATIONS": "read,review,approve",
+ "GITEA_TOKEN": "super-secret-token",
+ }
+ with patch.dict(os.environ, env, clear=True):
+ result = gitea_whoami(remote="prgs")
+ self.assertEqual(result["profile"]["profile_name"], "gitea-reviewer")
+ self.assertEqual(
+ result["profile"]["allowed_operations"], ["read", "review", "approve"]
+ )
+ blob = repr(result).lower()
+ for secret in ("super-secret-token", "token", "authorization", "basic "):
+ self.assertNotIn(secret, blob)
+
+
if __name__ == "__main__":
unittest.main()