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.
- hermes_mcp-0.4.0/.env.example +53 -0
- hermes_mcp-0.4.0/.github/CODEOWNERS +1 -0
- hermes_mcp-0.4.0/.github/ISSUE_TEMPLATE/bug_report.md +53 -0
- hermes_mcp-0.4.0/.github/ISSUE_TEMPLATE/feature_request.md +29 -0
- hermes_mcp-0.4.0/.github/PULL_REQUEST_TEMPLATE.md +33 -0
- hermes_mcp-0.4.0/.github/workflows/ci.yml +39 -0
- hermes_mcp-0.4.0/.github/workflows/release.yml +107 -0
- hermes_mcp-0.4.0/.gitignore +62 -0
- hermes_mcp-0.4.0/CHANGELOG.md +161 -0
- hermes_mcp-0.4.0/CLAUDE.md +91 -0
- hermes_mcp-0.4.0/CODE_OF_CONDUCT.md +41 -0
- hermes_mcp-0.4.0/CONTRIBUTING.md +60 -0
- hermes_mcp-0.4.0/LICENSE +201 -0
- hermes_mcp-0.4.0/PKG-INFO +429 -0
- hermes_mcp-0.4.0/README.md +396 -0
- hermes_mcp-0.4.0/SECURITY.md +69 -0
- hermes_mcp-0.4.0/THREAT_MODEL.md +261 -0
- hermes_mcp-0.4.0/deploy/cloudflared.service +15 -0
- hermes_mcp-0.4.0/deploy/hermes-mcp.service +36 -0
- hermes_mcp-0.4.0/deploy/ngrok.service +16 -0
- hermes_mcp-0.4.0/pyproject.toml +97 -0
- hermes_mcp-0.4.0/src/hermes_mcp/__init__.py +1 -0
- hermes_mcp-0.4.0/src/hermes_mcp/__main__.py +91 -0
- hermes_mcp-0.4.0/src/hermes_mcp/config.py +161 -0
- hermes_mcp-0.4.0/src/hermes_mcp/doctor.py +81 -0
- hermes_mcp-0.4.0/src/hermes_mcp/hermes_client.py +123 -0
- hermes_mcp-0.4.0/src/hermes_mcp/jobs.py +218 -0
- hermes_mcp-0.4.0/src/hermes_mcp/oauth.py +340 -0
- hermes_mcp-0.4.0/src/hermes_mcp/server.py +355 -0
- hermes_mcp-0.4.0/tests/__init__.py +0 -0
- hermes_mcp-0.4.0/tests/test_config.py +171 -0
- hermes_mcp-0.4.0/tests/test_doctor.py +85 -0
- hermes_mcp-0.4.0/tests/test_hermes_client.py +180 -0
- hermes_mcp-0.4.0/tests/test_jobs.py +321 -0
- hermes_mcp-0.4.0/tests/test_main.py +80 -0
- hermes_mcp-0.4.0/tests/test_oauth.py +411 -0
- hermes_mcp-0.4.0/tests/test_oauth_integration.py +224 -0
- 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.
|