Skip to content

Commit 9abea90

Browse files
committed
Add instrumentation for MCP resources and prompts. (#1400)
* Add instrumentation for MCP resources and prompts. * [MegaLinter] Apply linters fixes * Address review comments. * Remove if conditional.
1 parent 0997f49 commit 9abea90

File tree

3 files changed

+161
-52
lines changed

3 files changed

+161
-52
lines changed

newrelic/hooks/adapter_mcp.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,28 +12,72 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import logging
16+
1517
from newrelic.api.function_trace import FunctionTrace
1618
from newrelic.api.transaction import current_transaction
1719
from newrelic.common.object_names import callable_name
1820
from newrelic.common.object_wrapper import wrap_function_wrapper
1921
from newrelic.common.signature import bind_args
2022

23+
_logger = logging.getLogger(__name__)
24+
2125

2226
async def wrap_call_tool(wrapped, instance, args, kwargs):
2327
transaction = current_transaction()
2428
if not transaction:
2529
return await wrapped(*args, **kwargs)
2630

31+
func_name = callable_name(wrapped)
2732
bound_args = bind_args(wrapped, args, kwargs)
2833
tool_name = bound_args.get("name") or "tool"
29-
func_name = callable_name(wrapped)
3034
function_trace_name = f"{func_name}/{tool_name}"
3135

3236
with FunctionTrace(name=function_trace_name, group="Llm/tool/MCP", source=wrapped):
3337
return await wrapped(*args, **kwargs)
3438

3539

40+
async def wrap_read_resource(wrapped, instance, args, kwargs):
41+
transaction = current_transaction()
42+
if not transaction:
43+
return await wrapped(*args, **kwargs)
44+
45+
func_name = callable_name(wrapped)
46+
bound_args = bind_args(wrapped, args, kwargs)
47+
# Set a default value in case we can't parse out the URI scheme successfully
48+
resource_scheme = "resource"
49+
50+
try:
51+
resource_uri = bound_args.get("uri")
52+
resource_scheme = getattr(resource_uri, "scheme", "resource")
53+
except Exception:
54+
_logger.warning("Unable to parse resource URI scheme for MCP read_resource call")
55+
56+
function_trace_name = f"{func_name}/{resource_scheme}"
57+
58+
with FunctionTrace(name=function_trace_name, group="Llm/resource/MCP", source=wrapped):
59+
return await wrapped(*args, **kwargs)
60+
61+
62+
async def wrap_get_prompt(wrapped, instance, args, kwargs):
63+
transaction = current_transaction()
64+
if not transaction:
65+
return await wrapped(*args, **kwargs)
66+
67+
func_name = callable_name(wrapped)
68+
bound_args = bind_args(wrapped, args, kwargs)
69+
prompt_name = bound_args.get("name") or "prompt"
70+
function_trace_name = f"{func_name}/{prompt_name}"
71+
72+
with FunctionTrace(name=function_trace_name, group="Llm/prompt/MCP", source=wrapped):
73+
return await wrapped(*args, **kwargs)
74+
75+
3676
def instrument_mcp_client_session(module):
3777
if hasattr(module, "ClientSession"):
3878
if hasattr(module.ClientSession, "call_tool"):
3979
wrap_function_wrapper(module, "ClientSession.call_tool", wrap_call_tool)
80+
if hasattr(module.ClientSession, "read_resource"):
81+
wrap_function_wrapper(module, "ClientSession.read_resource", wrap_read_resource)
82+
if hasattr(module.ClientSession, "get_prompt"):
83+
wrap_function_wrapper(module, "ClientSession.get_prompt", wrap_get_prompt)

tests/adapter_mcp/test_mcp.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# Copyright 2010 New Relic, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import pytest
16+
from fastmcp.client import Client
17+
from fastmcp.client.transports import FastMCPTransport
18+
from fastmcp.server.server import FastMCP
19+
from testing_support.validators.validate_transaction_metrics import validate_transaction_metrics
20+
21+
from newrelic.api.background_task import background_task
22+
23+
24+
@pytest.fixture
25+
def fastmcp_server():
26+
server = FastMCP("Test Tools")
27+
28+
@server.tool()
29+
def add_exclamation(phrase):
30+
return f"{phrase}!"
31+
32+
@server.resource("greeting://{name}")
33+
def get_greeting(name):
34+
return f"Hello, {name}!"
35+
36+
@server.resource("files:///home/user/documents/{filename}")
37+
def get_file(filename):
38+
return f"Hello, Python! You requested the file: {filename}."
39+
40+
@server.resource("postgres://python/customers/schema")
41+
def get_db_info():
42+
return "Hello, Python!"
43+
44+
@server.prompt()
45+
def echo_prompt(message: str):
46+
return f"Echo this message: {message}"
47+
48+
return server
49+
50+
51+
@validate_transaction_metrics(
52+
"test_mcp:test_tool_tracing",
53+
scoped_metrics=[("Llm/tool/MCP/mcp.client.session:ClientSession.call_tool/add_exclamation", 1)],
54+
rollup_metrics=[("Llm/tool/MCP/mcp.client.session:ClientSession.call_tool/add_exclamation", 1)],
55+
background_task=True,
56+
)
57+
@background_task()
58+
def test_tool_tracing(loop, fastmcp_server):
59+
async def _test():
60+
async with Client(transport=FastMCPTransport(fastmcp_server)) as client:
61+
# Call the MCP tool, so we can validate the trace naming is correct.
62+
result = await client.call_tool("add_exclamation", {"phrase": "Python is awesome"})
63+
64+
content = str(result[0])
65+
assert "Python is awesome!" in content
66+
67+
loop.run_until_complete(_test())
68+
69+
70+
# Separate out the test function to work with the transaction metrics validator
71+
def run_read_resources(loop, fastmcp_server, resource_uri):
72+
async def _test():
73+
async with Client(transport=FastMCPTransport(fastmcp_server)) as client:
74+
result = await client.read_resource(resource_uri)
75+
content = str(result[0])
76+
assert "Hello, Python!" in content
77+
78+
loop.run_until_complete(_test())
79+
80+
81+
@pytest.mark.parametrize(
82+
"resource_uri",
83+
("greeting://Python", "files:///home/user/documents/python.pdf", "postgres://python/customers/schema"),
84+
)
85+
def test_resource_tracing(loop, fastmcp_server, resource_uri):
86+
resource_scheme = resource_uri.split(":/")[0]
87+
88+
@validate_transaction_metrics(
89+
"test_mcp:test_resource_tracing.<locals>._test",
90+
scoped_metrics=[(f"Llm/resource/MCP/mcp.client.session:ClientSession.read_resource/{resource_scheme}", 1)],
91+
rollup_metrics=[(f"Llm/resource/MCP/mcp.client.session:ClientSession.read_resource/{resource_scheme}", 1)],
92+
background_task=True,
93+
)
94+
@background_task()
95+
def _test():
96+
run_read_resources(loop, fastmcp_server, resource_uri)
97+
98+
_test()
99+
100+
101+
@validate_transaction_metrics(
102+
"test_mcp:test_prompt_tracing",
103+
scoped_metrics=[("Llm/prompt/MCP/mcp.client.session:ClientSession.get_prompt/echo_prompt", 1)],
104+
rollup_metrics=[("Llm/prompt/MCP/mcp.client.session:ClientSession.get_prompt/echo_prompt", 1)],
105+
background_task=True,
106+
)
107+
@background_task()
108+
def test_prompt_tracing(loop, fastmcp_server):
109+
async def _test():
110+
async with Client(transport=FastMCPTransport(fastmcp_server)) as client:
111+
result = await client.get_prompt("echo_prompt", {"message": "Python is cool"})
112+
113+
content = str(result)
114+
assert "Python is cool" in content
115+
116+
loop.run_until_complete(_test())

tests/adapter_mcp/test_tools.py

Lines changed: 0 additions & 51 deletions
This file was deleted.

0 commit comments

Comments
 (0)