Skip to content

[feat] Contribute mcp Instrumentor #435

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 46 commits into
base: main
Choose a base branch
from

Conversation

Johnnyl202
Copy link

@Johnnyl202 Johnnyl202 commented Jul 23, 2025

  1. Add MCPInstrumentor class

Provides OpenTelemetry support for MCP by injecting and extracting trace context. Enables span creation for both client-side (send_request) and server-side (_handle_request) flows to support end-to-end observability.

  1. Add test_mcpinstrumentor.py
    Implements a comprehensive unit test suite that:

Verifies trace context injection

Ensures proper fallback behavior when no context is present

Simulates end-to-end client-server communication with trace propagation

Tests behavior of instrumentation internals, including tracer provider setup

  1. Add contract_test

Validate the number of spans created and the correct attributes they have

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@Johnnyl202 Johnnyl202 requested a review from a team as a code owner July 23, 2025 01:08
.flake8 Outdated
@@ -19,6 +19,9 @@ exclude =
CVS
.venv*/
venv*/
**/venv*/
**/.venv*/
aws-opentelemetry-distro/src/amazon/opentelemetry/distro/mcpinstrumentor/venv
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file is used to exclude certain directories from checks. You can remove these as venvs should normally be created in the root directory (covered by above paths)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed to this. Thank you for suggestion!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove this from committed files

return logger


loggertwo = setup_loggertwo()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove this logger setup code

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, thank you!


return async_wrapper()

def getname(self, req):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: make function name more descriptive, like _span_name_from_request

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do. Thanks for suggestion!

return _instruments

def _instrument(self, **kwargs: Any) -> None:
tracer_provider = kwargs.get("tracer_provider") # Move this line up
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove inline comment

"opentelemetry-instrumentation",
"opentelemetry-semantic-conventions",
"wrapt",
"opentelemetry-sdk",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you could be missing some dependencies here (like mcp or openinference), check dependencies that had to be installed manually outside of this list and add them here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^^
Please add mcp as a dependency.
We should not have openinference as a dependency as the implementation does not require anything in their library. Please remove

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it. Thank you!

import sys
import unittest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../../../../../src"))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can absolute path be used instead here? seems unreliable

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed! Thanks for suggestion.

# Verify - should handle missing request gracefully
self.assertIsNone(req3)

def test_end_to_end_trace_context_propagation(self):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO context propagation should be tested in contract/integration tests, better to just test basic functionality here.

)

# Verify the tool call request structure matches AppSignal expectations
request_data = appsignal_request.model_dump()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these assertions don't really have to do with the instrumentation logic

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion! This is part of the mock workflow but I have made a new version of it.

}

# Verify expected response structure
self.assertTrue(expected_list_apps_result["success"])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is any behavior being validated here? It seems like we're just checking certain attributes in a hardcoded attribute

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion. Removed this line!

logger_two = setup_logger_two()


class MCPInstrumentor(BaseInstrumentor):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can you organize your functions top-to-bottom (i.e. most important functions at top _wrap_send_request and _wrap_handle_request and then helper functions)

request_data["params"] = {}
if "_meta" not in request_data["params"]:
request_data["params"]["_meta"] = {}
request_data["params"]["_meta"]["trace_context"] = {"trace_id": span_ctx.trace_id, "span_id": span_ctx.span_id}
Copy link
Contributor

@liustve liustve Jul 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we rename trace_context to traceparent and inject a W3C trace header string instead of the dictionary?
When the trace context gets received we should parse the string for the trace id and parent span id.
See: https://www.w3.org/TR/trace-context/#trace-context-http-headers-format

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, thank you!

"opentelemetry-instrumentation",
"opentelemetry-semantic-conventions",
"wrapt",
"opentelemetry-sdk",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^^
Please add mcp as a dependency.
We should not have openinference as a dependency as the implementation does not require anything in their library. Please remove

"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://pypi.org/project/mcp/

It looks likes this mcp does not support Python 3.9.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will go ahead and fix that. Thanks!


# Handle Request Wrapper
async def _wrap_handle_request(self, wrapped, instance, args, kwargs):
"""
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be helpful if you can add a comment of what the request object looks like.

Copy link
Author

@Johnnyl202 Johnnyl202 Jul 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, thanks for the suggestion!

import mcp.types as types

operation = "Server Handle Request"
if isinstance(request, types.ListToolsRequest):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have duplicate code where we check isinstance(request, [type]) in get_name. In my opinion we should delete get_name and consolidate the logic into this function.

Suggested change
if isinstance(request, types.ListToolsRequest):
if isinstance(request, types.ListToolsRequest):
span_name = "tools/list"

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the suggestion! I understand your point but I feel like we should keep it because each function handles distinct logic specific to client or server. To me, that's easier to read and if things go wrong, it is also easier to test fixes like I can just add an attribute to the client function to see what changes on CloudWatch.

def handle_attributes(self, span, request, is_client=True):
import mcp.types as types

operation = "Server Handle Request"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
operation = "Server Handle Request"
operation = "Server Handle Request"
span_name = "unknown"


return async_wrapper()

def _get_span_name(self, req):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should delete this function, see above comment

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please avoid including development-specific logging configurations and test setups in PR commits.

As a rule, before pushing:

  • Review your commit diff to ensure only intended changes are included
  • Remove any temporary debugging code, verbose logging, or test-specific configurations
  • Keep commits focused on the actual feature/fix being implemented

import unittest
from unittest.mock import MagicMock

project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../.."))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please avoid including development-specific logging configurations and test setups in PR commits.

As a rule, before pushing:

Review your commit diff to ensure only intended changes are included
Remove any temporary debugging code, verbose logging, or test-specific configurations
Keep commits focused on the actual feature/fix being implemented

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, thank you!

@liustve liustve changed the title Unit Test and MCP Instrumentor [feat] Contribute mcp Instrumentor Aug 1, 2025
"""Fallback operation name for unrecognized MCP operations."""


class MCPTraceContext:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are not semantic conventions. Please read https://opentelemetry.io/docs/specs/semconv/ I recommend you have a separate class for variables like these.



class MCPEnvironmentVariables:
"""Environment variable names for MCP instrumentation configuration."""
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are not semantic conventions. Please read https://opentelemetry.io/docs/specs/semconv/
I recommend you have a separate class for variables like these.

"""

# AWS-specific Remote Service Attributes
AWS_REMOTE_SERVICE = "aws.remote.service"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These AWS attributes are already defined in

.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was able to completely removed this. Thank you!

return f"mcp.call_tool.{tool_name}"

# Server-side span names
TOOLS_INITIALIZE = "tools/initialize"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the documentation:
MCP begins with lifecycle management through a capability negotiation handshake. As described in the [lifecycle management](https://modelcontextprotocol.io/docs/learn/architecture#lifecycle-management) section, the client sends an initialize request to establish the connection and negotiate supported features.

So naming it tools/initialize does not make too much sense to me since it has nothing to do with mcp tools

return f"tools/{tool_name}"


class MCPOperations:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are not semantic conventions. Please read https://opentelemetry.io/docs/specs/semconv/
I recommend you have a separate class for variables like these.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it thanks!

from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
from opentelemetry.instrumentation.utils import unwrap
from .semconv import MCPAttributes, MCPSpanNames, MCPOperations, MCPTraceContext, MCPEnvironmentVariables
_instruments = ("mcp >= 1.6.0",)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
_instruments = ("mcp >= 1.6.0",)

else:
return await wrapped(*args, **kwargs)

def _generate_mcp_attributes(self, span: trace.Span, request: ClientRequest, is_client: bool) -> None:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can also be a static method

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This calls other static methods (add_client_attributes and add_server_attributes), so I think keeping _generate_mcp_attributes as an instance method is cleaner. Otherwise, we’d have to call them like MCPInstrumentor.add_client_attributes, which feels less natural in this context. Let me know if I'm wrong. Thank you!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed! Thanks!

@@ -89,12 +89,16 @@ dependencies = [
# If a new patch is added into the list, it must also be added into tox.ini, dev-requirements.txt and _instrumentation_patch
patch = [
"botocore ~= 1.0",
"mcp >= 1.1.0"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well this doesn't make sense, your code indicates that your instrumentation library instruments mcp >= 1.6.0 but here you have mcp >= 1.1.0. So which is it?

_instruments = ("mcp >= 1.6.0",)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, this is my mistake. I was able to see that there's no module named "mcp.server.lowlevel" with version 1.1. The minimum version for my instrumentor to be imported is 1.3. However, there's some significant changes to how client message handling is done in the version of 1.6. Explanation from openinference: Arize-ai/openinference#1563 . I will change it to mcp >= 1.6.0. Thank you for pointing it out!

@@ -0,0 +1,2 @@
mcp>=1.1.0
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see above comment

)


if __name__ == "__main__":
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why? pytest doesn't work?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed, was running with python before when I was writing the tests. Sorry about that. Thanks for pointing it out!

# SPDX-License-Identifier: Apache-2.0
from typing import Any, Callable, Collection, Dict, Tuple

from mcp import ClientRequest
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you lazy load this import like you did with mcp.types. In your unit tests you should patch this import with a Mock so that it doesn't fail.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed! Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants