Skip to content

Commit 8e7cb4c

Browse files
authored
Merge pull request #1403 from newrelic/feature-mcp-instrumentation
Add MCP Instrumentation
2 parents a311b33 + e41c7e1 commit 8e7cb4c

File tree

5 files changed

+234
-0
lines changed

5 files changed

+234
-0
lines changed

newrelic/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2861,6 +2861,9 @@ def _process_module_builtin_defaults():
28612861

28622862
_process_module_definition("loguru", "newrelic.hooks.logger_loguru", "instrument_loguru")
28632863
_process_module_definition("loguru._logger", "newrelic.hooks.logger_loguru", "instrument_loguru_logger")
2864+
2865+
_process_module_definition("mcp.client.session", "newrelic.hooks.adapter_mcp", "instrument_mcp_client_session")
2866+
28642867
_process_module_definition("structlog._base", "newrelic.hooks.logger_structlog", "instrument_structlog__base")
28652868
_process_module_definition("structlog._frames", "newrelic.hooks.logger_structlog", "instrument_structlog__frames")
28662869
_process_module_definition("paste.httpserver", "newrelic.hooks.adapter_paste", "instrument_paste_httpserver")

newrelic/hooks/adapter_mcp.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
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 logging
16+
17+
from newrelic.api.function_trace import FunctionTrace
18+
from newrelic.api.transaction import current_transaction
19+
from newrelic.common.object_names import callable_name
20+
from newrelic.common.object_wrapper import wrap_function_wrapper
21+
from newrelic.common.signature import bind_args
22+
23+
_logger = logging.getLogger(__name__)
24+
25+
26+
async def wrap_call_tool(wrapped, instance, args, kwargs):
27+
transaction = current_transaction()
28+
if not transaction:
29+
return await wrapped(*args, **kwargs)
30+
31+
func_name = callable_name(wrapped)
32+
bound_args = bind_args(wrapped, args, kwargs)
33+
tool_name = bound_args.get("name") or "tool"
34+
function_trace_name = f"{func_name}/{tool_name}"
35+
36+
with FunctionTrace(name=function_trace_name, group="Llm/tool/MCP", source=wrapped):
37+
return await wrapped(*args, **kwargs)
38+
39+
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+
76+
def instrument_mcp_client_session(module):
77+
if hasattr(module, "ClientSession"):
78+
if hasattr(module.ClientSession, "call_tool"):
79+
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/conftest.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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+
from testing_support.fixture.event_loop import event_loop as loop
16+
from testing_support.fixtures import collector_agent_registration_fixture, collector_available_fixture
17+
18+
_default_settings = {
19+
"package_reporting.enabled": False, # Turn off package reporting for testing as it causes slowdowns.
20+
"transaction_tracer.explain_threshold": 0.0,
21+
"transaction_tracer.transaction_threshold": 0.0,
22+
"transaction_tracer.stack_trace_threshold": 0.0,
23+
"debug.log_data_collector_payloads": True,
24+
"debug.record_transaction_failure": True,
25+
}
26+
27+
collector_agent_registration = collector_agent_registration_fixture(
28+
app_name="Python Agent Test (adapter_mcp)", default_settings=_default_settings
29+
)

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())

tox.ini

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ envlist =
8383
python-adapter_gunicorn-{py37,py38,py39,py310,py311,py312,py313}-aiohttp03-gunicornlatest,
8484
python-adapter_hypercorn-{py38,py39,py310,py311,py312,py313}-hypercornlatest,
8585
python-adapter_hypercorn-py38-hypercorn{0010,0011,0012,0013},
86+
python-adapter_mcp-{py310,py311,py312,py313,pypy310},
8687
python-adapter_uvicorn-{py37,py38,py39,py310,py311,py312,py313}-uvicornlatest,
8788
python-adapter_uvicorn-py38-uvicorn014,
8889
python-adapter_waitress-{py37,py38,py39,py310,py311,py312,py313}-waitresslatest,
@@ -210,6 +211,7 @@ deps =
210211
adapter_hypercorn-hypercorn0011: hypercorn[h3]<0.12
211212
adapter_hypercorn-hypercorn0010: hypercorn[h3]<0.11
212213
adapter_hypercorn: niquests
214+
adapter_mcp: fastmcp
213215
adapter_uvicorn-uvicorn014: uvicorn<0.15
214216
adapter_uvicorn-uvicornlatest: uvicorn
215217
adapter_uvicorn: typing-extensions
@@ -477,6 +479,7 @@ changedir =
477479
adapter_gevent: tests/adapter_gevent
478480
adapter_gunicorn: tests/adapter_gunicorn
479481
adapter_hypercorn: tests/adapter_hypercorn
482+
adapter_mcp: tests/adapter_mcp
480483
adapter_uvicorn: tests/adapter_uvicorn
481484
adapter_waitress: tests/adapter_waitress
482485
agent_features: tests/agent_features

0 commit comments

Comments
 (0)