Skip to content

Commit 0f9c792

Browse files
committed
refactor: make context required at internal layers, optional at MCPServer
Addresses review feedback asking when Context could be None. The answer: only via direct calls (tests, programmatic use). Rather than guard at the leaf layers, make the internal layers type-honest. - MCPServer.call_tool/read_resource/get_prompt: keep context optional, auto-construct Context(mcp_server=self) when None (restores the behavior get_context() used to provide) - ToolManager/Tool, PromptManager/Prompt, ResourceManager/ResourceTemplate: context is now required — type signature matches production reality where _handle_* always provides it - Guards removed from Tool.run/Prompt.render/ResourceTemplate.create_resource; no longer reachable since context is required - Prompt.render and PromptManager.render_prompt: arguments parameter no longer has a default (both arguments and context are now required positional) - Added TODO noting Context constructor nullability is vestigial (follow-up)
1 parent 87ed4b0 commit 0f9c792

File tree

16 files changed

+94
-128
lines changed

16 files changed

+94
-128
lines changed

docs/migration.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -315,9 +315,9 @@ async def my_tool(x: int, ctx: Context) -> str:
315315

316316
### `MCPServer.call_tool()`, `read_resource()`, `get_prompt()` now accept a `context` parameter
317317

318-
`MCPServer.call_tool()`, `MCPServer.read_resource()`, and `MCPServer.get_prompt()` now accept an optional `context: Context | None = None` parameter. The framework passes this automatically during normal request handling you only need to supply it when calling these methods directly.
318+
`MCPServer.call_tool()`, `MCPServer.read_resource()`, and `MCPServer.get_prompt()` now accept an optional `context: Context | None = None` parameter. The framework passes this automatically during normal request handling. If you call these methods directly and omit `context`, a Context with no active request is constructed for you — tools that don't use `ctx` work normally, but any attempt to use `ctx.session`, `ctx.request_id`, etc. will raise.
319319

320-
If the tool, resource template, or prompt you're invoking declares a `ctx: Context` parameter, you must pass a `Context`. Calling without one raises `ToolError` for tools or `ValueError` for prompts and resource templates.
320+
The internal layers (`ToolManager.call_tool`, `Tool.run`, `Prompt.render`, `ResourceTemplate.create_resource`, etc.) now require `context` as a positional argument.
321321

322322
### Replace `RootModel` by union types with `TypeAdapter` validation
323323

src/mcp/server/mcpserver/context.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ async def my_tool(x: int, ctx: Context) -> str:
5858
_request_context: ServerRequestContext[LifespanContextT, RequestT] | None
5959
_mcp_server: MCPServer | None
6060

61+
# TODO(maxisbey): Consider making request_context/mcp_server required, or refactor Context entirely.
6162
def __init__(
6263
self,
6364
*,

src/mcp/server/mcpserver/prompts/base.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -135,17 +135,14 @@ def from_function(
135135

136136
async def render(
137137
self,
138-
arguments: dict[str, Any] | None = None,
139-
context: Context[LifespanContextT, RequestT] | None = None,
138+
arguments: dict[str, Any] | None,
139+
context: Context[LifespanContextT, RequestT],
140140
) -> list[Message]:
141141
"""Render the prompt with arguments.
142142
143143
Raises:
144-
ValueError: If the prompt requires a Context but none was provided,
145-
if required arguments are missing, or if rendering fails.
144+
ValueError: If required arguments are missing, or if rendering fails.
146145
"""
147-
if self.context_kwarg is not None and context is None:
148-
raise ValueError(f"Prompt {self.name!r} requires a Context, but none was provided")
149146
# Validate required arguments
150147
if self.arguments:
151148
required = {arg.name for arg in self.arguments if arg.required}

src/mcp/server/mcpserver/prompts/manager.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,12 @@ def add_prompt(
4848
async def render_prompt(
4949
self,
5050
name: str,
51-
arguments: dict[str, Any] | None = None,
52-
context: Context[LifespanContextT, RequestT] | None = None,
51+
arguments: dict[str, Any] | None,
52+
context: Context[LifespanContextT, RequestT],
5353
) -> list[Message]:
5454
"""Render a prompt by name with arguments."""
5555
prompt = self.get_prompt(name)
5656
if not prompt:
5757
raise ValueError(f"Unknown prompt: {name}")
5858

59-
return await prompt.render(arguments, context=context)
59+
return await prompt.render(arguments, context)

src/mcp/server/mcpserver/resources/resource_manager.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,7 @@ def add_template(
8080
self._templates[template.uri_template] = template
8181
return template
8282

83-
async def get_resource(
84-
self, uri: AnyUrl | str, context: Context[LifespanContextT, RequestT] | None = None
85-
) -> Resource:
83+
async def get_resource(self, uri: AnyUrl | str, context: Context[LifespanContextT, RequestT]) -> Resource:
8684
"""Get resource by URI, checking concrete resources first, then templates."""
8785
uri_str = str(uri)
8886
logger.debug("Getting resource", extra={"uri": uri_str})

src/mcp/server/mcpserver/resources/templates.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99,16 +99,13 @@ async def create_resource(
9999
self,
100100
uri: str,
101101
params: dict[str, Any],
102-
context: Context[LifespanContextT, RequestT] | None = None,
102+
context: Context[LifespanContextT, RequestT],
103103
) -> Resource:
104104
"""Create a resource from the template with the given parameters.
105105
106106
Raises:
107-
ValueError: If the template requires a Context but none was provided,
108-
or if creating the resource fails.
107+
ValueError: If creating the resource fails.
109108
"""
110-
if self.context_kwarg is not None and context is None:
111-
raise ValueError(f"Resource template {self.name!r} requires a Context, but none was provided")
112109
try:
113110
# Add context to params if needed
114111
params = inject_context(self.fn, params, context, self.context_kwarg)

src/mcp/server/mcpserver/server.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,9 @@ async def call_tool(
392392
self, name: str, arguments: dict[str, Any], context: Context[LifespanResultT, Any] | None = None
393393
) -> Sequence[ContentBlock] | dict[str, Any]:
394394
"""Call a tool by name with arguments."""
395-
return await self._tool_manager.call_tool(name, arguments, context=context, convert_result=True)
395+
if context is None:
396+
context = Context(mcp_server=self)
397+
return await self._tool_manager.call_tool(name, arguments, context, convert_result=True)
396398

397399
async def list_resources(self) -> list[MCPResource]:
398400
"""List all available resources."""
@@ -432,8 +434,10 @@ async def read_resource(
432434
self, uri: AnyUrl | str, context: Context[LifespanResultT, Any] | None = None
433435
) -> Iterable[ReadResourceContents]:
434436
"""Read a resource by URI."""
437+
if context is None:
438+
context = Context(mcp_server=self)
435439
try:
436-
resource = await self._resource_manager.get_resource(uri, context=context)
440+
resource = await self._resource_manager.get_resource(uri, context)
437441
except ValueError:
438442
raise ResourceError(f"Unknown resource: {uri}")
439443

@@ -1081,12 +1085,14 @@ async def get_prompt(
10811085
self, name: str, arguments: dict[str, Any] | None = None, context: Context[LifespanResultT, Any] | None = None
10821086
) -> GetPromptResult:
10831087
"""Get a prompt by name with arguments."""
1088+
if context is None:
1089+
context = Context(mcp_server=self)
10841090
try:
10851091
prompt = self._prompt_manager.get_prompt(name)
10861092
if not prompt:
10871093
raise ValueError(f"Unknown prompt: {name}")
10881094

1089-
messages = await prompt.render(arguments, context=context)
1095+
messages = await prompt.render(arguments, context)
10901096

10911097
return GetPromptResult(
10921098
description=prompt.description,

src/mcp/server/mcpserver/tools/base.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -92,17 +92,14 @@ def from_function(
9292
async def run(
9393
self,
9494
arguments: dict[str, Any],
95-
context: Context[LifespanContextT, RequestT] | None = None,
95+
context: Context[LifespanContextT, RequestT],
9696
convert_result: bool = False,
9797
) -> Any:
9898
"""Run the tool with arguments.
9999
100100
Raises:
101-
ToolError: If the tool requires a Context but none was provided,
102-
or if the tool function raises during execution.
101+
ToolError: If the tool function raises during execution.
103102
"""
104-
if self.context_kwarg is not None and context is None:
105-
raise ToolError(f"Tool {self.name!r} requires a Context, but none was provided")
106103
try:
107104
result = await self.fn_metadata.call_fn_with_arg_validation(
108105
self.fn,

src/mcp/server/mcpserver/tools/tool_manager.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,12 @@ async def call_tool(
8181
self,
8282
name: str,
8383
arguments: dict[str, Any],
84-
context: Context[LifespanContextT, RequestT] | None = None,
84+
context: Context[LifespanContextT, RequestT],
8585
convert_result: bool = False,
8686
) -> Any:
8787
"""Call a tool by name with arguments."""
8888
tool = self.get_tool(name)
8989
if not tool:
9090
raise ToolError(f"Unknown tool: {name}")
9191

92-
return await tool.run(arguments, context=context, convert_result=convert_result)
92+
return await tool.run(arguments, context, convert_result=convert_result)

tests/server/mcpserver/prompts/test_base.py

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,23 +14,27 @@ def fn() -> str:
1414
return "Hello, world!"
1515

1616
prompt = Prompt.from_function(fn)
17-
assert await prompt.render() == [UserMessage(content=TextContent(type="text", text="Hello, world!"))]
17+
assert await prompt.render(None, Context()) == [
18+
UserMessage(content=TextContent(type="text", text="Hello, world!"))
19+
]
1820

1921
@pytest.mark.anyio
2022
async def test_async_fn(self):
2123
async def fn() -> str:
2224
return "Hello, world!"
2325

2426
prompt = Prompt.from_function(fn)
25-
assert await prompt.render() == [UserMessage(content=TextContent(type="text", text="Hello, world!"))]
27+
assert await prompt.render(None, Context()) == [
28+
UserMessage(content=TextContent(type="text", text="Hello, world!"))
29+
]
2630

2731
@pytest.mark.anyio
2832
async def test_fn_with_args(self):
2933
async def fn(name: str, age: int = 30) -> str:
3034
return f"Hello, {name}! You're {age} years old."
3135

3236
prompt = Prompt.from_function(fn)
33-
assert await prompt.render(arguments={"name": "World"}) == [
37+
assert await prompt.render({"name": "World"}, Context()) == [
3438
UserMessage(content=TextContent(type="text", text="Hello, World! You're 30 years old."))
3539
]
3640

@@ -41,23 +45,27 @@ async def fn(name: str, age: int = 30) -> str: # pragma: no cover
4145

4246
prompt = Prompt.from_function(fn)
4347
with pytest.raises(ValueError):
44-
await prompt.render(arguments={"age": 40})
48+
await prompt.render({"age": 40}, Context())
4549

4650
@pytest.mark.anyio
4751
async def test_fn_returns_message(self):
4852
async def fn() -> UserMessage:
4953
return UserMessage(content="Hello, world!")
5054

5155
prompt = Prompt.from_function(fn)
52-
assert await prompt.render() == [UserMessage(content=TextContent(type="text", text="Hello, world!"))]
56+
assert await prompt.render(None, Context()) == [
57+
UserMessage(content=TextContent(type="text", text="Hello, world!"))
58+
]
5359

5460
@pytest.mark.anyio
5561
async def test_fn_returns_assistant_message(self):
5662
async def fn() -> AssistantMessage:
5763
return AssistantMessage(content=TextContent(type="text", text="Hello, world!"))
5864

5965
prompt = Prompt.from_function(fn)
60-
assert await prompt.render() == [AssistantMessage(content=TextContent(type="text", text="Hello, world!"))]
66+
assert await prompt.render(None, Context()) == [
67+
AssistantMessage(content=TextContent(type="text", text="Hello, world!"))
68+
]
6169

6270
@pytest.mark.anyio
6371
async def test_fn_returns_multiple_messages(self):
@@ -71,7 +79,7 @@ async def fn() -> list[Message]:
7179
return expected
7280

7381
prompt = Prompt.from_function(fn)
74-
assert await prompt.render() == expected
82+
assert await prompt.render(None, Context()) == expected
7583

7684
@pytest.mark.anyio
7785
async def test_fn_returns_list_of_strings(self):
@@ -84,7 +92,7 @@ async def fn() -> list[str]:
8492
return expected
8593

8694
prompt = Prompt.from_function(fn)
87-
assert await prompt.render() == [UserMessage(t) for t in expected]
95+
assert await prompt.render(None, Context()) == [UserMessage(t) for t in expected]
8896

8997
@pytest.mark.anyio
9098
async def test_fn_returns_resource_content(self):
@@ -103,7 +111,7 @@ async def fn() -> UserMessage:
103111
)
104112

105113
prompt = Prompt.from_function(fn)
106-
assert await prompt.render() == [
114+
assert await prompt.render(None, Context()) == [
107115
UserMessage(
108116
content=EmbeddedResource(
109117
type="resource",
@@ -137,7 +145,7 @@ async def fn() -> list[Message]:
137145
]
138146

139147
prompt = Prompt.from_function(fn)
140-
assert await prompt.render() == [
148+
assert await prompt.render(None, Context()) == [
141149
UserMessage(content=TextContent(type="text", text="Please analyze this file:")),
142150
UserMessage(
143151
content=EmbeddedResource(
@@ -170,7 +178,7 @@ async def fn() -> dict[str, Any]:
170178
}
171179

172180
prompt = Prompt.from_function(fn)
173-
assert await prompt.render() == [
181+
assert await prompt.render(None, Context()) == [
174182
UserMessage(
175183
content=EmbeddedResource(
176184
type="resource",
@@ -182,13 +190,3 @@ async def fn() -> dict[str, Any]:
182190
)
183191
)
184192
]
185-
186-
187-
@pytest.mark.anyio
188-
async def test_render_raises_when_context_required_but_not_provided():
189-
def fn(name: str, ctx: Context) -> str:
190-
raise NotImplementedError
191-
192-
prompt = Prompt.from_function(fn)
193-
with pytest.raises(ValueError, match="requires a Context"):
194-
await prompt.render({"name": "world"})

0 commit comments

Comments
 (0)