Skip to content

Commit ebbc057

Browse files
package(feat): #17 use lifespan and remove context
- promts also can access context and request - general refactoring - add simple server - update version
1 parent 0b06117 commit ebbc057

26 files changed

+457
-299
lines changed

README.md

Lines changed: 155 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,62 @@ in the [tests/app/ folder](tests/app).
2323
handling.
2424
- **Type-Safe**: Leverages `Pydantic` for robust data validation and
2525
serialization.
26-
- **Stateful Context**: Maintain state across tool calls using a context object.
27-
- **Request Access**: Access the incoming request object from your tools.
26+
- **Server State Management**: Access shared state through the lifespan context
27+
using the `get_state_key` method.
28+
- **Request Access**: Access the incoming request object from your tools and
29+
prompts.
30+
31+
## Server Architecture
32+
33+
The library provides a single `MCPServer` class that uses lifespan to manage
34+
shared state across the entire application lifecycle.
35+
36+
### MCPServer
37+
38+
The `MCPServer` is designed to work with Starlette's lifespan system for
39+
managing shared server state.
40+
41+
**Key Characteristics:**
42+
43+
- **Lifespan Based**: Uses Starlette's lifespan events to initialize and manage
44+
shared server state
45+
- **Application-Level State**: State persists across the entire application
46+
lifecycle, not per-request
47+
- **Flexible**: Can be used with any custom context class stored in the lifespan
48+
state
49+
50+
**Example Usage:**
51+
52+
```python
53+
import contextlib
54+
from collections.abc import AsyncIterator
55+
from typing import TypedDict
56+
from dataclasses import dataclass, field
57+
from starlette.applications import Starlette
58+
from http_mcp.server import MCPServer
59+
60+
@dataclass
61+
class Context:
62+
call_count: int = 0
63+
user_preferences: dict = field(default_factory=dict)
64+
65+
class State(TypedDict):
66+
context: Context
67+
68+
@contextlib.asynccontextmanager
69+
async def lifespan(_app: Starlette) -> AsyncIterator[State]:
70+
yield {"context": Context()}
71+
72+
mcp_server = MCPServer(
73+
name="my-server",
74+
version="1.0.0",
75+
tools=my_tools,
76+
prompts=my_prompts
77+
)
78+
79+
app = Starlette(lifespan=lifespan)
80+
app.mount("/mcp", mcp_server.app)
81+
```
2882

2983
## Tools
3084

@@ -52,26 +106,26 @@ class GreetOutput(BaseModel):
52106

53107
```python
54108
# app/tools/tools.py
55-
from http_mcp.tools import ToolArguments
109+
from http_mcp.types import Arguments
56110

57111
from app.tools.models import GreetInput, GreetOutput
58112

59-
def greet(args: ToolArguments[GreetInput, None]) -> GreetOutput:
60-
return GreetOutput(answer=f"Hello, {args.inputs.question}!") # access inputs via args.inputs
113+
def greet(args: Arguments[GreetInput]) -> GreetOutput:
114+
return GreetOutput(answer=f"Hello, {args.inputs.question}!")
61115

62116
```
63117

64118
```python
65119
# app/tools/__init__.py
66120

67-
from http_mcp.tools import Tool
121+
from http_mcp.types import Tool
68122
from app.tools.models import GreetInput, GreetOutput
69123
from app.tools.tools import greet
70124

71125
TOOLS = (
72126
Tool(
73127
func=greet,
74-
input=GreetInput,
128+
inputs=GreetInput,
75129
output=GreetOutput,
76130
),
77131
)
@@ -95,16 +149,14 @@ app.mount(
95149
"/mcp",
96150
mcp_server.app,
97151
)
98-
99152
```
100153

101-
## Stateful Context
154+
## Server State Management
102155

103-
This is the server context attribute; it can be seen as a global state for the
104-
server.
105-
106-
You can use a context object to maintain state across tool calls. The context
107-
object is passed to each tool call and can be used to store and retrieve data.
156+
The server uses Starlette's lifespan system to manage shared state across the
157+
entire application lifecycle. State is initialized when the application starts
158+
and persists until it shuts down. Context is accessed through the
159+
`get_state_key` method on the `Arguments` object.
108160

109161
Example:
110162

@@ -126,26 +178,38 @@ class Context:
126178
self.called_tools.append(tool_name)
127179
```
128180

129-
1. **Instantiate the context and the server:**
181+
2. **Set up the application with lifespan:**
130182

131183
```python
132-
from app.tools import TOOLS
184+
import contextlib
185+
from collections.abc import AsyncIterator
186+
from typing import TypedDict
187+
from starlette.applications import Starlette
133188
from app.context import Context
134189
from http_mcp.server import MCPServer
135190

136-
mcp_server: MCPServer[Context] = MCPServer(
191+
class State(TypedDict):
192+
context: Context
193+
194+
@contextlib.asynccontextmanager
195+
async def lifespan(_app: Starlette) -> AsyncIterator[State]:
196+
yield {"context": Context(called_tools=[])}
197+
198+
mcp_server = MCPServer(
137199
tools=TOOLS,
138200
name="test",
139201
version="1.0.0",
140-
context=Context(called_tools=[]),
141202
)
203+
204+
app = Starlette(lifespan=lifespan)
205+
app.mount("/mcp", mcp_server.app)
142206
```
143207

144-
1. **Access the context in your tools:**
208+
3. **Access the context in your tools:**
145209

146210
```python
147211
from pydantic import BaseModel, Field
148-
from http_mcp.tools import ToolArguments
212+
from http_mcp.types import Arguments
149213
from app.context import Context
150214

151215
class MyToolArguments(BaseModel):
@@ -154,23 +218,24 @@ class MyToolArguments(BaseModel):
154218
class MyToolOutput(BaseModel):
155219
answer: str = Field(description="The answer to the question")
156220

157-
async def my_tool(args: ToolArguments[MyToolArguments, Context]) -> MyToolOutput:
158-
# Access the context
159-
args.context.add_called_tool("my_tool")
221+
async def my_tool(args: Arguments[MyToolArguments]) -> MyToolOutput:
222+
# Access the context from lifespan state
223+
context = args.get_state_key("context", Context)
224+
context.add_called_tool("my_tool")
160225
...
161226

162227
return MyToolOutput(answer=f"Hello, {args.inputs.question}!")
163228
```
164229

165-
## Stateless Context
230+
## Request Access
166231

167232
You can access the incoming request object from your tools. The request object
168233
is passed to each tool call and can be used to access headers, cookies, and
169234
other request data (e.g. request.state, request.scope).
170235

171236
```python
172237
from pydantic import BaseModel, Field
173-
from http_mcp.tools import ToolArguments
238+
from http_mcp.types import Arguments
174239

175240
class MyToolArguments(BaseModel):
176241
question: str = Field(description="The question to answer")
@@ -179,26 +244,36 @@ class MyToolOutput(BaseModel):
179244
answer: str = Field(description="The answer to the question")
180245

181246

182-
async def my_tool(args: ToolArguments[MyToolArguments, None]) -> MyToolOutput:
247+
async def my_tool(args: Arguments[MyToolArguments]) -> MyToolOutput:
183248
# Access the request
184249
auth_header = args.request.headers.get("Authorization")
185250
...
186251

187252
return MyToolOutput(answer=f"Hello, {args.inputs.question}!")
253+
254+
# Use MCPServer:
255+
from http_mcp.server import MCPServer
256+
257+
mcp_server = MCPServer(
258+
name="my-server",
259+
version="1.0.0",
260+
tools=(my_tool,),
261+
)
188262
```
189263

190264
## Prompts
191265

192-
You can add interactive templates that are invoked by user choice.
266+
You can add interactive templates that are invoked by user choice. Prompts now
267+
support lifespan state access, similar to tools.
193268

194-
1. **Define the arguments and output for the prompts:**
269+
1. **Define the arguments for the prompts:**
195270

196271
```python
197272
from pydantic import BaseModel, Field
198273

199274
from http_mcp.mcp_types.content import TextContent
200275
from http_mcp.mcp_types.prompts import PromptMessage
201-
from http_mcp.prompts import Prompt
276+
from http_mcp.types import Arguments, Prompt
202277

203278

204279
class GetAdvice(BaseModel):
@@ -208,17 +283,22 @@ class GetAdvice(BaseModel):
208283
)
209284

210285

211-
def get_advice(args: GetAdvice) -> tuple[PromptMessage, ...]:
286+
def get_advice(args: Arguments[GetAdvice]) -> tuple[PromptMessage, ...]:
212287
"""Get advice on a topic."""
213288
template = """
214289
You are a helpful assistant that can give advice on {topic}.
215290
"""
216-
if args.include_actionable_steps:
291+
if args.inputs.include_actionable_steps:
217292
template += """
218293
The advice should include actionable steps.
219294
"""
220295
return (
221-
PromptMessage(role="user", content=TextContent(text=template.format(topic=args.topic))),
296+
PromptMessage(
297+
role="user",
298+
content=TextContent(
299+
text=template.format(topic=args.inputs.topic)
300+
),
301+
),
222302
)
223303

224304

@@ -230,7 +310,49 @@ PROMPTS = (
230310
)
231311
```
232312

233-
2. **Instantiate the server:**
313+
2. **Using prompts with lifespan state:**
314+
315+
```python
316+
from pydantic import BaseModel, Field
317+
from http_mcp.mcp_types.content import TextContent
318+
from http_mcp.mcp_types.prompts import PromptMessage
319+
from http_mcp.types import Arguments, Prompt
320+
from app.context import Context
321+
322+
class GetAdvice(BaseModel):
323+
topic: str = Field(description="The topic to get advice on")
324+
325+
def get_advice_with_context(args: Arguments[GetAdvice]) -> tuple[PromptMessage, ...]:
326+
"""Get advice on a topic with context awareness."""
327+
# Access the context from lifespan state
328+
context = args.get_state_key("context", Context)
329+
called_tools = context.get_called_tools()
330+
template = """
331+
You are a helpful assistant that can give advice on {topic}.
332+
Previously called tools: {tools}
333+
"""
334+
335+
return (
336+
PromptMessage(
337+
role="user",
338+
content=TextContent(
339+
text=template.format(
340+
topic=args.inputs.topic,
341+
tools=", ".join(called_tools) if called_tools else "none"
342+
)
343+
)
344+
),
345+
)
346+
347+
PROMPTS_WITH_CONTEXT = (
348+
Prompt(
349+
func=get_advice_with_context,
350+
arguments_type=GetAdvice,
351+
),
352+
)
353+
```
354+
355+
3. **Instantiate the server:**
234356

235357
```python
236358
from starlette.applications import Starlette
@@ -245,5 +367,4 @@ app.mount(
245367
"/mcp",
246368
mcp_server.app,
247369
)
248-
249370
```

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ readme = "README.md"
1717
license = "MIT"
1818
license-files = ["LICENSE"]
1919
requires-python = ">=3.10"
20-
version = "0.2.0"
20+
version = "0.3.0rc1"
2121

2222
dependencies = ["pydantic==2.11.7", "uvicorn==0.35.0", "starlette==0.47.2"]
2323

src/http_mcp/stdio_transport.py renamed to src/http_mcp/_stdio_transport.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,15 @@
77
from pydantic import ValidationError
88
from starlette.requests import Request
99

10+
from http_mcp._transport_base import BaseTransport
11+
from http_mcp._transport_types import ProtocolErrorCode
1012
from http_mcp.mcp_types.messages import (
1113
Error,
1214
JSONRPCError,
1315
JSONRPCMessage,
1416
JSONRPCRequest,
1517
)
1618
from http_mcp.server_interface import ServerInterface
17-
from http_mcp.transport_base import BaseTransport
18-
from http_mcp.transport_types import ProtocolErrorCode
1919

2020
if TYPE_CHECKING:
2121
from starlette.types import Scope

0 commit comments

Comments
 (0)