Skip to content
Draft
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
34 changes: 19 additions & 15 deletions src/Azure.DataApiBuilder.Mcp/Core/DynamicCustomTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ namespace Azure.DataApiBuilder.Mcp.Core
/// </summary>
public class DynamicCustomTool : IMcpTool
{
private readonly string _entityName;
private readonly Entity _entity;

/// <summary>
Expand All @@ -48,7 +47,7 @@ public class DynamicCustomTool : IMcpTool
/// <param name="entity">The entity configuration object.</param>
public DynamicCustomTool(string entityName, Entity entity)
{
_entityName = entityName ?? throw new ArgumentNullException(nameof(entityName));
EntityName = entityName ?? throw new ArgumentNullException(nameof(entityName));
_entity = entity ?? throw new ArgumentNullException(nameof(entity));

// Validate that this is a stored procedure
Expand All @@ -65,12 +64,17 @@ public DynamicCustomTool(string entityName, Entity entity)
/// </summary>
public ToolType ToolType { get; } = ToolType.Custom;

/// <summary>
/// Gets the entity name associated with this custom tool.
/// </summary>
public string EntityName { get; }

/// <summary>
/// Gets the metadata for this custom tool, including name, description, and input schema.
/// </summary>
public Tool GetToolMetadata()
{
string toolName = ConvertToToolName(_entityName);
string toolName = ConvertToToolName(EntityName);
string description = _entity.Description ?? $"Executes the {toolName} stored procedure";

// Build input schema based on parameters
Expand Down Expand Up @@ -114,25 +118,25 @@ public async Task<CallToolResult> ExecuteAsync(
}

// 3) Validate entity still exists in configuration
if (!config.Entities.TryGetValue(_entityName, out Entity? entityConfig))
if (!config.Entities.TryGetValue(EntityName, out Entity? entityConfig))
{
return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", $"Entity '{_entityName}' not found in configuration.", logger);
return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", $"Entity '{EntityName}' not found in configuration.", logger);
}

if (entityConfig.Source.Type != EntitySourceType.StoredProcedure)
{
return McpResponseBuilder.BuildErrorResult(toolName, "InvalidEntity", $"Entity {_entityName} is not a stored procedure.", logger);
return McpResponseBuilder.BuildErrorResult(toolName, "InvalidEntity", $"Entity {EntityName} is not a stored procedure.", logger);
}

// Check if custom tool is still enabled for this entity
if (entityConfig.Mcp?.CustomToolEnabled != true)
{
return McpErrorHelpers.ToolDisabled(toolName, logger, $"Custom tool is disabled for entity '{_entityName}'.");
return McpErrorHelpers.ToolDisabled(toolName, logger, $"Custom tool is disabled for entity '{EntityName}'.");
}

// 4) Resolve metadata
if (!McpMetadataHelper.TryResolveMetadata(
_entityName,
EntityName,
config,
serviceProvider,
out ISqlMetadataProvider sqlMetadataProvider,
Expand All @@ -150,18 +154,18 @@ public async Task<CallToolResult> ExecuteAsync(

if (!McpAuthorizationHelper.ValidateRoleContext(httpContext, authResolver, out string roleError))
{
return McpErrorHelpers.PermissionDenied(toolName, _entityName, "execute", roleError, logger);
return McpErrorHelpers.PermissionDenied(toolName, EntityName, "execute", roleError, logger);
}

if (!McpAuthorizationHelper.TryResolveAuthorizedRole(
httpContext!,
authResolver,
_entityName,
EntityName,
EntityActionOperation.Execute,
out string? effectiveRole,
out string authError))
{
return McpErrorHelpers.PermissionDenied(toolName, _entityName, "execute", authError, logger);
return McpErrorHelpers.PermissionDenied(toolName, EntityName, "execute", authError, logger);
}

// 6) Build request payload
Expand All @@ -175,7 +179,7 @@ public async Task<CallToolResult> ExecuteAsync(

// 7) Build stored procedure execution context
StoredProcedureRequestContext context = new(
entityName: _entityName,
entityName: EntityName,
dbo: dbObject,
requestPayloadRoot: requestPayloadRoot,
operationType: EntityActionOperation.Execute);
Expand Down Expand Up @@ -218,7 +222,7 @@ public async Task<CallToolResult> ExecuteAsync(
}
catch (DataApiBuilderException dabEx)
{
logger?.LogError(dabEx, "Error executing custom tool {ToolName} for entity {Entity}", toolName, _entityName);
logger?.LogError(dabEx, "Error executing custom tool {ToolName} for entity {Entity}", toolName, EntityName);
return McpResponseBuilder.BuildErrorResult(toolName, "ExecutionError", dabEx.Message, logger);
}
catch (SqlException sqlEx)
Expand All @@ -238,15 +242,15 @@ public async Task<CallToolResult> ExecuteAsync(
}

// 9) Build success response
return BuildExecuteSuccessResponse(toolName, _entityName, parameters, queryResult, logger);
return BuildExecuteSuccessResponse(toolName, EntityName, parameters, queryResult, logger);
}
catch (OperationCanceledException)
{
return McpResponseBuilder.BuildErrorResult(toolName, "OperationCanceled", "The operation was canceled.", logger);
}
catch (Exception ex)
{
logger?.LogError(ex, "Unexpected error in DynamicCustomTool for {EntityName}", _entityName);
logger?.LogError(ex, "Unexpected error in DynamicCustomTool for {EntityName}", EntityName);
return McpResponseBuilder.BuildErrorResult(toolName, "UnexpectedError", "An unexpected error occurred.", logger);
}
}
Expand Down
75 changes: 65 additions & 10 deletions src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Diagnostics;
using System.Text.Json;
using Azure.DataApiBuilder.Core.Telemetry;
using Azure.DataApiBuilder.Mcp.Model;
using Azure.DataApiBuilder.Mcp.Utils;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol;
using ModelContextProtocol.Protocol;
Expand Down Expand Up @@ -60,23 +63,75 @@ internal static IServiceCollection ConfigureMcpServer(this IServiceCollection se
throw new McpException($"Unknown tool: '{toolName}'");
}

// Start OpenTelemetry activity for MCP tool execution
using Activity? activity = TelemetryTracesHelper.DABActivitySource.StartActivity("mcp.tool.execute");

JsonDocument? arguments = null;
if (request.Params?.Arguments != null)
try
{
// Convert IReadOnlyDictionary<string, JsonElement> to JsonDocument
Dictionary<string, object?> jsonObject = new();
foreach (KeyValuePair<string, JsonElement> kvp in request.Params.Arguments)
// Extract entity name from arguments for telemetry
string? entityName = null;
string? operation = null;
string? dbProcedure = null;

if (request.Params?.Arguments != null)
{
jsonObject[kvp.Key] = kvp.Value;
// Convert IReadOnlyDictionary<string, JsonElement> to JsonDocument
Dictionary<string, object?> jsonObject = new();
foreach (KeyValuePair<string, JsonElement> kvp in request.Params.Arguments)
{
jsonObject[kvp.Key] = kvp.Value;

// Extract entity name if present
if (kvp.Key == "entity" && kvp.Value.ValueKind == JsonValueKind.String)
{
entityName = kvp.Value.GetString();
}
}

string json = JsonSerializer.Serialize(jsonObject);
arguments = JsonDocument.Parse(json);
}

string json = JsonSerializer.Serialize(jsonObject);
arguments = JsonDocument.Parse(json);
}
// Determine operation based on tool name
operation = McpTelemetryHelper.InferOperationFromToolName(toolName);

try
// For custom tools (DynamicCustomTool), extract stored procedure information
if (tool is DynamicCustomTool customTool)
{
// Get entity name and procedure from the custom tool
(entityName, dbProcedure) = McpTelemetryHelper.ExtractCustomToolMetadata(customTool, request.Services!);
}

// Track the start of MCP tool execution with telemetry
activity?.TrackMcpToolExecutionStarted(
toolName: toolName,
entityName: entityName,
operation: operation,
dbProcedure: dbProcedure);

// Execute the tool
CallToolResult result = await tool!.ExecuteAsync(arguments, request.Services!, ct);

// Track successful completion
activity?.TrackMcpToolExecutionFinished();

return result;
}
catch (Exception ex)
{
return await tool!.ExecuteAsync(arguments, request.Services!, ct);
// Track exception in telemetry with specific error code based on exception type
string errorCode = ex switch
{
OperationCanceledException => McpTelemetryErrorCodes.OPERATION_CANCELLED,
UnauthorizedAccessException => McpTelemetryErrorCodes.AUTHENTICATION_FAILED,
System.Data.Common.DbException => McpTelemetryErrorCodes.DATABASE_ERROR,
ArgumentException => McpTelemetryErrorCodes.INVALID_REQUEST,
_ => McpTelemetryErrorCodes.EXECUTION_FAILED
};

activity?.TrackMcpToolExecutionFinishedWithException(ex, errorCode: errorCode);
throw;
}
finally
{
Expand Down
52 changes: 52 additions & 0 deletions src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
using System.Collections;
using System.Diagnostics;
using System.Reflection;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using Azure.DataApiBuilder.Config.ObjectModel;
using Azure.DataApiBuilder.Core.AuthenticationHelpers.AuthenticationSimulator;
using Azure.DataApiBuilder.Core.Configurations;
using Azure.DataApiBuilder.Core.Telemetry;
using Azure.DataApiBuilder.Mcp.Model;
using Azure.DataApiBuilder.Mcp.Utils;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
Expand Down Expand Up @@ -270,20 +273,51 @@ private async Task HandleCallToolAsync(JsonElement? id, JsonElement root, Cancel
return;
}

// Start OpenTelemetry activity for MCP tool execution
using Activity? activity = TelemetryTracesHelper.DABActivitySource.StartActivity("mcp.tool.execute");

JsonDocument? argsDoc = null;
try
{
// Extract entity name and operation from arguments for telemetry
string? entityName = null;
string? operation = null;
string? dbProcedure = null;

if (@params.TryGetProperty("arguments", out JsonElement argsEl) && argsEl.ValueKind == JsonValueKind.Object)
{
string rawArgs = argsEl.GetRawText();
Console.Error.WriteLine($"[MCP DEBUG] callTool → tool: {toolName}, args: {rawArgs}");
argsDoc = JsonDocument.Parse(rawArgs);

// Extract entity name if present in arguments
if (argsDoc.RootElement.TryGetProperty("entity", out JsonElement entityEl) && entityEl.ValueKind == JsonValueKind.String)
{
entityName = entityEl.GetString();
}
}
else
{
Console.Error.WriteLine($"[MCP DEBUG] callTool → tool: {toolName}, args: <none>");
}

// Determine operation based on tool name
operation = McpTelemetryHelper.InferOperationFromToolName(toolName!);

// For custom tools (DynamicCustomTool), extract stored procedure information
if (tool is DynamicCustomTool customTool)
{
// Get entity name and procedure from the custom tool
(entityName, dbProcedure) = McpTelemetryHelper.ExtractCustomToolMetadata(customTool, _serviceProvider);
}

// Track the start of MCP tool execution with telemetry
activity?.TrackMcpToolExecutionStarted(
toolName: toolName!,
entityName: entityName,
operation: operation,
dbProcedure: dbProcedure);

// Execute the tool.
// If a MCP stdio role override is set in the environment, create
// a request HttpContext with the X-MS-API-ROLE header so tools and authorization
Expand Down Expand Up @@ -335,12 +369,30 @@ private async Task HandleCallToolAsync(JsonElement? id, JsonElement root, Cancel
callResult = await tool.ExecuteAsync(argsDoc, _serviceProvider, ct);
}

// Track successful completion
activity?.TrackMcpToolExecutionFinished();

// Normalize to MCP content blocks (array). We try to pass through if a 'Content' property exists,
// otherwise we wrap into a single text block.
object[] content = CoerceToMcpContentBlocks(callResult);

WriteResult(id, new { content });
}
catch (Exception ex)
{
// Track exception in telemetry with specific error code based on exception type
string errorCode = ex switch
{
OperationCanceledException => McpTelemetryErrorCodes.OPERATION_CANCELLED,
UnauthorizedAccessException => McpTelemetryErrorCodes.AUTHENTICATION_FAILED,
System.Data.Common.DbException => McpTelemetryErrorCodes.DATABASE_ERROR,
ArgumentException => McpTelemetryErrorCodes.INVALID_REQUEST,
_ => McpTelemetryErrorCodes.EXECUTION_FAILED
};

activity?.TrackMcpToolExecutionFinishedWithException(ex, errorCode: errorCode);
throw;
}
finally
{
argsDoc?.Dispose();
Expand Down
36 changes: 36 additions & 0 deletions src/Azure.DataApiBuilder.Mcp/Utils/McpTelemetryErrorCodes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

namespace Azure.DataApiBuilder.Mcp.Utils
{
/// <summary>
/// Constants for MCP telemetry error codes.
/// </summary>
internal static class McpTelemetryErrorCodes
{
/// <summary>
/// Generic execution failure error code.
/// </summary>
public const string EXECUTION_FAILED = "ExecutionFailed";

/// <summary>
/// Authentication or authorization failure error code.
/// </summary>
public const string AUTHENTICATION_FAILED = "AuthenticationFailed";

/// <summary>
/// Database operation failure error code.
/// </summary>
public const string DATABASE_ERROR = "DatabaseError";

/// <summary>
/// Invalid request or arguments error code.
/// </summary>
public const string INVALID_REQUEST = "InvalidRequest";

/// <summary>
/// Operation cancelled error code.
/// </summary>
public const string OPERATION_CANCELLED = "OperationCancelled";
}
}
Loading