Skip to content

Commit c646094

Browse files
authored
feat: Add support for auth related features (#363)
* test fix * lint * make invoke method return str * lint * try * version negotiation * small changes * lint * fix endpoint * add some todos * lint * initialise in init * lint * add support for 'Mcp-session-id' * lint * add todo * add mcp protocol version to the latest protocol * small fix * small fix * small fix * thread fixes * try * add tests * lint * change small * small debugging * add todos * small bug fixes * add todo * remove id field from notifications * refactor * preprocess tools with empty params * fix types * fix bugs * better error log * small cleanup * handle notifications * fix unit tests * lint * decouple client from transport * lint * use toolbox protocol for e2e tests * lint * remove mcp as default protocol * remove redundant lines * remove redundant lines * lint * revert some changes * initialise session in a better way * small fix * Made methods private * lint * rename base url * resolve comment * better readability * add auth tests * lint * fix test * rename authParam to authParams * refactor mcp versions * fix tests * lint * add auth param support code * lint * add unit test * lint * test fix * lint * fix test * better error handling * fix test * add debug statement * add debug statement * add debug statement * remove debug * remove not needed files * refactor mcp * lint * improve cov * lint * add feat files * small fix * small fix * Update test_e2e_mcp.py * add new tests * add more test cases * remove files * remove rebase changes * fix rebase issues * lint * fix rebase issues * add test case * fix test * fix convert schema logic * lint
1 parent 06613ff commit c646094

File tree

3 files changed

+148
-2
lines changed

3 files changed

+148
-2
lines changed

packages/toolbox-core/src/toolbox_core/mcp_transport/transport_base.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,24 @@ def base_url(self) -> str:
5757
return self._mcp_base_url
5858

5959
def _convert_tool_schema(self, tool_data: dict) -> ToolSchema:
60-
"""Converts a raw MCP tool dictionary into the Toolbox ToolSchema."""
60+
"""
61+
Safely converts the raw tool dictionary from the server into a ToolSchema object,
62+
robustly handling optional authentication metadata.
63+
"""
64+
param_auth = None
65+
invoke_auth = []
66+
67+
if "_meta" in tool_data and isinstance(tool_data["_meta"], dict):
68+
meta = tool_data["_meta"]
69+
if "toolbox/authParam" in meta and isinstance(
70+
meta["toolbox/authParam"], dict
71+
):
72+
param_auth = meta["toolbox/authParam"]
73+
if "toolbox/authInvoke" in meta and isinstance(
74+
meta["toolbox/authInvoke"], list
75+
):
76+
invoke_auth = meta["toolbox/authInvoke"]
77+
6178
parameters = []
6279
input_schema = tool_data.get("inputSchema", {})
6380
properties = input_schema.get("properties", {})
@@ -71,18 +88,26 @@ def _convert_tool_schema(self, tool_data: dict) -> ToolSchema:
7188
)
7289
else:
7390
additional_props = True
91+
92+
if param_auth and name in param_auth:
93+
auth_sources = param_auth[name]
94+
else:
95+
auth_sources = None
7496
parameters.append(
7597
ParameterSchema(
7698
name=name,
7799
type=schema["type"],
78100
description=schema.get("description", ""),
79101
required=name in required,
80102
additionalProperties=additional_props,
103+
authSources=auth_sources,
81104
)
82105
)
83106

84107
return ToolSchema(
85-
description=tool_data.get("description") or "", parameters=parameters
108+
description=tool_data.get("description") or "",
109+
parameters=parameters,
110+
authRequired=invoke_auth,
86111
)
87112

88113
async def close(self):

packages/toolbox-core/tests/mcp_transport/test_base.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,34 @@ def test_convert_tool_schema_complex_types(self, transport):
162162
assert p_obj.type == "object"
163163
assert p_obj.additionalProperties.type == "integer"
164164

165+
def test_convert_tool_schema_with_auth_metadata(self, transport):
166+
"""Test converting tool schema with auth metadata fields."""
167+
raw_tool = {
168+
"name": "auth_tool",
169+
"description": "Tool with auth params",
170+
"inputSchema": {
171+
"type": "object",
172+
"properties": {
173+
"apiKey": {"type": "string"},
174+
},
175+
},
176+
"_meta": {
177+
"toolbox/authParam": {"apiKey": ["my-auth-source"]},
178+
"toolbox/authInvoke": ["my-auth-invoke"],
179+
},
180+
}
181+
182+
schema = transport._convert_tool_schema(raw_tool)
183+
184+
assert isinstance(schema, ToolSchema)
185+
186+
# Check that authRequired (from toolbox/authInvoke) was populated
187+
assert schema.authRequired == ["my-auth-invoke"]
188+
189+
# Check that authSources (from toolbox/authParam) was populated on the parameter
190+
p_api_key = next(p for p in schema.parameters if p.name == "apiKey")
191+
assert p_api_key.authSources == ["my-auth-source"]
192+
165193
@pytest.mark.asyncio
166194
async def test_close_managed_session(self, mocker):
167195
mock_close = mocker.patch("aiohttp.ClientSession.close", new_callable=AsyncMock)

packages/toolbox-core/tests/test_e2e_mcp.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,99 @@ async def test_bind_params_callable(
139139
assert "row4" not in response
140140

141141

142+
@pytest.mark.asyncio
143+
@pytest.mark.usefixtures("toolbox_server")
144+
class TestAuth:
145+
async def test_run_tool_unauth_with_auth(
146+
self, toolbox: ToolboxClient, auth_token2: str
147+
):
148+
"""Tests running a tool that doesn't require auth, with auth provided."""
149+
150+
with pytest.raises(
151+
ValueError,
152+
match=rf"Validation failed for tool 'get-row-by-id': unused auth tokens: my-test-auth",
153+
):
154+
await toolbox.load_tool(
155+
"get-row-by-id",
156+
auth_token_getters={"my-test-auth": lambda: auth_token2},
157+
)
158+
159+
async def test_run_tool_no_auth(self, toolbox: ToolboxClient):
160+
"""Tests running a tool requiring auth without providing auth."""
161+
tool = await toolbox.load_tool("get-row-by-id-auth")
162+
with pytest.raises(
163+
PermissionError,
164+
match="One or more of the following authn services are required to invoke this tool: my-test-auth",
165+
):
166+
await tool(id="2")
167+
168+
async def test_run_tool_wrong_auth(self, toolbox: ToolboxClient, auth_token2: str):
169+
"""Tests running a tool with incorrect auth. The tool
170+
requires a different authentication than the one provided."""
171+
tool = await toolbox.load_tool("get-row-by-id-auth")
172+
auth_tool = tool.add_auth_token_getters({"my-test-auth": lambda: auth_token2})
173+
with pytest.raises(
174+
Exception,
175+
match="tool invocation not authorized. Please make sure your specify correct auth headers",
176+
):
177+
await auth_tool(id="2")
178+
179+
async def test_run_tool_auth(self, toolbox: ToolboxClient, auth_token1: str):
180+
"""Tests running a tool with correct auth."""
181+
tool = await toolbox.load_tool("get-row-by-id-auth")
182+
auth_tool = tool.add_auth_token_getters({"my-test-auth": lambda: auth_token1})
183+
response = await auth_tool(id="2")
184+
assert "row2" in response
185+
186+
@pytest.mark.asyncio
187+
async def test_run_tool_async_auth(self, toolbox: ToolboxClient, auth_token1: str):
188+
"""Tests running a tool with correct auth using an async token getter."""
189+
tool = await toolbox.load_tool("get-row-by-id-auth")
190+
191+
async def get_token_asynchronously():
192+
return auth_token1
193+
194+
auth_tool = tool.add_auth_token_getters(
195+
{"my-test-auth": get_token_asynchronously}
196+
)
197+
response = await auth_tool(id="2")
198+
assert "row2" in response
199+
200+
async def test_run_tool_param_auth_no_auth(self, toolbox: ToolboxClient):
201+
"""Tests running a tool with a param requiring auth, without auth."""
202+
tool = await toolbox.load_tool("get-row-by-email-auth")
203+
with pytest.raises(
204+
PermissionError,
205+
match="One or more of the following authn services are required to invoke this tool: my-test-auth",
206+
):
207+
await tool()
208+
209+
async def test_run_tool_param_auth(self, toolbox: ToolboxClient, auth_token1: str):
210+
"""Tests running a tool with a param requiring auth, with correct auth."""
211+
tool = await toolbox.load_tool(
212+
"get-row-by-email-auth",
213+
auth_token_getters={"my-test-auth": lambda: auth_token1},
214+
)
215+
response = await tool()
216+
assert "row4" in response
217+
assert "row5" in response
218+
assert "row6" in response
219+
220+
async def test_run_tool_param_auth_no_field(
221+
self, toolbox: ToolboxClient, auth_token1: str
222+
):
223+
"""Tests running a tool with a param requiring auth, with insufficient auth."""
224+
tool = await toolbox.load_tool(
225+
"get-row-by-content-auth",
226+
auth_token_getters={"my-test-auth": lambda: auth_token1},
227+
)
228+
with pytest.raises(
229+
Exception,
230+
match="no field named row_data in claims",
231+
):
232+
await tool()
233+
234+
142235
@pytest.mark.asyncio
143236
@pytest.mark.usefixtures("toolbox_server")
144237
class TestOptionalParams:

0 commit comments

Comments
 (0)