-
Notifications
You must be signed in to change notification settings - Fork 25
[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
base: main
Are you sure you want to change the base?
Conversation
.flake8
Outdated
@@ -19,6 +19,9 @@ exclude = | |||
CVS | |||
.venv*/ | |||
venv*/ | |||
**/venv*/ | |||
**/.venv*/ | |||
aws-opentelemetry-distro/src/amazon/opentelemetry/distro/mcpinstrumentor/venv |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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!
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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", |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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")) |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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"]) |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
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} |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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", |
There was a problem hiding this comment.
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", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks likes this mcp
does not support Python 3.9.
There was a problem hiding this comment.
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): | ||
""" |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
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.
if isinstance(request, types.ListToolsRequest): | |
if isinstance(request, types.ListToolsRequest): | |
span_name = "tools/list" |
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
operation = "Server Handle Request" | |
operation = "Server Handle Request" | |
span_name = "unknown" |
|
||
return async_wrapper() | ||
|
||
def _get_span_name(self, req): |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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__), "../../../..")) |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Got it, thank you!
…rumentor as an entry point
"""Fallback operation name for unrecognized MCP operations.""" | ||
|
||
|
||
class MCPTraceContext: |
There was a problem hiding this comment.
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.""" |
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
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
Line 8 in 2388f5f
AWS_REMOTE_SERVICE: str = "aws.remote.service" |
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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",) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_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: |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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!
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
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",)
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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__": |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed! Thanks!
…hon-instrumentation into MCP-Instrumentor
This reverts commit 5c1aeec.
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.
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
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.