Jeremiah Lowin Claude commited on
Commit
923cf05
·
1 Parent(s): 94e6d64

Add fastmcp inspect command with detailed server analysis

Browse files

- Add comprehensive server inspection utility supporting both FastMCP 1.x and 2.x
- Create detailed info dataclasses for tools, prompts, resources, and templates
- Implement CLI command with path:object notation and JSON output
- Add version reporting (fastmcp_version, mcp_version, server_version)
- Include comprehensive unit tests for utilities and CLI

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

src/fastmcp/cli/cli.py CHANGED
@@ -1,7 +1,9 @@
1
  """FastMCP CLI tools."""
2
 
 
3
  import importlib.metadata
4
  import importlib.util
 
5
  import os
6
  import platform
7
  import subprocess
@@ -19,6 +21,7 @@ import fastmcp
19
  from fastmcp.cli import claude
20
  from fastmcp.cli import run as run_module
21
  from fastmcp.server.server import FastMCP
 
22
  from fastmcp.utilities.logging import get_logger
23
 
24
  logger = get_logger("cli")
@@ -435,3 +438,107 @@ def install(
435
  else:
436
  logger.error(f"Failed to install {name} in Claude app")
437
  sys.exit(1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """FastMCP CLI tools."""
2
 
3
+ import asyncio
4
  import importlib.metadata
5
  import importlib.util
6
+ import json
7
  import os
8
  import platform
9
  import subprocess
 
21
  from fastmcp.cli import claude
22
  from fastmcp.cli import run as run_module
23
  from fastmcp.server.server import FastMCP
24
+ from fastmcp.utilities.inspect import get_fastmcp_info
25
  from fastmcp.utilities.logging import get_logger
26
 
27
  logger = get_logger("cli")
 
438
  else:
439
  logger.error(f"Failed to install {name} in Claude app")
440
  sys.exit(1)
441
+
442
+
443
+ @app.command()
444
+ def inspect(
445
+ server_spec: str = typer.Argument(
446
+ ...,
447
+ help="Python file to inspect, optionally with :object suffix",
448
+ ),
449
+ output: Annotated[
450
+ Path,
451
+ typer.Option(
452
+ "--output",
453
+ "-o",
454
+ help="Output file path for the JSON report (default: server-info.json)",
455
+ ),
456
+ ] = Path("server-info.json"),
457
+ ) -> None:
458
+ """Inspect a FastMCP server and generate a JSON report.
459
+
460
+ This command analyzes a FastMCP server (v1.x or v2.x) and generates
461
+ a comprehensive JSON report containing information about the server's
462
+ name, instructions, version, tools, prompts, resources, templates,
463
+ and capabilities.
464
+
465
+ Examples:
466
+ fastmcp inspect server.py
467
+ fastmcp inspect server.py -o report.json
468
+ fastmcp inspect server.py:mcp -o analysis.json
469
+ fastmcp inspect path/to/server.py:app -o /tmp/server-info.json
470
+ """
471
+
472
+ # Parse the server specification
473
+ file, server_object = run_module.parse_file_path(server_spec)
474
+
475
+ logger.debug(
476
+ "Inspecting server",
477
+ extra={
478
+ "file": str(file),
479
+ "server_object": server_object,
480
+ "output": str(output),
481
+ },
482
+ )
483
+
484
+ try:
485
+ # Import the server
486
+ server = run_module.import_server(file, server_object)
487
+
488
+ # Get server information
489
+ async def get_info():
490
+ return await get_fastmcp_info(server)
491
+
492
+ info = asyncio.run(get_info())
493
+
494
+ # Convert to dict for JSON serialization
495
+ def convert_dataclass_to_dict(obj):
496
+ """Convert dataclass instances to dicts for JSON serialization."""
497
+ if hasattr(obj, "__dataclass_fields__"):
498
+ return {
499
+ k: convert_dataclass_to_dict(v) for k, v in obj.__dict__.items()
500
+ }
501
+ elif isinstance(obj, list):
502
+ return [convert_dataclass_to_dict(item) for item in obj]
503
+ elif isinstance(obj, set):
504
+ return list(obj)
505
+ elif hasattr(obj, "model_dump"): # Pydantic models
506
+ return obj.model_dump()
507
+ elif hasattr(obj, "__dict__"): # Other objects with __dict__
508
+ return {
509
+ k: convert_dataclass_to_dict(v) for k, v in obj.__dict__.items()
510
+ }
511
+ else:
512
+ return obj
513
+
514
+ info_dict = convert_dataclass_to_dict(info)
515
+
516
+ # Ensure output directory exists
517
+ output.parent.mkdir(parents=True, exist_ok=True)
518
+
519
+ # Write JSON report (always pretty-printed)
520
+ with output.open("w", encoding="utf-8") as f:
521
+ json.dump(info_dict, f, indent=2, ensure_ascii=False)
522
+
523
+ logger.info(f"Server inspection complete. Report saved to {output}")
524
+
525
+ # Print summary to console
526
+ console.print(
527
+ f"[bold green]✓[/bold green] Inspected server: [bold]{info.name}[/bold]"
528
+ )
529
+ console.print(f" Tools: {len(info.tools)}")
530
+ console.print(f" Prompts: {len(info.prompts)}")
531
+ console.print(f" Resources: {len(info.resources)}")
532
+ console.print(f" Templates: {len(info.templates)}")
533
+ console.print(f" Report saved to: [cyan]{output}[/cyan]")
534
+
535
+ except Exception as e:
536
+ logger.error(
537
+ f"Failed to inspect server: {e}",
538
+ extra={
539
+ "server_spec": server_spec,
540
+ "error": str(e),
541
+ },
542
+ )
543
+ console.print(f"[bold red]✗[/bold red] Failed to inspect server: {e}")
544
+ sys.exit(1)
src/fastmcp/utilities/inspect.py ADDED
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Utilities for inspecting FastMCP instances."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.metadata
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from mcp.server.fastmcp import FastMCP as FastMCP1x
10
+
11
+ import fastmcp
12
+ from fastmcp.server.server import FastMCP
13
+
14
+
15
+ @dataclass
16
+ class ToolInfo:
17
+ """Information about a tool."""
18
+
19
+ key: str
20
+ name: str
21
+ description: str | None
22
+ input_schema: dict[str, Any]
23
+ annotations: dict[str, Any] | None = None
24
+ tags: list[str] | None = None
25
+ enabled: bool | None = None
26
+
27
+
28
+ @dataclass
29
+ class PromptInfo:
30
+ """Information about a prompt."""
31
+
32
+ key: str
33
+ name: str
34
+ description: str | None
35
+ arguments: list[dict[str, Any]] | None = None
36
+ tags: list[str] | None = None
37
+ enabled: bool | None = None
38
+
39
+
40
+ @dataclass
41
+ class ResourceInfo:
42
+ """Information about a resource."""
43
+
44
+ key: str
45
+ uri: str
46
+ name: str | None
47
+ description: str | None
48
+ mime_type: str | None = None
49
+ tags: list[str] | None = None
50
+ enabled: bool | None = None
51
+
52
+
53
+ @dataclass
54
+ class TemplateInfo:
55
+ """Information about a resource template."""
56
+
57
+ key: str
58
+ uri_template: str
59
+ name: str | None
60
+ description: str | None
61
+ mime_type: str | None = None
62
+ tags: list[str] | None = None
63
+ enabled: bool | None = None
64
+
65
+
66
+ @dataclass
67
+ class FastMCPInfo:
68
+ """Information extracted from a FastMCP instance."""
69
+
70
+ name: str
71
+ instructions: str | None
72
+ fastmcp_version: str
73
+ mcp_version: str
74
+ server_version: str
75
+ tools: list[ToolInfo]
76
+ prompts: list[PromptInfo]
77
+ resources: list[ResourceInfo]
78
+ templates: list[TemplateInfo]
79
+ capabilities: dict[str, Any]
80
+
81
+
82
+ async def get_fastmcp_info_v2(mcp: FastMCP[Any]) -> FastMCPInfo:
83
+ """Extract information from a FastMCP v2.x instance.
84
+
85
+ Args:
86
+ mcp: The FastMCP v2.x instance to inspect
87
+
88
+ Returns:
89
+ FastMCPInfo dataclass containing the extracted information
90
+ """
91
+ # Get all the components using FastMCP2's direct methods
92
+ tools_dict = await mcp.get_tools()
93
+ prompts_dict = await mcp.get_prompts()
94
+ resources_dict = await mcp.get_resources()
95
+ templates_dict = await mcp.get_resource_templates()
96
+
97
+ # Extract detailed tool information
98
+ tool_infos = []
99
+ for key, tool in tools_dict.items():
100
+ # Convert to MCP tool to get input schema
101
+ mcp_tool = tool.to_mcp_tool(name=key)
102
+ tool_infos.append(
103
+ ToolInfo(
104
+ key=key,
105
+ name=tool.name or key,
106
+ description=tool.description,
107
+ input_schema=mcp_tool.inputSchema if mcp_tool.inputSchema else {},
108
+ annotations=tool.annotations.model_dump() if tool.annotations else None,
109
+ tags=list(tool.tags) if tool.tags else None,
110
+ enabled=tool.enabled,
111
+ )
112
+ )
113
+
114
+ # Extract detailed prompt information
115
+ prompt_infos = []
116
+ for key, prompt in prompts_dict.items():
117
+ prompt_infos.append(
118
+ PromptInfo(
119
+ key=key,
120
+ name=prompt.name or key,
121
+ description=prompt.description,
122
+ arguments=[arg.model_dump() for arg in prompt.arguments]
123
+ if prompt.arguments
124
+ else None,
125
+ tags=list(prompt.tags) if prompt.tags else None,
126
+ enabled=prompt.enabled,
127
+ )
128
+ )
129
+
130
+ # Extract detailed resource information
131
+ resource_infos = []
132
+ for key, resource in resources_dict.items():
133
+ resource_infos.append(
134
+ ResourceInfo(
135
+ key=key,
136
+ uri=key, # For v2, key is the URI
137
+ name=resource.name,
138
+ description=resource.description,
139
+ mime_type=resource.mime_type,
140
+ tags=list(resource.tags) if resource.tags else None,
141
+ enabled=resource.enabled,
142
+ )
143
+ )
144
+
145
+ # Extract detailed template information
146
+ template_infos = []
147
+ for key, template in templates_dict.items():
148
+ template_infos.append(
149
+ TemplateInfo(
150
+ key=key,
151
+ uri_template=key, # For v2, key is the URI template
152
+ name=template.name,
153
+ description=template.description,
154
+ mime_type=template.mime_type,
155
+ tags=list(template.tags) if template.tags else None,
156
+ enabled=template.enabled,
157
+ )
158
+ )
159
+
160
+ # Basic MCP capabilities that FastMCP supports
161
+ capabilities = {
162
+ "tools": {"listChanged": True},
163
+ "resources": {"subscribe": False, "listChanged": False},
164
+ "prompts": {"listChanged": False},
165
+ "logging": {},
166
+ }
167
+
168
+ return FastMCPInfo(
169
+ name=mcp.name,
170
+ instructions=mcp.instructions,
171
+ fastmcp_version=fastmcp.__version__,
172
+ mcp_version=importlib.metadata.version("mcp"),
173
+ server_version=fastmcp.__version__, # v2.x uses FastMCP version
174
+ tools=tool_infos,
175
+ prompts=prompt_infos,
176
+ resources=resource_infos,
177
+ templates=template_infos,
178
+ capabilities=capabilities,
179
+ )
180
+
181
+
182
+ async def get_fastmcp_info_v1(mcp: Any) -> FastMCPInfo:
183
+ """Extract information from a FastMCP v1.x instance using a Client.
184
+
185
+ Args:
186
+ mcp: The FastMCP v1.x instance to inspect
187
+
188
+ Returns:
189
+ FastMCPInfo dataclass containing the extracted information
190
+ """
191
+ from fastmcp import Client
192
+
193
+ # Use a client to interact with the FastMCP1x server
194
+ async with Client(mcp) as client:
195
+ # Get components via client calls (these return MCP objects)
196
+ mcp_tools = await client.list_tools()
197
+ mcp_prompts = await client.list_prompts()
198
+ mcp_resources = await client.list_resources()
199
+
200
+ # Try to get resource templates (FastMCP 1.x does have templates)
201
+ try:
202
+ mcp_templates = await client.list_resource_templates()
203
+ except Exception:
204
+ mcp_templates = []
205
+
206
+ # Extract detailed tool information from MCP Tool objects
207
+ tool_infos = []
208
+ for mcp_tool in mcp_tools:
209
+ # Extract annotations if they exist
210
+ annotations = None
211
+ if hasattr(mcp_tool, "annotations") and mcp_tool.annotations:
212
+ if hasattr(mcp_tool.annotations, "model_dump"):
213
+ annotations = mcp_tool.annotations.model_dump()
214
+ elif isinstance(mcp_tool.annotations, dict):
215
+ annotations = mcp_tool.annotations
216
+ else:
217
+ annotations = None
218
+
219
+ tool_infos.append(
220
+ ToolInfo(
221
+ key=mcp_tool.name, # For 1.x, key and name are the same
222
+ name=mcp_tool.name,
223
+ description=mcp_tool.description,
224
+ input_schema=mcp_tool.inputSchema if mcp_tool.inputSchema else {},
225
+ annotations=annotations,
226
+ tags=None, # 1.x doesn't have tags
227
+ enabled=None, # 1.x doesn't have enabled field
228
+ )
229
+ )
230
+
231
+ # Extract detailed prompt information from MCP Prompt objects
232
+ prompt_infos = []
233
+ for mcp_prompt in mcp_prompts:
234
+ # Convert arguments if they exist
235
+ arguments = None
236
+ if hasattr(mcp_prompt, "arguments") and mcp_prompt.arguments:
237
+ arguments = [arg.model_dump() for arg in mcp_prompt.arguments]
238
+
239
+ prompt_infos.append(
240
+ PromptInfo(
241
+ key=mcp_prompt.name, # For 1.x, key and name are the same
242
+ name=mcp_prompt.name,
243
+ description=mcp_prompt.description,
244
+ arguments=arguments,
245
+ tags=None, # 1.x doesn't have tags
246
+ enabled=None, # 1.x doesn't have enabled field
247
+ )
248
+ )
249
+
250
+ # Extract detailed resource information from MCP Resource objects
251
+ resource_infos = []
252
+ for mcp_resource in mcp_resources:
253
+ resource_infos.append(
254
+ ResourceInfo(
255
+ key=str(mcp_resource.uri), # For 1.x, key and uri are the same
256
+ uri=str(mcp_resource.uri),
257
+ name=mcp_resource.name,
258
+ description=mcp_resource.description,
259
+ mime_type=mcp_resource.mimeType,
260
+ tags=None, # 1.x doesn't have tags
261
+ enabled=None, # 1.x doesn't have enabled field
262
+ )
263
+ )
264
+
265
+ # Extract detailed template information from MCP ResourceTemplate objects
266
+ template_infos = []
267
+ for mcp_template in mcp_templates:
268
+ template_infos.append(
269
+ TemplateInfo(
270
+ key=str(
271
+ mcp_template.uriTemplate
272
+ ), # For 1.x, key and uriTemplate are the same
273
+ uri_template=str(mcp_template.uriTemplate),
274
+ name=mcp_template.name,
275
+ description=mcp_template.description,
276
+ mime_type=mcp_template.mimeType,
277
+ tags=None, # 1.x doesn't have tags
278
+ enabled=None, # 1.x doesn't have enabled field
279
+ )
280
+ )
281
+
282
+ # Basic MCP capabilities
283
+ capabilities = {
284
+ "tools": {"listChanged": True},
285
+ "resources": {"subscribe": False, "listChanged": False},
286
+ "prompts": {"listChanged": False},
287
+ "logging": {},
288
+ }
289
+
290
+ return FastMCPInfo(
291
+ name=mcp.name,
292
+ instructions=getattr(mcp, "instructions", None),
293
+ fastmcp_version=fastmcp.__version__, # Report current fastmcp version
294
+ mcp_version=importlib.metadata.version("mcp"),
295
+ server_version="1.0", # FastMCP 1.x version
296
+ tools=tool_infos,
297
+ prompts=prompt_infos,
298
+ resources=resource_infos,
299
+ templates=template_infos, # FastMCP1x does have templates
300
+ capabilities=capabilities,
301
+ )
302
+
303
+
304
+ def _is_fastmcp_v1(mcp: Any) -> bool:
305
+ """Check if the given instance is a FastMCP v1.x instance."""
306
+
307
+ # Check if it's an instance of FastMCP1x and not FastMCP2
308
+ return isinstance(mcp, FastMCP1x) and not isinstance(mcp, FastMCP)
309
+
310
+
311
+ async def get_fastmcp_info(mcp: FastMCP[Any] | Any) -> FastMCPInfo:
312
+ """Extract information from a FastMCP instance into a dataclass.
313
+
314
+ This function automatically detects whether the instance is FastMCP v1.x or v2.x
315
+ and uses the appropriate extraction method.
316
+
317
+ Args:
318
+ mcp: The FastMCP instance to inspect (v1.x or v2.x)
319
+
320
+ Returns:
321
+ FastMCPInfo dataclass containing the extracted information
322
+ """
323
+ if _is_fastmcp_v1(mcp):
324
+ return await get_fastmcp_info_v1(mcp)
325
+ else:
326
+ return await get_fastmcp_info_v2(mcp)
tests/cli/test_inspect.py ADDED
@@ -0,0 +1,354 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the CLI inspect command."""
2
+
3
+ import json
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+ from typer.testing import CliRunner
8
+
9
+ from fastmcp.cli.cli import app
10
+
11
+
12
+ class TestInspectCommand:
13
+ """Tests for the fastmcp inspect CLI command."""
14
+
15
+ def setup_method(self):
16
+ """Set up test fixtures."""
17
+ self.runner = CliRunner()
18
+
19
+ def test_inspect_basic_server(self):
20
+ """Test inspecting a basic FastMCP 2.x server."""
21
+ # Create a temporary server file
22
+ server_content = '''
23
+ from fastmcp import FastMCP
24
+
25
+ mcp = FastMCP("TestServer", instructions="A test server")
26
+
27
+ @mcp.tool
28
+ def add(a: int, b: int) -> int:
29
+ """Add two numbers."""
30
+ return a + b
31
+
32
+ @mcp.resource("resource://data")
33
+ def get_data() -> str:
34
+ """Get test data."""
35
+ return "test data"
36
+
37
+ @mcp.prompt
38
+ def test_prompt(message: str) -> list:
39
+ """Test prompt."""
40
+ return [{"role": "user", "content": message}]
41
+ '''
42
+
43
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
44
+ f.write(server_content)
45
+ server_file = f.name
46
+
47
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
48
+ output_file = f.name
49
+
50
+ try:
51
+ # Run the inspect command
52
+ result = self.runner.invoke(
53
+ app, ["inspect", server_file, "-o", output_file]
54
+ )
55
+
56
+ assert result.exit_code == 0
57
+ assert "✓ Inspected server: TestServer" in result.stdout
58
+ assert "Tools: 1" in result.stdout
59
+ assert "Prompts: 1" in result.stdout
60
+ assert "Resources: 1" in result.stdout
61
+
62
+ # Check the JSON output
63
+ with open(output_file) as f:
64
+ data = json.load(f)
65
+
66
+ assert data["name"] == "TestServer"
67
+ assert data["instructions"] == "A test server"
68
+ assert "fastmcp_version" in data
69
+ assert "mcp_version" in data
70
+ assert "server_version" in data
71
+
72
+ # Check tools
73
+ assert len(data["tools"]) == 1
74
+ tool = data["tools"][0]
75
+ assert tool["key"] == "add"
76
+ assert tool["name"] == "add"
77
+ assert tool["description"] == "Add two numbers."
78
+ assert "input_schema" in tool
79
+ assert tool["enabled"] is True
80
+
81
+ # Check resources
82
+ assert len(data["resources"]) == 1
83
+ resource = data["resources"][0]
84
+ assert resource["key"] == "resource://data"
85
+ assert resource["uri"] == "resource://data"
86
+ assert resource["name"] == "get_data"
87
+
88
+ # Check prompts
89
+ assert len(data["prompts"]) == 1
90
+ prompt = data["prompts"][0]
91
+ assert prompt["key"] == "test_prompt"
92
+ assert prompt["name"] == "test_prompt"
93
+ assert prompt["description"] == "Test prompt."
94
+
95
+ # Check capabilities
96
+ assert "capabilities" in data
97
+ assert "tools" in data["capabilities"]
98
+
99
+ finally:
100
+ # Clean up
101
+ Path(server_file).unlink(missing_ok=True)
102
+ Path(output_file).unlink(missing_ok=True)
103
+
104
+ def test_inspect_with_object_spec(self):
105
+ """Test inspecting a server with object specification."""
106
+ server_content = '''
107
+ from fastmcp import FastMCP
108
+
109
+ server = FastMCP("ObjectSpecServer")
110
+
111
+ @server.tool
112
+ def multiply(a: int, b: int) -> int:
113
+ """Multiply two numbers."""
114
+ return a * b
115
+ '''
116
+
117
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
118
+ f.write(server_content)
119
+ server_file = f.name
120
+
121
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
122
+ output_file = f.name
123
+
124
+ try:
125
+ # Run the inspect command with object specification
126
+ result = self.runner.invoke(
127
+ app, ["inspect", f"{server_file}:server", "-o", output_file]
128
+ )
129
+
130
+ assert result.exit_code == 0
131
+ assert "✓ Inspected server: ObjectSpecServer" in result.stdout
132
+
133
+ # Check the JSON output
134
+ with open(output_file) as f:
135
+ data = json.load(f)
136
+
137
+ assert data["name"] == "ObjectSpecServer"
138
+ assert len(data["tools"]) == 1
139
+ assert data["tools"][0]["name"] == "multiply"
140
+
141
+ finally:
142
+ # Clean up
143
+ Path(server_file).unlink(missing_ok=True)
144
+ Path(output_file).unlink(missing_ok=True)
145
+
146
+ def test_inspect_default_output(self):
147
+ """Test inspecting with default output filename."""
148
+ server_content = '''
149
+ from fastmcp import FastMCP
150
+
151
+ mcp = FastMCP("DefaultOutputServer")
152
+
153
+ @mcp.tool
154
+ def test_tool() -> str:
155
+ """Test tool."""
156
+ return "test"
157
+ '''
158
+
159
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
160
+ f.write(server_content)
161
+ server_file = f.name
162
+
163
+ try:
164
+ # Run the inspect command without specifying output file
165
+ result = self.runner.invoke(app, ["inspect", server_file])
166
+
167
+ assert result.exit_code == 0
168
+ assert "✓ Inspected server: DefaultOutputServer" in result.stdout
169
+ assert "Report saved to: server-info.json" in result.stdout
170
+
171
+ # Check the default output file exists
172
+ default_output = Path("server-info.json")
173
+ assert default_output.exists()
174
+
175
+ # Check the JSON content
176
+ with open(default_output) as f:
177
+ data = json.load(f)
178
+
179
+ assert data["name"] == "DefaultOutputServer"
180
+
181
+ finally:
182
+ # Clean up
183
+ Path(server_file).unlink(missing_ok=True)
184
+ Path("server-info.json").unlink(missing_ok=True)
185
+
186
+ def test_inspect_invalid_server_file(self):
187
+ """Test inspecting a non-existent server file."""
188
+ result = self.runner.invoke(
189
+ app, ["inspect", "nonexistent.py", "-o", "output.json"]
190
+ )
191
+
192
+ assert result.exit_code == 1
193
+ # The error happens at the file parsing level, so no stdout output
194
+
195
+ def test_inspect_server_with_error(self):
196
+ """Test inspecting a server file with syntax errors."""
197
+ server_content = """
198
+ from fastmcp import FastMCP
199
+
200
+ mcp = FastMCP("ErrorServer")
201
+ # Syntax error below
202
+ @mcp.tool
203
+ def broken_tool(
204
+ """
205
+
206
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
207
+ f.write(server_content)
208
+ server_file = f.name
209
+
210
+ try:
211
+ result = self.runner.invoke(
212
+ app, ["inspect", server_file, "-o", "output.json"]
213
+ )
214
+
215
+ assert result.exit_code == 1
216
+ assert "✗ Failed to inspect server:" in result.stdout
217
+
218
+ finally:
219
+ # Clean up
220
+ Path(server_file).unlink(missing_ok=True)
221
+ Path("output.json").unlink(missing_ok=True)
222
+
223
+ def test_inspect_comprehensive_json_structure(self):
224
+ """Test that the JSON output has the correct structure."""
225
+ server_content = '''
226
+ from fastmcp import FastMCP
227
+
228
+ mcp = FastMCP("ComprehensiveServer", instructions="Full test server")
229
+
230
+ @mcp.tool
231
+ def calculate(x: int, y: int) -> int:
232
+ """Calculate something."""
233
+ return x + y
234
+
235
+ @mcp.resource("resource://static")
236
+ def static_resource() -> str:
237
+ """Static resource."""
238
+ return "static"
239
+
240
+ @mcp.resource("resource://template/{id}")
241
+ def template_resource(id: str) -> str:
242
+ """Template resource."""
243
+ return f"data-{id}"
244
+
245
+ @mcp.prompt
246
+ def analysis_prompt(data: str) -> list:
247
+ """Analysis prompt."""
248
+ return [{"role": "user", "content": f"Analyze: {data}"}]
249
+ '''
250
+
251
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
252
+ f.write(server_content)
253
+ server_file = f.name
254
+
255
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
256
+ output_file = f.name
257
+
258
+ try:
259
+ result = self.runner.invoke(
260
+ app, ["inspect", server_file, "-o", output_file]
261
+ )
262
+
263
+ assert result.exit_code == 0
264
+
265
+ # Load and validate JSON structure
266
+ with open(output_file) as f:
267
+ data = json.load(f)
268
+
269
+ # Check top-level structure
270
+ required_fields = [
271
+ "name",
272
+ "instructions",
273
+ "fastmcp_version",
274
+ "mcp_version",
275
+ "server_version",
276
+ "tools",
277
+ "prompts",
278
+ "resources",
279
+ "templates",
280
+ "capabilities",
281
+ ]
282
+ for field in required_fields:
283
+ assert field in data, f"Missing field: {field}"
284
+
285
+ # Check version fields are strings
286
+ assert isinstance(data["fastmcp_version"], str)
287
+ assert isinstance(data["mcp_version"], str)
288
+ assert isinstance(data["server_version"], str)
289
+
290
+ # Check that we have the expected components
291
+ assert len(data["tools"]) == 1
292
+ assert len(data["resources"]) == 1
293
+ assert len(data["templates"]) == 1
294
+ assert len(data["prompts"]) == 1
295
+
296
+ # Check tool structure
297
+ tool = data["tools"][0]
298
+ tool_fields = [
299
+ "key",
300
+ "name",
301
+ "description",
302
+ "input_schema",
303
+ "annotations",
304
+ "tags",
305
+ "enabled",
306
+ ]
307
+ for field in tool_fields:
308
+ assert field in tool, f"Missing tool field: {field}"
309
+
310
+ # Check resource structure
311
+ resource = data["resources"][0]
312
+ resource_fields = [
313
+ "key",
314
+ "uri",
315
+ "name",
316
+ "description",
317
+ "mime_type",
318
+ "tags",
319
+ "enabled",
320
+ ]
321
+ for field in resource_fields:
322
+ assert field in resource, f"Missing resource field: {field}"
323
+
324
+ # Check template structure
325
+ template = data["templates"][0]
326
+ template_fields = [
327
+ "key",
328
+ "uri_template",
329
+ "name",
330
+ "description",
331
+ "mime_type",
332
+ "tags",
333
+ "enabled",
334
+ ]
335
+ for field in template_fields:
336
+ assert field in template, f"Missing template field: {field}"
337
+
338
+ # Check prompt structure
339
+ prompt = data["prompts"][0]
340
+ prompt_fields = [
341
+ "key",
342
+ "name",
343
+ "description",
344
+ "arguments",
345
+ "tags",
346
+ "enabled",
347
+ ]
348
+ for field in prompt_fields:
349
+ assert field in prompt, f"Missing prompt field: {field}"
350
+
351
+ finally:
352
+ # Clean up
353
+ Path(server_file).unlink(missing_ok=True)
354
+ Path(output_file).unlink(missing_ok=True)
tests/utilities/test_inspect.py ADDED
@@ -0,0 +1,388 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the inspect.py module."""
2
+
3
+ # Import FastMCP1x for testing (always available since mcp is a dependency)
4
+ from mcp.server.fastmcp import FastMCP as FastMCP1x
5
+
6
+ import fastmcp
7
+ from fastmcp import Client, FastMCP
8
+ from fastmcp.utilities.inspect import (
9
+ FastMCPInfo,
10
+ ToolInfo,
11
+ _is_fastmcp_v1,
12
+ get_fastmcp_info,
13
+ get_fastmcp_info_v1,
14
+ )
15
+
16
+
17
+ class TestFastMCPInfo:
18
+ """Tests for the FastMCPInfo dataclass."""
19
+
20
+ def test_fastmcp_info_creation(self):
21
+ """Test that FastMCPInfo can be created with all required fields."""
22
+ tool = ToolInfo(
23
+ key="tool1", name="tool1", description="Test tool", input_schema={}
24
+ )
25
+ info = FastMCPInfo(
26
+ name="TestServer",
27
+ instructions="Test instructions",
28
+ fastmcp_version="1.0.0",
29
+ mcp_version="1.0.0",
30
+ server_version="1.0.0",
31
+ tools=[tool],
32
+ prompts=[],
33
+ resources=[],
34
+ templates=[],
35
+ capabilities={"tools": {"listChanged": True}},
36
+ )
37
+
38
+ assert info.name == "TestServer"
39
+ assert info.instructions == "Test instructions"
40
+ assert info.fastmcp_version == "1.0.0"
41
+ assert info.mcp_version == "1.0.0"
42
+ assert info.server_version == "1.0.0"
43
+ assert len(info.tools) == 1
44
+ assert info.tools[0].name == "tool1"
45
+ assert info.capabilities == {"tools": {"listChanged": True}}
46
+
47
+ def test_fastmcp_info_with_none_instructions(self):
48
+ """Test that FastMCPInfo works with None instructions."""
49
+ info = FastMCPInfo(
50
+ name="TestServer",
51
+ instructions=None,
52
+ fastmcp_version="1.0.0",
53
+ mcp_version="1.0.0",
54
+ server_version="1.0.0",
55
+ tools=[],
56
+ prompts=[],
57
+ resources=[],
58
+ templates=[],
59
+ capabilities={},
60
+ )
61
+
62
+ assert info.instructions is None
63
+
64
+
65
+ class TestGetFastMCPInfo:
66
+ """Tests for the get_fastmcp_info function."""
67
+
68
+ async def test_empty_server(self):
69
+ """Test get_fastmcp_info with an empty server."""
70
+ mcp = FastMCP("EmptyServer", instructions="Empty server for testing")
71
+
72
+ info = await get_fastmcp_info(mcp)
73
+
74
+ assert info.name == "EmptyServer"
75
+ assert info.instructions == "Empty server for testing"
76
+ assert info.fastmcp_version == fastmcp.__version__
77
+ assert info.mcp_version is not None
78
+ assert info.server_version == fastmcp.__version__ # v2.x uses FastMCP version
79
+ assert info.tools == []
80
+ assert info.prompts == []
81
+ assert info.resources == []
82
+ assert info.templates == []
83
+ assert "tools" in info.capabilities
84
+ assert "resources" in info.capabilities
85
+ assert "prompts" in info.capabilities
86
+ assert "logging" in info.capabilities
87
+
88
+ async def test_server_with_tools(self):
89
+ """Test get_fastmcp_info with a server that has tools."""
90
+ mcp = FastMCP("ToolServer")
91
+
92
+ @mcp.tool
93
+ def add_numbers(a: int, b: int) -> int:
94
+ return a + b
95
+
96
+ @mcp.tool
97
+ def greet(name: str) -> str:
98
+ return f"Hello, {name}!"
99
+
100
+ info = await get_fastmcp_info(mcp)
101
+
102
+ assert info.name == "ToolServer"
103
+ assert len(info.tools) == 2
104
+ tool_names = [tool.name for tool in info.tools]
105
+ assert "add_numbers" in tool_names
106
+ assert "greet" in tool_names
107
+
108
+ async def test_server_with_resources(self):
109
+ """Test get_fastmcp_info with a server that has resources."""
110
+ mcp = FastMCP("ResourceServer")
111
+
112
+ @mcp.resource("resource://static")
113
+ def get_static_data() -> str:
114
+ return "Static data"
115
+
116
+ @mcp.resource("resource://dynamic/{param}")
117
+ def get_dynamic_data(param: str) -> str:
118
+ return f"Dynamic data: {param}"
119
+
120
+ info = await get_fastmcp_info(mcp)
121
+
122
+ assert info.name == "ResourceServer"
123
+ assert len(info.resources) == 1 # Static resource
124
+ assert len(info.templates) == 1 # Dynamic resource becomes template
125
+ resource_uris = [res.uri for res in info.resources]
126
+ template_uris = [tmpl.uri_template for tmpl in info.templates]
127
+ assert "resource://static" in resource_uris
128
+ assert "resource://dynamic/{param}" in template_uris
129
+
130
+ async def test_server_with_prompts(self):
131
+ """Test get_fastmcp_info with a server that has prompts."""
132
+ mcp = FastMCP("PromptServer")
133
+
134
+ @mcp.prompt
135
+ def analyze_data(data: str) -> list:
136
+ return [{"role": "user", "content": f"Analyze: {data}"}]
137
+
138
+ @mcp.prompt("custom_prompt")
139
+ def custom_analysis(text: str) -> list:
140
+ return [{"role": "user", "content": f"Custom: {text}"}]
141
+
142
+ info = await get_fastmcp_info(mcp)
143
+
144
+ assert info.name == "PromptServer"
145
+ assert len(info.prompts) == 2
146
+ prompt_names = [prompt.name for prompt in info.prompts]
147
+ assert "analyze_data" in prompt_names
148
+ assert "custom_prompt" in prompt_names
149
+
150
+ async def test_comprehensive_server(self):
151
+ """Test get_fastmcp_info with a server that has all component types."""
152
+ mcp = FastMCP("ComprehensiveServer", instructions="A server with everything")
153
+
154
+ # Add a tool
155
+ @mcp.tool
156
+ def calculate(x: int, y: int) -> int:
157
+ return x * y
158
+
159
+ # Add a resource
160
+ @mcp.resource("resource://data")
161
+ def get_data() -> str:
162
+ return "Some data"
163
+
164
+ # Add a template
165
+ @mcp.resource("resource://item/{id}")
166
+ def get_item(id: str) -> str:
167
+ return f"Item {id}"
168
+
169
+ # Add a prompt
170
+ @mcp.prompt
171
+ def analyze(content: str) -> list:
172
+ return [{"role": "user", "content": content}]
173
+
174
+ info = await get_fastmcp_info(mcp)
175
+
176
+ assert info.name == "ComprehensiveServer"
177
+ assert info.instructions == "A server with everything"
178
+ assert info.fastmcp_version == fastmcp.__version__
179
+
180
+ # Check all components are present
181
+ assert len(info.tools) == 1
182
+ tool_names = [tool.name for tool in info.tools]
183
+ assert "calculate" in tool_names
184
+
185
+ assert len(info.resources) == 1
186
+ resource_uris = [res.uri for res in info.resources]
187
+ assert "resource://data" in resource_uris
188
+
189
+ assert len(info.templates) == 1
190
+ template_uris = [tmpl.uri_template for tmpl in info.templates]
191
+ assert "resource://item/{id}" in template_uris
192
+
193
+ assert len(info.prompts) == 1
194
+ prompt_names = [prompt.name for prompt in info.prompts]
195
+ assert "analyze" in prompt_names
196
+
197
+ # Check capabilities
198
+ assert "tools" in info.capabilities
199
+ assert "resources" in info.capabilities
200
+ assert "prompts" in info.capabilities
201
+ assert "logging" in info.capabilities
202
+
203
+ async def test_server_no_instructions(self):
204
+ """Test get_fastmcp_info with a server that has no instructions."""
205
+ mcp = FastMCP("NoInstructionsServer")
206
+
207
+ info = await get_fastmcp_info(mcp)
208
+
209
+ assert info.name == "NoInstructionsServer"
210
+ assert info.instructions is None
211
+
212
+ async def test_server_with_client_integration(self):
213
+ """Test that the extracted info matches what a client would see."""
214
+ mcp = FastMCP("IntegrationServer")
215
+
216
+ @mcp.tool
217
+ def test_tool() -> str:
218
+ return "test"
219
+
220
+ @mcp.resource("resource://test")
221
+ def test_resource() -> str:
222
+ return "test resource"
223
+
224
+ @mcp.prompt
225
+ def test_prompt() -> list:
226
+ return [{"role": "user", "content": "test"}]
227
+
228
+ # Get info using our function
229
+ info = await get_fastmcp_info(mcp)
230
+
231
+ # Verify using client
232
+ async with Client(mcp) as client:
233
+ tools = await client.list_tools()
234
+ resources = await client.list_resources()
235
+ prompts = await client.list_prompts()
236
+
237
+ assert len(info.tools) == len(tools)
238
+ assert len(info.resources) == len(resources)
239
+ assert len(info.prompts) == len(prompts)
240
+
241
+ assert info.tools[0].name == tools[0].name
242
+ assert info.resources[0].uri == str(resources[0].uri)
243
+ assert info.prompts[0].name == prompts[0].name
244
+
245
+
246
+ class TestFastMCP1xCompatibility:
247
+ """Tests for FastMCP 1.x compatibility."""
248
+
249
+ async def test_fastmcp1x_detection(self):
250
+ """Test that FastMCP1x instances are correctly detected."""
251
+ mcp1x = FastMCP1x("Test1x")
252
+ mcp2x = FastMCP("Test2x")
253
+
254
+ assert _is_fastmcp_v1(mcp1x) is True
255
+ assert _is_fastmcp_v1(mcp2x) is False
256
+
257
+ async def test_fastmcp1x_empty_server(self):
258
+ """Test get_fastmcp_info_v1 with an empty FastMCP1x server."""
259
+ mcp = FastMCP1x("Test1x")
260
+
261
+ info = await get_fastmcp_info_v1(mcp)
262
+
263
+ assert info.name == "Test1x"
264
+ assert info.instructions is None
265
+ assert info.fastmcp_version == fastmcp.__version__
266
+ assert info.mcp_version is not None
267
+ assert info.server_version == "1.0" # v1.x servers use "1.0"
268
+ assert info.tools == []
269
+ assert info.prompts == []
270
+ assert info.resources == []
271
+ assert info.templates == [] # No templates added in this test
272
+ assert "tools" in info.capabilities
273
+
274
+ async def test_fastmcp1x_with_tools(self):
275
+ """Test get_fastmcp_info_v1 with a FastMCP1x server that has tools."""
276
+ mcp = FastMCP1x("Test1x")
277
+
278
+ @mcp.tool()
279
+ def add_numbers(a: int, b: int) -> int:
280
+ return a + b
281
+
282
+ @mcp.tool()
283
+ def greet(name: str) -> str:
284
+ return f"Hello, {name}!"
285
+
286
+ info = await get_fastmcp_info_v1(mcp)
287
+
288
+ assert info.name == "Test1x"
289
+ assert len(info.tools) == 2
290
+ tool_names = [tool.name for tool in info.tools]
291
+ assert "add_numbers" in tool_names
292
+ assert "greet" in tool_names
293
+
294
+ async def test_fastmcp1x_with_resources(self):
295
+ """Test get_fastmcp_info_v1 with a FastMCP1x server that has resources."""
296
+ mcp = FastMCP1x("Test1x")
297
+
298
+ @mcp.resource("resource://data")
299
+ def get_data() -> str:
300
+ return "Some data"
301
+
302
+ info = await get_fastmcp_info_v1(mcp)
303
+
304
+ assert info.name == "Test1x"
305
+ assert len(info.resources) == 1
306
+ resource_uris = [res.uri for res in info.resources]
307
+ assert "resource://data" in resource_uris
308
+ assert len(info.templates) == 0 # No templates added in this test
309
+
310
+ async def test_fastmcp1x_with_prompts(self):
311
+ """Test get_fastmcp_info_v1 with a FastMCP1x server that has prompts."""
312
+ mcp = FastMCP1x("Test1x")
313
+
314
+ @mcp.prompt("analyze")
315
+ def analyze_data(data: str) -> list:
316
+ return [{"role": "user", "content": f"Analyze: {data}"}]
317
+
318
+ info = await get_fastmcp_info_v1(mcp)
319
+
320
+ assert info.name == "Test1x"
321
+ assert len(info.prompts) == 1
322
+ prompt_names = [prompt.name for prompt in info.prompts]
323
+ assert "analyze" in prompt_names
324
+
325
+ async def test_dispatcher_with_fastmcp1x(self):
326
+ """Test that the main get_fastmcp_info function correctly dispatches to v1."""
327
+ mcp = FastMCP1x("Test1x")
328
+
329
+ @mcp.tool()
330
+ def test_tool() -> str:
331
+ return "test"
332
+
333
+ info = await get_fastmcp_info(mcp)
334
+
335
+ assert info.name == "Test1x"
336
+ assert len(info.tools) == 1
337
+ tool_names = [tool.name for tool in info.tools]
338
+ assert "test_tool" in tool_names
339
+ assert len(info.templates) == 0 # No templates added in this test
340
+
341
+ async def test_dispatcher_with_fastmcp2x(self):
342
+ """Test that the main get_fastmcp_info function correctly dispatches to v2."""
343
+ mcp = FastMCP("Test2x")
344
+
345
+ @mcp.tool
346
+ def test_tool() -> str:
347
+ return "test"
348
+
349
+ info = await get_fastmcp_info(mcp)
350
+
351
+ assert info.name == "Test2x"
352
+ assert len(info.tools) == 1
353
+ tool_names = [tool.name for tool in info.tools]
354
+ assert "test_tool" in tool_names
355
+
356
+ async def test_fastmcp1x_vs_fastmcp2x_comparison(self):
357
+ """Test that both versions can be inspected and compared."""
358
+ mcp1x = FastMCP1x("Test1x")
359
+ mcp2x = FastMCP("Test2x")
360
+
361
+ @mcp1x.tool()
362
+ def tool1x() -> str:
363
+ return "1x"
364
+
365
+ @mcp2x.tool
366
+ def tool2x() -> str:
367
+ return "2x"
368
+
369
+ info1x = await get_fastmcp_info(mcp1x)
370
+ info2x = await get_fastmcp_info(mcp2x)
371
+
372
+ assert info1x.name == "Test1x"
373
+ assert info2x.name == "Test2x"
374
+ assert len(info1x.tools) == 1
375
+ assert len(info2x.tools) == 1
376
+
377
+ tool1x_names = [tool.name for tool in info1x.tools]
378
+ tool2x_names = [tool.name for tool in info2x.tools]
379
+ assert "tool1x" in tool1x_names
380
+ assert "tool2x" in tool2x_names
381
+
382
+ # Check server versions
383
+ assert info1x.server_version == "1.0"
384
+ assert info2x.server_version == fastmcp.__version__
385
+
386
+ # No templates added in these tests
387
+ assert len(info1x.templates) == 0
388
+ assert len(info2x.templates) == 0