From 7d9296aed526ce5df82287b484b939b8baf7d833 Mon Sep 17 00:00:00 2001 From: Elias Posen Date: Fri, 3 Apr 2026 16:38:34 -0400 Subject: [PATCH 1/3] claude_agent_sdk tools as optional dependency --- pctx-py/pyproject.toml | 2 + pctx-py/src/pctx_client/_client.py | 89 ++++++++++++++++++- .../scripts/claude_agent_sdk_code_mode.py | 38 ++++++++ pctx-py/uv.lock | 29 +++++- 4 files changed, 155 insertions(+), 3 deletions(-) create mode 100644 pctx-py/tests/scripts/claude_agent_sdk_code_mode.py diff --git a/pctx-py/pyproject.toml b/pctx-py/pyproject.toml index e36e7424..63f17a29 100644 --- a/pctx-py/pyproject.toml +++ b/pctx-py/pyproject.toml @@ -21,6 +21,7 @@ crewai = ["crewai>=1.6.1"] openai = ["openai-agents>=0.12.0"] pydantic-ai = ["pydantic-ai>=1.60.0"] bm25s = ["bm25s[stem]>=0.2.12"] +claude = ["claude-agent-sdk>=0.1.55"] [project.urls] "Homepage" = "https://github.com/portofcontext/pctx" @@ -45,6 +46,7 @@ dev = [ "crewai>=1.6.1", "openai-agents>=0.12.0", "pydantic-ai>=1.60.0", + "claude-agent-sdk>=0.1.55", "bm25s[stem]>=0.2.12", "sphinx", "sphinx-autobuild", diff --git a/pctx-py/src/pctx_client/_client.py b/pctx-py/src/pctx_client/_client.py index 03fc399d..0adfef78 100644 --- a/pctx-py/src/pctx_client/_client.py +++ b/pctx-py/src/pctx_client/_client.py @@ -6,7 +6,7 @@ import asyncio import warnings -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from urllib.parse import urlparse from httpx import AsyncClient @@ -37,6 +37,7 @@ try: from agents import FunctionTool from bm25s import BM25 + from claude_agent_sdk import SdkMcpTool as ClaudeSdkMcpTool from crewai.tools import BaseTool as CrewAiBaseTool from langchain_core.tools import BaseTool as LangchainBaseTool from pydantic_ai.tools import Tool as PydanticAITool @@ -826,3 +827,89 @@ async def get_function_details(functions: list[str]) -> str: # filter according to disclosure return [t for t in all_tools if disclosure.contains_tool(t.name)] + + + def claude_agent_sdk_tools( + self, + disclosure: ToolDisclosure | ToolDisclosureName = ToolDisclosure.CATALOG, + descriptions: dict[ToolName, str] | None = None, + ) -> "list[ClaudeSdkMcpTool]": + """ + Expose PCTX tools as Claude Agent SDK tools + + Args: + disclosure: Controls which tools are exposed and how function context is + provided to the model. CATALOG (default) exposes list_functions, + get_function_details, and execute_typescript — the agent discovers + and retrieves function signatures before executing. FS exposes + execute_bash and execute_typescript — the agent browses the virtual + filesystem directly. + descriptions: Optional custom descriptions to override defaults. + + Requires the 'claude' extra to be installed: + pip install pctx[claude] + + Raises: + ImportError: If claude is not installed. + + Examples: + >>> tools = pctx.claude_agent_sdk_tools() # default: catalog + >>> tools = pctx.claude_agent_sdk_tools(disclosure="filesystem") + >>> tools = pctx.claude_agent_sdk_tools(descriptions={"execute_typescript": "Custom"}) + """ + disclosure = ToolDisclosure(disclosure) + try: + from claude_agent_sdk import tool as claude_tool + except ImportError as e: + raise ImportError( + "Claude Agent SDK is not installed. Install it with: pip install pctx[claude]" + ) from e + + def _text_content_block(val: str) -> dict: + return {"content": [{"type": "text", "text": val}]} + + # build all tools + @claude_tool("execute_bash", get_tool_description("execute_bash", overrides=descriptions), ExecuteBashInput.model_json_schema()) + async def execute_bash(args: dict[str, Any]) -> str: + tool_input = ExecuteBashInput(**args) + bash_out = await self.execute_bash(tool_input.command) + return _text_content_block(bash_out.markdown()) + + @claude_tool("execute_typescript", get_tool_description("execute_typescript", disclosure=disclosure, overrides=descriptions), ExecuteTypescriptInput.model_json_schema()) + async def execute_typescript(args: dict[str, Any]) -> str: + tool_input = ExecuteTypescriptInput(**args) + exec_out = await self.execute_typescript(tool_input.code, disclosure=disclosure) + return _text_content_block(exec_out.markdown()) + + @claude_tool("list_functions", get_tool_description("list_functions", overrides=descriptions), {"type": "object"}) + async def list_functions(_args: dict[str, Any]) -> str: + listed = await self.list_functions() + return _text_content_block(listed.code) + + @claude_tool("get_function_details", get_tool_description("get_function_details", overrides=descriptions), GetFunctionDetailsInput.model_json_schema()) + async def get_function_details(args: dict[str, Any]) -> str: + tool_input = GetFunctionDetailsInput(**args) + details = await self.get_function_details(tool_input.functions) + return _text_content_block(details.code) + + class SearchFunctionsInput(BaseModel): + query: str + k: int = 10 + + @claude_tool("search_functions", get_tool_description("search_functions", overrides=descriptions), SearchFunctionsInput.model_json_schema()) + async def search_functions(args: dict[str, Any]) -> str: + print(f"Claude fn called search_functions: {args}") + tool_input = SearchFunctionsInput(**args) + functions = await self.search_functions(tool_input.query, tool_input.k) + return _text_content_block(self._search_functions_result_to_string(functions)) + + all_tools = [ + execute_bash, + execute_typescript, + list_functions, + search_functions, + get_function_details, + ] + + # filter according to disclosure + return [t for t in all_tools if disclosure.contains_tool(t.name)] diff --git a/pctx-py/tests/scripts/claude_agent_sdk_code_mode.py b/pctx-py/tests/scripts/claude_agent_sdk_code_mode.py new file mode 100644 index 00000000..550af5fa --- /dev/null +++ b/pctx-py/tests/scripts/claude_agent_sdk_code_mode.py @@ -0,0 +1,38 @@ +import asyncio + +from claude_agent_sdk import ClaudeAgentOptions, create_sdk_mcp_server, query +from rich import print + +from pctx_client import Pctx, tool + + +@tool +def get_weather(city: str) -> str: + """Get weather for a given city.""" + return f"It's always sunny in {city}!" + + +@tool +def get_time(city: str) -> str: + """Get time for a given city.""" + return f"It is midnight in {city}!" + + +async def run_agent(): + async with Pctx(tools=[get_weather, get_time]) as p: + claude_tools = p.claude_agent_sdk_tools() + mcp = create_sdk_mcp_server(name="weather + time codemode", tools=claude_tools) + print([f"mcp__tools__{t.name}" for t in claude_tools]) + async for message in query( + prompt="You are a helpful assistant, use tools when you need to access real-time information, you must use the tools mcp not websearch. What is the weather & time in SF?", + options=ClaudeAgentOptions( + mcp_servers={"tools": mcp}, + allowed_tools=[f"mcp__tools__{t.name}" for t in claude_tools] + ), + ): + print(message) + + +if __name__ == "__main__": + + asyncio.run(run_agent()) diff --git a/pctx-py/uv.lock b/pctx-py/uv.lock index 49a7bf13..a2554bba 100644 --- a/pctx-py/uv.lock +++ b/pctx-py/uv.lock @@ -731,6 +731,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2d/6e/956e62975305a4e31daf6114a73b3b0683a8f36f8d70b20aabd466770edb/chromadb-1.1.1-cp39-abi3-win_amd64.whl", hash = "sha256:a77aa026a73a18181fd89bbbdb86191c9a82fd42aa0b549ff18d8cae56394c8b", size = 19844042, upload-time = "2025-10-05T02:49:16.925Z" }, ] +[[package]] +name = "claude-agent-sdk" +version = "0.1.55" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "mcp" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/3c/ed4b3918071c24bf249608cf0b0da451cbcd8e1b96ab1f95cd2d27f0cecc/claude_agent_sdk-0.1.55.tar.gz", hash = "sha256:d171f7eeaa6e5e2e95acea55e95c143ab6b23f5bf9e1c54a7c74842fcb7bb710", size = 121663, upload-time = "2026-04-03T00:36:53.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/da/847233b5b2cfa492985bce7de1d7c3f68065308fae1f692e0632206c4547/claude_agent_sdk-0.1.55-py3-none-macosx_11_0_arm64.whl", hash = "sha256:70fa9f20f9b3a632d25d2666b533505a754d36429a4ad91a465eee87ac5798f7", size = 58391088, upload-time = "2026-04-03T00:36:56.808Z" }, + { url = "https://files.pythonhosted.org/packages/b4/73/0347a89b9a4e8f3b7f606558ff2680eee3c1a723531fe8940d6aeebcb966/claude_agent_sdk-0.1.55-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:01c4ed18aa3dd38a5c1a37fc5a775549abc9d6658bad65a69e36dafbbecd3e29", size = 60260265, upload-time = "2026-04-03T00:36:59.939Z" }, + { url = "https://files.pythonhosted.org/packages/41/1a/9a496f69cc59d327e4e3e8b972ef3147af2de4aaaa2245a5c84e82dacc3c/claude_agent_sdk-0.1.55-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:329cb0d8369ccf1f69442058a1e02e22767d5304b08920e1486cd3773e2cb70e", size = 71728118, upload-time = "2026-04-03T00:37:03.17Z" }, + { url = "https://files.pythonhosted.org/packages/b4/55/70e8d1fad8c872cf4fa18e5bd9fbe1545a1f8cc8a9773833c1b6367b5037/claude_agent_sdk-0.1.55-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:eb7d92443722bab90f6eccccad11f415c21f081343614287de67baa4c5fbae03", size = 71841263, upload-time = "2026-04-03T00:37:06.677Z" }, + { url = "https://files.pythonhosted.org/packages/81/ff/260c39b32f823eed1f91dbd77e1a6c235d4dd6aecc18f8b86e0f4007fad2/claude_agent_sdk-0.1.55-py3-none-win_amd64.whl", hash = "sha256:d03d6668b63ec8c130f07e803ea9784d73905676bc0617955f1fef7dd263b664", size = 73961125, upload-time = "2026-04-03T00:37:09.977Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -1385,6 +1403,7 @@ wheels = [ name = "griffelib" version = "2.0.0" source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/06/eccbd311c9e2b3ca45dbc063b93134c57a1ccc7607c5e545264ad092c4a9/griffelib-2.0.0.tar.gz", hash = "sha256:e504d637a089f5cab9b5daf18f7645970509bf4f53eda8d79ed71cce8bd97934", size = 166312, upload-time = "2026-03-23T21:06:55.954Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004, upload-time = "2026-02-09T19:09:40.561Z" }, ] @@ -3320,7 +3339,7 @@ wheels = [ [[package]] name = "pctx-client" -version = "0.3.0" +version = "0.3.1" source = { editable = "." } dependencies = [ { name = "docstring-parser" }, @@ -3333,6 +3352,9 @@ dependencies = [ bm25s = [ { name = "bm25s", extra = ["stem"] }, ] +claude = [ + { name = "claude-agent-sdk" }, +] crewai = [ { name = "crewai" }, ] @@ -3349,6 +3371,7 @@ pydantic-ai = [ [package.dev-dependencies] dev = [ { name = "bm25s", extra = ["stem"] }, + { name = "claude-agent-sdk" }, { name = "crewai" }, { name = "ipython", version = "8.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "ipython", version = "9.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -3375,6 +3398,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "bm25s", extras = ["stem"], marker = "extra == 'bm25s'", specifier = ">=0.2.12" }, + { name = "claude-agent-sdk", marker = "extra == 'claude'", specifier = ">=0.1.55" }, { name = "crewai", marker = "extra == 'crewai'", specifier = ">=1.6.1" }, { name = "docstring-parser", specifier = ">=0.17.0" }, { name = "httpx", specifier = ">=0.28.1" }, @@ -3384,11 +3408,12 @@ requires-dist = [ { name = "pydantic-ai", marker = "extra == 'pydantic-ai'", specifier = ">=1.60.0" }, { name = "websockets", specifier = ">=15.0.1" }, ] -provides-extras = ["langchain", "crewai", "openai", "pydantic-ai", "bm25s"] +provides-extras = ["langchain", "crewai", "openai", "pydantic-ai", "bm25s", "claude"] [package.metadata.requires-dev] dev = [ { name = "bm25s", extras = ["stem"], specifier = ">=0.2.12" }, + { name = "claude-agent-sdk", specifier = ">=0.1.55" }, { name = "crewai", specifier = ">=1.6.1" }, { name = "ipython", specifier = ">=8.26" }, { name = "langchain", specifier = ">=1.1.2" }, From b37042ed781b6ef12faea01cccf4fdfc20275659 Mon Sep 17 00:00:00 2001 From: Elias Posen Date: Fri, 3 Apr 2026 16:42:07 -0400 Subject: [PATCH 2/3] update py readme with claude extra --- pctx-py/README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pctx-py/README.md b/pctx-py/README.md index 8200f6d3..a4348cf5 100644 --- a/pctx-py/README.md +++ b/pctx-py/README.md @@ -554,6 +554,11 @@ p = Pctx(tools=[custom_function], servers=servers) pip install pctx-client[langchain] ``` +- `claude_agent_sdk`: Export pctx's CodeMode tools as [Claude Agent SDK tools](https://platform.claude.com/docs/en/agent-sdk/python#tool) +```bash +pip install pctx-client[claude] +``` + - `crewai`: Export pctx's Code Mode tools as [CrewAI tools](https://docs.crewai.com/en/concepts/tools) ```bash From ea72c33a50c5aa3ec74e071bc04fb7783e151e23 Mon Sep 17 00:00:00 2001 From: Elias Posen Date: Mon, 6 Apr 2026 09:11:16 -0400 Subject: [PATCH 3/3] py formatting --- pctx-py/src/pctx_client/_client.py | 49 ++++++++++++++----- .../scripts/claude_agent_sdk_code_mode.py | 3 +- 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/pctx-py/src/pctx_client/_client.py b/pctx-py/src/pctx_client/_client.py index 0adfef78..7b3f8b5a 100644 --- a/pctx-py/src/pctx_client/_client.py +++ b/pctx-py/src/pctx_client/_client.py @@ -828,7 +828,6 @@ async def get_function_details(functions: list[str]) -> str: # filter according to disclosure return [t for t in all_tools if disclosure.contains_tool(t.name)] - def claude_agent_sdk_tools( self, disclosure: ToolDisclosure | ToolDisclosureName = ToolDisclosure.CATALOG, @@ -864,44 +863,70 @@ def claude_agent_sdk_tools( raise ImportError( "Claude Agent SDK is not installed. Install it with: pip install pctx[claude]" ) from e - + def _text_content_block(val: str) -> dict: return {"content": [{"type": "text", "text": val}]} # build all tools - @claude_tool("execute_bash", get_tool_description("execute_bash", overrides=descriptions), ExecuteBashInput.model_json_schema()) + @claude_tool( + "execute_bash", + get_tool_description("execute_bash", overrides=descriptions), + ExecuteBashInput.model_json_schema(), + ) async def execute_bash(args: dict[str, Any]) -> str: tool_input = ExecuteBashInput(**args) bash_out = await self.execute_bash(tool_input.command) return _text_content_block(bash_out.markdown()) - @claude_tool("execute_typescript", get_tool_description("execute_typescript", disclosure=disclosure, overrides=descriptions), ExecuteTypescriptInput.model_json_schema()) + @claude_tool( + "execute_typescript", + get_tool_description( + "execute_typescript", disclosure=disclosure, overrides=descriptions + ), + ExecuteTypescriptInput.model_json_schema(), + ) async def execute_typescript(args: dict[str, Any]) -> str: tool_input = ExecuteTypescriptInput(**args) - exec_out = await self.execute_typescript(tool_input.code, disclosure=disclosure) + exec_out = await self.execute_typescript( + tool_input.code, disclosure=disclosure + ) return _text_content_block(exec_out.markdown()) - @claude_tool("list_functions", get_tool_description("list_functions", overrides=descriptions), {"type": "object"}) + @claude_tool( + "list_functions", + get_tool_description("list_functions", overrides=descriptions), + {"type": "object"}, + ) async def list_functions(_args: dict[str, Any]) -> str: listed = await self.list_functions() return _text_content_block(listed.code) - - @claude_tool("get_function_details", get_tool_description("get_function_details", overrides=descriptions), GetFunctionDetailsInput.model_json_schema()) + + @claude_tool( + "get_function_details", + get_tool_description("get_function_details", overrides=descriptions), + GetFunctionDetailsInput.model_json_schema(), + ) async def get_function_details(args: dict[str, Any]) -> str: tool_input = GetFunctionDetailsInput(**args) details = await self.get_function_details(tool_input.functions) return _text_content_block(details.code) - + class SearchFunctionsInput(BaseModel): query: str k: int = 10 - - @claude_tool("search_functions", get_tool_description("search_functions", overrides=descriptions), SearchFunctionsInput.model_json_schema()) + + @claude_tool( + "search_functions", + get_tool_description("search_functions", overrides=descriptions), + SearchFunctionsInput.model_json_schema(), + ) async def search_functions(args: dict[str, Any]) -> str: print(f"Claude fn called search_functions: {args}") tool_input = SearchFunctionsInput(**args) functions = await self.search_functions(tool_input.query, tool_input.k) - return _text_content_block(self._search_functions_result_to_string(functions)) + return _text_content_block( + self._search_functions_result_to_string(functions) + ) all_tools = [ execute_bash, diff --git a/pctx-py/tests/scripts/claude_agent_sdk_code_mode.py b/pctx-py/tests/scripts/claude_agent_sdk_code_mode.py index 550af5fa..87d42a0c 100644 --- a/pctx-py/tests/scripts/claude_agent_sdk_code_mode.py +++ b/pctx-py/tests/scripts/claude_agent_sdk_code_mode.py @@ -27,12 +27,11 @@ async def run_agent(): prompt="You are a helpful assistant, use tools when you need to access real-time information, you must use the tools mcp not websearch. What is the weather & time in SF?", options=ClaudeAgentOptions( mcp_servers={"tools": mcp}, - allowed_tools=[f"mcp__tools__{t.name}" for t in claude_tools] + allowed_tools=[f"mcp__tools__{t.name}" for t in claude_tools], ), ): print(message) if __name__ == "__main__": - asyncio.run(run_agent())