From df3de48684a39b487e16973592c6b660cf0e84be Mon Sep 17 00:00:00 2001 From: Dana Wensberg Date: Wed, 25 Mar 2026 15:18:16 -0400 Subject: [PATCH 1/4] feat: MCP tool support with auth, allowedTools filtering, and NativeTool type (v0.3.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Breaking: MCPTool fields renamed (url→server, allow→allowed_tools) to align with the monorepo API. New types: McpAuth, NativeTool, McpToolAnnotations. Added _normalize_tool() for proper snake_case→camelCase serialization. Updated README with MCP tools documentation and examples. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/test.yml | 24 ++++ .gitignore | 3 +- README.md | 88 ++++++++++++- pyproject.toml | 2 +- subconscious/__init__.py | 8 +- subconscious/client.py | 59 +++++---- subconscious/types.py | 54 +++++++- tests/__init__.py | 0 tests/test_types.py | 250 +++++++++++++++++++++++++++++++++++++ 9 files changed, 457 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 tests/__init__.py create mode 100644 tests/test_types.py 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..8d9dc43 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"], + "server": "https://mcp.example.com", + "allowedTools": ["search", "get_page"], } ``` @@ -275,6 +275,88 @@ 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. + +```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(server="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( + server="https://mcp.notion.so/v1", + allowed_tools=["search", "get_page"], # case-insensitive + ), + ], + }, + options={"await_completion": True}, +) + +# With authentication +run = client.run( + engine="tim-gpt", + input={ + "instructions": "Check my calendar", + "tools": [ + MCPTool( + server="https://mcp.google.com/v1", + auth=McpAuth(type="bearer", token="your-oauth-token"), + ), + ], + }, + options={"await_completion": True}, +) + +# API key auth with custom header +run = client.run( + engine="tim-gpt", + input={ + "instructions": "Query the database", + "tools": [ + MCPTool( + server="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", "server": "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..5b2e662 100644 --- a/subconscious/__init__.py +++ b/subconscious/__init__.py @@ -24,6 +24,9 @@ PlatformTool, FunctionTool, MCPTool, + McpAuth, + NativeTool, + McpToolAnnotations, # Stream events StreamEvent, DeltaEvent, @@ -41,7 +44,7 @@ ValidationError, ) -__version__ = "0.2.1" +__version__ = "0.3.0" __author__ = "Subconscious Systems" __email__ = "contact@subconscious.dev" @@ -66,6 +69,9 @@ "PlatformTool", "FunctionTool", "MCPTool", + "McpAuth", + "NativeTool", + "McpToolAnnotations", # Stream events "StreamEvent", "DeltaEvent", diff --git a/subconscious/client.py b/subconscious/client.py index ba11c7f..c76ad03 100644 --- a/subconscious/client.py +++ b/subconscious/client.py @@ -80,6 +80,41 @@ 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", + "tool_config": "toolConfig", + "read_only_hint": "readOnlyHint", + "destructive_hint": "destructiveHint", + "idempotent_hint": "idempotentHint", + "open_world_hint": "openWorldHint", +} + + +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 +240,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 +401,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..c4b0649 100644 --- a/subconscious/types.py +++ b/subconscious/types.py @@ -152,17 +152,63 @@ class FunctionTool: """Parameter values hidden from model and injected at call time.""" +@dataclass +class McpAuth: + """Authentication configuration for an MCP server.""" + + type: Literal["bearer", "api_key"] + token: Optional[str] = None + header: Optional[str] = None + + @dataclass class MCPTool: - """An MCP (Model Context Protocol) tool.""" + """An MCP (Model Context Protocol) tool. - url: str + Attributes: + server: 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 + """ + + server: str type: Literal["mcp"] = "mcp" - allow: Optional[List[str]] = None + allowed_tools: Optional[List[str]] = None + auth: Optional[McpAuth] = None + + +@dataclass +class NativeTool: + """A provider-native tool (e.g. Anthropic computer_use). + + Passed through to the provider without normalization. + """ + + name: str + provider: str + tool_config: Dict[str, Any] + url: str + method: Literal["POST", "GET"] = "POST" + type: Literal["native"] = "native" + timeout: Optional[int] = None + headers: Optional[Dict[str, str]] = None + defaults: Optional[Dict[str, Any]] = None + + +@dataclass +class McpToolAnnotations: + """Metadata hints from an MCP server about a tool's behavior.""" + + title: Optional[str] = None + read_only_hint: Optional[bool] = None + destructive_hint: Optional[bool] = None + idempotent_hint: Optional[bool] = None + open_world_hint: Optional[bool] = None # Tool union type -Tool = Union[PlatformTool, FunctionTool, MCPTool, Dict[str, Any]] +Tool = Union[PlatformTool, FunctionTool, MCPTool, NativeTool, Dict[str, Any]] @dataclass 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..47cbf59 --- /dev/null +++ b/tests/test_types.py @@ -0,0 +1,250 @@ +"""Tests for type definitions and tool serialization.""" + +import pytest + +from subconscious.types import ( + FunctionTool, + McpAuth, + McpToolAnnotations, + MCPTool, + NativeTool, + PlatformTool, + Tool, +) +from subconscious.client import _normalize_tool + + +# --------------------------------------------------------------------------- +# MCPTool construction +# --------------------------------------------------------------------------- + +class TestMCPTool: + def test_basic_construction(self): + tool = MCPTool(server="https://example.com/mcp") + assert tool.server == "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(server="https://x.com/mcp", allowed_tools=["search", "fetch"]) + assert tool.allowed_tools == ["search", "fetch"] + + def test_with_wildcard(self): + tool = MCPTool(server="https://x.com/mcp", allowed_tools=["*"]) + assert tool.allowed_tools == ["*"] + + def test_with_empty_list_blocks_all(self): + tool = MCPTool(server="https://x.com/mcp", allowed_tools=[]) + assert tool.allowed_tools == [] + + def test_with_bearer_auth(self): + auth = McpAuth(type="bearer", token="tok123") + tool = MCPTool(server="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(server="https://x.com/mcp", auth=auth) + assert tool.auth.type == "api_key" + assert tool.auth.token == "key456" + assert tool.auth.header == "X-Api-Key" + + +# --------------------------------------------------------------------------- +# NativeTool construction +# --------------------------------------------------------------------------- + +class TestNativeTool: + def test_basic_construction(self): + tool = NativeTool( + name="computer_use", + provider="anthropic", + tool_config={"display_width": 1024}, + url="https://api.example.com/tools/computer", + ) + assert tool.name == "computer_use" + assert tool.provider == "anthropic" + assert tool.tool_config == {"display_width": 1024} + assert tool.url == "https://api.example.com/tools/computer" + assert tool.type == "native" + assert tool.method == "POST" + assert tool.timeout is None + assert tool.headers is None + assert tool.defaults is None + + def test_with_optional_fields(self): + tool = NativeTool( + name="computer_use", + provider="anthropic", + tool_config={}, + url="https://api.example.com/tools/computer", + method="GET", + timeout=30, + headers={"X-Custom": "val"}, + defaults={"display_width": 1024}, + ) + assert tool.method == "GET" + assert tool.timeout == 30 + assert tool.headers == {"X-Custom": "val"} + assert tool.defaults == {"display_width": 1024} + + +# --------------------------------------------------------------------------- +# McpToolAnnotations +# --------------------------------------------------------------------------- + +class TestMcpToolAnnotations: + def test_all_none_defaults(self): + ann = McpToolAnnotations() + assert ann.title is None + assert ann.read_only_hint is None + assert ann.destructive_hint is None + assert ann.idempotent_hint is None + assert ann.open_world_hint is None + + def test_partial_construction(self): + ann = McpToolAnnotations(title="Search", read_only_hint=True) + assert ann.title == "Search" + assert ann.read_only_hint is True + assert ann.destructive_hint is None + + def test_full_construction(self): + ann = McpToolAnnotations( + title="Delete", + read_only_hint=False, + destructive_hint=True, + idempotent_hint=False, + open_world_hint=True, + ) + assert ann.destructive_hint is True + assert ann.open_world_hint is True + + +# --------------------------------------------------------------------------- +# 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(server="https://x.com/mcp") + assert tool.type == "mcp" + + def test_native_tool_is_tool(self): + tool: Tool = NativeTool( + name="t", provider="p", tool_config={}, url="https://x.com" + ) + assert tool.type == "native" + + 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( + server="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["server"] == "https://x.com/mcp" + assert result["type"] == "mcp" + + def test_mcp_with_auth_nested_serialization(self): + auth = McpAuth(type="bearer", token="tok123") + tool = MCPTool(server="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(server="https://x.com/mcp") + result = _normalize_tool(tool) + assert "allowedTools" not in result + assert "auth" not in result + assert set(result.keys()) == {"server", "type"} + + def test_native_tool_serialization(self): + tool = NativeTool( + name="computer_use", + provider="anthropic", + tool_config={"w": 1024}, + url="https://x.com", + timeout=30, + ) + result = _normalize_tool(tool) + assert result["name"] == "computer_use" + assert result["toolConfig"] == {"w": 1024} + assert result["timeout"] == 30 + assert "headers" not in result # None stripped + assert "defaults" not in result # None stripped + + def test_dict_passthrough(self): + raw = {"type": "custom", "name": "raw"} + result = _normalize_tool(raw) + assert result == raw + + def test_annotations_key_mapping(self): + ann = McpToolAnnotations( + title="Search", + read_only_hint=True, + destructive_hint=False, + ) + result = _normalize_tool(ann) + assert result["readOnlyHint"] is True + assert result["destructiveHint"] is False + assert "read_only_hint" not in result + assert "idempotentHint" not in result # None stripped + + +# --------------------------------------------------------------------------- +# 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} From 02b17107cad3bc8ee9268f88ce4eced54e63e568 Mon Sep 17 00:00:00 2001 From: Dana Wensberg Date: Wed, 25 Mar 2026 15:30:03 -0400 Subject: [PATCH 2/4] refactor: remove NativeTool and McpToolAnnotations from public API These are internal API types that should not be exposed in the SDK. Removes types, exports, key mappings, and related tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- subconscious/__init__.py | 4 -- subconscious/client.py | 5 -- subconscious/types.py | 31 +----------- tests/test_types.py | 106 --------------------------------------- 4 files changed, 1 insertion(+), 145 deletions(-) diff --git a/subconscious/__init__.py b/subconscious/__init__.py index 5b2e662..74e105b 100644 --- a/subconscious/__init__.py +++ b/subconscious/__init__.py @@ -25,8 +25,6 @@ FunctionTool, MCPTool, McpAuth, - NativeTool, - McpToolAnnotations, # Stream events StreamEvent, DeltaEvent, @@ -70,8 +68,6 @@ "FunctionTool", "MCPTool", "McpAuth", - "NativeTool", - "McpToolAnnotations", # Stream events "StreamEvent", "DeltaEvent", diff --git a/subconscious/client.py b/subconscious/client.py index c76ad03..01f547b 100644 --- a/subconscious/client.py +++ b/subconscious/client.py @@ -83,11 +83,6 @@ def _resolve_schema(schema: Any) -> Optional[Dict[str, Any]]: # Python snake_case → API camelCase key mapping for tool serialization _TOOL_KEY_MAP = { "allowed_tools": "allowedTools", - "tool_config": "toolConfig", - "read_only_hint": "readOnlyHint", - "destructive_hint": "destructiveHint", - "idempotent_hint": "idempotentHint", - "open_world_hint": "openWorldHint", } diff --git a/subconscious/types.py b/subconscious/types.py index c4b0649..026f3cb 100644 --- a/subconscious/types.py +++ b/subconscious/types.py @@ -178,37 +178,8 @@ class MCPTool: auth: Optional[McpAuth] = None -@dataclass -class NativeTool: - """A provider-native tool (e.g. Anthropic computer_use). - - Passed through to the provider without normalization. - """ - - name: str - provider: str - tool_config: Dict[str, Any] - url: str - method: Literal["POST", "GET"] = "POST" - type: Literal["native"] = "native" - timeout: Optional[int] = None - headers: Optional[Dict[str, str]] = None - defaults: Optional[Dict[str, Any]] = None - - -@dataclass -class McpToolAnnotations: - """Metadata hints from an MCP server about a tool's behavior.""" - - title: Optional[str] = None - read_only_hint: Optional[bool] = None - destructive_hint: Optional[bool] = None - idempotent_hint: Optional[bool] = None - open_world_hint: Optional[bool] = None - - # Tool union type -Tool = Union[PlatformTool, FunctionTool, MCPTool, NativeTool, Dict[str, Any]] +Tool = Union[PlatformTool, FunctionTool, MCPTool, Dict[str, Any]] @dataclass diff --git a/tests/test_types.py b/tests/test_types.py index 47cbf59..1833e44 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -5,9 +5,7 @@ from subconscious.types import ( FunctionTool, McpAuth, - McpToolAnnotations, MCPTool, - NativeTool, PlatformTool, Tool, ) @@ -54,76 +52,6 @@ def test_with_api_key_auth(self): assert tool.auth.header == "X-Api-Key" -# --------------------------------------------------------------------------- -# NativeTool construction -# --------------------------------------------------------------------------- - -class TestNativeTool: - def test_basic_construction(self): - tool = NativeTool( - name="computer_use", - provider="anthropic", - tool_config={"display_width": 1024}, - url="https://api.example.com/tools/computer", - ) - assert tool.name == "computer_use" - assert tool.provider == "anthropic" - assert tool.tool_config == {"display_width": 1024} - assert tool.url == "https://api.example.com/tools/computer" - assert tool.type == "native" - assert tool.method == "POST" - assert tool.timeout is None - assert tool.headers is None - assert tool.defaults is None - - def test_with_optional_fields(self): - tool = NativeTool( - name="computer_use", - provider="anthropic", - tool_config={}, - url="https://api.example.com/tools/computer", - method="GET", - timeout=30, - headers={"X-Custom": "val"}, - defaults={"display_width": 1024}, - ) - assert tool.method == "GET" - assert tool.timeout == 30 - assert tool.headers == {"X-Custom": "val"} - assert tool.defaults == {"display_width": 1024} - - -# --------------------------------------------------------------------------- -# McpToolAnnotations -# --------------------------------------------------------------------------- - -class TestMcpToolAnnotations: - def test_all_none_defaults(self): - ann = McpToolAnnotations() - assert ann.title is None - assert ann.read_only_hint is None - assert ann.destructive_hint is None - assert ann.idempotent_hint is None - assert ann.open_world_hint is None - - def test_partial_construction(self): - ann = McpToolAnnotations(title="Search", read_only_hint=True) - assert ann.title == "Search" - assert ann.read_only_hint is True - assert ann.destructive_hint is None - - def test_full_construction(self): - ann = McpToolAnnotations( - title="Delete", - read_only_hint=False, - destructive_hint=True, - idempotent_hint=False, - open_world_hint=True, - ) - assert ann.destructive_hint is True - assert ann.open_world_hint is True - - # --------------------------------------------------------------------------- # Tool union type assignability # --------------------------------------------------------------------------- @@ -141,12 +69,6 @@ def test_mcp_tool_is_tool(self): tool: Tool = MCPTool(server="https://x.com/mcp") assert tool.type == "mcp" - def test_native_tool_is_tool(self): - tool: Tool = NativeTool( - name="t", provider="p", tool_config={}, url="https://x.com" - ) - assert tool.type == "native" - def test_dict_is_tool(self): tool: Tool = {"type": "custom", "name": "raw"} assert tool["type"] == "custom" @@ -186,39 +108,11 @@ def test_strips_none_values(self): assert "auth" not in result assert set(result.keys()) == {"server", "type"} - def test_native_tool_serialization(self): - tool = NativeTool( - name="computer_use", - provider="anthropic", - tool_config={"w": 1024}, - url="https://x.com", - timeout=30, - ) - result = _normalize_tool(tool) - assert result["name"] == "computer_use" - assert result["toolConfig"] == {"w": 1024} - assert result["timeout"] == 30 - assert "headers" not in result # None stripped - assert "defaults" not in result # None stripped - def test_dict_passthrough(self): raw = {"type": "custom", "name": "raw"} result = _normalize_tool(raw) assert result == raw - def test_annotations_key_mapping(self): - ann = McpToolAnnotations( - title="Search", - read_only_hint=True, - destructive_hint=False, - ) - result = _normalize_tool(ann) - assert result["readOnlyHint"] is True - assert result["destructiveHint"] is False - assert "read_only_hint" not in result - assert "idempotentHint" not in result # None stripped - - # --------------------------------------------------------------------------- # Backward compatibility # --------------------------------------------------------------------------- From cf0201d89d3afe6c011e6eca33b7f94b0adb84bd Mon Sep 17 00:00:00 2001 From: Dana Wensberg Date: Thu, 26 Mar 2026 13:11:08 -0400 Subject: [PATCH 3/4] rename MCPTool.server to MCPTool.url, enhance McpAuth docs Aligns the Python SDK with the Node SDK by renaming the `server` field to `url` on MCPTool. Adds detailed docstring to McpAuth explaining bearer vs api_key auth methods. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 16 +++++++++------- subconscious/types.py | 23 ++++++++++++++++++++--- tests/test_types.py | 26 +++++++++++++------------- 3 files changed, 42 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 8d9dc43..6196229 100644 --- a/README.md +++ b/README.md @@ -214,7 +214,7 @@ custom_function = { # MCP tools (connect to any MCP server) mcp_tool = { "type": "mcp", - "server": "https://mcp.example.com", + "url": "https://mcp.example.com", "allowedTools": ["search", "get_page"], } ``` @@ -290,7 +290,7 @@ run = client.run( input={ "instructions": "Find my recent meeting notes", "tools": [ - MCPTool(server="https://mcp.notion.so/v1"), + MCPTool(url="https://mcp.notion.so/v1"), ], }, options={"await_completion": True}, @@ -303,7 +303,7 @@ run = client.run( "instructions": "Search my documents", "tools": [ MCPTool( - server="https://mcp.notion.so/v1", + url="https://mcp.notion.so/v1", allowed_tools=["search", "get_page"], # case-insensitive ), ], @@ -311,14 +311,14 @@ run = client.run( options={"await_completion": True}, ) -# With authentication +# With bearer auth (most common — e.g. OAuth tokens) run = client.run( engine="tim-gpt", input={ "instructions": "Check my calendar", "tools": [ MCPTool( - server="https://mcp.google.com/v1", + url="https://mcp.google.com/v1", auth=McpAuth(type="bearer", token="your-oauth-token"), ), ], @@ -327,13 +327,15 @@ run = client.run( ) # API key auth with custom header +# The header 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( - server="https://mcp.example.com", + url="https://mcp.example.com", auth=McpAuth(type="api_key", token="key123", header="X-Api-Key"), ), ], @@ -354,7 +356,7 @@ run = client.run( You can also pass MCP tools as plain dicts: ```python -{"type": "mcp", "server": "https://mcp.example.com", "allowedTools": ["search"]} +{"type": "mcp", "url": "https://mcp.example.com", "allowedTools": ["search"]} ``` ### Error Handling diff --git a/subconscious/types.py b/subconscious/types.py index 026f3cb..0a58f26 100644 --- a/subconscious/types.py +++ b/subconscious/types.py @@ -154,7 +154,24 @@ class FunctionTool: @dataclass class McpAuth: - """Authentication configuration for an MCP server.""" + """MCP Authentication. + + Used for MCP tools that require authentication. + Will take the shape of one of the following: + + - Bearer: ``{ "type": "bearer", "token": "" }`` + - API key: ``{ "type": "api_key", "token": "", "header": "
" }`` + + 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: Optional[str] = None @@ -166,13 +183,13 @@ class MCPTool: """An MCP (Model Context Protocol) tool. Attributes: - server: URL of the MCP server + 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 """ - server: str + url: str type: Literal["mcp"] = "mcp" allowed_tools: Optional[List[str]] = None auth: Optional[McpAuth] = None diff --git a/tests/test_types.py b/tests/test_types.py index 1833e44..85b9e00 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -18,27 +18,27 @@ class TestMCPTool: def test_basic_construction(self): - tool = MCPTool(server="https://example.com/mcp") - assert tool.server == "https://example.com/mcp" + 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(server="https://x.com/mcp", allowed_tools=["search", "fetch"]) + tool = MCPTool(url="https://x.com/mcp", allowed_tools=["search", "fetch"]) assert tool.allowed_tools == ["search", "fetch"] def test_with_wildcard(self): - tool = MCPTool(server="https://x.com/mcp", allowed_tools=["*"]) + tool = MCPTool(url="https://x.com/mcp", allowed_tools=["*"]) assert tool.allowed_tools == ["*"] def test_with_empty_list_blocks_all(self): - tool = MCPTool(server="https://x.com/mcp", allowed_tools=[]) + 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(server="https://x.com/mcp", auth=auth) + 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" @@ -46,7 +46,7 @@ def test_with_bearer_auth(self): def test_with_api_key_auth(self): auth = McpAuth(type="api_key", token="key456", header="X-Api-Key") - tool = MCPTool(server="https://x.com/mcp", auth=auth) + 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" @@ -66,7 +66,7 @@ def test_function_tool_is_tool(self): assert tool.type == "function" def test_mcp_tool_is_tool(self): - tool: Tool = MCPTool(server="https://x.com/mcp") + tool: Tool = MCPTool(url="https://x.com/mcp") assert tool.type == "mcp" def test_dict_is_tool(self): @@ -81,19 +81,19 @@ def test_dict_is_tool(self): class TestNormalizeTool: def test_mcp_key_mapping(self): tool = MCPTool( - server="https://x.com/mcp", + 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["server"] == "https://x.com/mcp" + 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(server="https://x.com/mcp", auth=auth) + tool = MCPTool(url="https://x.com/mcp", auth=auth) result = _normalize_tool(tool) assert isinstance(result["auth"], dict) assert result["auth"]["type"] == "bearer" @@ -102,11 +102,11 @@ def test_mcp_with_auth_nested_serialization(self): assert "header" not in result["auth"] def test_strips_none_values(self): - tool = MCPTool(server="https://x.com/mcp") + 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()) == {"server", "type"} + assert set(result.keys()) == {"url", "type"} def test_dict_passthrough(self): raw = {"type": "custom", "name": "raw"} From b48f6a09219365e8344ee321069c2f366e713928 Mon Sep 17 00:00:00 2001 From: Dana Wensberg Date: Thu, 26 Mar 2026 13:32:13 -0400 Subject: [PATCH 4/4] improve McpAuth docs: show HTTP header shapes, make token required - McpAuth.token is now required (str, not Optional) - Docstrings/comments show the HTTP header each auth method produces - README adds auth table with header shapes for bearer and api_key Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 13 ++++++++++++- subconscious/types.py | 8 ++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 6196229..e0e8aa1 100644 --- a/README.md +++ b/README.md @@ -279,6 +279,15 @@ Each tool can have its own headers and defaults - they're only applied when that 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 @@ -312,6 +321,7 @@ run = client.run( ) # With bearer auth (most common — e.g. OAuth tokens) +# → sends header: { "Authorization": "Bearer " } run = client.run( engine="tim-gpt", input={ @@ -327,7 +337,8 @@ run = client.run( ) # API key auth with custom header -# The header is typically "X-Api-Key" but may vary — +# → 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", diff --git a/subconscious/types.py b/subconscious/types.py index 0a58f26..99ee906 100644 --- a/subconscious/types.py +++ b/subconscious/types.py @@ -157,10 +157,10 @@ class McpAuth: """MCP Authentication. Used for MCP tools that require authentication. - Will take the shape of one of the following: + Translates to an HTTP header sent with every tool call: - - Bearer: ``{ "type": "bearer", "token": "" }`` - - API key: ``{ "type": "api_key", "token": "", "header": "
" }`` + - 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 — @@ -174,7 +174,7 @@ class McpAuth: """ type: Literal["bearer", "api_key"] - token: Optional[str] = None + token: str header: Optional[str] = None