Skip to content

Commit 39b421a

Browse files
strawgateclaude
andauthored
OTEL: Instrument all MCP list operations and enrich delegate spans (#3890)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5593cf3 commit 39b421a

7 files changed

Lines changed: 530 additions & 100 deletions

File tree

src/fastmcp/client/mixins/prompts.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,18 @@ async def list_prompts_mcp(
4949
RuntimeError: If called while the client is not connected.
5050
McpError: If the request results in a TimeoutError | JSONRPCError
5151
"""
52-
logger.debug(f"[{self.name}] called list_prompts")
52+
with client_span(
53+
"prompts/list",
54+
"prompts/list",
55+
"",
56+
session_id=self.transport.get_session_id(),
57+
):
58+
logger.debug(f"[{self.name}] called list_prompts")
5359

54-
result = await self._await_with_session_monitoring(
55-
self.session.list_prompts(cursor=cursor)
56-
)
57-
return result
60+
result = await self._await_with_session_monitoring(
61+
self.session.list_prompts(cursor=cursor)
62+
)
63+
return result
5864

5965
async def list_prompts(
6066
self: Client,

src/fastmcp/client/mixins/resources.py

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,18 @@ async def list_resources_mcp(
4848
RuntimeError: If called while the client is not connected.
4949
McpError: If the request results in a TimeoutError | JSONRPCError
5050
"""
51-
logger.debug(f"[{self.name}] called list_resources")
51+
with client_span(
52+
"resources/list",
53+
"resources/list",
54+
"",
55+
session_id=self.transport.get_session_id(),
56+
):
57+
logger.debug(f"[{self.name}] called list_resources")
5258

53-
result = await self._await_with_session_monitoring(
54-
self.session.list_resources(cursor=cursor)
55-
)
56-
return result
59+
result = await self._await_with_session_monitoring(
60+
self.session.list_resources(cursor=cursor)
61+
)
62+
return result
5763

5864
async def list_resources(
5965
self: Client,
@@ -118,12 +124,18 @@ async def list_resource_templates_mcp(
118124
RuntimeError: If called while the client is not connected.
119125
McpError: If the request results in a TimeoutError | JSONRPCError
120126
"""
121-
logger.debug(f"[{self.name}] called list_resource_templates")
127+
with client_span(
128+
"resources/templates/list",
129+
"resources/templates/list",
130+
"",
131+
session_id=self.transport.get_session_id(),
132+
):
133+
logger.debug(f"[{self.name}] called list_resource_templates")
122134

123-
result = await self._await_with_session_monitoring(
124-
self.session.list_resource_templates(cursor=cursor)
125-
)
126-
return result
135+
result = await self._await_with_session_monitoring(
136+
self.session.list_resource_templates(cursor=cursor)
137+
)
138+
return result
127139

128140
async def list_resource_templates(
129141
self: Client,

src/fastmcp/client/mixins/tools.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,18 @@ async def list_tools_mcp(
5353
RuntimeError: If called while the client is not connected.
5454
McpError: If the request results in a TimeoutError | JSONRPCError
5555
"""
56-
logger.debug(f"[{self.name}] called list_tools")
56+
with client_span(
57+
"tools/list",
58+
"tools/list",
59+
"",
60+
session_id=self.transport.get_session_id(),
61+
):
62+
logger.debug(f"[{self.name}] called list_tools")
5763

58-
result = await self._await_with_session_monitoring(
59-
self.session.list_tools(cursor=cursor)
60-
)
61-
return result
64+
result = await self._await_with_session_monitoring(
65+
self.session.list_tools(cursor=cursor)
66+
)
67+
return result
6268

6369
async def list_tools(
6470
self: Client,

src/fastmcp/server/server.py

Lines changed: 96 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -620,31 +620,33 @@ async def list_tools(self, *, run_middleware: bool = True) -> Sequence[Tool]:
620620
call_next=lambda context: self.list_tools(run_middleware=False),
621621
)
622622

623-
# Get all tools, apply session transforms, then filter enabled
624-
# and model-visible (app-only tools are hidden from the model).
625-
tools = list(await super().list_tools())
626-
tools = await apply_session_transforms(tools)
627-
tools = [t for t in tools if is_enabled(t) and _is_model_visible(t)]
628-
629-
# Rewrite per-tool Prefab renderer URIs based on the tool's
630-
# mount-point address. The walk pairs each tool with the
631-
# provider that yielded it, computes the hashed URI, and
632-
# produces a model_copy with the URI in place. Original
633-
# Tool objects are not mutated.
634-
tools = self._rewrite_prefab_uris(tools)
635-
636-
skip_auth, token = _get_auth_context()
637-
authorized: list[Tool] = []
638-
for tool in tools:
639-
if not skip_auth and tool.auth is not None:
640-
ctx = AuthContext(token=token, component=tool)
641-
try:
642-
if not await run_auth_checks(tool.auth, ctx):
623+
# Core logic: list tools
624+
with server_span("tools/list", "tools/list", self.name, "tool", ""):
625+
# Get all tools, apply session transforms, then filter enabled
626+
# and model-visible (app-only tools are hidden from the model).
627+
tools = list(await super().list_tools())
628+
tools = await apply_session_transforms(tools)
629+
tools = [t for t in tools if is_enabled(t) and _is_model_visible(t)]
630+
631+
# Rewrite per-tool Prefab renderer URIs based on the tool's
632+
# mount-point address. The walk pairs each tool with the
633+
# provider that yielded it, computes the hashed URI, and
634+
# produces a model_copy with the URI in place. Original
635+
# Tool objects are not mutated.
636+
tools = self._rewrite_prefab_uris(tools)
637+
638+
skip_auth, token = _get_auth_context()
639+
authorized: list[Tool] = []
640+
for tool in tools:
641+
if not skip_auth and tool.auth is not None:
642+
ctx = AuthContext(token=token, component=tool)
643+
try:
644+
if not await run_auth_checks(tool.auth, ctx):
645+
continue
646+
except AuthorizationError:
643647
continue
644-
except AuthorizationError:
645-
continue
646-
authorized.append(tool)
647-
return authorized
648+
authorized.append(tool)
649+
return authorized
648650

649651
async def _get_tool(
650652
self, name: str, version: VersionSpec | None = None
@@ -754,32 +756,36 @@ async def list_resources(
754756
call_next=lambda context: self.list_resources(run_middleware=False),
755757
)
756758

757-
# Get all resources, apply session transforms, then filter enabled
758-
resources = list(await super().list_resources())
759-
resources = await apply_session_transforms(resources)
760-
resources = [r for r in resources if is_enabled(r)]
761-
762-
# Append synthetic Prefab renderer resources — one per
763-
# prefab tool, hashed by mount address. These don't live on
764-
# any provider's storage; they're computed on demand.
765-
from fastmcp.server.providers.prefab_synthesis import (
766-
synthesize_prefab_resources,
767-
)
768-
769-
resources.extend(await synthesize_prefab_resources(self))
759+
# Core logic: list resources
760+
with server_span(
761+
"resources/list", "resources/list", self.name, "resource", ""
762+
):
763+
# Get all resources, apply session transforms, then filter enabled
764+
resources = list(await super().list_resources())
765+
resources = await apply_session_transforms(resources)
766+
resources = [r for r in resources if is_enabled(r)]
767+
768+
# Append synthetic Prefab renderer resources — one per
769+
# prefab tool, hashed by mount address. These don't live on
770+
# any provider's storage; they're computed on demand.
771+
from fastmcp.server.providers.prefab_synthesis import (
772+
synthesize_prefab_resources,
773+
)
770774

771-
skip_auth, token = _get_auth_context()
772-
authorized: list[Resource] = []
773-
for resource in resources:
774-
if not skip_auth and resource.auth is not None:
775-
ctx = AuthContext(token=token, component=resource)
776-
try:
777-
if not await run_auth_checks(resource.auth, ctx):
775+
resources.extend(await synthesize_prefab_resources(self))
776+
777+
skip_auth, token = _get_auth_context()
778+
authorized: list[Resource] = []
779+
for resource in resources:
780+
if not skip_auth and resource.auth is not None:
781+
ctx = AuthContext(token=token, component=resource)
782+
try:
783+
if not await run_auth_checks(resource.auth, ctx):
784+
continue
785+
except AuthorizationError:
778786
continue
779-
except AuthorizationError:
780-
continue
781-
authorized.append(resource)
782-
return authorized
787+
authorized.append(resource)
788+
return authorized
783789

784790
async def _get_resource(
785791
self, uri: str, version: VersionSpec | None = None
@@ -887,23 +893,31 @@ async def list_resource_templates(
887893
),
888894
)
889895

890-
# Get all templates, apply session transforms, then filter enabled
891-
templates = list(await super().list_resource_templates())
892-
templates = await apply_session_transforms(templates)
893-
templates = [t for t in templates if is_enabled(t)]
894-
895-
skip_auth, token = _get_auth_context()
896-
authorized: list[ResourceTemplate] = []
897-
for template in templates:
898-
if not skip_auth and template.auth is not None:
899-
ctx = AuthContext(token=token, component=template)
900-
try:
901-
if not await run_auth_checks(template.auth, ctx):
896+
# Core logic: list resource templates
897+
with server_span(
898+
"resources/templates/list",
899+
"resources/templates/list",
900+
self.name,
901+
"resource_template",
902+
"",
903+
):
904+
# Get all templates, apply session transforms, then filter enabled
905+
templates = list(await super().list_resource_templates())
906+
templates = await apply_session_transforms(templates)
907+
templates = [t for t in templates if is_enabled(t)]
908+
909+
skip_auth, token = _get_auth_context()
910+
authorized: list[ResourceTemplate] = []
911+
for template in templates:
912+
if not skip_auth and template.auth is not None:
913+
ctx = AuthContext(token=token, component=template)
914+
try:
915+
if not await run_auth_checks(template.auth, ctx):
916+
continue
917+
except AuthorizationError:
902918
continue
903-
except AuthorizationError:
904-
continue
905-
authorized.append(template)
906-
return authorized
919+
authorized.append(template)
920+
return authorized
907921

908922
async def _get_resource_template(
909923
self, uri: str, version: VersionSpec | None = None
@@ -1011,23 +1025,25 @@ async def list_prompts(self, *, run_middleware: bool = True) -> Sequence[Prompt]
10111025
call_next=lambda context: self.list_prompts(run_middleware=False),
10121026
)
10131027

1014-
# Get all prompts, apply session transforms, then filter enabled
1015-
prompts = list(await super().list_prompts())
1016-
prompts = await apply_session_transforms(prompts)
1017-
prompts = [p for p in prompts if is_enabled(p)]
1018-
1019-
skip_auth, token = _get_auth_context()
1020-
authorized: list[Prompt] = []
1021-
for prompt in prompts:
1022-
if not skip_auth and prompt.auth is not None:
1023-
ctx = AuthContext(token=token, component=prompt)
1024-
try:
1025-
if not await run_auth_checks(prompt.auth, ctx):
1028+
# Core logic: list prompts
1029+
with server_span("prompts/list", "prompts/list", self.name, "prompt", ""):
1030+
# Get all prompts, apply session transforms, then filter enabled
1031+
prompts = list(await super().list_prompts())
1032+
prompts = await apply_session_transforms(prompts)
1033+
prompts = [p for p in prompts if is_enabled(p)]
1034+
1035+
skip_auth, token = _get_auth_context()
1036+
authorized: list[Prompt] = []
1037+
for prompt in prompts:
1038+
if not skip_auth and prompt.auth is not None:
1039+
ctx = AuthContext(token=token, component=prompt)
1040+
try:
1041+
if not await run_auth_checks(prompt.auth, ctx):
1042+
continue
1043+
except AuthorizationError:
10261044
continue
1027-
except AuthorizationError:
1028-
continue
1029-
authorized.append(prompt)
1030-
return authorized
1045+
authorized.append(prompt)
1046+
return authorized
10311047

10321048
async def _get_prompt(
10331049
self, name: str, version: VersionSpec | None = None

0 commit comments

Comments
 (0)