firstops 0.2.0__tar.gz → 0.3.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 (60) hide show
  1. firstops-0.3.0/.github/CODEOWNERS +5 -0
  2. firstops-0.3.0/Makefile +61 -0
  3. {firstops-0.2.0 → firstops-0.3.0}/PKG-INFO +41 -9
  4. {firstops-0.2.0 → firstops-0.3.0}/README.md +35 -6
  5. firstops-0.3.0/examples/google_adk_basic.py +73 -0
  6. {firstops-0.2.0 → firstops-0.3.0}/pyproject.toml +7 -3
  7. {firstops-0.2.0 → firstops-0.3.0}/src/firstops/integrations/_common.py +1 -0
  8. firstops-0.3.0/src/firstops/integrations/google_adk.py +55 -0
  9. {firstops-0.2.0 → firstops-0.3.0}/src/firstops/proxy.py +47 -11
  10. {firstops-0.2.0 → firstops-0.3.0}/tests/test_integrations.py +47 -0
  11. {firstops-0.2.0 → firstops-0.3.0}/.github/workflows/publish.yml +0 -0
  12. {firstops-0.2.0 → firstops-0.3.0}/.github/workflows/test.yml +0 -0
  13. {firstops-0.2.0 → firstops-0.3.0}/.gitignore +0 -0
  14. {firstops-0.2.0 → firstops-0.3.0}/LICENSE +0 -0
  15. {firstops-0.2.0 → firstops-0.3.0}/examples/README.md +0 -0
  16. {firstops-0.2.0 → firstops-0.3.0}/examples/_shared.py +0 -0
  17. {firstops-0.2.0 → firstops-0.3.0}/examples/claude_sdk_basic.py +0 -0
  18. {firstops-0.2.0 → firstops-0.3.0}/examples/claude_sdk_mcp.py +0 -0
  19. {firstops-0.2.0 → firstops-0.3.0}/examples/customers_openai.txt +0 -0
  20. {firstops-0.2.0 → firstops-0.3.0}/examples/langgraph_basic.py +0 -0
  21. {firstops-0.2.0 → firstops-0.3.0}/examples/langgraph_notion_mcp.py +0 -0
  22. {firstops-0.2.0 → firstops-0.3.0}/examples/openai_agents_basic.py +0 -0
  23. {firstops-0.2.0 → firstops-0.3.0}/examples/openai_agents_mcp.py +0 -0
  24. {firstops-0.2.0 → firstops-0.3.0}/examples/out/customers.txt +0 -0
  25. {firstops-0.2.0 → firstops-0.3.0}/src/firstops/__init__.py +0 -0
  26. {firstops-0.2.0 → firstops-0.3.0}/src/firstops/_identity.py +0 -0
  27. {firstops-0.2.0 → firstops-0.3.0}/src/firstops/_runtime.py +0 -0
  28. {firstops-0.2.0 → firstops-0.3.0}/src/firstops/channels.py +0 -0
  29. {firstops-0.2.0 → firstops-0.3.0}/src/firstops/client.py +0 -0
  30. {firstops-0.2.0 → firstops-0.3.0}/src/firstops/coverage.py +0 -0
  31. {firstops-0.2.0 → firstops-0.3.0}/src/firstops/dpop.py +0 -0
  32. {firstops-0.2.0 → firstops-0.3.0}/src/firstops/enforcement.py +0 -0
  33. {firstops-0.2.0 → firstops-0.3.0}/src/firstops/events.py +0 -0
  34. {firstops-0.2.0 → firstops-0.3.0}/src/firstops/integrations/__init__.py +0 -0
  35. {firstops-0.2.0 → firstops-0.3.0}/src/firstops/integrations/claude.py +0 -0
  36. {firstops-0.2.0 → firstops-0.3.0}/src/firstops/integrations/langgraph.py +0 -0
  37. {firstops-0.2.0 → firstops-0.3.0}/src/firstops/integrations/openai_agents.py +0 -0
  38. {firstops-0.2.0 → firstops-0.3.0}/src/firstops/llm.py +0 -0
  39. {firstops-0.2.0 → firstops-0.3.0}/src/firstops/tools.py +0 -0
  40. {firstops-0.2.0 → firstops-0.3.0}/tests/__init__.py +0 -0
  41. {firstops-0.2.0 → firstops-0.3.0}/tests/test_channels.py +0 -0
  42. {firstops-0.2.0 → firstops-0.3.0}/tests/test_contract_parity.py +0 -0
  43. {firstops-0.2.0 → firstops-0.3.0}/tests/test_dpop.py +0 -0
  44. {firstops-0.2.0 → firstops-0.3.0}/tests/test_enforcement.py +0 -0
  45. {firstops-0.2.0 → firstops-0.3.0}/tests/test_enforcement_adversarial.py +0 -0
  46. {firstops-0.2.0 → firstops-0.3.0}/tests/test_events.py +0 -0
  47. {firstops-0.2.0 → firstops-0.3.0}/tests/test_events_adversarial.py +0 -0
  48. {firstops-0.2.0 → firstops-0.3.0}/tests/test_identity_adversarial.py +0 -0
  49. {firstops-0.2.0 → firstops-0.3.0}/tests/test_integrations_bughunt.py +0 -0
  50. {firstops-0.2.0 → firstops-0.3.0}/tests/test_llm_route.py +0 -0
  51. {firstops-0.2.0 → firstops-0.3.0}/tests/test_llm_route_adversarial.py +0 -0
  52. {firstops-0.2.0 → firstops-0.3.0}/tests/test_m3_bughunt.py +0 -0
  53. {firstops-0.2.0 → firstops-0.3.0}/tests/test_m3_scrub_coverage.py +0 -0
  54. {firstops-0.2.0 → firstops-0.3.0}/tests/test_proxy.py +0 -0
  55. {firstops-0.2.0 → firstops-0.3.0}/tests/test_proxy_regression.py +0 -0
  56. {firstops-0.2.0 → firstops-0.3.0}/tests/test_proxy_router_regression.py +0 -0
  57. {firstops-0.2.0 → firstops-0.3.0}/tests/test_runtime.py +0 -0
  58. {firstops-0.2.0 → firstops-0.3.0}/tests/test_runtime_adversarial.py +0 -0
  59. {firstops-0.2.0 → firstops-0.3.0}/tests/test_tools.py +0 -0
  60. {firstops-0.2.0 → firstops-0.3.0}/tests/test_tools_adversarial.py +0 -0
@@ -0,0 +1,5 @@
1
+ # Ownership of the release machinery. Changes to these paths should be
2
+ # reviewed by a maintainer (auto-requests review on PRs touching them).
3
+ /.github/ @anshal21
4
+ /pyproject.toml @anshal21
5
+ /Makefile @anshal21
@@ -0,0 +1,61 @@
1
+ # FirstOps SDK — dev + release tasks.
2
+ #
3
+ # Release: make release VERSION=0.2.1
4
+ # -> bumps pyproject version, runs tests, commits, tags v0.2.1, pushes the
5
+ # tag, and the GitHub `publish.yml` workflow builds + publishes to PyPI.
6
+ #
7
+ # PyPI versions are immutable: every release needs a NEW version. This target
8
+ # keeps the pyproject version and the git tag in lockstep so they can't drift.
9
+
10
+ .PHONY: help install test build clean release
11
+
12
+ PYTHON ?= python3
13
+ VENV ?= .venv
14
+ PY = $(VENV)/bin/python
15
+
16
+ help:
17
+ @echo "make install create venv + install package and dev deps"
18
+ @echo "make test run the test suite"
19
+ @echo "make build build wheel + sdist into dist/"
20
+ @echo "make clean remove build artifacts"
21
+ @echo "make release VERSION=X.Y.Z bump version, test, tag, and publish to PyPI"
22
+
23
+ $(VENV):
24
+ $(PYTHON) -m venv $(VENV)
25
+
26
+ install: $(VENV)
27
+ $(PY) -m pip install --upgrade pip
28
+ $(PY) -m pip install -e ".[dev]" build
29
+
30
+ test:
31
+ $(PY) -m pytest -q
32
+
33
+ build: clean
34
+ $(PY) -m build
35
+
36
+ clean:
37
+ rm -rf dist build src/*.egg-info *.egg-info
38
+
39
+ release:
40
+ @if [ -z "$(VERSION)" ]; then \
41
+ echo "Usage: make release VERSION=X.Y.Z"; exit 1; fi
42
+ @echo "$(VERSION)" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+([abrc][0-9]+|\.post[0-9]+|\.dev[0-9]+)?$$' \
43
+ || { echo "VERSION must look like 1.2.3 (got '$(VERSION)')"; exit 1; }
44
+ @if [ -n "$$(git status --porcelain)" ]; then \
45
+ echo "Working tree not clean — commit or stash your changes first."; \
46
+ echo "(A release commit should contain ONLY the version bump.)"; exit 1; fi
47
+ @if git rev-parse "v$(VERSION)" >/dev/null 2>&1; then \
48
+ echo "Tag v$(VERSION) already exists. Pick a new version (PyPI is immutable)."; exit 1; fi
49
+ @echo ">> bumping pyproject.toml -> $(VERSION)"
50
+ @$(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)"
51
+ @echo ">> running tests"
52
+ @$(PY) -m pytest -q
53
+ @echo ">> committing + tagging v$(VERSION)"
54
+ @git add pyproject.toml
55
+ @git commit -m "Release v$(VERSION)"
56
+ @git tag -a "v$(VERSION)" -m "firstops v$(VERSION)"
57
+ @echo ">> pushing branch + tag (triggers the PyPI publish workflow)"
58
+ @git push
59
+ @git push origin "v$(VERSION)"
60
+ @echo ">> released v$(VERSION). Watch the publish run:"
61
+ @echo " gh run watch \$$(gh run list --workflow=publish.yml -L1 --json databaseId -q '.[0].databaseId') --exit-status"
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: firstops
3
- Version: 0.2.0
4
- Summary: Govern MCP, tool calls, and LLM traffic for AI agents — across LangGraph, Claude Agent SDK, and OpenAI Agents.
3
+ Version: 0.3.0
4
+ Summary: Govern MCP, tool calls, and LLM traffic for AI agents — across LangGraph, Claude Agent SDK, OpenAI Agents, and Google ADK.
5
5
  Project-URL: Homepage, https://firstops.dev
6
6
  Project-URL: Documentation, https://github.com/firstops-dev/firstops-python
7
7
  Project-URL: Repository, https://github.com/firstops-dev/firstops-python
@@ -9,7 +9,7 @@ Project-URL: Issues, https://github.com/firstops-dev/firstops-python/issues
9
9
  Author-email: FirstOps <dev@firstops.dev>
10
10
  License-Expression: MIT
11
11
  License-File: LICENSE
12
- Keywords: agent,claude,dpop,governance,guardrails,langchain,langgraph,llm,mcp,openai-agents,security
12
+ Keywords: agent,claude,dpop,google-adk,governance,guardrails,langchain,langgraph,llm,mcp,openai-agents,security
13
13
  Classifier: Development Status :: 3 - Alpha
14
14
  Classifier: Intended Audience :: Developers
15
15
  Classifier: License :: OSI Approved :: MIT License
@@ -23,8 +23,11 @@ Classifier: Topic :: Software Development :: Libraries
23
23
  Requires-Python: >=3.10
24
24
  Requires-Dist: cryptography>=42.0
25
25
  Requires-Dist: httpx>=0.27
26
+ Provides-Extra: adk
27
+ Requires-Dist: google-adk[extensions]; extra == 'adk'
26
28
  Provides-Extra: all
27
29
  Requires-Dist: claude-agent-sdk; extra == 'all'
30
+ Requires-Dist: google-adk[extensions]; extra == 'all'
28
31
  Requires-Dist: langchain-mcp-adapters; extra == 'all'
29
32
  Requires-Dist: langchain-openai; extra == 'all'
30
33
  Requires-Dist: langchain>=1.0; extra == 'all'
@@ -46,14 +49,20 @@ Description-Content-Type: text/markdown
46
49
 
47
50
  # FirstOps Python SDK
48
51
 
49
- 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, and the OpenAI Agents SDK, or any custom loop.
52
+ 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.
53
+
54
+ ## Install
50
55
 
51
56
  ```bash
52
- pip install "firstops[langgraph]" # or [claude], [openai], [all]
57
+ pip install "firstops[langgraph]" # LangGraph + LangChain
58
+ pip install "firstops[claude]" # Claude Agent SDK
59
+ pip install "firstops[openai]" # OpenAI Agents SDK
60
+ pip install "firstops[adk]" # Google ADK
61
+ pip install "firstops[all]" # all of the above
62
+ pip install firstops # core only (management client / custom loops)
53
63
  ```
54
64
 
55
- - **Python 3.10+**
56
- - Core deps: `cryptography`, `httpx`. Your agent framework comes in via the extra you pick.
65
+ Python 3.10+. Core deps are just `cryptography` and `httpx` — your agent framework comes in via the extra you pick.
57
66
 
58
67
  ---
59
68
 
@@ -116,6 +125,16 @@ guard = firstops_tool_input_guardrail(fo)
116
125
  def send_email(to: str, body: str) -> str: ...
117
126
  ```
118
127
 
128
+ **Google ADK** — one `before_tool_callback` governs every tool (block + rewrite args):
129
+
130
+ ```python
131
+ from google.adk.agents import LlmAgent
132
+ from firstops.integrations.google_adk import firstops_before_tool_callback
133
+
134
+ agent = LlmAgent(name="assistant", model=..., tools=[...],
135
+ before_tool_callback=firstops_before_tool_callback(fo))
136
+ ```
137
+
119
138
  **Any framework / custom loop** — the base API:
120
139
 
121
140
  ```python
@@ -134,6 +153,17 @@ mcp = MultiServerMCPClient({"notion": {"url": firstops.mcp_url("<connection-id>"
134
153
  tools = await mcp.get_tools()
135
154
  ```
136
155
 
156
+ ## Examples
157
+
158
+ Runnable agents in [`examples/`](examples/) — each governs the LLM and tool calls; the `*_mcp` variants add a Notion MCP server:
159
+
160
+ - LangGraph — [`langgraph_basic.py`](examples/langgraph_basic.py), [`langgraph_notion_mcp.py`](examples/langgraph_notion_mcp.py)
161
+ - Claude Agent SDK — [`claude_sdk_basic.py`](examples/claude_sdk_basic.py), [`claude_sdk_mcp.py`](examples/claude_sdk_mcp.py)
162
+ - OpenAI Agents SDK — [`openai_agents_basic.py`](examples/openai_agents_basic.py), [`openai_agents_mcp.py`](examples/openai_agents_mcp.py)
163
+ - Google ADK — [`google_adk_basic.py`](examples/google_adk_basic.py)
164
+
165
+ See [`examples/README.md`](examples/README.md) for the env vars to run them.
166
+
137
167
  ## Management client
138
168
 
139
169
  Provision agents and connections from your backend:
@@ -152,8 +182,10 @@ admin.connections.register(principal_id=agent.id, name="slack", upstream_url="ht
152
182
 
153
183
  ## Documentation
154
184
 
155
- - Guides: <https://firstops.dev/docs>
156
- - Repository: <https://github.com/firstops-dev/firstops-python>
185
+ - [Docs home](https://firstops.dev/docs)
186
+ - 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)
187
+ - Concepts: [Identity](https://firstops.dev/docs/concepts/identity) · [Enforcement](https://firstops.dev/docs/concepts/enforcement) · [Connections](https://firstops.dev/docs/concepts/connections)
188
+ - [Repository](https://github.com/firstops-dev/firstops-python)
157
189
 
158
190
  ## License
159
191
 
@@ -1,13 +1,19 @@
1
1
  # FirstOps Python SDK
2
2
 
3
- 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, and the OpenAI Agents SDK, or any custom loop.
3
+ 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.
4
+
5
+ ## Install
4
6
 
5
7
  ```bash
6
- pip install "firstops[langgraph]" # or [claude], [openai], [all]
8
+ pip install "firstops[langgraph]" # LangGraph + LangChain
9
+ pip install "firstops[claude]" # Claude Agent SDK
10
+ pip install "firstops[openai]" # OpenAI Agents SDK
11
+ pip install "firstops[adk]" # Google ADK
12
+ pip install "firstops[all]" # all of the above
13
+ pip install firstops # core only (management client / custom loops)
7
14
  ```
8
15
 
9
- - **Python 3.10+**
10
- - Core deps: `cryptography`, `httpx`. Your agent framework comes in via the extra you pick.
16
+ Python 3.10+. Core deps are just `cryptography` and `httpx` — your agent framework comes in via the extra you pick.
11
17
 
12
18
  ---
13
19
 
@@ -70,6 +76,16 @@ guard = firstops_tool_input_guardrail(fo)
70
76
  def send_email(to: str, body: str) -> str: ...
71
77
  ```
72
78
 
79
+ **Google ADK** — one `before_tool_callback` governs every tool (block + rewrite args):
80
+
81
+ ```python
82
+ from google.adk.agents import LlmAgent
83
+ from firstops.integrations.google_adk import firstops_before_tool_callback
84
+
85
+ agent = LlmAgent(name="assistant", model=..., tools=[...],
86
+ before_tool_callback=firstops_before_tool_callback(fo))
87
+ ```
88
+
73
89
  **Any framework / custom loop** — the base API:
74
90
 
75
91
  ```python
@@ -88,6 +104,17 @@ mcp = MultiServerMCPClient({"notion": {"url": firstops.mcp_url("<connection-id>"
88
104
  tools = await mcp.get_tools()
89
105
  ```
90
106
 
107
+ ## Examples
108
+
109
+ Runnable agents in [`examples/`](examples/) — each governs the LLM and tool calls; the `*_mcp` variants add a Notion MCP server:
110
+
111
+ - LangGraph — [`langgraph_basic.py`](examples/langgraph_basic.py), [`langgraph_notion_mcp.py`](examples/langgraph_notion_mcp.py)
112
+ - Claude Agent SDK — [`claude_sdk_basic.py`](examples/claude_sdk_basic.py), [`claude_sdk_mcp.py`](examples/claude_sdk_mcp.py)
113
+ - OpenAI Agents SDK — [`openai_agents_basic.py`](examples/openai_agents_basic.py), [`openai_agents_mcp.py`](examples/openai_agents_mcp.py)
114
+ - Google ADK — [`google_adk_basic.py`](examples/google_adk_basic.py)
115
+
116
+ See [`examples/README.md`](examples/README.md) for the env vars to run them.
117
+
91
118
  ## Management client
92
119
 
93
120
  Provision agents and connections from your backend:
@@ -106,8 +133,10 @@ admin.connections.register(principal_id=agent.id, name="slack", upstream_url="ht
106
133
 
107
134
  ## Documentation
108
135
 
109
- - Guides: <https://firstops.dev/docs>
110
- - Repository: <https://github.com/firstops-dev/firstops-python>
136
+ - [Docs home](https://firstops.dev/docs)
137
+ - 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)
138
+ - Concepts: [Identity](https://firstops.dev/docs/concepts/identity) · [Enforcement](https://firstops.dev/docs/concepts/enforcement) · [Connections](https://firstops.dev/docs/concepts/connections)
139
+ - [Repository](https://github.com/firstops-dev/firstops-python)
111
140
 
112
141
  ## License
113
142
 
@@ -0,0 +1,73 @@
1
+ """Google ADK agent governed by FirstOps.
2
+
3
+ One `before_tool_callback` governs every tool call (block + rewrite args). The
4
+ LLM runs through the sidecar chain-link via LiteLLM pointed at OpenAI.
5
+
6
+ Run with the env vars in README.md (needs OPENAI_API_KEY).
7
+ """
8
+
9
+ import asyncio
10
+ import os
11
+
12
+ import firstops
13
+ from firstops.integrations.google_adk import firstops_before_tool_callback
14
+ from google.adk.agents import LlmAgent
15
+ from google.adk.models.lite_llm import LiteLlm
16
+ from google.adk.runners import InMemoryRunner
17
+ from google.genai import types
18
+
19
+ from _shared import load_config, trace
20
+
21
+ APP = "firstops-demo"
22
+
23
+
24
+ def get_weather(city: str) -> dict:
25
+ """Get the current weather for a city."""
26
+ print(f" [TOOL get_weather] city={city}")
27
+ return {"weather": f"21C and sunny in {city}"}
28
+
29
+
30
+ def send_email(to: str, body: str) -> dict:
31
+ """Send an email to a recipient."""
32
+ print(f" [TOOL send_email] to={to} body={body!r}")
33
+ return {"status": "sent"}
34
+
35
+
36
+ async def main():
37
+ cfg = load_config()
38
+ fo = firstops.init(
39
+ cfg["agent_id"], cfg["key_pem"], gateway_url=cfg["gateway"], port=cfg["port"]
40
+ )
41
+ trace(fo)
42
+ try:
43
+ agent = LlmAgent(
44
+ name="assistant",
45
+ model=LiteLlm(
46
+ model="openai/gpt-4o-mini",
47
+ api_base=firstops.llm_base_url("openai"), # -> sidecar chain-link
48
+ api_key=os.environ["OPENAI_API_KEY"],
49
+ ),
50
+ instruction="You are a helpful assistant.",
51
+ tools=[get_weather, send_email],
52
+ before_tool_callback=firstops_before_tool_callback(fo), # governs tools
53
+ )
54
+ runner = InMemoryRunner(agent=agent, app_name=APP)
55
+ session = await runner.session_service.create_session(app_name=APP, user_id="u1")
56
+ msg = types.Content(
57
+ role="user",
58
+ parts=[types.Part(text="Check the weather in Paris, then email it to alice@example.com.")],
59
+ )
60
+ print("\n>>> running Google ADK agent\n")
61
+ async for event in runner.run_async(
62
+ user_id="u1", session_id=session.id, new_message=msg
63
+ ):
64
+ if event.content and event.content.parts:
65
+ for part in event.content.parts:
66
+ if getattr(part, "text", None) and part.text.strip():
67
+ print(f" [ADK] {part.text.strip()[:160]}")
68
+ finally:
69
+ firstops.shutdown()
70
+
71
+
72
+ if __name__ == "__main__":
73
+ asyncio.run(main())
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "firstops"
7
- version = "0.2.0"
8
- description = "Govern MCP, tool calls, and LLM traffic for AI agents — across LangGraph, Claude Agent SDK, and OpenAI Agents."
7
+ version = "0.3.0"
8
+ description = "Govern MCP, tool calls, and LLM traffic for AI agents — across LangGraph, Claude Agent SDK, OpenAI Agents, and Google ADK."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
11
  license = "MIT"
@@ -26,7 +26,7 @@ classifiers = [
26
26
  ]
27
27
  keywords = [
28
28
  "mcp", "dpop", "agent", "security", "governance", "llm",
29
- "langgraph", "langchain", "openai-agents", "claude", "guardrails",
29
+ "langgraph", "langchain", "openai-agents", "claude", "google-adk", "guardrails",
30
30
  ]
31
31
  dependencies = [
32
32
  "cryptography>=42.0",
@@ -54,6 +54,9 @@ claude = [
54
54
  openai = [
55
55
  "openai-agents",
56
56
  ]
57
+ adk = [
58
+ "google-adk[extensions]",
59
+ ]
57
60
  all = [
58
61
  "langchain>=1.0",
59
62
  "langgraph",
@@ -61,6 +64,7 @@ all = [
61
64
  "langchain-mcp-adapters",
62
65
  "claude-agent-sdk",
63
66
  "openai-agents",
67
+ "google-adk[extensions]",
64
68
  ]
65
69
  dev = [
66
70
  "pytest>=8.0",
@@ -22,6 +22,7 @@ ACTION_MODIFY = "modify"
22
22
  HARNESS_LANGGRAPH = "langgraph"
23
23
  HARNESS_CLAUDE = "claude-agent-sdk"
24
24
  HARNESS_OPENAI_AGENTS = "openai-agents"
25
+ HARNESS_GOOGLE_ADK = "google-adk"
25
26
 
26
27
 
27
28
  def _json_safe(value: Any) -> Any:
@@ -0,0 +1,55 @@
1
+ """Google ADK adapter — `before_tool_callback` governs every tool call.
2
+
3
+ ADK's agent-level `before_tool_callback` can both **block** a tool (return a
4
+ result, which short-circuits the call) and **rewrite its args** (mutate the args
5
+ dict in place) — so FirstOps gets block and scrub on Google ADK.
6
+
7
+ Usage::
8
+
9
+ from google.adk.agents import LlmAgent
10
+ from firstops.integrations.google_adk import firstops_before_tool_callback
11
+
12
+ agent = LlmAgent(
13
+ name="assistant",
14
+ model=...,
15
+ tools=[...],
16
+ before_tool_callback=firstops_before_tool_callback(fo),
17
+ )
18
+
19
+ The callback is a plain function (ADK invokes it as
20
+ ``callback(tool=, args=, tool_context=)``), so this adapter needs no ADK import.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import Any
26
+
27
+ from firstops import _runtime
28
+ from firstops.integrations._common import (
29
+ ACTION_DENY,
30
+ ACTION_MODIFY,
31
+ HARNESS_GOOGLE_ADK,
32
+ decide,
33
+ )
34
+
35
+
36
+ def firstops_before_tool_callback(fo=None):
37
+ """Return a ``before_tool_callback`` that governs every tool call."""
38
+ rt = fo if fo is not None else _runtime.runtime()
39
+
40
+ def _before_tool(tool, args, tool_context) -> dict[str, Any] | None:
41
+ tool_name = getattr(tool, "name", "") or ""
42
+ action, payload = decide(
43
+ rt, tool_name, args, can_apply_modify=True, harness=HARNESS_GOOGLE_ADK
44
+ )
45
+ if action == ACTION_DENY:
46
+ # A non-None return short-circuits the tool; this becomes the result
47
+ # the model sees.
48
+ return {"status": "denied", "error": f"blocked by FirstOps policy: {payload}"}
49
+ if action == ACTION_MODIFY and isinstance(payload, dict) and isinstance(args, dict):
50
+ # Rewrite the call's args in place (full replacement with scrubbed input).
51
+ args.clear()
52
+ args.update(payload)
53
+ return None
54
+
55
+ return _before_tool
@@ -44,6 +44,14 @@ _LLM_STRIP_HEADERS = frozenset(
44
44
  }
45
45
  )
46
46
 
47
+ # Response headers we must not forward back to the client: hop-by-hop, plus the
48
+ # ones BaseHTTPRequestHandler sets itself (Server/Date) — forwarding the
49
+ # upstream's copies duplicates them, which strict clients (aiohttp/litellm)
50
+ # reject with "Duplicate 'Server' header".
51
+ _RESP_STRIP_HEADERS = frozenset(
52
+ {"transfer-encoding", "connection", "server", "date"}
53
+ )
54
+
47
55
  # Hard cap on a forwarded request body (defensive against a huge Content-Length).
48
56
  _MAX_BODY_BYTES = 100 * 1024 * 1024
49
57
 
@@ -210,6 +218,12 @@ def _make_handler(identity: Identity, local_port: int, enforcement, llm_upstream
210
218
  client = httpx.Client(timeout=httpx.Timeout(120.0, connect=10.0))
211
219
 
212
220
  class ProxyHandler(BaseHTTPRequestHandler):
221
+ # HTTP/1.1 so strict clients (litellm/aiohttp, Node MCP) get clean
222
+ # keep-alive framing. Non-streaming responses carry an exact
223
+ # Content-Length (see _forward); streaming responses close the
224
+ # connection explicitly.
225
+ protocol_version = "HTTP/1.1"
226
+
213
227
  def do_POST(self):
214
228
  self._dispatch("POST")
215
229
 
@@ -264,6 +278,7 @@ def _make_handler(identity: Identity, local_port: int, enforcement, llm_upstream
264
278
  if denial is not None:
265
279
  self.send_response(403)
266
280
  self.send_header("Content-Type", "application/json")
281
+ self.send_header("Content-Length", str(len(denial)))
267
282
  self.end_headers()
268
283
  self.wfile.write(denial)
269
284
  return
@@ -364,20 +379,39 @@ def _make_handler(identity: Identity, local_port: int, enforcement, llm_upstream
364
379
  method, url, headers=headers, content=body,
365
380
  timeout=httpx.Timeout(120.0, connect=10.0),
366
381
  ) as resp:
382
+ is_stream = "text/event-stream" in resp.headers.get("content-type", "")
383
+
384
+ if is_stream:
385
+ # Streaming (SSE): pass bytes through raw with flushing so
386
+ # events arrive live. No Content-Length, so close the
387
+ # connection to delimit the body under HTTP/1.1.
388
+ self.close_connection = True
389
+ self.send_response(resp.status_code)
390
+ for k, v in resp.headers.items():
391
+ if k.lower() not in _RESP_STRIP_HEADERS:
392
+ self.send_header(k, v)
393
+ self.send_header("Connection", "close")
394
+ self.end_headers()
395
+ for chunk in resp.iter_raw():
396
+ self.wfile.write(chunk)
397
+ self.wfile.flush()
398
+ return
399
+
400
+ # Non-streaming: buffer the DECODED body and send it with an
401
+ # exact Content-Length, dropping Content-Encoding/Length from
402
+ # upstream. This gives a cleanly-framed response that ANY
403
+ # client reads correctly (httpx, aiohttp, Node) — a
404
+ # close-delimited gzipped body trips stricter clients.
405
+ payload = resp.read() # httpx auto-decompresses
367
406
  self.send_response(resp.status_code)
368
407
  for k, v in resp.headers.items():
369
- if k.lower() not in ("transfer-encoding", "connection"):
408
+ if k.lower() not in _RESP_STRIP_HEADERS and k.lower() not in (
409
+ "content-encoding", "content-length",
410
+ ):
370
411
  self.send_header(k, v)
412
+ self.send_header("Content-Length", str(len(payload)))
371
413
  self.end_headers()
372
-
373
- # Forward the body RAW: httpx auto-decompresses iter_bytes()/
374
- # read(), but we keep the upstream Content-Encoding/Length
375
- # headers, so we must pass the original (possibly gzipped)
376
- # bytes through untouched or the client's decode fails.
377
- # Flush per chunk so SSE/streaming responses arrive live.
378
- for chunk in resp.iter_raw():
379
- self.wfile.write(chunk)
380
- self.wfile.flush()
414
+ self.wfile.write(payload)
381
415
  except httpx.HTTPError as e:
382
416
  logger.error("upstream request failed: %s", e)
383
417
  self.send_error(502, "upstream request failed")
@@ -386,10 +420,12 @@ def _make_handler(identity: Identity, local_port: int, enforcement, llm_upstream
386
420
  """Stream an SSE response, rewriting gateway URLs to localhost."""
387
421
  try:
388
422
  with httpx.stream("GET", url, headers=headers, timeout=None) as resp:
423
+ self.close_connection = True # SSE: no length, close-delimited
389
424
  self.send_response(resp.status_code)
390
425
  for k, v in resp.headers.items():
391
- if k.lower() not in ("transfer-encoding", "connection"):
426
+ if k.lower() not in _RESP_STRIP_HEADERS:
392
427
  self.send_header(k, v)
428
+ self.send_header("Connection", "close")
393
429
  self.end_headers()
394
430
 
395
431
  gateway_msg_url = gateway + "/mcp/sse/message"
@@ -181,3 +181,50 @@ from firstops.integrations import langgraph
181
181
  def test_langgraph_middleware_requires_langchain():
182
182
  with pytest.raises(RuntimeError, match="langchain"):
183
183
  langgraph.FirstOpsMiddleware(_rt(Decision(action="allow")))
184
+
185
+
186
+ # ---- Google ADK adapter ----------------------------------------------------
187
+
188
+ from firstops.integrations import google_adk
189
+
190
+
191
+ class _AdkTool:
192
+ def __init__(self, name):
193
+ self.name = name
194
+
195
+
196
+ def test_adk_allow_returns_none_and_stamps_harness():
197
+ rt = _rt(Decision(action="allow"))
198
+ cb = google_adk.firstops_before_tool_callback(rt)
199
+ args = {"city": "Paris"}
200
+ assert cb(tool=_AdkTool("get_weather"), args=args, tool_context=None) is None
201
+ assert args == {"city": "Paris"} # unchanged
202
+ ev = rt.enforcement.events[0]
203
+ assert ev.tool_name == "get_weather"
204
+ assert ev.metadata == {"harness": "google-adk"}
205
+
206
+
207
+ def test_adk_deny_short_circuits_with_result_dict():
208
+ rt = _rt(Decision(action="deny", reason="destructive"))
209
+ out = google_adk.firstops_before_tool_callback(rt)(
210
+ tool=_AdkTool("run_shell"), args={"cmd": "x"}, tool_context=None
211
+ )
212
+ assert out is not None # non-None return blocks the tool
213
+ assert out["status"] == "denied"
214
+ assert "destructive" in out["error"]
215
+
216
+
217
+ def test_adk_modify_rewrites_args_in_place():
218
+ rt = _rt(_modify({"to": "[REDACTED]"}))
219
+ args = {"to": "secret@example.com"}
220
+ out = google_adk.firstops_before_tool_callback(rt)(
221
+ tool=_AdkTool("send_email"), args=args, tool_context=None
222
+ )
223
+ assert out is None # proceed
224
+ assert args == {"to": "[REDACTED]"} # rewritten in place
225
+
226
+
227
+ def test_adk_adapter_needs_no_framework():
228
+ # The callback is a plain function — constructing it must not require ADK.
229
+ cb = google_adk.firstops_before_tool_callback(_rt(Decision(action="allow")))
230
+ assert callable(cb)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes