Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Ownership of the release machinery. Changes to these paths should be
# reviewed by a maintainer (auto-requests review on PRs touching them).
/.github/ @anshal21
/pyproject.toml @anshal21
/Makefile @anshal21
61 changes: 61 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# FirstOps SDK — dev + release tasks.
#
# Release: make release VERSION=0.2.1
# -> bumps pyproject version, runs tests, commits, tags v0.2.1, pushes the
# tag, and the GitHub `publish.yml` workflow builds + publishes to PyPI.
#
# PyPI versions are immutable: every release needs a NEW version. This target
# keeps the pyproject version and the git tag in lockstep so they can't drift.

.PHONY: help install test build clean release

PYTHON ?= python3
VENV ?= .venv
PY = $(VENV)/bin/python

help:
@echo "make install create venv + install package and dev deps"
@echo "make test run the test suite"
@echo "make build build wheel + sdist into dist/"
@echo "make clean remove build artifacts"
@echo "make release VERSION=X.Y.Z bump version, test, tag, and publish to PyPI"

$(VENV):
$(PYTHON) -m venv $(VENV)

install: $(VENV)
$(PY) -m pip install --upgrade pip
$(PY) -m pip install -e ".[dev]" build

test:
$(PY) -m pytest -q

build: clean
$(PY) -m build

clean:
rm -rf dist build src/*.egg-info *.egg-info

release:
@if [ -z "$(VERSION)" ]; then \
echo "Usage: make release VERSION=X.Y.Z"; exit 1; fi
@echo "$(VERSION)" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+([abrc][0-9]+|\.post[0-9]+|\.dev[0-9]+)?$$' \
|| { echo "VERSION must look like 1.2.3 (got '$(VERSION)')"; exit 1; }
@if [ -n "$$(git status --porcelain)" ]; then \
echo "Working tree not clean — commit or stash your changes first."; \
echo "(A release commit should contain ONLY the version bump.)"; exit 1; fi
@if git rev-parse "v$(VERSION)" >/dev/null 2>&1; then \
echo "Tag v$(VERSION) already exists. Pick a new version (PyPI is immutable)."; exit 1; fi
@echo ">> bumping pyproject.toml -> $(VERSION)"
@$(PY) -c "import re,pathlib; p=pathlib.Path('pyproject.toml'); s=p.read_text(); s,n=re.subn(r'(?m)^version = \".*\"','version = \"$(VERSION)\"',s,count=1); assert n==1,'version line not found'; p.write_text(s)"
@echo ">> running tests"
@$(PY) -m pytest -q
@echo ">> committing + tagging v$(VERSION)"
@git add pyproject.toml
@git commit -m "Release v$(VERSION)"
@git tag -a "v$(VERSION)" -m "firstops v$(VERSION)"
@echo ">> pushing branch + tag (triggers the PyPI publish workflow)"
@git push
@git push origin "v$(VERSION)"
@echo ">> released v$(VERSION). Watch the publish run:"
@echo " gh run watch \$$(gh run list --workflow=publish.yml -L1 --json databaseId -q '.[0].databaseId') --exit-status"
148 changes: 113 additions & 35 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,65 +1,143 @@
# FirstOps Python SDK

Secure MCP proxy sidecar with [DPoP](https://datatracker.ietf.org/doc/html/rfc9449) authentication for AI agents.

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.
Govern what your AI agents do. FirstOps applies identity, policy enforcement, credential brokering, and audit to every **LLM call**, **tool call**, and **MCP call** your agent makes — across LangGraph, the Claude Agent SDK, the OpenAI Agents SDK, and Google ADK, or any custom loop.

## Install

```bash
pip install firstops
pip install "firstops[langgraph]" # LangGraph + LangChain
pip install "firstops[claude]" # Claude Agent SDK
pip install "firstops[openai]" # OpenAI Agents SDK
pip install "firstops[adk]" # Google ADK
pip install "firstops[all]" # all of the above
pip install firstops # core only (management client / custom loops)
```

## Quick Start
Python 3.10+. Core deps are just `cryptography` and `httpx` — your agent framework comes in via the extra you pick.

---

## What FirstOps governs

| Surface | How it's wired | What you get |
|---|---|---|
| **LLM calls** | point the model `base_url` at the local sidecar | inspect prompts/responses, scrub PII, block, audit |
| **Tool calls** | one adapter (or `@firstops.tool`) | block / rewrite args / audit — including framework built-ins |
| **MCP servers** | point the MCP client at the local proxy | server-side policy + **credential brokering** (the agent never holds the upstream token) |

Every action is evaluated by FirstOps and returns `allow` / `deny` / `modify` — your agent logic doesn't change.

## Quick start (LangGraph)

```python
import firstops
from firstops.integrations.langgraph import FirstOpsMiddleware
from langchain.agents import create_agent
from langchain_openai import ChatOpenAI

# Start the proxy sidecar (runs in background thread)
firstops.init(
agent_id="your-agent-id",
fo = firstops.init(
agent_id="<agent-uuid>", # from the FirstOps dashboard
private_key_pem=open("agent-key.pem").read(),
)

# Point your MCP client at localhost:9322 instead of the remote server.
# The proxy handles auth transparently — DPoP proofs, bearer tokens, SSE streaming.
# Route the LLM through FirstOps; wire one middleware to govern every tool call.
llm = ChatOpenAI(model="gpt-4o-mini", base_url=firstops.llm_base_url("openai"), api_key="sk-...")
agent = create_agent(model=llm, tools=[...], middleware=[FirstOpsMiddleware(fo)])

# When done:
firstops.shutdown()
agent.invoke({"messages": [{"role": "user", "content": "..."}]})
```

### What happens
The whole integration is `init()` + a `base_url` swap + one middleware. See [`examples/`](examples/) for runnable agents, including MCP.

1. `firstops.init()` starts a local HTTP proxy on `127.0.0.1:9322`
2. Your MCP client sends requests to `localhost:9322/mcp/...`
3. The proxy signs each request with a DPoP proof (RFC 9449, ES256)
4. The signed request is forwarded to the FirstOps gateway
5. SSE streaming responses are proxied back with URLs rewritten to localhost
## Other harnesses

### Configuration
**Claude Agent SDK** — one `PreToolUse` hook governs every tool (built-ins, MCP, custom):

| Parameter | Default | Description |
|-----------|---------|-------------|
| `agent_id` | *required* | Your agent's principal ID |
| `private_key_pem` | *required* | EC P-256 private key (PEM format) |
| `port` | `9322` | Local proxy port |
| `gateway_url` | `https://api.firstops.ai` | FirstOps gateway URL |
```python
from claude_agent_sdk import query, ClaudeAgentOptions
from firstops.integrations.claude import firstops_hooks

## Requirements
options = ClaudeAgentOptions(hooks=firstops_hooks(fo), permission_mode="bypassPermissions")
async for _ in query(prompt="...", options=options):
pass
```

- Python 3.10+
- Dependencies: `cryptography`, `httpx`
**OpenAI Agents SDK** — a guardrail per tool + the model routed through the sidecar:

## Development
```python
from agents import Agent, function_tool, set_default_openai_client
from firstops.integrations.openai_agents import firstops_tool_input_guardrail
from openai import AsyncOpenAI

```bash
git clone https://github.com/firstops-dev/firstops-python.git
cd firstops-python
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pytest
set_default_openai_client(AsyncOpenAI(base_url=firstops.llm_base_url("openai"), api_key="sk-..."))
guard = firstops_tool_input_guardrail(fo)

@function_tool(tool_input_guardrails=[guard])
def send_email(to: str, body: str) -> str: ...
```

**Google ADK** — one `before_tool_callback` governs every tool (block + rewrite args):

```python
from google.adk.agents import LlmAgent
from firstops.integrations.google_adk import firstops_before_tool_callback

agent = LlmAgent(name="assistant", model=..., tools=[...],
before_tool_callback=firstops_before_tool_callback(fo))
```

**Any framework / custom loop** — the base API:

```python
@firstops.tool # govern any callable: block / scrub args / audit
def send_email(to: str, body: str): ...
```

## MCP servers

Point your MCP client at the local proxy; FirstOps brokers the upstream credentials.

```python
from langchain_mcp_adapters.client import MultiServerMCPClient

mcp = MultiServerMCPClient({"notion": {"url": firstops.mcp_url("<connection-id>"), "transport": "streamable_http"}})
tools = await mcp.get_tools()
```

## Examples

Runnable agents in [`examples/`](examples/) — each governs the LLM and tool calls; the `*_mcp` variants add a Notion MCP server:

- LangGraph — [`langgraph_basic.py`](examples/langgraph_basic.py), [`langgraph_notion_mcp.py`](examples/langgraph_notion_mcp.py)
- Claude Agent SDK — [`claude_sdk_basic.py`](examples/claude_sdk_basic.py), [`claude_sdk_mcp.py`](examples/claude_sdk_mcp.py)
- OpenAI Agents SDK — [`openai_agents_basic.py`](examples/openai_agents_basic.py), [`openai_agents_mcp.py`](examples/openai_agents_mcp.py)
- Google ADK — [`google_adk_basic.py`](examples/google_adk_basic.py)

See [`examples/README.md`](examples/README.md) for the env vars to run them.

## Management client

Provision agents and connections from your backend:

```python
from firstops import FirstOps

admin = FirstOps(api_key="fo_key_...")
agent = admin.agents.create(name="research-bot") # -> id + private_key (shown once)
admin.connections.register(principal_id=agent.id, name="slack", upstream_url="https://mcp.slack.com/sse")
```

## How it works

`firstops.init()` starts a local sidecar and establishes the agent's identity (a DPoP-bound principal — RFC 9449). Tool and LLM actions are forwarded to the FirstOps gateway, which evaluates them against your policies and returns the verdict; MCP and LLM traffic flow through the sidecar with credentials brokered. Enforcement fails open on infrastructure errors; authentication fails closed.

## Documentation

- [Docs home](https://firstops.dev/docs)
- Guides: [LangChain / LangGraph](https://firstops.dev/docs/guides/langchain) · [Claude Agent SDK](https://firstops.dev/docs/guides/claude-sdk) · [OpenAI Agents SDK](https://firstops.dev/docs/guides/openai-agents) · [Google ADK](https://firstops.dev/docs/guides/google-adk)
- Concepts: [Identity](https://firstops.dev/docs/concepts/identity) · [Enforcement](https://firstops.dev/docs/concepts/enforcement) · [Connections](https://firstops.dev/docs/concepts/connections)
- [Repository](https://github.com/firstops-dev/firstops-python)

## License

MIT
44 changes: 44 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# FirstOps SDK — Examples

Runnable agents that govern every LLM call, tool call, and MCP call through
FirstOps.

## Setup

```bash
python -m venv .venv && source .venv/bin/activate
pip install -e .. # the FirstOps SDK (this repo)
pip install "langchain>=1.0" langgraph langchain-openai langchain-mcp-adapters openai
```

## Config (env vars)

| Var | Meaning |
|-----|---------|
| `FO_AGENT_ID` | Agent principal ID (UUID) from `client.agents.create(...)` |
| `FO_PRIVATE_KEY_PATH` | Path to the agent's EC P-256 private-key PEM |
| `FO_GATEWAY` | FirstOps gateway base URL (default `https://api.firstops.dev`) |
| `FO_PORT` | Local sidecar port (default `9322`) |
| `OPENAI_API_KEY` | OpenAI key — passes through the sidecar to OpenAI, never stored |
| `FO_MCP_CONNECTION_ID` | (MCP example) a registered MCP connection ID for the agent |

## Examples

- **`langgraph_basic.py`** — a LangGraph agent with two local tools and the LLM
routed through the sidecar chain-link. Exercises tool governance + LLM
governance.
- **`langgraph_notion_mcp.py`** — adds a Notion MCP server (via the sidecar's
MCP proxy) and a local `write_to_file` tool, then asks the agent to fetch
customer info from Notion and write it to a local file. Exercises **MCP +
local tool** governance together.

```bash
FO_AGENT_ID=... FO_PRIVATE_KEY_PATH=... OPENAI_API_KEY=... \
python langgraph_basic.py

FO_AGENT_ID=... FO_PRIVATE_KEY_PATH=... OPENAI_API_KEY=... \
FO_MCP_CONNECTION_ID=... python langgraph_notion_mcp.py
```

Each run prints a `[GOVERN]` line for every governed action (channel, tool,
decision), so you can see exactly what FirstOps evaluated.
32 changes: 32 additions & 0 deletions examples/_shared.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Shared boilerplate for the example agents: config + a governance tracer."""

import os


def load_config() -> dict:
agent_id = os.environ["FO_AGENT_ID"].strip()
key_path = os.environ["FO_PRIVATE_KEY_PATH"]
with open(key_path) as f:
key_pem = f.read()
return {
"agent_id": agent_id,
"key_pem": key_pem,
"gateway": os.environ.get("FO_GATEWAY", "https://api.firstops.dev"),
"port": int(os.environ.get("FO_PORT", "9322")),
}


def trace(fo) -> None:
"""Wrap the enforcement client so every governed action prints."""
orig = fo.enforcement.evaluate

def traced(event):
d = orig(event)
print(
f" [GOVERN] {event.channel:<13} {event.event_type:<14} "
f"{event.tool_name:<32} -> {d.action}"
+ (f" (FAILED_OPEN: {d.reason})" if d.failed_open else "")
)
return d

fo.enforcement.evaluate = traced
66 changes: 66 additions & 0 deletions examples/claude_sdk_basic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Claude Agent SDK agent governed by FirstOps.

The Claude Agent SDK runs tools (Bash, Write, Read, MCP) inside the Claude Code
subprocess. FirstOps governs each one via a single PreToolUse hook — block,
rewrite args (updatedInput), or allow — the daemon model, in-process.

`permission_mode="bypassPermissions"` makes the FirstOps hook the sole gate:
a hook `deny` still blocks; everything else flows. Run with the env vars in
README.md (no OpenAI key needed — Claude uses your local Claude Code auth).
"""

import asyncio
from pathlib import Path

import firstops
from claude_agent_sdk import (
AssistantMessage,
ClaudeAgentOptions,
ResultMessage,
TextBlock,
ToolUseBlock,
query,
)
from firstops.integrations.claude import firstops_hooks

from _shared import load_config, trace

WORKDIR = Path(__file__).parent / "claude_work"


async def main():
cfg = load_config()
fo = firstops.init(
cfg["agent_id"], cfg["key_pem"], gateway_url=cfg["gateway"], port=cfg["port"]
)
trace(fo)
WORKDIR.mkdir(exist_ok=True)
try:
options = ClaudeAgentOptions(
hooks=firstops_hooks(fo), # ← every tool call governed by FirstOps
allowed_tools=["Bash", "Write", "Read"],
permission_mode="bypassPermissions",
cwd=str(WORKDIR),
)
prompt = (
"Create a file named greeting.txt containing exactly "
"'Hello from a FirstOps-governed Claude agent'. "
"Then run a bash command to print today's date. "
"Finally, read greeting.txt back and report its contents."
)
print("\n>>> running Claude agent (tools governed via PreToolUse hook)\n")
async for message in query(prompt=prompt, options=options):
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, TextBlock) and block.text.strip():
print(f" [CLAUDE] {block.text.strip()[:160]}")
elif isinstance(block, ToolUseBlock):
print(f" [TOOL-USE] {block.name} {block.input}")
elif isinstance(message, ResultMessage):
print(f"\n>>> result:\n{getattr(message, 'result', message)}")
finally:
firstops.shutdown()


if __name__ == "__main__":
asyncio.run(main())
Loading
Loading