Skip to content

Commit 4740fe1

Browse files
committed
feat(tests): implement resource reading and prompt retrieval via middleware
- Added functionality to handle `resources/read` and `prompts/get` requests through middleware, allowing seamless proxying to mounted backend services. - Introduced new integration tests to validate the correct behavior of the middleware for reading resources and retrieving prompts. - Updated Gherkin scenarios to cover the new resource reading feature, ensuring comprehensive testing of the passthrough functionality. - Enhanced existing fixtures to support the new middleware interactions, improving overall test coverage and reliability.
1 parent 781600a commit 4740fe1

7 files changed

Lines changed: 164 additions & 1 deletion

File tree

‎scripts/local/tests/pytest-unit-shard-weights.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@
5858
"src/tests/unit/test_cyt_mcp_cli.py": 0.37,
5959
"src/tests/unit/test_cyt_mcp_config.py": 0.72,
6060
"src/tests/unit/test_cyt_mcp_mcp_entry.py": 0.48,
61-
"src/tests/unit/test_cyt_mcp_offerings_cache.py": 0.24,
61+
"src/tests/unit/gherkin/test_cyt_mcp_offerings_passthrough_gherkin.py": 0.24,
6262
"src/tests/unit/test_cyt_mcp_search.py": 0.63,
6363
"src/tests/unit/test_cyt_mcp_session_log_wire_name.py": 0.02,
6464
"src/tests/unit/test_cyt_mcp_session_runtime.py": 0.24,

‎src/tests/fixtures/cyt_mcp_offerings_passthrough/scenarios.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@
2020
"method": "tools/list",
2121
"cached_tool_name": "gitnexus_cypher",
2222
"expects_call_next": false
23+
},
24+
{
25+
"id": "read_resource_passthrough",
26+
"method": "resources/read",
27+
"expected_resource_body": "demo resource body"
2328
}
2429
]
2530
}

‎src/tests/integration/test_cyt_mcp_offerings_passthrough_integration.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,12 @@
1111
from cyt_mcp.search import MCP_WIRE_SEARCH_TOOL_NAME
1212
from tests.support.cyt_mcp_offerings_passthrough_fixtures import (
1313
build_aggregator_with_offerings_backend,
14+
get_prompt_via_middleware,
1415
list_offerings_via_middleware,
16+
load_backend_offerings,
1517
load_offerings_passthrough_scenarios,
1618
make_offerings_list_context,
19+
read_resource_via_middleware,
1720
)
1821

1922

@@ -106,3 +109,39 @@ async def test_tools_list_still_serves_stubs_while_offerings_passthrough() -> No
106109
assert scenario.cached_tool_name in names
107110
assert MCP_WIRE_SEARCH_TOOL_NAME in names
108111
call_next.assert_not_awaited()
112+
113+
114+
@pytest.mark.integration
115+
@pytest.mark.asyncio
116+
async def test_middleware_read_resource_proxies_mounted_backend() -> None:
117+
server, middleware, _coordinator, spec = build_aggregator_with_offerings_backend()
118+
scenario = next(
119+
item
120+
for item in load_offerings_passthrough_scenarios()
121+
if item.id == "read_resource_passthrough"
122+
)
123+
assert scenario.expected_resource_body is not None
124+
resource_uri = spec.resources[0].uri
125+
namespaced_uri = f"gitnexus://{spec.server_key}/{resource_uri.split('://', 1)[1]}"
126+
127+
result = await read_resource_via_middleware(
128+
middleware,
129+
server,
130+
uri=namespaced_uri,
131+
)
132+
133+
contents = getattr(result, "contents", None) or []
134+
assert contents
135+
assert scenario.expected_resource_body in str(getattr(contents[0], "content", ""))
136+
137+
138+
@pytest.mark.integration
139+
@pytest.mark.asyncio
140+
async def test_middleware_get_prompt_proxies_mounted_backend() -> None:
141+
server, middleware, _coordinator, spec = build_aggregator_with_offerings_backend()
142+
offerings = load_backend_offerings()
143+
prompt_name = f"{spec.server_key}_{offerings.prompts[0].name}"
144+
145+
result = await get_prompt_via_middleware(middleware, server, name=prompt_name)
146+
147+
assert str(getattr(result, "name", "")) == prompt_name

‎src/tests/support/cyt_mcp_offerings_passthrough_fixtures.py‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,14 @@
1212
from fastmcp import FastMCP
1313
from fastmcp.server.middleware import MiddlewareContext
1414
from mcp.types import (
15+
GetPromptRequestParams,
1516
ListPromptsRequest,
1617
ListResourcesRequest,
1718
ListResourceTemplatesRequest,
1819
ListToolsRequest,
20+
ReadResourceRequestParams,
1921
)
22+
from pydantic.networks import AnyUrl
2023

2124
from cyt_mcp.aggregator import build_aggregator
2225
from cyt_mcp.config import AggregatorConfig, sample_aggregator_config
@@ -67,6 +70,8 @@ class OfferingsPassthroughScenario:
6770
expected_template_names: tuple[str, ...] = ()
6871
cached_tool_name: str | None = None
6972
expects_call_next: bool | None = None
73+
expected_resource_body: str | None = None
74+
read_resource_uri: str | None = None
7075

7176

7277
def _load_json(path: Path) -> dict[str, Any]:
@@ -161,6 +166,14 @@ def load_offerings_passthrough_scenarios(
161166
expects_call_next=(
162167
bool(item["expects_call_next"]) if "expects_call_next" in item else None
163168
),
169+
expected_resource_body=(
170+
str(item["expected_resource_body"])
171+
if item.get("expected_resource_body")
172+
else None
173+
),
174+
read_resource_uri=(
175+
str(item["read_resource_uri"]) if item.get("read_resource_uri") else None
176+
),
164177
),
165178
)
166179
return tuple(scenarios)
@@ -295,3 +308,47 @@ async def _call_next(_ctx: MiddlewareContext[Any]) -> Sequence[Any]:
295308

296309
result = await handler(context, _call_next)
297310
return cast(Sequence[Any], result)
311+
312+
313+
def make_read_resource_context(uri: str) -> MiddlewareContext[Any]:
314+
context = MagicMock()
315+
context.method = "resources/read"
316+
context.message = ReadResourceRequestParams(uri=AnyUrl(uri))
317+
context.fastmcp_context = None
318+
return context
319+
320+
321+
def make_get_prompt_context(name: str) -> MiddlewareContext[Any]:
322+
context = MagicMock()
323+
context.method = "prompts/get"
324+
context.message = GetPromptRequestParams(name=name)
325+
context.fastmcp_context = None
326+
return context
327+
328+
329+
async def read_resource_via_middleware(
330+
middleware: SessionWorkspaceMiddleware,
331+
server: FastMCP,
332+
*,
333+
uri: str,
334+
) -> Any:
335+
context = make_read_resource_context(uri)
336+
337+
async def _call_next(_ctx: MiddlewareContext[Any]) -> Any:
338+
return await server.read_resource(uri)
339+
340+
return await middleware.on_read_resource(context, _call_next)
341+
342+
343+
async def get_prompt_via_middleware(
344+
middleware: SessionWorkspaceMiddleware,
345+
server: FastMCP,
346+
*,
347+
name: str,
348+
) -> Any:
349+
context = make_get_prompt_context(name)
350+
351+
async def _call_next(_ctx: MiddlewareContext[Any]) -> Any:
352+
return await server._get_prompt(name)
353+
354+
return await middleware.on_get_prompt(context, _call_next)

‎src/tests/unit/gherkin/features/cyt_mcp_offerings_passthrough.feature‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,8 @@ Feature: cyt-mcp offerings passthrough
2222
When middleware handles tools/list
2323
Then tools/list should serve stub projections including gitnexus_cypher
2424
And tools/list should not proxy to backend tools/list
25+
26+
Scenario: Read resource proxies to mounted backend content
27+
Given a cyt-mcp aggregator with mounted backend offerings
28+
When middleware handles resources/read for gitnexus://gitnexus/repo/demo
29+
Then resources/read should return backend body demo resource body

‎src/tests/unit/gherkin/test_cyt_mcp_offerings_passthrough_gherkin.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
build_aggregator_with_offerings_backend,
1515
list_offerings_via_middleware,
1616
make_offerings_list_context,
17+
read_resource_via_middleware,
1718
)
1819
from tests.unit.gherkin.conftest import GherkinContext
1920

@@ -140,3 +141,23 @@ def then_tools_include_stub(tool_name: str, gherkin_context: GherkinContext) ->
140141
def then_tools_do_not_proxy(gherkin_context: GherkinContext) -> None:
141142
call_next = gherkin_context.payload["call_next"]
142143
call_next.assert_not_awaited()
144+
145+
146+
@when(parsers.parse("middleware handles resources/read for {uri}"))
147+
def when_read_resource(uri: str, gherkin_context: GherkinContext) -> None:
148+
payload = gherkin_context.payload
149+
gherkin_context.payload["result"] = asyncio.run(
150+
read_resource_via_middleware(
151+
payload["middleware"],
152+
payload["server"],
153+
uri=uri,
154+
),
155+
)
156+
157+
158+
@then(parsers.parse("resources/read should return backend body {body}"))
159+
def then_read_resource_body(body: str, gherkin_context: GherkinContext) -> None:
160+
result = gherkin_context.payload["result"]
161+
contents = getattr(result, "contents", None) or []
162+
assert contents
163+
assert body in str(getattr(contents[0], "content", ""))

‎src/tests/unit/test_session_workspace_middleware.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,17 @@
66
from pathlib import Path
77
from unittest.mock import AsyncMock, MagicMock, patch
88

9+
from fastmcp.prompts.base import PromptResult
10+
from fastmcp.resources.base import ResourceResult
911
from fastmcp.tools.base import Tool
1012
from mcp.types import (
13+
GetPromptRequestParams,
1114
ListPromptsRequest,
1215
ListResourcesRequest,
1316
ListResourceTemplatesRequest,
1417
ListToolsRequest,
1518
Prompt,
19+
ReadResourceRequestParams,
1620
Resource,
1721
ResourceTemplate,
1822
)
@@ -285,3 +289,35 @@ def test_list_offering_handlers_never_ignore_call_next(tmp_path: Path) -> None:
285289
assert result == [sentinel], f"{method} must proxy via call_next"
286290
call_next.assert_awaited_once_with(context)
287291
call_next.reset_mock()
292+
293+
294+
def test_on_get_prompt_proxies_to_backends(tmp_path: Path) -> None:
295+
middleware, coordinator = _middleware_for_scope(tmp_path, catalog_scope="user")
296+
backend_result = PromptResult(messages=[])
297+
call_next = AsyncMock(return_value=backend_result)
298+
context = MagicMock()
299+
context.method = "prompts/get"
300+
context.message = GetPromptRequestParams(name="gitnexus_review")
301+
context.fastmcp_context = None
302+
303+
with patch.object(coordinator, "ensure_backends_mounted"):
304+
result = asyncio.run(middleware.on_get_prompt(context, call_next))
305+
306+
assert result is backend_result
307+
call_next.assert_awaited_once_with(context)
308+
309+
310+
def test_on_read_resource_proxies_to_backends(tmp_path: Path) -> None:
311+
middleware, coordinator = _middleware_for_scope(tmp_path, catalog_scope="user")
312+
backend_result = ResourceResult(contents=[])
313+
call_next = AsyncMock(return_value=backend_result)
314+
context = MagicMock()
315+
context.method = "resources/read"
316+
context.message = ReadResourceRequestParams(uri=AnyUrl("gitnexus://gitnexus/repo/demo"))
317+
context.fastmcp_context = None
318+
319+
with patch.object(coordinator, "ensure_backends_mounted"):
320+
result = asyncio.run(middleware.on_read_resource(context, call_next))
321+
322+
assert result is backend_result
323+
call_next.assert_awaited_once_with(context)

0 commit comments

Comments
 (0)