Skip to content

Commit df25ba3

Browse files
anshal21claude
andcommitted
Initial release — DPoP proxy sidecar SDK
Python SDK for FirstOps agent authentication. Provides a local HTTP proxy that transparently adds DPoP-signed headers (RFC 9449, ES256) to MCP requests. Includes CI workflows for testing (Python 3.10–3.13) and PyPI publishing via trusted publisher on tag push. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
0 parents  commit df25ba3

12 files changed

Lines changed: 699 additions & 0 deletions

File tree

.github/workflows/publish.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
name: Publish to PyPI
2+
3+
on:
4+
push:
5+
tags: ["v*"]
6+
7+
jobs:
8+
publish:
9+
runs-on: ubuntu-latest
10+
environment: pypi
11+
permissions:
12+
id-token: write # Required for PyPI trusted publisher
13+
14+
steps:
15+
- uses: actions/checkout@v4
16+
17+
- uses: actions/setup-python@v5
18+
with:
19+
python-version: "3.12"
20+
21+
- name: Install build tools
22+
run: pip install build
23+
24+
- name: Build package
25+
run: python -m build
26+
27+
- name: Publish to PyPI
28+
uses: pypa/gh-action-pypi-publish@release/v1

.github/workflows/test.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
name: Test
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
matrix:
14+
python-version: ["3.10", "3.11", "3.12", "3.13"]
15+
16+
steps:
17+
- uses: actions/checkout@v4
18+
19+
- uses: actions/setup-python@v5
20+
with:
21+
python-version: ${{ matrix.python-version }}
22+
23+
- name: Install dependencies
24+
run: pip install -e ".[dev]"
25+
26+
- name: Run tests
27+
run: pytest -v

.gitignore

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Virtual environments
2+
.venv/
3+
venv/
4+
env/
5+
6+
# Python
7+
__pycache__/
8+
*.pyc
9+
*.pyo
10+
*.egg-info/
11+
dist/
12+
build/
13+
14+
# Testing
15+
.pytest_cache/
16+
.coverage
17+
htmlcov/
18+
19+
# IDE
20+
.idea/
21+
.vscode/
22+
*.swp
23+
*.swo
24+
25+
# OS
26+
.DS_Store
27+
Thumbs.db

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 FirstOps
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# FirstOps Python SDK
2+
3+
Secure MCP proxy sidecar with [DPoP](https://datatracker.ietf.org/doc/html/rfc9449) authentication for AI agents.
4+
5+
FirstOps secures agent-to-tool connections. This SDK runs a lightweight local proxy that transparently adds DPoP-signed authentication headers to every MCP request your agent makes — no changes to your agent code required.
6+
7+
## Install
8+
9+
```bash
10+
pip install firstops
11+
```
12+
13+
## Quick Start
14+
15+
```python
16+
import firstops
17+
18+
# Start the proxy sidecar (runs in background thread)
19+
firstops.init(
20+
agent_id="your-agent-id",
21+
private_key_pem=open("agent-key.pem").read(),
22+
)
23+
24+
# Point your MCP client at localhost:9322 instead of the remote server.
25+
# The proxy handles auth transparently — DPoP proofs, bearer tokens, SSE streaming.
26+
27+
# When done:
28+
firstops.shutdown()
29+
```
30+
31+
### What happens
32+
33+
1. `firstops.init()` starts a local HTTP proxy on `127.0.0.1:9322`
34+
2. Your MCP client sends requests to `localhost:9322/mcp/...`
35+
3. The proxy signs each request with a DPoP proof (RFC 9449, ES256)
36+
4. The signed request is forwarded to the FirstOps gateway
37+
5. SSE streaming responses are proxied back with URLs rewritten to localhost
38+
39+
### Configuration
40+
41+
| Parameter | Default | Description |
42+
|-----------|---------|-------------|
43+
| `agent_id` | *required* | Your agent's principal ID |
44+
| `private_key_pem` | *required* | EC P-256 private key (PEM format) |
45+
| `port` | `9322` | Local proxy port |
46+
| `gateway_url` | `https://api.firstops.ai` | FirstOps gateway URL |
47+
48+
## Requirements
49+
50+
- Python 3.10+
51+
- Dependencies: `cryptography`, `httpx`
52+
53+
## Development
54+
55+
```bash
56+
git clone https://github.com/firstops-dev/firstops-python.git
57+
cd firstops-python
58+
python -m venv .venv && source .venv/bin/activate
59+
pip install -e ".[dev]"
60+
pytest
61+
```
62+
63+
## License
64+
65+
MIT

pyproject.toml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
[build-system]
2+
requires = ["hatchling"]
3+
build-backend = "hatchling.build"
4+
5+
[project]
6+
name = "firstops"
7+
version = "0.1.0"
8+
description = "FirstOps SDK — secure MCP proxy sidecar with DPoP authentication"
9+
readme = "README.md"
10+
requires-python = ">=3.10"
11+
license = "MIT"
12+
authors = [
13+
{ name = "FirstOps", email = "dev@firstops.dev" },
14+
]
15+
classifiers = [
16+
"Development Status :: 3 - Alpha",
17+
"Intended Audience :: Developers",
18+
"License :: OSI Approved :: MIT License",
19+
"Programming Language :: Python :: 3",
20+
"Programming Language :: Python :: 3.10",
21+
"Programming Language :: Python :: 3.11",
22+
"Programming Language :: Python :: 3.12",
23+
"Programming Language :: Python :: 3.13",
24+
"Topic :: Security",
25+
"Topic :: Software Development :: Libraries",
26+
]
27+
keywords = ["mcp", "dpop", "proxy", "agent", "security"]
28+
dependencies = [
29+
"cryptography>=42.0",
30+
"httpx>=0.27",
31+
]
32+
33+
[project.urls]
34+
Homepage = "https://firstops.dev"
35+
Documentation = "https://github.com/firstops-dev/firstops-python"
36+
Repository = "https://github.com/firstops-dev/firstops-python"
37+
Issues = "https://github.com/firstops-dev/firstops-python/issues"
38+
39+
[project.optional-dependencies]
40+
dev = [
41+
"pytest>=8.0",
42+
"pytest-asyncio>=0.23",
43+
]
44+
45+
[tool.hatch.build.targets.wheel]
46+
packages = ["src/firstops"]

src/firstops/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""FirstOps SDK — secure MCP proxy sidecar with DPoP authentication."""
2+
3+
from firstops.proxy import init, shutdown
4+
5+
__all__ = ["init", "shutdown"]

src/firstops/dpop.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""DPoP proof generation (RFC 9449) using ES256 (P-256)."""
2+
3+
import base64
4+
import hashlib
5+
import json
6+
import os
7+
import time
8+
9+
from cryptography.hazmat.primitives.asymmetric import ec, utils
10+
from cryptography.hazmat.primitives.hashes import SHA256
11+
from cryptography.hazmat.primitives.serialization import load_pem_private_key
12+
13+
14+
def load_private_key(pem_data: str) -> ec.EllipticCurvePrivateKey:
15+
"""Load an EC P-256 private key from PEM string."""
16+
key = load_pem_private_key(pem_data.encode(), password=None)
17+
if not isinstance(key, ec.EllipticCurvePrivateKey):
18+
raise ValueError("expected EC private key")
19+
if not isinstance(key.curve, ec.SECP256R1):
20+
raise ValueError("expected P-256 curve")
21+
return key
22+
23+
24+
def public_key_jwk(key: ec.EllipticCurvePrivateKey) -> dict[str, str]:
25+
"""Return the JWK representation of the public key."""
26+
pub = key.public_key()
27+
numbers = pub.public_numbers()
28+
return {
29+
"kty": "EC",
30+
"crv": "P-256",
31+
"x": _b64url_int(numbers.x, 32),
32+
"y": _b64url_int(numbers.y, 32),
33+
}
34+
35+
36+
def jwk_thumbprint(key: ec.EllipticCurvePrivateKey) -> str:
37+
"""Compute RFC 7638 JWK thumbprint (base64url SHA-256)."""
38+
jwk = public_key_jwk(key)
39+
# RFC 7638: members in lexicographic order for EC: crv, kty, x, y
40+
canonical = f'{{"crv":"{jwk["crv"]}","kty":"{jwk["kty"]}","x":"{jwk["x"]}","y":"{jwk["y"]}"}}'
41+
digest = hashlib.sha256(canonical.encode()).digest()
42+
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
43+
44+
45+
def create_proof(key: ec.EllipticCurvePrivateKey, method: str, url: str) -> str:
46+
"""Create a DPoP proof JWT for the given HTTP method and URL."""
47+
jwk = public_key_jwk(key)
48+
49+
header = {"typ": "dpop+jwt", "alg": "ES256", "jwk": jwk}
50+
claims = {
51+
"jti": base64.urlsafe_b64encode(os.urandom(16)).rstrip(b"=").decode(),
52+
"htm": method,
53+
"htu": url,
54+
"iat": int(time.time()),
55+
}
56+
57+
header_b64 = _b64url_json(header)
58+
claims_b64 = _b64url_json(claims)
59+
signed_content = f"{header_b64}.{claims_b64}"
60+
61+
# Sign with ES256 — cryptography hashes internally
62+
der_sig = key.sign(signed_content.encode(), ec.ECDSA(SHA256()))
63+
# Convert DER to IEEE P1363 (fixed 64 bytes)
64+
r, s = utils.decode_dss_signature(der_sig)
65+
sig_bytes = r.to_bytes(32, "big") + s.to_bytes(32, "big")
66+
sig_b64 = base64.urlsafe_b64encode(sig_bytes).rstrip(b"=").decode()
67+
68+
return f"{signed_content}.{sig_b64}"
69+
70+
71+
def _b64url_json(obj: dict) -> str:
72+
data = json.dumps(obj, separators=(",", ":")).encode()
73+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
74+
75+
76+
def _b64url_int(n: int, size: int) -> str:
77+
b = n.to_bytes(size, "big")
78+
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()

0 commit comments

Comments
 (0)