Skip to content

Commit 640c8cd

Browse files
committed
Release V2 of SDK
1 parent 293d0fc commit 640c8cd

46 files changed

Lines changed: 6739 additions & 64 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 197 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,161 @@
11
# FirstOps Python SDK
22

3-
Secure MCP proxy sidecar with [DPoP](https://datatracker.ietf.org/doc/html/rfc9449) authentication for AI agents.
3+
The FirstOps SDK has two halves:
44

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.
5+
1. **Management client** (`FirstOps`) — programmatically create agents, register MCP connections, and manage their lifecycle from your backend. Authenticates with a tenant-scoped API key.
6+
2. **Runtime proxy** (`firstops.init`) — a lightweight in-process sidecar that transparently signs every MCP request with a [DPoP](https://datatracker.ietf.org/doc/html/rfc9449) proof. Runs inside the agent process.
7+
8+
The two halves are used at different points in an agent's lifecycle. The management client runs in your **platform code** (the backend that provisions agents). The runtime proxy runs inside the **agent itself** (the process that calls MCP tools).
69

710
## Install
811

912
```bash
1013
pip install firstops
1114
```
1215

13-
## Quick Start
16+
## Requirements
17+
18+
- Python 3.10+
19+
- Dependencies: `cryptography`, `httpx`
20+
21+
---
22+
23+
## 1. Management Client — Provisioning Agents
24+
25+
Use this in your platform's backend code to create agents and wire up their MCP connections on demand.
26+
27+
### Get an API key
28+
29+
1. Log in to the FirstOps dashboard as an admin.
30+
2. Go to **Settings → API Keys** and create a key with the scopes you need:
31+
- `agents:write` — create and delete agent principals
32+
- `agents:read` — list and get agents
33+
- `connections:write` — register and delete MCP connections
34+
- `connections:read` — list connections
35+
3. Copy the raw key (starts with `fo_key_`). It is shown **once** — store it in your secrets manager.
36+
37+
### Quick Start
38+
39+
```python
40+
from firstops import FirstOps
41+
42+
# Initialize the management client
43+
client = FirstOps(api_key="fo_key_...")
44+
45+
# 1. Create an agent (returns principal ID, token, and private key)
46+
agent = client.agents.create(name="research-bot-for-alice")
47+
print(f"Agent ID: {agent.id}")
48+
print(f"Agent token: {agent.token}") # fo_agent_<id> — used in Authorization header
49+
print(f"Private key: {agent.private_key}") # PEM — shown once, save it securely
50+
51+
# 2. Register MCP connections for the agent
52+
slack_conn = client.connections.register(
53+
principal_id=agent.id,
54+
name="slack",
55+
upstream_url="https://mcp.slack.com/sse",
56+
)
57+
58+
gdrive_conn = client.connections.register(
59+
principal_id=agent.id,
60+
name="google-drive",
61+
upstream_url="https://mcp.google.com/drive/sse",
62+
auth_type="oauth2",
63+
)
64+
65+
# 3. List the agent's connections
66+
for conn in client.connections.list(principal_id=agent.id):
67+
print(f"{conn.name}{conn.status}")
68+
69+
# 4. Remove a connection when the user no longer needs it
70+
client.connections.delete(slack_conn.id)
71+
72+
# 5. Delete the agent when the user deletes their instance
73+
client.agents.delete(agent.id)
74+
```
75+
76+
### Context Manager
77+
78+
`FirstOps` is also usable as a context manager for automatic connection cleanup:
79+
80+
```python
81+
with FirstOps(api_key="fo_key_...") as client:
82+
agents = client.agents.list()
83+
```
84+
85+
### API Reference
86+
87+
#### `FirstOps(api_key, base_url="https://api.firstops.ai", timeout=30.0)`
88+
89+
The top-level management client.
90+
91+
| Parameter | Default | Description |
92+
|-----------|---------|-------------|
93+
| `api_key` | *required* | Tenant-scoped API key (must start with `fo_key_`) |
94+
| `base_url` | `https://api.firstops.ai` | FirstOps API base URL |
95+
| `timeout` | `30.0` | HTTP timeout in seconds |
96+
97+
#### `client.agents`
98+
99+
| Method | Required Scope | Returns |
100+
|--------|----------------|---------|
101+
| `create(name: str)` | `agents:write` | `Agent` (with `private_key`) |
102+
| `list()` | `agents:read` | `list[Agent]` |
103+
| `get(agent_id: str)` | `agents:read` | `Agent` |
104+
| `delete(agent_id: str)` | `agents:write` | `None` |
105+
106+
**Note:** `agent.private_key` is only populated on `create()`. It is never returned again — store it alongside your agent record at creation time.
107+
108+
#### `client.connections`
109+
110+
| Method | Required Scope | Returns |
111+
|--------|----------------|---------|
112+
| `register(principal_id, name, upstream_url, ...)` | `connections:write` | `Connection` |
113+
| `list(principal_id=None)` | `connections:read` | `list[Connection]` |
114+
| `delete(connection_id: str)` | `connections:write` | `None` |
115+
116+
Full signature for `register`:
117+
118+
```python
119+
client.connections.register(
120+
principal_id="pr_...", # required — the agent's principal ID
121+
name="slack", # required — display name
122+
upstream_url="https://mcp.slack.com/sse", # required — remote MCP server URL
123+
auth_type="", # optional — "oauth2", "bearer", etc.
124+
transport_type="", # optional — "sse" or empty for auto-detect
125+
upstream_headers=None, # optional — dict of headers to forward
126+
upstream_query_params=None, # optional — dict of query params
127+
source="sdk", # optional — audit label
128+
)
129+
```
130+
131+
### Error Handling
132+
133+
All API errors raise `FirstOpsError`:
134+
135+
```python
136+
from firstops import FirstOps, FirstOpsError
137+
138+
try:
139+
client.agents.delete("pr_does_not_exist")
140+
except FirstOpsError as e:
141+
print(f"Error {e.status_code}: {e.message}")
142+
```
143+
144+
---
145+
146+
## 2. Runtime Proxy — Securing MCP Calls Inside an Agent
147+
148+
Use this inside the agent process itself. It starts a local HTTP proxy that transparently adds DPoP-signed authentication headers to every MCP request your agent makes — no changes to your agent code required.
149+
150+
### Quick Start
14151

15152
```python
16153
import firstops
17154

18155
# Start the proxy sidecar (runs in background thread)
19156
firstops.init(
20-
agent_id="your-agent-id",
21-
private_key_pem=open("agent-key.pem").read(),
157+
agent_id="your-agent-id", # from client.agents.create(...).id
158+
private_key_pem=open("agent-key.pem").read(), # from client.agents.create(...).private_key
22159
)
23160

24161
# Point your MCP client at localhost:9322 instead of the remote server.
@@ -40,15 +177,65 @@ firstops.shutdown()
40177

41178
| Parameter | Default | Description |
42179
|-----------|---------|-------------|
43-
| `agent_id` | *required* | Your agent's principal ID |
44-
| `private_key_pem` | *required* | EC P-256 private key (PEM format) |
180+
| `agent_id` | *required* | Agent's principal ID (without `fo_agent_` prefix) |
181+
| `private_key_pem` | *required* | EC P-256 private key in PEM format |
45182
| `port` | `9322` | Local proxy port |
46183
| `gateway_url` | `https://api.firstops.ai` | FirstOps gateway URL |
47184

48-
## Requirements
185+
---
49186

50-
- Python 3.10+
51-
- Dependencies: `cryptography`, `httpx`
187+
## End-to-End Example: Dynamic Agent Platform
188+
189+
Here's how the two halves fit together in a typical "SaaS that offers AI agents" platform:
190+
191+
```python
192+
# ─── Platform backend (your FastAPI/Django service) ─────────────
193+
from firstops import FirstOps
194+
195+
firstops = FirstOps(api_key=os.environ["FIRSTOPS_API_KEY"])
196+
197+
@app.post("/users/{user_id}/agents")
198+
def create_user_agent(user_id: str, config: dict):
199+
# Create a FirstOps agent identity for this end-user's instance
200+
agent = firstops.agents.create(name=f"research-bot-{user_id}")
201+
202+
# Store the agent credentials alongside the user's record
203+
db.save_agent(
204+
user_id=user_id,
205+
agent_id=agent.id,
206+
private_key=agent.private_key, # encrypt this at rest
207+
)
208+
209+
# Wire up the tools the user selected
210+
for tool in config["selected_tools"]:
211+
firstops.connections.register(
212+
principal_id=agent.id,
213+
name=tool["name"],
214+
upstream_url=tool["url"],
215+
)
216+
217+
return {"agent_id": agent.id}
218+
219+
@app.delete("/users/{user_id}/agents/{agent_id}")
220+
def delete_user_agent(user_id: str, agent_id: str):
221+
firstops.agents.delete(agent_id) # cascades to connections
222+
db.delete_agent(agent_id)
223+
224+
225+
# ─── Agent runtime (the worker process that actually runs the agent) ───
226+
import firstops as fo_runtime
227+
228+
def run_agent_task(agent_id: str, private_key: str, task: str):
229+
fo_runtime.init(agent_id=agent_id, private_key_pem=private_key)
230+
try:
231+
# Your MCP-using agent logic — point MCP clients at 127.0.0.1:9322
232+
mcp_client = MCPClient(base_url="http://127.0.0.1:9322")
233+
return mcp_client.run(task)
234+
finally:
235+
fo_runtime.shutdown()
236+
```
237+
238+
---
52239

53240
## Development
54241

examples/README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# FirstOps SDK — Examples
2+
3+
Runnable agents that govern every LLM call, tool call, and MCP call through
4+
FirstOps.
5+
6+
## Setup
7+
8+
```bash
9+
python -m venv .venv && source .venv/bin/activate
10+
pip install -e .. # the FirstOps SDK (this repo)
11+
pip install "langchain>=1.0" langgraph langchain-openai langchain-mcp-adapters openai
12+
```
13+
14+
## Config (env vars)
15+
16+
| Var | Meaning |
17+
|-----|---------|
18+
| `FO_AGENT_ID` | Agent principal ID (UUID) from `client.agents.create(...)` |
19+
| `FO_PRIVATE_KEY_PATH` | Path to the agent's EC P-256 private-key PEM |
20+
| `FO_GATEWAY` | FirstOps gateway base URL (default `https://api.firstops.dev`) |
21+
| `FO_PORT` | Local sidecar port (default `9322`) |
22+
| `OPENAI_API_KEY` | OpenAI key — passes through the sidecar to OpenAI, never stored |
23+
| `FO_MCP_CONNECTION_ID` | (MCP example) a registered MCP connection ID for the agent |
24+
25+
## Examples
26+
27+
- **`langgraph_basic.py`** — a LangGraph agent with two local tools and the LLM
28+
routed through the sidecar chain-link. Exercises tool governance + LLM
29+
governance.
30+
- **`langgraph_notion_mcp.py`** — adds a Notion MCP server (via the sidecar's
31+
MCP proxy) and a local `write_to_file` tool, then asks the agent to fetch
32+
customer info from Notion and write it to a local file. Exercises **MCP +
33+
local tool** governance together.
34+
35+
```bash
36+
FO_AGENT_ID=... FO_PRIVATE_KEY_PATH=... OPENAI_API_KEY=... \
37+
python langgraph_basic.py
38+
39+
FO_AGENT_ID=... FO_PRIVATE_KEY_PATH=... OPENAI_API_KEY=... \
40+
FO_MCP_CONNECTION_ID=... python langgraph_notion_mcp.py
41+
```
42+
43+
Each run prints a `[GOVERN]` line for every governed action (channel, tool,
44+
decision), so you can see exactly what FirstOps evaluated.

examples/_shared.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Shared boilerplate for the example agents: config + a governance tracer."""
2+
3+
import os
4+
5+
6+
def load_config() -> dict:
7+
agent_id = os.environ["FO_AGENT_ID"].strip()
8+
key_path = os.environ["FO_PRIVATE_KEY_PATH"]
9+
with open(key_path) as f:
10+
key_pem = f.read()
11+
return {
12+
"agent_id": agent_id,
13+
"key_pem": key_pem,
14+
"gateway": os.environ.get("FO_GATEWAY", "https://api.firstops.dev"),
15+
"port": int(os.environ.get("FO_PORT", "9322")),
16+
}
17+
18+
19+
def trace(fo) -> None:
20+
"""Wrap the enforcement client so every governed action prints."""
21+
orig = fo.enforcement.evaluate
22+
23+
def traced(event):
24+
d = orig(event)
25+
print(
26+
f" [GOVERN] {event.channel:<13} {event.event_type:<14} "
27+
f"{event.tool_name:<32} -> {d.action}"
28+
+ (f" (FAILED_OPEN: {d.reason})" if d.failed_open else "")
29+
)
30+
return d
31+
32+
fo.enforcement.evaluate = traced

examples/claude_sdk_basic.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""Claude Agent SDK agent governed by FirstOps.
2+
3+
The Claude Agent SDK runs tools (Bash, Write, Read, MCP) inside the Claude Code
4+
subprocess. FirstOps governs each one via a single PreToolUse hook — block,
5+
rewrite args (updatedInput), or allow — the daemon model, in-process.
6+
7+
`permission_mode="bypassPermissions"` makes the FirstOps hook the sole gate:
8+
a hook `deny` still blocks; everything else flows. Run with the env vars in
9+
README.md (no OpenAI key needed — Claude uses your local Claude Code auth).
10+
"""
11+
12+
import asyncio
13+
from pathlib import Path
14+
15+
import firstops
16+
from claude_agent_sdk import (
17+
AssistantMessage,
18+
ClaudeAgentOptions,
19+
ResultMessage,
20+
TextBlock,
21+
ToolUseBlock,
22+
query,
23+
)
24+
from firstops.integrations.claude import firstops_hooks
25+
26+
from _shared import load_config, trace
27+
28+
WORKDIR = Path(__file__).parent / "claude_work"
29+
30+
31+
async def main():
32+
cfg = load_config()
33+
fo = firstops.init(
34+
cfg["agent_id"], cfg["key_pem"], gateway_url=cfg["gateway"], port=cfg["port"]
35+
)
36+
trace(fo)
37+
WORKDIR.mkdir(exist_ok=True)
38+
try:
39+
options = ClaudeAgentOptions(
40+
hooks=firstops_hooks(fo), # ← every tool call governed by FirstOps
41+
allowed_tools=["Bash", "Write", "Read"],
42+
permission_mode="bypassPermissions",
43+
cwd=str(WORKDIR),
44+
)
45+
prompt = (
46+
"Create a file named greeting.txt containing exactly "
47+
"'Hello from a FirstOps-governed Claude agent'. "
48+
"Then run a bash command to print today's date. "
49+
"Finally, read greeting.txt back and report its contents."
50+
)
51+
print("\n>>> running Claude agent (tools governed via PreToolUse hook)\n")
52+
async for message in query(prompt=prompt, options=options):
53+
if isinstance(message, AssistantMessage):
54+
for block in message.content:
55+
if isinstance(block, TextBlock) and block.text.strip():
56+
print(f" [CLAUDE] {block.text.strip()[:160]}")
57+
elif isinstance(block, ToolUseBlock):
58+
print(f" [TOOL-USE] {block.name} {block.input}")
59+
elif isinstance(message, ResultMessage):
60+
print(f"\n>>> result:\n{getattr(message, 'result', message)}")
61+
finally:
62+
firstops.shutdown()
63+
64+
65+
if __name__ == "__main__":
66+
asyncio.run(main())

0 commit comments

Comments
 (0)