diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..bcaa05c --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,24 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - run: pip install -e ".[dev]" + + - run: pytest tests/ -v diff --git a/.gitignore b/.gitignore index f8a1c16..5cfeb33 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ tmp/* __pycache__/ - +.pytest_cache/ +*.egg-info/ diff --git a/README.md b/README.md index 884a0c5..e0e8aa1 100644 --- a/README.md +++ b/README.md @@ -211,11 +211,11 @@ custom_function = { }, } -# MCP tools +# MCP tools (connect to any MCP server) mcp_tool = { "type": "mcp", "url": "https://mcp.example.com", - "allow": ["read", "write"], + "allowedTools": ["search", "get_page"], } ``` @@ -275,6 +275,101 @@ tool_with_headers_and_defaults = { Each tool can have its own headers and defaults - they're only applied when that specific tool is called. +### MCP Tools + +Connect to any [Model Context Protocol](https://modelcontextprotocol.io/) server and use its tools in your runs. Subconscious discovers tools from the server, filters by your `allowedTools` list, and proxies calls automatically. + +#### Authentication + +MCP servers that require authentication accept an `auth` object. The auth translates to an HTTP header sent with every tool call: + +| Method | When to use | Header sent | +| --- | --- | --- | +| **Bearer** | Most common — OAuth tokens, JWTs, etc. | `Authorization: Bearer ` | +| **API key** | Service-specific API keys | `
: ` (header is typically `X-Api-Key` — check your MCP server's docs) | + +```python +from subconscious import Subconscious, MCPTool, McpAuth + +client = Subconscious() + +# Basic — use all tools from an MCP server +run = client.run( + engine="tim-gpt", + input={ + "instructions": "Find my recent meeting notes", + "tools": [ + MCPTool(url="https://mcp.notion.so/v1"), + ], + }, + options={"await_completion": True}, +) + +# Filter to specific tools +run = client.run( + engine="tim-gpt", + input={ + "instructions": "Search my documents", + "tools": [ + MCPTool( + url="https://mcp.notion.so/v1", + allowed_tools=["search", "get_page"], # case-insensitive + ), + ], + }, + options={"await_completion": True}, +) + +# With bearer auth (most common — e.g. OAuth tokens) +# → sends header: { "Authorization": "Bearer " } +run = client.run( + engine="tim-gpt", + input={ + "instructions": "Check my calendar", + "tools": [ + MCPTool( + url="https://mcp.google.com/v1", + auth=McpAuth(type="bearer", token="your-oauth-token"), + ), + ], + }, + options={"await_completion": True}, +) + +# API key auth with custom header +# → sends header: { "X-Api-Key": "" } +# The header name is typically "X-Api-Key" but may vary — +# check the docs of the MCP server you are connecting to. +run = client.run( + engine="tim-gpt", + input={ + "instructions": "Query the database", + "tools": [ + MCPTool( + url="https://mcp.example.com", + auth=McpAuth(type="api_key", token="key123", header="X-Api-Key"), + ), + ], + }, + options={"await_completion": True}, +) +``` + +**`allowedTools` filtering:** + +| Value | Behavior | +| --- | --- | +| Omitted / `None` | All tools from the server are enabled | +| `["*"]` | All tools enabled (explicit wildcard) | +| `["search", "fetch"]` | Only these tools (case-insensitive) | +| `[]` | No tools (blocks all) | + +You can also pass MCP tools as plain dicts: + +```python +{"type": "mcp", "url": "https://mcp.example.com", "allowedTools": ["search"]} +``` + ### Error Handling ```python diff --git a/pyproject.toml b/pyproject.toml index e75b39d..c42b17c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "subconscious-sdk" -version = "0.2.1" +version = "0.3.0" description = "The official Python SDK for the Subconscious API" readme = "README.md" authors = [ diff --git a/subconscious/__init__.py b/subconscious/__init__.py index 691bd25..74e105b 100644 --- a/subconscious/__init__.py +++ b/subconscious/__init__.py @@ -24,6 +24,7 @@ PlatformTool, FunctionTool, MCPTool, + McpAuth, # Stream events StreamEvent, DeltaEvent, @@ -41,7 +42,7 @@ ValidationError, ) -__version__ = "0.2.1" +__version__ = "0.3.0" __author__ = "Subconscious Systems" __email__ = "contact@subconscious.dev" @@ -66,6 +67,7 @@ "PlatformTool", "FunctionTool", "MCPTool", + "McpAuth", # Stream events "StreamEvent", "DeltaEvent", diff --git a/subconscious/client.py b/subconscious/client.py index ba11c7f..01f547b 100644 --- a/subconscious/client.py +++ b/subconscious/client.py @@ -80,6 +80,36 @@ def _resolve_schema(schema: Any) -> Optional[Dict[str, Any]]: # Unknown type - try to use it as-is return schema +# Python snake_case → API camelCase key mapping for tool serialization +_TOOL_KEY_MAP = { + "allowed_tools": "allowedTools", +} + + +def _normalize_tool(tool: Any) -> Dict[str, Any]: + """Convert a tool dataclass to an API-compatible dict. + + Strips None values and maps snake_case keys to camelCase. + """ + if not hasattr(tool, "__dict__"): + return tool + + result = {} + for k, v in tool.__dict__.items(): + if v is None: + continue + # Recursively normalize nested dataclasses (e.g. McpAuth) + if hasattr(v, "__dict__"): + v = { + _TOOL_KEY_MAP.get(nk, nk): nv + for nk, nv in v.__dict__.items() + if nv is not None + } + key = _TOOL_KEY_MAP.get(k, k) + result[key] = v + return result + + TERMINAL_STATUSES: List[RunStatus] = ["succeeded", "failed", "canceled", "timed_out"] @@ -205,16 +235,7 @@ class Result(BaseModel): input_dict["reasoningFormat"] = _resolve_schema(input_dict["reasoningFormat"]) # Normalize tools to dicts - tools = input_dict.get("tools", []) - normalized_tools = [] - for tool in tools: - if hasattr(tool, "__dict__"): - normalized_tools.append( - {k: v for k, v in tool.__dict__.items() if v is not None} - ) - else: - normalized_tools.append(tool) - input_dict["tools"] = normalized_tools + input_dict["tools"] = [_normalize_tool(t) for t in input_dict.get("tools", [])] # Make request data = self._request( @@ -375,17 +396,8 @@ class Result(BaseModel): if "reasoningFormat" in input_dict: input_dict["reasoningFormat"] = _resolve_schema(input_dict["reasoningFormat"]) - # Normalize tools - tools = input_dict.get("tools", []) - normalized_tools = [] - for tool in tools: - if hasattr(tool, "__dict__"): - normalized_tools.append( - {k: v for k, v in tool.__dict__.items() if v is not None} - ) - else: - normalized_tools.append(tool) - input_dict["tools"] = normalized_tools + # Normalize tools to dicts + input_dict["tools"] = [_normalize_tool(t) for t in input_dict.get("tools", [])] url = f"{self._base_url}/runs/stream" headers = { diff --git a/subconscious/types.py b/subconscious/types.py index 60e99a4..99ee906 100644 --- a/subconscious/types.py +++ b/subconscious/types.py @@ -152,13 +152,47 @@ class FunctionTool: """Parameter values hidden from model and injected at call time.""" +@dataclass +class McpAuth: + """MCP Authentication. + + Used for MCP tools that require authentication. + Translates to an HTTP header sent with every tool call: + + - Bearer: ``{ "Authorization": "Bearer " }`` + - API key: ``{ "
": "" }`` + + Bearer auth is the most common method (e.g. OAuth tokens). + For API key auth, the header is typically ``X-Api-Key`` but may vary — + check the documentation of the MCP server you are connecting to. + + Attributes: + type: Auth method — ``"bearer"`` or ``"api_key"``. + token: The token or key value. + header: For ``api_key`` auth only, the header name to send the token + in (e.g. ``"X-Api-Key"``). + """ + + type: Literal["bearer", "api_key"] + token: str + header: Optional[str] = None + + @dataclass class MCPTool: - """An MCP (Model Context Protocol) tool.""" + """An MCP (Model Context Protocol) tool. + + Attributes: + url: URL of the MCP server + allowed_tools: Tool names to enable. Case-insensitive. + ["*"] or omit for all tools. [] blocks all. + auth: Optional authentication for the MCP server + """ url: str type: Literal["mcp"] = "mcp" - allow: Optional[List[str]] = None + allowed_tools: Optional[List[str]] = None + auth: Optional[McpAuth] = None # Tool union type diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_types.py b/tests/test_types.py new file mode 100644 index 0000000..85b9e00 --- /dev/null +++ b/tests/test_types.py @@ -0,0 +1,144 @@ +"""Tests for type definitions and tool serialization.""" + +import pytest + +from subconscious.types import ( + FunctionTool, + McpAuth, + MCPTool, + PlatformTool, + Tool, +) +from subconscious.client import _normalize_tool + + +# --------------------------------------------------------------------------- +# MCPTool construction +# --------------------------------------------------------------------------- + +class TestMCPTool: + def test_basic_construction(self): + tool = MCPTool(url="https://example.com/mcp") + assert tool.url == "https://example.com/mcp" + assert tool.type == "mcp" + assert tool.allowed_tools is None + assert tool.auth is None + + def test_with_allowed_tools(self): + tool = MCPTool(url="https://x.com/mcp", allowed_tools=["search", "fetch"]) + assert tool.allowed_tools == ["search", "fetch"] + + def test_with_wildcard(self): + tool = MCPTool(url="https://x.com/mcp", allowed_tools=["*"]) + assert tool.allowed_tools == ["*"] + + def test_with_empty_list_blocks_all(self): + tool = MCPTool(url="https://x.com/mcp", allowed_tools=[]) + assert tool.allowed_tools == [] + + def test_with_bearer_auth(self): + auth = McpAuth(type="bearer", token="tok123") + tool = MCPTool(url="https://x.com/mcp", auth=auth) + assert tool.auth is not None + assert tool.auth.type == "bearer" + assert tool.auth.token == "tok123" + assert tool.auth.header is None + + def test_with_api_key_auth(self): + auth = McpAuth(type="api_key", token="key456", header="X-Api-Key") + tool = MCPTool(url="https://x.com/mcp", auth=auth) + assert tool.auth.type == "api_key" + assert tool.auth.token == "key456" + assert tool.auth.header == "X-Api-Key" + + +# --------------------------------------------------------------------------- +# Tool union type assignability +# --------------------------------------------------------------------------- + +class TestToolUnion: + def test_platform_tool_is_tool(self): + tool: Tool = PlatformTool(id="fast_search") + assert tool.type == "platform" + + def test_function_tool_is_tool(self): + tool: Tool = FunctionTool(name="my_func") + assert tool.type == "function" + + def test_mcp_tool_is_tool(self): + tool: Tool = MCPTool(url="https://x.com/mcp") + assert tool.type == "mcp" + + def test_dict_is_tool(self): + tool: Tool = {"type": "custom", "name": "raw"} + assert tool["type"] == "custom" + + +# --------------------------------------------------------------------------- +# Serialization (_normalize_tool) +# --------------------------------------------------------------------------- + +class TestNormalizeTool: + def test_mcp_key_mapping(self): + tool = MCPTool( + url="https://x.com/mcp", + allowed_tools=["search", "fetch"], + ) + result = _normalize_tool(tool) + assert "allowedTools" in result + assert result["allowedTools"] == ["search", "fetch"] + assert "allowed_tools" not in result + assert result["url"] == "https://x.com/mcp" + assert result["type"] == "mcp" + + def test_mcp_with_auth_nested_serialization(self): + auth = McpAuth(type="bearer", token="tok123") + tool = MCPTool(url="https://x.com/mcp", auth=auth) + result = _normalize_tool(tool) + assert isinstance(result["auth"], dict) + assert result["auth"]["type"] == "bearer" + assert result["auth"]["token"] == "tok123" + # header is None so should be stripped + assert "header" not in result["auth"] + + def test_strips_none_values(self): + tool = MCPTool(url="https://x.com/mcp") + result = _normalize_tool(tool) + assert "allowedTools" not in result + assert "auth" not in result + assert set(result.keys()) == {"url", "type"} + + def test_dict_passthrough(self): + raw = {"type": "custom", "name": "raw"} + result = _normalize_tool(raw) + assert result == raw + +# --------------------------------------------------------------------------- +# Backward compatibility +# --------------------------------------------------------------------------- + +class TestBackwardCompat: + def test_function_tool_unchanged(self): + tool = FunctionTool( + name="my_tool", + description="Does stuff", + url="https://x.com/tool", + method="POST", + parameters={"type": "object", "properties": {}}, + headers={"X-Key": "val"}, + defaults={"org": "acme"}, + ) + assert tool.name == "my_tool" + assert tool.type == "function" + result = _normalize_tool(tool) + assert result["name"] == "my_tool" + assert result["headers"] == {"X-Key": "val"} + assert result["defaults"] == {"org": "acme"} + + def test_platform_tool_unchanged(self): + tool = PlatformTool(id="fast_search", options={"limit": 10}) + assert tool.id == "fast_search" + assert tool.type == "platform" + result = _normalize_tool(tool) + assert result["id"] == "fast_search" + assert result["options"] == {"limit": 10}