Skip to content

Commit 777141e

Browse files
XInke YIthe-ayyi
authored andcommitted
feat: support setting title and description for server
1 parent 8ac0cab commit 777141e

File tree

7 files changed

+154
-31
lines changed

7 files changed

+154
-31
lines changed

src/mcp/server/fastmcp/server.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,8 @@ def __init__( # noqa: PLR0913
173173
lifespan: (Callable[[FastMCP[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]] | None) = None,
174174
auth: AuthSettings | None = None,
175175
transport_security: TransportSecuritySettings | None = None,
176+
title: str | None = None,
177+
description: str | None = None,
176178
):
177179
# Auto-enable DNS rebinding protection for localhost (IPv4 and IPv6)
178180
if transport_security is None and host in ("127.0.0.1", "localhost", "::1"):
@@ -207,6 +209,8 @@ def __init__( # noqa: PLR0913
207209
instructions=instructions,
208210
website_url=website_url,
209211
icons=icons,
212+
title=title,
213+
description=description,
210214
# TODO(Marcelo): It seems there's a type mismatch between the lifespan type from an FastMCP and Server.
211215
# We need to create a Lifespan type that is a generic on the server type, like Starlette does.
212216
lifespan=(lifespan_wrapper(self, self.settings.lifespan) if self.settings.lifespan else default_lifespan), # type: ignore
@@ -249,6 +253,14 @@ def name(self) -> str:
249253
def instructions(self) -> str | None:
250254
return self._mcp_server.instructions
251255

256+
@property
257+
def title(self) -> str | None:
258+
return self._mcp_server.title
259+
260+
@property
261+
def description(self) -> str | None:
262+
return self._mcp_server.description
263+
252264
@property
253265
def website_url(self) -> str | None:
254266
return self._mcp_server.website_url

src/mcp/server/lowlevel/server.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,12 +146,16 @@ def __init__(
146146
[Server[LifespanResultT, RequestT]],
147147
AbstractAsyncContextManager[LifespanResultT],
148148
] = lifespan,
149+
title: str | None = None,
150+
description: str | None = None,
149151
):
150152
self.name = name
151153
self.version = version
152154
self.instructions = instructions
153155
self.website_url = website_url
154156
self.icons = icons
157+
self.title = title
158+
self.description = description
155159
self.lifespan = lifespan
156160
self.request_handlers: dict[type, Callable[..., Awaitable[types.ServerResult]]] = {
157161
types.PingRequest: _ping_handler,
@@ -186,6 +190,8 @@ def pkg_version(package: str) -> str:
186190
experimental_capabilities or {},
187191
),
188192
instructions=self.instructions,
193+
title=self.title,
194+
description=self.description,
189195
website_url=self.website_url,
190196
icons=self.icons,
191197
)

src/mcp/server/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,7 @@ class InitializationOptions(BaseModel):
1616
server_version: str
1717
capabilities: ServerCapabilities
1818
instructions: str | None = None
19+
title: str | None = None
20+
description: str | None = None
1921
website_url: str | None = None
2022
icons: list[Icon] | None = None

src/mcp/server/session.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,8 @@ async def _received_request(self, responder: RequestResponder[types.ClientReques
177177
serverInfo=types.Implementation(
178178
name=self._init_options.server_name,
179179
version=self._init_options.server_version,
180+
title=self._init_options.title,
181+
description=self._init_options.description,
180182
websiteUrl=self._init_options.website_url,
181183
icons=self._init_options.icons,
182184
),

src/mcp/types.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,15 @@ class BaseMetadata(BaseModel):
244244
if present).
245245
"""
246246

247+
description: str | None = None
248+
"""
249+
An optional human-readable description of what this implementation does.
250+
251+
This can be used by clients or servers to provide context about their purpose
252+
and capabilities. For example, a server might describe the types of resources
253+
or tools it provides, while a client might describe its intended use case.
254+
"""
255+
247256

248257
class Icon(BaseModel):
249258
"""An icon for display in user interfaces."""

tests/server/fastmcp/test_server.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
ContentBlock,
2424
EmbeddedResource,
2525
ImageContent,
26+
InitializeResult,
2627
TextContent,
2728
TextResourceContents,
2829
)
@@ -38,6 +39,39 @@ async def test_create_server(self):
3839
assert mcp.name == "FastMCP"
3940
assert mcp.instructions == "Server instructions"
4041

42+
@pytest.mark.anyio
43+
async def test_server_with_title_and_description(self):
44+
"""Test that FastMCP server title and description are passed through to serverInfo."""
45+
mcp = FastMCP(
46+
name="test-fastmcp-server",
47+
title="Test FastMCP Server Title",
48+
description="A test server that demonstrates title and description support.",
49+
)
50+
51+
assert mcp.title == "Test FastMCP Server Title"
52+
assert mcp.description == "A test server that demonstrates title and description support."
53+
54+
async with client_session(mcp._mcp_server) as client_session_instance:
55+
result = await client_session_instance.initialize()
56+
57+
assert isinstance(result, InitializeResult)
58+
assert result.serverInfo.name == "test-fastmcp-server"
59+
assert result.serverInfo.title == "Test FastMCP Server Title"
60+
assert result.serverInfo.description == "A test server that demonstrates title and description support."
61+
62+
@pytest.mark.anyio
63+
async def test_server_without_title_and_description(self):
64+
"""Test that FastMCP server works correctly when title and description are not provided."""
65+
mcp = FastMCP(name="test-fastmcp-server")
66+
67+
async with client_session(mcp._mcp_server) as client_session_instance:
68+
result = await client_session_instance.initialize()
69+
70+
assert isinstance(result, InitializeResult)
71+
assert result.serverInfo.name == "test-fastmcp-server"
72+
assert result.serverInfo.title is None
73+
assert result.serverInfo.description is None
74+
4175
@pytest.mark.anyio
4276
async def test_normalize_path(self):
4377
"""Test path normalization for mount paths."""

tests/server/test_session.py

Lines changed: 89 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ async def test_server_session_initialize():
3535
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1)
3636

3737
# Create a message handler to catch exceptions
38-
async def message_handler( # pragma: no cover
38+
async def message_handler(
3939
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
4040
) -> None:
4141
if isinstance(message, Exception):
@@ -55,15 +55,15 @@ async def run_server():
5555
capabilities=ServerCapabilities(),
5656
),
5757
) as server_session:
58-
async for message in server_session.incoming_messages: # pragma: no branch
59-
if isinstance(message, Exception): # pragma: no cover
58+
async for message in server_session.incoming_messages:
59+
assert message is not None, "Expected to receive messages"
60+
if isinstance(message, Exception):
6061
raise message
6162

62-
if isinstance(message, ClientNotification) and isinstance(
63-
message.root, InitializedNotification
64-
): # pragma: no branch
65-
received_initialized = True
66-
return
63+
assert isinstance(message, ClientNotification), "Expected ClientNotification"
64+
assert isinstance(message.root, InitializedNotification), "Expected InitializedNotification"
65+
received_initialized = True
66+
return
6767

6868
try:
6969
async with (
@@ -77,12 +77,70 @@ async def run_server():
7777
tg.start_soon(run_server)
7878

7979
await client_session.initialize()
80-
except anyio.ClosedResourceError: # pragma: no cover
80+
except anyio.ClosedResourceError:
81+
# This can happen if the server closes before the client finishes
8182
pass
8283

8384
assert received_initialized
8485

8586

87+
@pytest.mark.anyio
88+
async def test_server_session_initialize_with_title_and_description():
89+
"""Test that server_title and server_description are passed through to serverInfo."""
90+
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1)
91+
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1)
92+
93+
async def message_handler(
94+
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
95+
) -> None:
96+
if isinstance(message, Exception):
97+
raise message
98+
99+
async def run_server():
100+
async with ServerSession(
101+
client_to_server_receive,
102+
server_to_client_send,
103+
InitializationOptions(
104+
server_name="test-server",
105+
server_version="1.0.0",
106+
title="Test Server Title",
107+
description="A description of what this server does.",
108+
capabilities=ServerCapabilities(),
109+
),
110+
) as server_session:
111+
async for message in server_session.incoming_messages:
112+
assert message is not None, "Expected to receive messages"
113+
if isinstance(message, Exception):
114+
raise message
115+
116+
assert isinstance(message, ClientNotification), "Expected ClientNotification"
117+
assert isinstance(message.root, InitializedNotification), "Expected InitializedNotification"
118+
return
119+
120+
result: types.InitializeResult | None = None
121+
try:
122+
async with (
123+
ClientSession(
124+
server_to_client_receive,
125+
client_to_server_send,
126+
message_handler=message_handler,
127+
) as client_session,
128+
anyio.create_task_group() as tg,
129+
):
130+
tg.start_soon(run_server)
131+
132+
result = await client_session.initialize()
133+
except anyio.ClosedResourceError:
134+
# This can happen if the server closes before the client finishes
135+
pass
136+
137+
assert result is not None
138+
assert result.serverInfo.name == "test-server"
139+
assert result.serverInfo.title == "Test Server Title"
140+
assert result.serverInfo.version == "1.0.0"
141+
assert result.serverInfo.description == "A description of what this server does."
142+
143+
86144
@pytest.mark.anyio
87145
async def test_server_capabilities():
88146
server = Server("test")
@@ -97,7 +155,7 @@ async def test_server_capabilities():
97155

98156
# Add a prompts handler
99157
@server.list_prompts()
100-
async def list_prompts() -> list[Prompt]: # pragma: no cover
158+
async def list_prompts() -> list[Prompt]:
101159
return []
102160

103161
caps = server.get_capabilities(notification_options, experimental_capabilities)
@@ -107,7 +165,7 @@ async def list_prompts() -> list[Prompt]: # pragma: no cover
107165

108166
# Add a resources handler
109167
@server.list_resources()
110-
async def list_resources() -> list[Resource]: # pragma: no cover
168+
async def list_resources() -> list[Resource]:
111169
return []
112170

113171
caps = server.get_capabilities(notification_options, experimental_capabilities)
@@ -117,7 +175,7 @@ async def list_resources() -> list[Resource]: # pragma: no cover
117175

118176
# Add a complete handler
119177
@server.completion()
120-
async def complete( # pragma: no cover
178+
async def complete(
121179
ref: PromptReference | ResourceTemplateReference,
122180
argument: CompletionArgument,
123181
context: CompletionContext | None,
@@ -153,15 +211,15 @@ async def run_server():
153211
capabilities=ServerCapabilities(),
154212
),
155213
) as server_session:
156-
async for message in server_session.incoming_messages: # pragma: no branch
157-
if isinstance(message, Exception): # pragma: no cover
214+
async for message in server_session.incoming_messages:
215+
assert message is not None, "Expected to receive messages"
216+
if isinstance(message, Exception):
158217
raise message
159218

160-
if isinstance(message, types.ClientNotification) and isinstance(
161-
message.root, InitializedNotification
162-
): # pragma: no branch
163-
received_initialized = True
164-
return
219+
assert isinstance(message, types.ClientNotification), "Expected ClientNotification"
220+
assert isinstance(message.root, InitializedNotification), "Expected InitializedNotification"
221+
received_initialized = True
222+
return
165223

166224
async def mock_client():
167225
nonlocal received_protocol_version
@@ -239,18 +297,18 @@ async def run_server():
239297
capabilities=ServerCapabilities(),
240298
),
241299
) as server_session:
242-
async for message in server_session.incoming_messages: # pragma: no branch
243-
if isinstance(message, Exception): # pragma: no cover
300+
async for message in server_session.incoming_messages:
301+
assert message is not None, "Expected to receive messages"
302+
if isinstance(message, Exception):
244303
raise message
245304

246305
# We should receive a ping request before initialization
247-
if isinstance(message, RequestResponder) and isinstance(
248-
message.request.root, types.PingRequest
249-
): # pragma: no branch
250-
# Respond to the ping
251-
with message:
252-
await message.respond(types.ServerResult(types.EmptyResult()))
253-
return
306+
assert isinstance(message, RequestResponder), "Expected RequestResponder"
307+
assert isinstance(message.request.root, types.PingRequest), "Expected PingRequest"
308+
# Respond to the ping
309+
with message:
310+
await message.respond(types.ServerResult(types.EmptyResult()))
311+
return
254312

255313
async def mock_client():
256314
nonlocal ping_response_received, ping_response_id
@@ -508,9 +566,9 @@ async def mock_client():
508566

509567
# Wait for the error response
510568
error_message = await server_to_client_receive.receive()
511-
if isinstance(error_message.message.root, types.JSONRPCError): # pragma: no branch
512-
error_response_received = True
513-
error_code = error_message.message.root.error.code
569+
assert isinstance(error_message.message.root, types.JSONRPCError), "Expected JSONRPCError response"
570+
error_response_received = True
571+
error_code = error_message.message.root.error.code
514572

515573
async with (
516574
client_to_server_send,

0 commit comments

Comments
 (0)