Skip to content
Merged
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
99 changes: 76 additions & 23 deletions src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System.Text.Json;
using Azure.DataApiBuilder.Auth;
using Azure.DataApiBuilder.Config.DatabasePrimitives;
using Azure.DataApiBuilder.Config.ObjectModel;
using Azure.DataApiBuilder.Core.Authorization;
using Azure.DataApiBuilder.Core.Configurations;
Expand Down Expand Up @@ -177,15 +178,37 @@ public Task<CallToolResult> ExecuteAsync(

try
{
DatabaseObject? databaseObject = null;
if (entity.Source.Type == EntitySourceType.StoredProcedure)
{
databaseObject = McpMetadataHelper.TryResolveDatabaseObject(
entityName,
runtimeConfig,
serviceProvider,
out string resolveError,
cancellationToken);

if (databaseObject is null)
{
// Init normally populates DatabaseStoredProcedure for every SP entity
// (or throws and aborts startup). Reaching here means an init invariant
// regressed. Throw so the surrounding catch drops just this entity from
// the response - returning the SP with no parameter info would mislead
// the agent into thinking the SP takes no arguments.
throw new InvalidOperationException(
$"Could not resolve DB metadata for stored procedure entity '{entityName}'. Error: {resolveError}");
Comment thread
Aniruddh25 marked this conversation as resolved.
}
}

Dictionary<string, object?> entityInfo = nameOnly
? BuildBasicEntityInfo(entityName, entity)
: BuildFullEntityInfo(entityName, entity, currentUserRole);
: BuildFullEntityInfo(entityName, entity, currentUserRole, databaseObject);

entityList.Add(entityInfo);
}
catch (Exception ex)
{
logger?.LogWarning(ex, "Failed to build info for entity {EntityName}", entityName);
logger?.LogWarning(ex, "Failed to build info for entity '{EntityName}'", entityName);
}
}
}
Expand Down Expand Up @@ -403,10 +426,11 @@ private static bool ShouldIncludeEntity(string entityName, HashSet<string>? enti
/// <param name="entityName">The name of the entity to include in the dictionary.</param>
/// <param name="entity">The entity object from which to extract additional information.</param>
/// <param name="currentUserRole">The role of the current user, used to determine permissions.</param>
/// <param name="databaseObject">The resolved database object metadata if available.</param>
/// <returns>
/// A dictionary containing the entity's name, description, fields, parameters (if applicable), and permissions.
/// </returns>
private static Dictionary<string, object?> BuildFullEntityInfo(string entityName, Entity entity, string? currentUserRole)
private static Dictionary<string, object?> BuildFullEntityInfo(string entityName, Entity entity, string? currentUserRole, DatabaseObject? databaseObject)
{
// Use GraphQL singular name as alias if available, otherwise use entity name
string displayName = !string.IsNullOrWhiteSpace(entity.GraphQL?.Singular)
Expand All @@ -422,7 +446,7 @@ private static bool ShouldIncludeEntity(string entityName, HashSet<string>? enti

if (entity.Source.Type == EntitySourceType.StoredProcedure)
{
info["parameters"] = BuildParameterMetadataInfo(entity.Source.Parameters);
info["parameters"] = BuildParameterMetadataInfo(databaseObject);
}

info["permissions"] = BuildPermissionsInfo(entity, currentUserRole);
Expand Down Expand Up @@ -456,33 +480,62 @@ private static List<object> BuildFieldMetadataInfo(List<FieldMetadata>? fields)
}

/// <summary>
/// Builds a list of parameter metadata objects containing information about each parameter.
/// Builds the parameter list for a stored procedure entity.
/// Each entry has: name, required, default, description.
///
/// The per-field rules are agreed in issue #3400:
/// name - DB metadata is the source of truth; config cannot override.
/// required - defaults to true when not set in config.
/// (SQL Server's is_nullable describes the type, not whether the
/// parameter must be supplied at call time, so it is unreliable.)
/// default - config-only. T-SQL parameter defaults are not exposed as
/// structured metadata, so they cannot be discovered from the DB.
/// description - config-only. SQL Server has no description column for parameters.
///
/// The merge of config onto DB metadata is already performed upstream by
/// <see cref="Core.Services.MetadataProviders.SqlMetadataProvider"/> /
/// <see cref="Core.Services.MetadataProviders.MsSqlMetadataProvider"/> when populating
/// <see cref="DatabaseStoredProcedure"/>. Each <see cref="ParameterDefinition"/> therefore
/// already reflects the config overlay; we just project it.
///
/// For an SP entity that successfully initialized, the metadata provider always has a
/// populated <see cref="DatabaseStoredProcedure"/>: init throws otherwise (e.g.
/// SqlMetadataProvider.FillSchemaForStoredProcedureAsync raises via HandleOrRecordException
/// when config declares a parameter the DB doesn't have, and startup aborts). If this
/// invariant ever regresses we throw rather than fabricate empty parameter info, so the
/// surrounding per-entity catch drops just this entity from the response.
/// </summary>
/// <param name="parameters">A list of <see cref="ParameterMetadata"/> objects representing the parameters to process. Can be null.</param>
/// <returns>A list of dictionaries, each containing the parameter's name, whether it is required, its default
/// value, and its description. Returns an empty list if <paramref name="parameters"/> is null.</returns>
private static List<object> BuildParameterMetadataInfo(List<ParameterMetadata>? parameters)
/// <param name="databaseObject">DB metadata for the entity. Must be a populated <see cref="DatabaseStoredProcedure"/>.</param>
/// <returns>A list whose elements are dictionaries (one per parameter), each with the keys
/// <c>name</c>, <c>required</c>, <c>default</c>, and <c>description</c>.</returns>
/// <exception cref="InvalidOperationException">Thrown when <paramref name="databaseObject"/> is not a <see cref="DatabaseStoredProcedure"/> with a populated <see cref="StoredProcedureDefinition"/>.</exception>
private static List<object> BuildParameterMetadataInfo(DatabaseObject? databaseObject)
{
List<object> result = new();

if (parameters != null)
IReadOnlyDictionary<string, ParameterDefinition>? dbParameters =
(databaseObject as DatabaseStoredProcedure)?.StoredProcedureDefinition?.Parameters
?? throw new InvalidOperationException(
"Stored-procedure metadata is missing at describe_entities time. " +
"SqlMetadataProvider.FillSchemaForStoredProcedureAsync should have populated this during init.");

List<object> result = new(dbParameters.Count);
foreach ((string parameterName, ParameterDefinition definition) in dbParameters)
{
foreach (ParameterMetadata param in parameters)
{
Dictionary<string, object?> paramInfo = new()
{
["name"] = param.Name,
["required"] = param.Required,
["default"] = param.Default,
["description"] = param.Description ?? string.Empty
};
result.Add(paramInfo);
}
result.Add(BuildParameterEntry(parameterName, definition));
}

return result;
}

private static Dictionary<string, object?> BuildParameterEntry(
string name,
ParameterDefinition definition) => new()
{
["name"] = name,
["required"] = definition.Required ?? true,
["default"] = definition.Default,
["description"] = definition.Description ?? string.Empty
};

/// <summary>
/// Build a list of permission metadata info for the current user's role
/// </summary>
Expand Down
80 changes: 49 additions & 31 deletions src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -261,17 +261,26 @@ private void HandleListTools(JsonElement? id)
/// <param name="root">The root JSON element of the incoming JSON-RPC request.</param>
/// <remarks>
/// Log level precedence (highest to lowest):
/// 1. CLI --LogLevel flag - cannot be overridden
/// 2. Config runtime.telemetry.log-level - cannot be overridden by MCP
/// 3. MCP logging/setLevel - only works if neither CLI nor Config explicitly set a level
/// 4. Default: None for MCP stdio mode (silent by default to keep stdout clean for JSON-RPC)
///
/// If CLI or Config set the log level, this method accepts the request but silently ignores it.
/// The client won't get an error, but CLI/Config wins.
///
/// When MCP sets a level other than "none", this also restores Console.Error to the real stderr
/// stream so that logs become visible (Console may have been redirected to null at startup).
/// It also enables MCP log notifications so logs are sent to the client via notifications/message.
/// 1. MCP <c>logging/setLevel</c> (Agent) - always wins, overrides CLI and Config.
/// 2. CLI <c>--LogLevel</c> flag.
/// 3. Config <c>runtime.telemetry.log-level</c>.
/// 4. Default: <c>None</c> for MCP stdio mode (silent by default to keep stdout clean for JSON-RPC),
/// <c>Error</c> in Production, <c>Debug</c> in Development.
///
/// Per MCP spec the response is always success (empty result object) even when the input is
/// an unrecognized level — in that case no side effect runs and no state changes.
///
/// Side effects performed in order on a valid request:
/// 1. Toggle <see cref="IMcpLogNotificationWriter.IsEnabled"/> based on the level
/// (<c>"none"</c> disables, anything else enables). This is done BEFORE
/// <see cref="ILogLevelController.UpdateFromMcp"/> so the audit log line that
/// <c>UpdateFromMcp</c> emits is forwarded to the agent rather than dropped.
/// 2. Call <see cref="ILogLevelController.UpdateFromMcp"/>, which updates the level and
/// flips <see cref="ILogLevelController.IsAgentOverriding"/> so subsequent runtime-config
/// hot-reloads do not overwrite the agent's choice.
/// 3. Restore <see cref="Console.Error"/> to the real stderr stream when logging is enabled,
/// in case startup redirected it to <see cref="TextWriter.Null"/> (default for
/// <c>--mcp-stdio</c> or <c>--LogLevel none</c>).
/// </remarks>
private void HandleSetLogLevel(JsonElement? id, JsonElement root)
{
Expand Down Expand Up @@ -299,35 +308,44 @@ private void HandleSetLogLevel(JsonElement? id, JsonElement root)
return;
}

// Attempt to update the log level
// If CLI or Config overrode, this returns false but we still return success to the client
bool updated = logLevelController.UpdateFromMcp(level);

// Determine if logging is enabled (level != "none")
// Note: Even if CLI/Config overrode the level, we still enable notifications
// when the client requests logging. They'll get logs at the overridden level.
bool isLoggingEnabled = !string.Equals(level, "none", StringComparison.OrdinalIgnoreCase);

// Only restore stderr when this MCP call actually changed the effective level.
// If CLI/Config overrode (updated == false), stderr is already in the correct state:
// - CLI/Config level == "none": stderr was redirected to TextWriter.Null at startup
// and must stay that way; restoring it would re-introduce noisy output even
// though the operator explicitly asked for silence.
// - CLI/Config level != "none": stderr was never redirected, so restoring is a no-op.
if (updated && isLoggingEnabled)
// Validate the level BEFORE touching any side-effect (notification writer, stderr).
// "none" is the disable signal and is not a recognized MCP level; everything else
// must round-trip through McpLogLevelConverter so a typo can't silently turn the
// notification stream on while UpdateFromMcp ignores the bad value.
bool isDisableRequest = string.Equals(level, "none", StringComparison.OrdinalIgnoreCase);
bool isValidLevel = isDisableRequest || McpLogLevelConverter.TryConvertFromMcp(level, out _);
Comment thread
Aniruddh25 marked this conversation as resolved.
if (!isValidLevel)
{
RestoreStderrIfNeeded();
// Unknown level - return success per MCP spec but make no state changes.
WriteResult(id, new { });
return;
}

// Enable or disable MCP log notifications based on the requested level
// When CLI/Config overrode, notifications are still enabled - client asked for logs,
// they just get them at the CLI/Config level instead of the requested level.
bool isLoggingEnabled = !isDisableRequest;

// Enable or disable MCP log notifications based on the requested level BEFORE updating
// the level. Doing it in this order means the agent-override Information line emitted
// by UpdateFromMcp is forwarded to the agent (otherwise it would be dropped because
// the notification writer was still disabled at the moment of emission).
IMcpLogNotificationWriter? notificationWriter = _serviceProvider.GetService<IMcpLogNotificationWriter>();
if (notificationWriter != null)
{
notificationWriter.IsEnabled = isLoggingEnabled;
}

// Update the log level. Validation above guarantees this returns true for non-"none"
// values; for "none" it returns false (no LogLevel mapping) and we just keep
// notifications off without touching the current level.
bool updated = logLevelController.UpdateFromMcp(level);

// Restore stderr if the agent successfully turned logging on. When `--mcp-stdio` (or
// `--LogLevel none`) was the startup default, stderr was redirected to TextWriter.Null;
// re-enable it now so subsequent logs flow.
if (updated && isLoggingEnabled)
{
RestoreStderrIfNeeded();
}

// Always return success (empty result object) per MCP spec
WriteResult(id, new { });
}
Expand Down
38 changes: 37 additions & 1 deletion src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,35 @@ namespace Azure.DataApiBuilder.Mcp.Utils
/// </summary>
public static class McpMetadataHelper
{
/// <summary>
/// Convenience wrapper around <see cref="TryResolveMetadata"/> for callers that only need the
/// resolved <see cref="DatabaseObject"/>. Returns the database object on success, or <c>null</c>
/// when metadata cannot be resolved (with the failure reason surfaced via <paramref name="error"/>).
/// Callers are responsible for logging at the appropriate verbosity for their tool context.
/// </summary>
public static DatabaseObject? TryResolveDatabaseObject(
string entityName,
RuntimeConfig config,
IServiceProvider serviceProvider,
out string error,
CancellationToken cancellationToken = default)
{
if (TryResolveMetadata(
entityName,
config,
serviceProvider,
out _,
out DatabaseObject dbObject,
out _,
out error,
cancellationToken))
{
return dbObject;
}

return null;
}

public static bool TryResolveMetadata(
string entityName,
RuntimeConfig config,
Expand All @@ -35,7 +64,14 @@ public static bool TryResolveMetadata(
return false;
}

var metadataProviderFactory = serviceProvider.GetRequiredService<Azure.DataApiBuilder.Core.Services.MetadataProviders.IMetadataProviderFactory>();
// Use GetService (not GetRequiredService) so the helper honours its Try* contract.
Azure.DataApiBuilder.Core.Services.MetadataProviders.IMetadataProviderFactory? metadataProviderFactory =
serviceProvider.GetService<Azure.DataApiBuilder.Core.Services.MetadataProviders.IMetadataProviderFactory>();
if (metadataProviderFactory is null)
{
error = "Metadata provider factory is not registered.";
return false;
}

// Resolve datasource name for the entity.
try
Expand Down
Loading