hermes-mcp 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. hermes_mcp-0.4.0/.env.example +53 -0
  2. hermes_mcp-0.4.0/.github/CODEOWNERS +1 -0
  3. hermes_mcp-0.4.0/.github/ISSUE_TEMPLATE/bug_report.md +53 -0
  4. hermes_mcp-0.4.0/.github/ISSUE_TEMPLATE/feature_request.md +29 -0
  5. hermes_mcp-0.4.0/.github/PULL_REQUEST_TEMPLATE.md +33 -0
  6. hermes_mcp-0.4.0/.github/workflows/ci.yml +39 -0
  7. hermes_mcp-0.4.0/.github/workflows/release.yml +107 -0
  8. hermes_mcp-0.4.0/.gitignore +62 -0
  9. hermes_mcp-0.4.0/CHANGELOG.md +161 -0
  10. hermes_mcp-0.4.0/CLAUDE.md +91 -0
  11. hermes_mcp-0.4.0/CODE_OF_CONDUCT.md +41 -0
  12. hermes_mcp-0.4.0/CONTRIBUTING.md +60 -0
  13. hermes_mcp-0.4.0/LICENSE +201 -0
  14. hermes_mcp-0.4.0/PKG-INFO +429 -0
  15. hermes_mcp-0.4.0/README.md +396 -0
  16. hermes_mcp-0.4.0/SECURITY.md +69 -0
  17. hermes_mcp-0.4.0/THREAT_MODEL.md +261 -0
  18. hermes_mcp-0.4.0/deploy/cloudflared.service +15 -0
  19. hermes_mcp-0.4.0/deploy/hermes-mcp.service +36 -0
  20. hermes_mcp-0.4.0/deploy/ngrok.service +16 -0
  21. hermes_mcp-0.4.0/pyproject.toml +97 -0
  22. hermes_mcp-0.4.0/src/hermes_mcp/__init__.py +1 -0
  23. hermes_mcp-0.4.0/src/hermes_mcp/__main__.py +91 -0
  24. hermes_mcp-0.4.0/src/hermes_mcp/config.py +161 -0
  25. hermes_mcp-0.4.0/src/hermes_mcp/doctor.py +81 -0
  26. hermes_mcp-0.4.0/src/hermes_mcp/hermes_client.py +123 -0
  27. hermes_mcp-0.4.0/src/hermes_mcp/jobs.py +218 -0
  28. hermes_mcp-0.4.0/src/hermes_mcp/oauth.py +340 -0
  29. hermes_mcp-0.4.0/src/hermes_mcp/server.py +355 -0
  30. hermes_mcp-0.4.0/tests/__init__.py +0 -0
  31. hermes_mcp-0.4.0/tests/test_config.py +171 -0
  32. hermes_mcp-0.4.0/tests/test_doctor.py +85 -0
  33. hermes_mcp-0.4.0/tests/test_hermes_client.py +180 -0
  34. hermes_mcp-0.4.0/tests/test_jobs.py +321 -0
  35. hermes_mcp-0.4.0/tests/test_main.py +80 -0
  36. hermes_mcp-0.4.0/tests/test_oauth.py +411 -0
  37. hermes_mcp-0.4.0/tests/test_oauth_integration.py +224 -0
  38. hermes_mcp-0.4.0/tests/test_server_smoke.py +531 -0
@@ -0,0 +1,53 @@
1
+ # --- REQUIRED -----------------------------------------------------------------
2
+ #
3
+ # Static OAuth 2.1 client credentials used by any MCP client (Claude Desktop's
4
+ # Custom Connector, OpenAI Codex CLI, Cursor, ...) to authenticate against
5
+ # this bridge. Generate a fresh pair with:
6
+ # hermes-mcp mint-client
7
+ # Paste the same values into your MCP client's connector / mcp.json / config.toml.
8
+
9
+ OAUTH_CLIENT_ID=
10
+ OAUTH_CLIENT_SECRET=
11
+
12
+ # Public HTTPS URL where this server is reachable (your tunnel hostname).
13
+ # Used as the OAuth issuer and to derive the resource-server URL.
14
+ # Must be HTTPS, except http://localhost is allowed for local testing.
15
+ OAUTH_ISSUER_URL=
16
+
17
+ # Bearer token for the local Hermes Agent gateway's OpenAI-compatible API
18
+ # (the `API_SERVER_KEY` value from ~/.hermes/.env).
19
+ HERMES_API_KEY=
20
+
21
+ # --- OPTIONAL -----------------------------------------------------------------
22
+
23
+ # Base URL of the Hermes gateway. Default: http://127.0.0.1:8642
24
+ # HERMES_API_URL=http://127.0.0.1:8642
25
+
26
+ # Model identifier for /v1/chat/completions. Default: hermes-agent
27
+ # HERMES_MODEL=hermes-agent
28
+
29
+ # Bind address. Default 127.0.0.1 — your tunnel (cloudflared/ngrok) reaches it.
30
+ # Do NOT bind to 0.0.0.0 unless you understand the implications.
31
+ # BIND_HOST=127.0.0.1
32
+
33
+ # Port. Default 8765.
34
+ # BIND_PORT=8765
35
+
36
+ # Max wall-clock seconds for a single hermes_ask call. Default 300.
37
+ # HERMES_REQUEST_TIMEOUT_SECONDS=300
38
+
39
+ # Comma-separated list of additional Host header values to accept (typically
40
+ # your public tunnel hostname). 127.0.0.1, localhost, and ::1 are always
41
+ # allowed. MCP's DNS-rebinding protection uses this list.
42
+ # MCP_ALLOWED_HOSTS=hermes.example.com
43
+
44
+ # Comma-separated list of OAuth redirect-URI custom schemes to accept.
45
+ # Each MCP client uses its own scheme: Claude → claude/claudeai, Cursor →
46
+ # cursor, Continue (VSCode) → vscode, etc. `https` and `http`-on-localhost
47
+ # are always accepted as a security baseline. Default covers the clients
48
+ # we test against. Add to this list (REPLACING the default) for new clients.
49
+ # OAUTH_ALLOWED_REDIRECT_SCHEMES=claude,claudeai,cursor
50
+
51
+ # Log level (DEBUG / INFO / WARNING / ERROR / CRITICAL). Default INFO.
52
+ # DEBUG enables logging of full prompt bodies — leave at INFO unless debugging.
53
+ # LOG_LEVEL=INFO
@@ -0,0 +1 @@
1
+ * @mlennie
@@ -0,0 +1,53 @@
1
+ ---
2
+ name: Bug report
3
+ about: Something isn't working
4
+ labels: bug
5
+ ---
6
+
7
+ ## Versions
8
+
9
+ | Component | Version |
10
+ |---|---|
11
+ | hermes-mcp | |
12
+ | Hermes Agent (`hermes --version`) | |
13
+ | Python (`python --version`) | |
14
+ | OS / distro | |
15
+
16
+ ## Setup
17
+
18
+ - **Tunnel type:** <!-- cloudflared / ngrok / other / none (local only) -->
19
+ - **Claude client:** <!-- Claude Desktop / Claude Mobile / API direct -->
20
+ - **HERMES_TOOLSETS set?** <!-- yes (list them) / no -->
21
+ - **Custom HERMES_BIN?** <!-- yes / no (using PATH) -->
22
+
23
+ ## What happened
24
+
25
+ <!-- What did you observe? -->
26
+
27
+ ## What you expected
28
+
29
+ <!-- What should have happened instead? -->
30
+
31
+ ## Steps to reproduce
32
+
33
+ 1.
34
+ 2.
35
+ 3.
36
+
37
+ ## Doctor output
38
+
39
+ ```
40
+ # Run: hermes-mcp doctor
41
+ # Paste the full output here
42
+ ```
43
+
44
+ ## Relevant logs
45
+
46
+ ```
47
+ # Run: LOG_LEVEL=DEBUG hermes-mcp serve (or journalctl -u hermes-mcp -n 100)
48
+ # Redact your bearer token and any sensitive prompt content before pasting.
49
+ ```
50
+
51
+ ## What you've already tried
52
+
53
+ <!-- Saves everyone time -->
@@ -0,0 +1,29 @@
1
+ ---
2
+ name: Feature request
3
+ about: Suggest a new capability
4
+ labels: enhancement
5
+ ---
6
+
7
+ ## Problem
8
+
9
+ <!-- What friction or limitation are you hitting? Be concrete — "I want X" is less useful than "I'm trying to do Y and I can't because Z." -->
10
+
11
+ ## Proposed solution
12
+
13
+ <!-- What would you like hermes-mcp to do differently? -->
14
+
15
+ ## Alternatives you've considered
16
+
17
+ <!-- Other approaches, workarounds, or reasons they don't work for you. -->
18
+
19
+ ## Security considerations
20
+
21
+ <!-- hermes-mcp sits between Claude and your local machine. Does this change:
22
+ - the authentication surface (new endpoints, new auth paths)?
23
+ - what Hermes can be asked to do?
24
+ - what gets logged, stored, or transmitted?
25
+ If yes, describe the impact. If you're unsure, say so — we'll work through it together. -->
26
+
27
+ ## Does this add a new MCP tool?
28
+
29
+ <!-- The single-tool design (hermes_ask only) is intentional — it keeps the attack surface small and puts authorization in Hermes's hands. If you're proposing a new tool, explain why hermes_ask can't cover it. -->
@@ -0,0 +1,33 @@
1
+ ## What this does
2
+
3
+ <!-- One paragraph. What changes and why. Link the issue if there is one (Fixes #NNN). -->
4
+
5
+ ## Type of change
6
+
7
+ - [ ] Bug fix
8
+ - [ ] New feature
9
+ - [ ] Security fix
10
+ - [ ] Refactor (no behavior change)
11
+ - [ ] Docs / config only
12
+
13
+ ## Security impact
14
+
15
+ <!-- hermes-mcp is a thin auth+subprocess wrapper. Before merging, confirm:
16
+ - Does this change the authentication or bearer-token handling? If yes, describe.
17
+ - Does this change how argv is constructed for the hermes subprocess? If yes, confirm shell=True is still absent.
18
+ - Does this add logging of prompt content above DEBUG level? It must not.
19
+ - Does this add any outbound network call from hermes-mcp itself? It must not (no telemetry policy).
20
+ If none of the above apply, write "None." -->
21
+
22
+ ## Testing done
23
+
24
+ - [ ] `ruff check .` passes
25
+ - [ ] `ruff format --check .` passes
26
+ - [ ] `mypy src/` passes
27
+ - [ ] `pytest` passes
28
+ - [ ] Manually tested against a real Hermes installation *(required if touching `hermes_client.py` or `server.py`)*
29
+
30
+ ## Checklist
31
+
32
+ - [ ] `CHANGELOG.md` updated under `Unreleased`
33
+ - [ ] Breaking changes (env var renames, CLI flag changes) noted in `CHANGELOG.md`
@@ -0,0 +1,39 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ["3.11", "3.12"]
14
+
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+
18
+ - name: Set up Python ${{ matrix.python-version }}
19
+ uses: actions/setup-python@v5
20
+ with:
21
+ python-version: ${{ matrix.python-version }}
22
+
23
+ - name: Install uv
24
+ uses: astral-sh/setup-uv@v3
25
+
26
+ - name: Install dependencies
27
+ run: uv pip install --system -e ".[dev]"
28
+
29
+ - name: Lint (ruff)
30
+ run: ruff check .
31
+
32
+ - name: Format check (ruff)
33
+ run: ruff format --check .
34
+
35
+ - name: Type check (mypy)
36
+ run: mypy src/
37
+
38
+ - name: Tests (pytest)
39
+ run: pytest
@@ -0,0 +1,107 @@
1
+ name: Release
2
+
3
+ # Fires when a `vX.Y.Z` tag is pushed. Builds the wheel/sdist once, then
4
+ # publishes to PyPI via Trusted Publishing (OIDC, no API token stored) and
5
+ # creates a GitHub Release with the matching CHANGELOG section.
6
+ #
7
+ # One-time setup on PyPI (per project, https://pypi.org/manage/project/hermes-mcp/settings/publishing/):
8
+ # - Owner: mlennie
9
+ # - Repository name: hermes-mcp
10
+ # - Workflow filename: release.yml
11
+ # - Environment name: (leave blank, or set to `release` if you also
12
+ # create a GitHub environment with that name)
13
+ #
14
+ # If trusted publishing is not configured on PyPI yet, the `publish-pypi`
15
+ # job will fail with a clear error pointing at the PyPI settings page —
16
+ # the `github-release` job runs independently and still succeeds.
17
+
18
+ on:
19
+ push:
20
+ tags:
21
+ - 'v*'
22
+
23
+ jobs:
24
+ build:
25
+ name: Build wheel + sdist
26
+ runs-on: ubuntu-latest
27
+ steps:
28
+ - uses: actions/checkout@v4
29
+
30
+ - name: Set up Python
31
+ uses: actions/setup-python@v5
32
+ with:
33
+ python-version: '3.12'
34
+
35
+ - name: Install build dependencies
36
+ run: pip install --upgrade build
37
+
38
+ - name: Verify tag matches package version
39
+ run: |
40
+ tag="${GITHUB_REF_NAME#v}"
41
+ pkg=$(python -c "import tomllib, pathlib; \
42
+ print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])")
43
+ if [ "$tag" != "$pkg" ]; then
44
+ echo "::error::Tag v$tag does not match pyproject.toml version $pkg"
45
+ exit 1
46
+ fi
47
+
48
+ - name: Build
49
+ run: python -m build
50
+
51
+ - name: Upload artifacts
52
+ uses: actions/upload-artifact@v4
53
+ with:
54
+ name: dist
55
+ path: dist/
56
+
57
+ publish-pypi:
58
+ name: Publish to PyPI
59
+ needs: build
60
+ runs-on: ubuntu-latest
61
+ permissions:
62
+ id-token: write
63
+ steps:
64
+ - name: Download artifacts
65
+ uses: actions/download-artifact@v4
66
+ with:
67
+ name: dist
68
+ path: dist/
69
+
70
+ - name: Publish via trusted publishing
71
+ uses: pypa/gh-action-pypi-publish@release/v1
72
+
73
+ github-release:
74
+ name: Create GitHub Release
75
+ needs: build
76
+ runs-on: ubuntu-latest
77
+ permissions:
78
+ contents: write
79
+ steps:
80
+ - uses: actions/checkout@v4
81
+
82
+ - name: Download artifacts
83
+ uses: actions/download-artifact@v4
84
+ with:
85
+ name: dist
86
+ path: dist/
87
+
88
+ - name: Extract release notes from CHANGELOG
89
+ run: |
90
+ python <<'PY'
91
+ import os, re, pathlib
92
+ tag = os.environ["GITHUB_REF_NAME"].lstrip("v")
93
+ changelog = pathlib.Path("CHANGELOG.md").read_text()
94
+ pattern = rf"## \[{re.escape(tag)}\][^\n]*\n(.*?)(?=^## \[|\Z)"
95
+ m = re.search(pattern, changelog, flags=re.DOTALL | re.MULTILINE)
96
+ notes = m.group(1).strip() if m else (
97
+ f"See [CHANGELOG.md](./CHANGELOG.md) for v{tag}."
98
+ )
99
+ pathlib.Path("release-notes.md").write_text(notes + "\n")
100
+ PY
101
+
102
+ - name: Create release
103
+ uses: softprops/action-gh-release@v2
104
+ with:
105
+ body_path: release-notes.md
106
+ files: dist/*
107
+ fail_on_unmatched_files: true
@@ -0,0 +1,62 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ develop-eggs/
9
+ dist/
10
+ downloads/
11
+ eggs/
12
+ .eggs/
13
+ lib/
14
+ lib64/
15
+ parts/
16
+ sdist/
17
+ var/
18
+ wheels/
19
+ *.egg-info/
20
+ *.egg
21
+ .installed.cfg
22
+
23
+ # Virtual envs
24
+ .venv/
25
+ venv/
26
+ env/
27
+ ENV/
28
+
29
+ # Tests / coverage
30
+ .coverage
31
+ .coverage.*
32
+ htmlcov/
33
+ .pytest_cache/
34
+ .tox/
35
+ .nox/
36
+ coverage.xml
37
+ *.cover
38
+
39
+ # Type checking
40
+ .mypy_cache/
41
+ .dmypy.json
42
+ .pyre/
43
+ .ruff_cache/
44
+
45
+ # Editors / IDE
46
+ .idea/
47
+ .vscode/
48
+ *.swp
49
+ *.swo
50
+ .DS_Store
51
+
52
+ # Env
53
+ .env
54
+ .env.local
55
+ .env.*.local
56
+
57
+ # Build / packaging
58
+ *.egg-info/
59
+ .eggs/
60
+
61
+ # Misc
62
+ *.log
@@ -0,0 +1,161 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.4.0] - 2026-05-17
11
+
12
+ ### Added
13
+ - **`hermes_reset()` tool.** Clears every job from the in-memory `JobStore`
14
+ in one call, returning JSON like `{"cleared": 4, "by_status": {"running": 1, "pending": 3}}`.
15
+ Same caveat as `hermes_cancel`: does NOT stop in-flight worker threads
16
+ or gateway calls — workers whose jobs are wiped run to completion and
17
+ no-op when their `mark_completed` / `mark_failed` finds an unknown id.
18
+ The tool description warns the LLM that the job store is shared across
19
+ all MCP callers (multiple Claude sessions, background Hermes-agent
20
+ workflows), so reset is a global operation that should be confirmed
21
+ with the user when other work might be in flight.
22
+ - `JobStore.reset_all() -> tuple[int, dict[JobStatus, int]]` helper backing
23
+ the tool. Reaps expired terminal jobs before counting so the returned
24
+ `by_status` reflects only jobs that were actually live in the store at
25
+ call time. Typed against the existing `JobStatus` literal for stronger
26
+ static checks.
27
+ - **Multi-client groundwork.** Removed the hardcoded Claude-only
28
+ assumptions from the OAuth flow and tool descriptions. Any MCP client
29
+ that speaks Streamable HTTP + OAuth 2.1 **and supports pasting in a
30
+ static `client_id` / `client_secret`** can now connect. Today that's
31
+ still primarily Claude Desktop / Claude.ai. Codex CLI was tested and
32
+ found to require Dynamic Client Registration (which we currently
33
+ disable); Cursor / Continue likely have the same requirement. DCR
34
+ support is tracked as a follow-up so those clients can join — see the
35
+ Client compatibility section of the README for the current matrix.
36
+ - **`OAUTH_ALLOWED_REDIRECT_SCHEMES` env var.** Comma-separated list of
37
+ OAuth redirect-URI custom schemes to accept (default:
38
+ `claude,claudeai,cursor`). `https` and `http`-on-localhost are always
39
+ allowed as a security baseline. Lets operators extend the allowlist
40
+ for new clients (e.g. `vscode` for Continue) without code changes.
41
+
42
+ ### Changed
43
+ - Tool descriptions for `hermes_ask` / `hermes_check` / `hermes_cancel` /
44
+ `hermes_reset` are now client-neutral. No longer hardcode "Claude" as
45
+ the consumer; async-mode timeout guidance now notes that enforcement
46
+ varies by client (Claude.ai is ~2 min; Codex CLI, Cursor, others
47
+ differ). All async/sync decision heuristics remain unchanged.
48
+ - README, CLAUDE.md, `.env.example`, and source-file docstrings reframed
49
+ around generic MCP clients. The README's Client compatibility section
50
+ is honest about the current matrix: Claude is the only client tested
51
+ end-to-end; Codex CLI is confirmed incompatible until DCR support
52
+ lands; Cursor and Continue are likely in the same boat.
53
+ - `hermes-mcp mint-client` output now points at any MCP client's config
54
+ format, not just Claude Desktop's Custom Connector UI.
55
+
56
+ ## [0.3.0] - 2026-05-16
57
+
58
+ ### Added
59
+ - **Async job mode for `hermes_ask`.** New optional `async_mode: bool = False`
60
+ parameter. When `True`, the call returns a JSON string
61
+ `{"job_id":"<id>","status":"pending"}` immediately and runs the gateway
62
+ request in a background thread. Designed to escape the MCP client's
63
+ per-call timeout (~2 minutes for Claude.ai / Claude Desktop) when Hermes
64
+ needs to chew on a long multi-step task.
65
+ - **`hermes_check(job_id)` tool.** Returns JSON with `status` ∈
66
+ `{pending, running, completed, failed, cancelled, unknown}`, plus
67
+ `created_at` / `finished_at` epoch timestamps, `prompt_chars`, optional
68
+ `session_id`, and `result` or `error`.
69
+ - **`hermes_cancel(job_id)` tool.** Releases the bookkeeping for an
70
+ in-flight async job. **Does NOT stop the gateway work** — Python cannot
71
+ safely kill a thread mid-I/O, so the worker runs to completion and any
72
+ side effects happen anyway. Use this when you want to release the
73
+ *result*, not undo the *work*. Tool description spells this out.
74
+ - In-memory `JobStore` (`src/hermes_mcp/jobs.py`) with ~24h TTL, 1000-job
75
+ cap, lazy cleanup on access. Like OAuth state, jobs are not persisted —
76
+ a server restart loses every in-flight or completed job.
77
+ - Tool description for `hermes_ask` documents `async_mode` and tells the
78
+ caller about `hermes_check` and `hermes_cancel`.
79
+
80
+ ### Changed
81
+ - **Single-tool design rescinded** (see CLAUDE.md). The server now exposes
82
+ three tools tightly coupled around the async-job lifecycle: `hermes_ask`
83
+ (submit), `hermes_check` (poll), `hermes_cancel` (release). The shape of
84
+ `hermes_ask` in sync mode is unchanged — old callers continue to work
85
+ without changes.
86
+ - `JobStore.mark_completed` and `JobStore.mark_failed` are now
87
+ terminal-state-aware: a late-finishing worker thread cannot overwrite a
88
+ cancellation (or any other terminal state). Both methods now return
89
+ `bool` to signal whether the state actually changed.
90
+
91
+ ### Security
92
+ - Unexpected worker-thread exceptions surface only their type name in the
93
+ job record's `error` field (not `str(exc)`). Matches the existing
94
+ invariant that gateway error bodies are not echoed in user-visible
95
+ errors; the full traceback still lands in the server log at ERROR.
96
+ - Cancelled jobs never accept a late `result` payload from the worker
97
+ thread — prevents a "phantom result" race where the user thinks they
98
+ cancelled and then sees a result appear anyway.
99
+
100
+ ## [0.2.0] - 2026-05-10
101
+
102
+ ### Changed (BREAKING)
103
+ - **Auth replaced** with OAuth 2.1 (authorization code + PKCE) instead of a single bearer token.
104
+ Claude Desktop's Custom Connector UI requires this.
105
+ - New required env vars: `OAUTH_CLIENT_ID`, `OAUTH_CLIENT_SECRET`, `OAUTH_ISSUER_URL`.
106
+ - Removed: `MCP_BEARER_TOKEN`.
107
+ - **Backend swapped** from `hermes -z` subprocess to HTTP POST against the
108
+ Hermes gateway's OpenAI-compatible API (`/v1/chat/completions`). Same brain
109
+ Telegram talks to — sessions, skills, loaded tools all carry over.
110
+ - New required env var: `HERMES_API_KEY` (the `API_SERVER_KEY` from `~/.hermes/.env`).
111
+ - New optional env vars: `HERMES_API_URL` (default `http://127.0.0.1:8642`),
112
+ `HERMES_MODEL` (default `hermes-agent`).
113
+ - Removed: `HERMES_BIN`, `HERMES_TOOLSETS`, `HERMES_TIMEOUT_SECONDS`
114
+ (replaced by `HERMES_REQUEST_TIMEOUT_SECONDS`).
115
+ - `session_id` is now forwarded as the `X-Hermes-Session-Id` header.
116
+
117
+ ### Added
118
+ - `hermes-mcp mint-client` subcommand to generate a fresh client_id / client_secret pair.
119
+ - `MCP_ALLOWED_HOSTS` env var so DNS-rebinding protection accepts the public tunnel hostname.
120
+ - `BIND_HOST` non-loopback values now emit a startup warning.
121
+ - `httpx` runtime dependency (`>=0.27,<1.0`).
122
+ - systemd hardening flags on `deploy/hermes-mcp.service`: `ProtectSystem=strict`,
123
+ `ProtectHome=read-only` (with `ReadWritePaths=` for the env directory),
124
+ `RestrictAddressFamilies`, `LockPersonality`, `MemoryDenyWriteExecute`,
125
+ `CapabilityBoundingSet=`, `SystemCallFilter=@system-service`.
126
+
127
+ ### Security
128
+ - **OAuth redirect-URI scheme allowlist** (`https`, `http` for localhost only,
129
+ `claude`, `claudeai`). Prevents `/authorize` becoming an open redirector to
130
+ `javascript:` / `data:` / `file:` URIs.
131
+ - **Atomic refresh-token rotation.** Concurrent `/token` requests with the
132
+ same refresh token: only the first one wins; the second is rejected as
133
+ `invalid_grant`. Approximates RFC 6819 reuse detection.
134
+ - **Atomic authorization-code single-use.** Pop-then-mint sequence ensures
135
+ a code cannot be redeemed twice.
136
+ - **`/authorize` and access-token caps.** Drive-by attackers cannot grow
137
+ in-memory state unboundedly; expired entries are reaped opportunistically.
138
+ - **Log injection mitigation.** OAuth `state` parameter is sanitized
139
+ (newlines escaped, truncated to 64 chars) before logging.
140
+ - **Gateway error bodies redacted** from user-visible errors. A misbehaving
141
+ gateway can no longer inject content into the bridge's `HermesError`
142
+ responses to Claude. Bodies remain in DEBUG logs only.
143
+ - `httpx.post`/`httpx.get` calls use `follow_redirects=False`.
144
+
145
+ ## [0.1.0] - TBD
146
+
147
+ ### Added
148
+ - Initial release.
149
+ - `hermes_ask(prompt, session_id?, toolsets?)` MCP tool wrapping `hermes -z` and `hermes --continue`.
150
+ - Streamable HTTP transport via FastMCP + uvicorn.
151
+ - Bearer-token auth middleware (`hmac.compare_digest`).
152
+ - Startup doctor self-check (`hermes --version`).
153
+ - Env-var configuration with `.env.example`.
154
+ - systemd units for `hermes-mcp`, cloudflared, and ngrok in `deploy/`.
155
+ - README with architecture diagram, threat model, and tunnel setup walkthroughs.
156
+
157
+ [Unreleased]: https://github.com/mlennie/hermes-mcp/compare/v0.4.0...HEAD
158
+ [0.4.0]: https://github.com/mlennie/hermes-mcp/releases/tag/v0.4.0
159
+ [0.3.0]: https://github.com/mlennie/hermes-mcp/releases/tag/v0.3.0
160
+ [0.2.0]: https://github.com/mlennie/hermes-mcp/releases/tag/v0.2.0
161
+ [0.1.0]: https://github.com/mlennie/hermes-mcp/releases/tag/v0.1.0
@@ -0,0 +1,91 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Commands
6
+
7
+ ```bash
8
+ # Setup
9
+ uv venv .venv --python 3.11 && source .venv/bin/activate && uv pip install -e ".[dev]"
10
+
11
+ # Full CI suite (must all pass)
12
+ ruff check . && ruff format --check . && mypy src/ && pytest
13
+
14
+ # Individual checks
15
+ ruff check . # lint
16
+ ruff format . # auto-format
17
+ mypy src/ # type-check (strict mode, src/ only — mcp module excluded)
18
+ pytest # all tests
19
+ pytest tests/test_oauth.py # single test file
20
+ pytest -k "test_name" # single test by name
21
+
22
+ # Run / inspect the server
23
+ hermes-mcp serve # or: python -m hermes_mcp serve
24
+ hermes-mcp doctor # startup self-check (probes the gateway)
25
+ hermes-mcp mint-client # generate a fresh OAuth client_id / client_secret
26
+ ```
27
+
28
+ ## Architecture
29
+
30
+ **hermes-mcp** is an MCP bridge that lets MCP clients (today: Claude Desktop / Claude.ai; future: Codex CLI / Cursor / Continue once DCR ships) delegate tasks to a locally running **Hermes Agent**. The client calls MCP tools (`hermes_ask`, `hermes_check`, `hermes_cancel`, `hermes_reset`) over an HTTPS tunnel; the bridge gates that with OAuth 2.1 and forwards each call to the Hermes gateway's OpenAI-compatible HTTP API.
31
+
32
+ **Static-client-only constraint.** The OAuth provider currently disables Dynamic Client Registration (`ClientRegistrationOptions(enabled=False)` in `build_app`; `StaticClientProvider.register_client` raises `NotImplementedError`). This means a client can only connect if it supports pasting in a static pre-shared `client_id` / `client_secret`. Claude Desktop's Custom Connector UI does. Codex CLI does not (empirically confirmed — `codex mcp login` auto-attempts DCR and fails). Adding DCR support is a tracked follow-up; tool-description / scheme-allowlist changes in 0.4.0 already removed the other Claude-only assumptions.
33
+
34
+ ```
35
+ MCP client (Claude Desktop / Claude.ai / Codex CLI / Cursor / ...)
36
+ │ HTTPS via cloudflared tunnel
37
+
38
+ hermes-mcp (this project, listening on 127.0.0.1:8765)
39
+ ├─ OAuth 2.1 (authorization code + PKCE), single static client_id/secret
40
+ └─ HTTP POST to the gateway
41
+
42
+
43
+ hermes-gateway (127.0.0.1:8642, OpenAI-compatible /v1/chat/completions)
44
+ └─ same AIAgent loop that drives Telegram (skills, tools, sessions)
45
+ ```
46
+
47
+ The gateway is a **separate, long-running process** owned by the user (typically a `systemd --user` service). hermes-mcp does not spawn it; it just sends HTTP requests.
48
+
49
+ The six source modules in `src/hermes_mcp/` have clean single responsibilities:
50
+
51
+ - **`config.py`** — frozen `Config` dataclass parsed from env vars. Required: `OAUTH_CLIENT_ID`, `OAUTH_CLIENT_SECRET`, `OAUTH_ISSUER_URL`, `HERMES_API_KEY`. Validates the issuer URL is HTTPS (or `http://localhost`), the client_secret is ≥32 chars, and warns if `BIND_HOST` is non-loopback.
52
+ - **`oauth.py`** — `StaticClientProvider` implements the MCP SDK's `OAuthAuthorizationServerProvider` protocol with one pre-shared client. Mints opaque 256-bit access tokens (1h TTL) and refresh tokens (30d, rotated atomically on use). PKCE-S256 enforced by the SDK. DCR is disabled. `_StaticClient.validate_redirect_uri` enforces a scheme allowlist: `https` and `http`-on-localhost are always allowed (security baseline); custom URI schemes are operator-configured via `OAUTH_ALLOWED_REDIRECT_SCHEMES` (default `claude,claudeai,cursor`). This prevents `/authorize` from becoming an open redirector to `javascript:` / `data:` URIs while letting any MCP client whose scheme is in the allowlist complete the flow.
53
+ - **`hermes_client.py`** — `HermesClient.ask()` does `httpx.post` to the gateway's `/v1/chat/completions` with `Authorization: Bearer $HERMES_API_KEY`. `session_id` is forwarded as `X-Hermes-Session-Id`. `toolsets` is accepted for backward-compat but ignored — toolset selection now lives in the Hermes config (`platform_toolsets.api_server`). Gateway error bodies are NOT echoed in user-visible errors (DEBUG only).
54
+ - **`jobs.py`** — `JobStore` is a thread-safe in-memory dict of `Job` records, used by `hermes_ask(..., async_mode=True)`, `hermes_check`, `hermes_cancel`, and `hermes_reset`. Lazy TTL reap (24h) on every access, 1000-job cap. In-memory only by design; restart drops everything. `mark_completed`/`mark_failed` are terminal-state-aware so a late-finishing worker cannot overwrite a cancellation. `reset_all()` reaps first, then wipes the store and returns `(cleared, by_status)`. Times use `time.time()` (wall clock, epoch seconds) so they round-trip cleanly through JSON to the caller; small risk of confusion if the system clock jumps backwards, accepted in exchange for code simplicity.
55
+ - **`server.py`** — `build_app()` constructs a `FastMCP` instance with `auth_server_provider`, `AuthSettings`, and `transport_security`. Registers four tools: `hermes_ask` (sync default; `async_mode=True` spawns a daemon thread and returns a `job_id`), `hermes_check(job_id)`, `hermes_cancel(job_id)`, and `hermes_reset()`. FastMCP itself adds `/authorize`, `/token`, `/.well-known/oauth-authorization-server`, and the `RequireAuthMiddleware` that gates `/mcp`. `serve()` runs uvicorn.
56
+ - **`doctor.py`** — `run_checks()` probes the gateway's `/v1/health` (no auth) and `/v1/models` (with `HERMES_API_KEY`); warns if `HERMES_MODEL` isn't in the returned model list.
57
+
58
+ **Four-tool design.** The tools form a tight lifecycle: submit (`hermes_ask`), poll (`hermes_check`), abandon a single job (`hermes_cancel`), wipe the store (`hermes_reset`). Do not add tools for *new* use cases (different actions, different domains) without discussing in an issue first.
59
+
60
+ **`hermes_reset` is a global operation.** The job store is shared across every MCP caller (multiple Claude sessions, background Hermes-agent workflows, etc.). Resetting wipes them all. The tool description warns the LLM to confirm with the user before calling it when other work might be in flight.
61
+
62
+ **Cancellation is a tombstone, not a kill switch.** `hermes_cancel` updates this server's bookkeeping; the worker thread keeps running and the gateway keeps doing whatever it was doing. There is no way around this in CPython — you cannot safely kill a thread blocked on `httpx.post`. The tool's description spells this out loudly so the LLM relays the caveat to the user. If we ever want real cancellation, the path is to rewrite `HermesClient` against `httpx.AsyncClient` with cancellation tokens and run the whole server on asyncio — large refactor, scoped for a future major version.
63
+
64
+ ## Key constraints
65
+
66
+ - All four required env vars must be set or the server refuses to start.
67
+ - `client_secret` comparison uses `hmac.compare_digest()` (delegated to the MCP SDK's `ClientAuthenticator`).
68
+ - Access tokens are in-memory only — by design. Restart invalidates all sessions. **Claude Desktop does NOT re-auth transparently** in practice: it surfaces "Error occurred during tool execution" on the next call and the user has to manually Disconnect / Reconnect the connector once. The `client_id` / `client_secret` are saved on the connector, so the reconnect doesn't require re-pasting credentials. (Persisting tokens — and async-mode jobs — to disk is on the v0.4.0 roadmap.)
69
+ - Async-mode jobs are also in-memory only (`jobs.py`). A server restart drops every job, in-flight or completed; if a user is mid-poll they will see `status: unknown`. The same Disconnect/Reconnect dance applies after a restart.
70
+ - Refresh-token rotation is **atomic-pop-then-mint** in `oauth.py` — concurrent `/token` requests with the same refresh token cannot both succeed.
71
+ - Prompt content must only be logged at DEBUG level, not INFO (privacy by default). The `state` query parameter is sanitized before logging. Async-job records intentionally store only `prompt_chars` (not the prompt itself).
72
+ - Unexpected (non-`HermesError`) exceptions in the async worker thread surface as `error: "unexpected error: <ExceptionType>"` — never `str(exc)` — to preserve the existing invariant that gateway and library error bodies are not echoed in user-facing errors. Full traceback lands in the server log at ERROR.
73
+ - `BIND_HOST` defaults to `127.0.0.1`; binding elsewhere gets a startup warning.
74
+ - mypy is run on `src/` only — the `mcp` package lacks stubs and is excluded.
75
+ - Python ≥ 3.11 required; CI tests 3.11 and 3.12.
76
+ - Test count is 112 as of v0.3.0; a sudden drop is a regression smell.
77
+
78
+ ## Deployment shape
79
+
80
+ This project ships with `deploy/hermes-mcp.service` and `deploy/cloudflared.service` as **systemd user units** (matching the `hermes-gateway` / `mcp-proxy` services it sits next to). Env file lives at `~/.config/hermes-mcp/env` mode 0600. `loginctl enable-linger` is required so user services start at boot.
81
+
82
+ `deploy/hermes-mcp.service` ships with non-trivial hardening flags: `ProtectSystem=strict`, `ProtectHome=read-only` + `ReadWritePaths=%h/.config/hermes-mcp`, `RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX`, `LockPersonality=true`, `MemoryDenyWriteExecute=true`, empty `CapabilityBoundingSet=`, and `SystemCallFilter=@system-service` (excluding `@privileged @resources`). They are verified to start cleanly with the current Python deps; **do not strip them without intent** and re-test the service start. If a future dependency needs JIT or syscalls outside `@system-service`, narrow the rule rather than removing it.
83
+
84
+ ## Release process
85
+
86
+ Per-release steps:
87
+ 1. Bump version in **both** `src/hermes_mcp/__init__.py` and `pyproject.toml`. The release workflow checks they match the pushed tag and fails the build if they don't.
88
+ 2. Move the `Unreleased` section in `CHANGELOG.md` to the new version heading with today's date. Keep the `[Unreleased]` heading empty above it. The release workflow's `github-release` job extracts this section verbatim as the release notes.
89
+ 3. Commit, tag `vX.Y.Z`, `git push origin main vX.Y.Z`. The tag push fires `.github/workflows/release.yml`, which builds the wheel + sdist, publishes to PyPI via OIDC trusted publishing (no API tokens stored anywhere), and creates a GitHub Release with the CHANGELOG section and built artifacts attached.
90
+
91
+ One-time setup (already done for this project, listed here so a maintainer rotating the secret doesn't re-do it from scratch): a trusted publisher is configured at https://pypi.org/manage/project/hermes-mcp/settings/publishing/ pointing at this repo, workflow filename `release.yml`, no environment. If the workflow is renamed or moved, the PyPI trusted-publisher entry must be updated to match or the publish step will fail.