diff --git a/newrelic/hooks/mlmodel_langchain.py b/newrelic/hooks/mlmodel_langchain.py index c924fba55..004c27a67 100644 --- a/newrelic/hooks/mlmodel_langchain.py +++ b/newrelic/hooks/mlmodel_langchain.py @@ -575,11 +575,12 @@ def wrap_tool_sync_run(wrapped, instance, args, kwargs): except Exception: filtered_tool_input = tool_input - agentic_subcomponent_data = {"type": "APM-AI_TOOL", "name": tool_name} - ft = FunctionTrace(name=f"{wrapped.__name__}/{tool_name}", group="Llm/tool/LangChain") ft.__enter__() - ft._add_agent_attribute("subcomponent", json.dumps(agentic_subcomponent_data)) + # Subcomponent attribute is only added when the tool executes locally + if _is_local_tool(instance): + agentic_subcomponent_data = {"type": "APM-AI_TOOL", "name": tool_name} + ft._add_agent_attribute("subcomponent", json.dumps(agentic_subcomponent_data)) linking_metadata = get_trace_linking_metadata() try: return_val = wrapped(**run_args) @@ -639,11 +640,12 @@ async def wrap_tool_async_run(wrapped, instance, args, kwargs): except Exception: filtered_tool_input = tool_input - agentic_subcomponent_data = {"type": "APM-AI_TOOL", "name": tool_name} - ft = FunctionTrace(name=f"{wrapped.__name__}/{tool_name}", group="Llm/tool/LangChain") ft.__enter__() - ft._add_agent_attribute("subcomponent", json.dumps(agentic_subcomponent_data)) + # Subcomponent attribute is only added when the tool executes locally + if _is_local_tool(instance): + agentic_subcomponent_data = {"type": "APM-AI_TOOL", "name": tool_name} + ft._add_agent_attribute("subcomponent", json.dumps(agentic_subcomponent_data)) linking_metadata = get_trace_linking_metadata() try: return_val = await wrapped(**run_args) @@ -680,6 +682,31 @@ async def wrap_tool_async_run(wrapped, instance, args, kwargs): return return_val +# Known modules whose tools execute remotely. +_REMOTE_TOOL_MODULE_PREFIXES = ("langchain_mcp_adapters", "langgraph.pregel.remote") + + +def _is_local_tool(instance): + """ + Best effort check that a LangChain tool executes locally. + + Returns False only for known remote tools. + """ + try: + # StructuredTool holds the underlying callable in the func or coroutine attributes. + # Custom BaseTool subclasses hold their logic on the class itself. + candidates = (getattr(instance, "func", None), getattr(instance, "coroutine", None), type(instance)) + for candidate in candidates: + module = getattr(candidate, "__module__", "") or "" + if module.startswith(_REMOTE_TOOL_MODULE_PREFIXES): + return False + except Exception: + pass + + # If we can't prove it was a remote tool, assume it was local. + return True + + def _capture_tool_info(instance, wrapped, args, kwargs): run_args = bind_args(wrapped, args, kwargs) diff --git a/tests/mlmodel_langchain/cassette.yaml b/tests/mlmodel_langchain/cassette.yaml index 4fc75a779..123701528 100644 --- a/tests/mlmodel_langchain/cassette.yaml +++ b/tests/mlmodel_langchain/cassette.yaml @@ -110,13 +110,12 @@ interactions: body: string: '{"id": "chatcmpl-DyjwCWB6oPS6gcxdy4b9JW4Ey6Lv5", "object": "chat.completion", "created": 1783368636, "model": "gpt-3.5-turbo-0125", "choices": [{"index": - 0, "message": {"role": "assistant", "content": "Hello!", - "refusal": null, "annotations": []}, "logprobs": null, "finish_reason": "stop"}], - "usage": {"prompt_tokens": 107, "completion_tokens": 2, "total_tokens": 109, - "prompt_tokens_details": {"cached_tokens": 0, "audio_tokens": 0}, "completion_tokens_details": - {"reasoning_tokens": 0, "audio_tokens": 0, "accepted_prediction_tokens": 0, - "rejected_prediction_tokens": 0}}, "service_tier": "default", "system_fingerprint": - null}' + 0, "message": {"role": "assistant", "content": "Hello!", "refusal": null, + "annotations": []}, "logprobs": null, "finish_reason": "stop"}], "usage": + {"prompt_tokens": 107, "completion_tokens": 2, "total_tokens": 109, "prompt_tokens_details": + {"cached_tokens": 0, "audio_tokens": 0}, "completion_tokens_details": {"reasoning_tokens": + 0, "audio_tokens": 0, "accepted_prediction_tokens": 0, "rejected_prediction_tokens": + 0}}, "service_tier": "default", "system_fingerprint": null}' headers: access-control-expose-headers: - X-Request-ID @@ -1015,4 +1014,153 @@ interactions: status: code: 200 message: OK +- request: + body: '{"messages":[{"content":"You are a text manipulation algorithm.","role":"system"},{"content":"Call + the add_exclamation_mcp tool with message=\"Hello\". Reply with only the tool + output, no other text.","role":"user"}],"model":"gpt-3.5-turbo","stream":false,"temperature":0.7,"tools":[{"type":"function","function":{"name":"add_exclamation_mcp","description":"Adds + an exclamation mark to the input message.","parameters":{"properties":{"message":{"type":"string"}},"required":["message"],"type":"object"}}}]}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate, zstd + authorization: + - XXXXXX + connection: + - keep-alive + content-type: + - application/json + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: '{"id": "chatcmpl-DzVrTIy5YWxOo2JjHyNNLCEoZeIQ5", "object": "chat.completion", + "created": 1783552855, "model": "gpt-3.5-turbo-0125", "choices": [{"index": + 0, "message": {"role": "assistant", "content": null, "tool_calls": [{"id": + "call_FI6Wqecvc3Pm1sIJIWbfiPqn", "type": "function", "function": {"name": + "add_exclamation_mcp", "arguments": "{\"message\":\"Hello\"}"}}], "refusal": + null, "annotations": []}, "logprobs": null, "finish_reason": "tool_calls"}], + "usage": {"prompt_tokens": 82, "completion_tokens": 17, "total_tokens": 99, + "prompt_tokens_details": {"cached_tokens": 0, "audio_tokens": 0}, "completion_tokens_details": + {"reasoning_tokens": 0, "audio_tokens": 0, "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0}}, "service_tier": "default", "system_fingerprint": + null}' + headers: + access-control-expose-headers: + - X-Request-ID + - CF-Ray + - CF-Ray + cf-cache-status: + - DYNAMIC + cf-ray: + - a182d27a8acc5ee6-PDX + connection: + - keep-alive + content-type: + - application/json + date: + - Wed, 08 Jul 2026 23:20:56 GMT + openai-organization: + - nr-test-org + openai-processing-ms: + - '1537' + openai-project: + - nr-test-project + openai-version: + - '2020-10-01' + server: + - cloudflare + transfer-encoding: + - chunked + x-ratelimit-limit-requests: + - '10000' + x-ratelimit-limit-tokens: + - '50000000' + x-ratelimit-remaining-requests: + - '9999' + x-ratelimit-remaining-tokens: + - '49999975' + x-ratelimit-reset-requests: + - 6ms + x-ratelimit-reset-tokens: + - 0s + x-request-id: + - req_769ac9ab9bce44929dff73538cc803d1 + status: + code: 200 + message: OK +- request: + body: '{"messages":[{"content":"You are a text manipulation algorithm.","role":"system"},{"content":"Call + the add_exclamation_mcp tool with message=\"Hello\". Reply with only the tool + output, no other text.","role":"user"},{"content":null,"name":"my_agent","role":"assistant","tool_calls":[{"type":"function","id":"call_FI6Wqecvc3Pm1sIJIWbfiPqn","function":{"name":"add_exclamation_mcp","arguments":"{\"message\": + \"Hello\"}"}}]},{"content":[{"type":"text","text":"Hello!"}],"role":"tool","tool_call_id":"call_FI6Wqecvc3Pm1sIJIWbfiPqn"}],"model":"gpt-3.5-turbo","stream":false,"temperature":0.7,"tools":[{"type":"function","function":{"name":"add_exclamation_mcp","description":"Adds + an exclamation mark to the input message.","parameters":{"properties":{"message":{"type":"string"}},"required":["message"],"type":"object"}}}]}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate, zstd + authorization: + - XXXXXX + connection: + - keep-alive + content-type: + - application/json + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: '{"id": "chatcmpl-DzVrUApwqEqiJqmsGYi0xyFRiHOG5", "object": "chat.completion", + "created": 1783552856, "model": "gpt-3.5-turbo-0125", "choices": [{"index": + 0, "message": {"role": "assistant", "content": "{\"message\":\"Hello!\"}", + "refusal": null, "annotations": []}, "logprobs": null, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 115, "completion_tokens": 7, "total_tokens": 122, + "prompt_tokens_details": {"cached_tokens": 0, "audio_tokens": 0}, "completion_tokens_details": + {"reasoning_tokens": 0, "audio_tokens": 0, "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0}}, "service_tier": "default", "system_fingerprint": + null}' + headers: + access-control-expose-headers: + - X-Request-ID + - CF-Ray + - CF-Ray + cf-cache-status: + - DYNAMIC + cf-ray: + - a182d2894ae25ee6-PDX + connection: + - keep-alive + content-type: + - application/json + date: + - Wed, 08 Jul 2026 23:20:57 GMT + openai-organization: + - nr-test-org + openai-processing-ms: + - '409' + openai-project: + - nr-test-project + openai-version: + - '2020-10-01' + server: + - cloudflare + transfer-encoding: + - chunked + x-ratelimit-limit-requests: + - '10000' + x-ratelimit-limit-tokens: + - '50000000' + x-ratelimit-remaining-requests: + - '9999' + x-ratelimit-remaining-tokens: + - '49999975' + x-ratelimit-reset-requests: + - 6ms + x-ratelimit-reset-tokens: + - 0s + x-request-id: + - req_3a560a188af14f67890e2b1ad837bd71 + status: + code: 200 + message: OK version: 1 diff --git a/tests/mlmodel_langchain/test_remote_tools.py b/tests/mlmodel_langchain/test_remote_tools.py new file mode 100644 index 000000000..0d7eab58b --- /dev/null +++ b/tests/mlmodel_langchain/test_remote_tools.py @@ -0,0 +1,112 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest +from langchain.messages import HumanMessage +from mcp.server.fastmcp import FastMCP +from mcp.shared.memory import create_connected_server_and_client_session +from testing_support.fixtures import dt_enabled, reset_core_stats_engine, validate_attributes +from testing_support.validators.validate_custom_events import validate_custom_events +from testing_support.validators.validate_span_events import validate_span_events +from testing_support.validators.validate_transaction_metrics import validate_transaction_metrics + +from newrelic.api.background_task import background_task +from newrelic.hooks.mlmodel_langchain import _is_local_tool + +PROMPT = { + "messages": [ + HumanMessage( + 'Call the add_exclamation_mcp tool with message="Hello". Reply with only the tool output, no other text.' + ) + ] +} + +tool_recorded_event = [ + ( + {"type": "LlmTool"}, + { + "id": None, + "run_id": None, + # MCP adapters return the result as a content block carrying a random uuid, so + # the exact output string is non-deterministic. We can only assert the key + # is present, not its value. + "output": None, + "name": "add_exclamation_mcp", + "agent_name": "my_agent", + "span_id": None, + "trace_id": "trace-id", + "input": "{'message': 'Hello'}", + "vendor": "langchain", + "ingest_source": "Python", + "duration": None, + }, + ) +] + + +@pytest.fixture +def mcp_server(): + server = FastMCP("Test Tools") + + @server.tool() + def add_exclamation_mcp(message: str) -> str: + """Adds an exclamation mark to the input message.""" + return f"{message}!" + + return server + + +@dt_enabled +@reset_core_stats_engine() +def test_remote_mcp_tool_no_subcomponent(loop, set_trace_info, chat_openai_client, mcp_server): + @validate_custom_events(tool_recorded_event) + @validate_transaction_metrics( + "test_remote_mcp_tool_no_subcomponent", + scoped_metrics=[("Llm/tool/LangChain/arun/add_exclamation_mcp", 1)], + rollup_metrics=[("Llm/tool/LangChain/arun/add_exclamation_mcp", 1)], + background_task=True, + ) + @validate_attributes("agent", ["llm"]) + # Agent span still has a subcomponent attribute as the agent is executing locally + @validate_span_events(count=1, exact_agents={"subcomponent": '{"type": "APM-AI_AGENT", "name": "my_agent"}'}) + # Tool span must NOT have a subcomponent attribute as it will be captured on the server side + @validate_span_events( + count=1, + exact_intrinsics={"name": "Llm/tool/LangChain/arun/add_exclamation_mcp"}, + unexpected_agents=["subcomponent"], + ) + @background_task(name="test_remote_mcp_tool_no_subcomponent") + def _test(): + set_trace_info() + + async def _run(): + from langchain.agents import create_agent + from langchain_mcp_adapters.tools import load_mcp_tools + + async with create_connected_server_and_client_session(mcp_server) as session: + tools = await load_mcp_tools(session) + # The loaded tool is a genuine remote proxy, so the fix must treat it as non-local. + assert _is_local_tool(tools[0]) is False + + agent = create_agent( + model=chat_openai_client.with_config(model="gpt-5.1", timeout=30), + tools=tools, + system_prompt="You are a text manipulation algorithm.", + name="my_agent", + ) + return await agent.ainvoke(PROMPT) + + loop.run_until_complete(_run()) + + _test() diff --git a/tox.ini b/tox.ini index 0731678e8..8850f6fc1 100644 --- a/tox.ini +++ b/tox.ini @@ -477,6 +477,7 @@ deps = mlmodel_langchain: langchain-openai mlmodel_langchain: pytest-recording mlmodel_langchain: langgraph + mlmodel_langchain: langchain-mcp-adapters mlmodel_langchain: pypdf mlmodel_langchain: tiktoken mlmodel_langchain: faiss-cpu