Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 33 additions & 6 deletions newrelic/hooks/mlmodel_langchain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down
162 changes: 155 additions & 7 deletions tests/mlmodel_langchain/cassette.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
112 changes: 112 additions & 0 deletions tests/mlmodel_langchain/test_remote_tools.py
Original file line number Diff line number Diff line change
@@ -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()
1 change: 1 addition & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading