From 87b3fc0d66952c3b0961c988eb7d69f5928f598c Mon Sep 17 00:00:00 2001 From: Anusha Kolan Date: Fri, 5 Dec 2025 14:11:40 -0800 Subject: [PATCH] [MCP] Added support for `--mcp-stdio` flag to `dab start` (#2983) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why make this change? - Add MCP stdio support to Data API Builder and wire it through both the engine and CLI so DAB can be used as a Model Context Protocol (MCP) server. - Ensures MCP sessions can run under a specific DAB authorization role, making it possible to test and use MCP tools with permissions from `dab-config.json`. ## What is this change? Service entrypoint - Detects `--mcp-stdio` early, configures stdin/stdout encodings, and redirects all non‑MCP output to STDERR to keep STDOUT clean for MCP JSON. - Parses an optional `role:` argument (e.g. role:anonymous, role:authenticated) and injects it into configuration as `MCP:Role`, defaulting to `anonymous` when omitted. - In MCP stdio mode, forces `Runtime:Host:Authentication:Provider = "Simulator"` via in‑memory configuration so the requested role is always available during MCP sessions. - Starts the full ASP.NET Core host, registers all MCP tools from DI, and runs the MCP stdio loop instead of the normal HTTP `host.Run(`). CLI Integration - Adds `--mcp-stdio` to `dab start` to launch the engine in MCP stdio mode. - Adds an optional positional `role` argument (e.g. `role:anonymous`) captured as `StartOptions.McpRole`. - Keeps existing behavior for non‑MCP `dab start` unchanged. Note - `ExecuteEntityTool` now looks for MCP tool inputs under arguments (the standard MCP field) and falls back to the legacy parameters property only if arguments is missing. This aligns our server with how current MCP clients (like VS Code) actually send tool arguments, and preserves backward compatibility for any older clients that still use parameters. ## How was this tested? Integration-like manual testing via MCP clients against: - Engine-based MCP server: `dotnet Azure.DataApiBuilder.Service.dll --mcp-stdio role:authenticated`. - CLI-based MCP server: `dab start --mcp-stdio role:authenticated`. Manual verification of all MCP tools: - `describe_entities` shows correct entities and effective permissions for the active role. - `read_records`, `create_record`, `update_record`, `delete_record`, `execute_entity` succeed when the role has the appropriate permissions. ## Sample Request(s) 1. MCP server via CLI (dab) ` { "mcpServers": { "dab-with-exe": { "command": "C:\\DAB\\data-api-builder\\out\\publish\\Debug\\net8.0\\win-x64\\dab\\Microsoft.DataApiBuilder.exe", "args": ["start", "--mcp-stdio", "role:authenticated", "--config", "C:\\DAB\\data-api-builder\\dab-config.json"], "env": { "DAB_ENVIRONMENT": "Development" } } } ` 2. MCP server via engine DLL ` { "mcpServers": { "dab": { "command": "dotnet", "args": [ "C:\\DAB\\data-api-builder\\out\\publish\\Debug\\net8.0\\win-x64\\dab\\Azure.DataApiBuilder.Service.dll", "--mcp-stdio", "role:authenticated", "--config", "C:\\DAB\\data-api-builder\\dab-config.json" ], "type": "stdio" } } } ` --- .../Core/McpProtocolDefaults.cs | 30 ++ .../Core/McpStdioServer.cs | 486 ++++++++++++++++++ .../IMcpStdioServer.cs | 7 + src/Cli/Commands/StartOptions.cs | 13 +- src/Cli/ConfigGenerator.cs | 11 + src/Cli/Exporter.cs | 8 +- src/Service/Program.cs | 68 ++- src/Service/Startup.cs | 13 +- src/Service/Utilities/McpStdioHelper.cs | 97 ++++ 9 files changed, 717 insertions(+), 16 deletions(-) create mode 100644 src/Azure.DataApiBuilder.Mcp/Core/McpProtocolDefaults.cs create mode 100644 src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs create mode 100644 src/Azure.DataApiBuilder.Mcp/IMcpStdioServer.cs create mode 100644 src/Service/Utilities/McpStdioHelper.cs diff --git a/src/Azure.DataApiBuilder.Mcp/Core/McpProtocolDefaults.cs b/src/Azure.DataApiBuilder.Mcp/Core/McpProtocolDefaults.cs new file mode 100644 index 0000000000..8e307a7c0e --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Core/McpProtocolDefaults.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.Configuration; + +namespace Azure.DataApiBuilder.Mcp.Core +{ + /// + /// Centralized defaults and configuration keys for MCP protocol settings. + /// + public static class McpProtocolDefaults + { + /// + /// Default MCP protocol version advertised when no configuration override is provided. + /// + public const string DEFAULT_PROTOCOL_VERSION = "2025-06-18"; + + /// + /// Configuration key used to override the MCP protocol version. + /// + public const string PROTOCOL_VERSION_CONFIG_KEY = "MCP:ProtocolVersion"; + + /// + /// Helper to resolve the effective protocol version from configuration. + /// Falls back to when the key is not set. + /// + public static string ResolveProtocolVersion(IConfiguration? configuration) + { + return configuration?.GetValue(PROTOCOL_VERSION_CONFIG_KEY) ?? DEFAULT_PROTOCOL_VERSION; + } + } +} + diff --git a/src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs b/src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs new file mode 100644 index 0000000000..79ccf39356 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs @@ -0,0 +1,486 @@ +using System.Collections; +using System.Reflection; +using System.Security.Claims; +using System.Text; +using System.Text.Json; +using Azure.DataApiBuilder.Core.AuthenticationHelpers.AuthenticationSimulator; +using Azure.DataApiBuilder.Mcp.Model; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Protocol; + +namespace Azure.DataApiBuilder.Mcp.Core +{ + /// + /// MCP stdio server: + /// - Reads JSON-RPC requests (initialize, listTools, callTool) from STDIN + /// - Writes ONLY MCP JSON responses to STDOUT + /// - Writes diagnostics to STDERR (so STDOUT remains “pure MCP”) + /// + public class McpStdioServer : IMcpStdioServer + { + private readonly McpToolRegistry _toolRegistry; + private readonly IServiceProvider _serviceProvider; + private readonly string _protocolVersion; + + private const int MAX_LINE_LENGTH = 1024 * 1024; // 1 MB limit for incoming JSON-RPC requests + + public McpStdioServer(McpToolRegistry toolRegistry, IServiceProvider serviceProvider) + { + _toolRegistry = toolRegistry ?? throw new ArgumentNullException(nameof(toolRegistry)); + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + + // Allow protocol version to be configured via IConfiguration, using centralized defaults. + IConfiguration? configuration = _serviceProvider.GetService(); + _protocolVersion = McpProtocolDefaults.ResolveProtocolVersion(configuration); + } + + /// + /// Runs the MCP stdio server loop, reading JSON-RPC requests from STDIN and writing MCP JSON responses to STDOUT. + /// + /// Token to signal cancellation of the server loop. + /// A task representing the asynchronous operation. + public async Task RunAsync(CancellationToken cancellationToken) + { + Console.Error.WriteLine("[MCP DEBUG] MCP stdio server started."); + + // Use UTF-8 WITHOUT BOM + UTF8Encoding utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); + + using Stream stdin = Console.OpenStandardInput(); + using Stream stdout = Console.OpenStandardOutput(); + using StreamReader reader = new(stdin, utf8NoBom); + using StreamWriter writer = new(stdout, utf8NoBom) { AutoFlush = true }; + + // Redirect Console.Out to use our writer + Console.SetOut(writer); + while (!cancellationToken.IsCancellationRequested) + { + string? line = await reader.ReadLineAsync(cancellationToken); + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + if (line.Length > MAX_LINE_LENGTH) + { + WriteError(id: null, code: -32600, message: "Request too large"); + continue; + } + + JsonDocument doc; + try + { + doc = JsonDocument.Parse(line); + } + catch (JsonException jsonEx) + { + Console.Error.WriteLine($"[MCP DEBUG] JSON parse error: {jsonEx.Message}"); + WriteError(id: null, code: -32700, message: "Parse error"); + continue; + } + catch (Exception ex) + { + Console.Error.WriteLine($"[MCP DEBUG] Unexpected error parsing request: {ex.Message}"); + WriteError(id: null, code: -32603, message: "Internal error"); + continue; + } + + using (doc) + { + JsonElement root = doc.RootElement; + + JsonElement? id = null; + if (root.TryGetProperty("id", out JsonElement idEl)) + { + id = idEl; // preserve original type (string or number) + } + + if (!root.TryGetProperty("method", out JsonElement methodEl)) + { + WriteError(id, -32600, "Invalid Request"); + continue; + } + + string method = methodEl.GetString() ?? string.Empty; + + try + { + switch (method) + { + case "initialize": + HandleInitialize(id); + break; + + case "notifications/initialized": + break; + + case "tools/list": + HandleListTools(id); + break; + + case "tools/call": + await HandleCallToolAsync(id, root, cancellationToken); + break; + + case "ping": + WriteResult(id, new { ok = true }); + break; + + case "shutdown": + WriteResult(id, new { ok = true }); + return; + + default: + WriteError(id, -32601, $"Method not found: {method}"); + break; + } + } + catch (Exception) + { + WriteError(id, -32603, "Internal error"); + } + } + } + } + + /// + /// Handles the "initialize" JSON-RPC method by sending the MCP protocol version, server capabilities, and server info to the client. + /// + /// + /// The request identifier extracted from the incoming JSON-RPC request. Used to correlate the response with the request. + /// + /// + /// This method constructs and writes the MCP "initialize" response to STDOUT. It uses the protocol version defined by PROTOCOL_VERSION + /// and includes supported capabilities and server information. No notifications are sent here; the server waits for the client to send + /// "notifications/initialized" before sending any notifications. + /// + private void HandleInitialize(JsonElement? id) + { + // Extract the actual id value from the request + object? requestId = id.HasValue ? GetIdValue(id.Value) : null; + + // Create the initialize response + var response = new + { + jsonrpc = "2.0", + id = requestId, + result = new + { + protocolVersion = _protocolVersion, + capabilities = new + { + tools = new { listChanged = true }, + logging = new { } + }, + serverInfo = new + { + name = "Data API Builder", + version = "1.0.0" + } + } + }; + + string json = JsonSerializer.Serialize(response); + Console.Out.WriteLine(json); + } + + /// + /// Handles the "tools/list" JSON-RPC method by sending the list of available tools to the client. + /// + /// + /// The request identifier extracted from the incoming JSON-RPC request. Used to correlate the response with the request. + /// + private void HandleListTools(JsonElement? id) + { + List toolsWire = new(); + int count = 0; + + // Tools are expected to be registered during application startup only. + // If this ever changes and tools can be added/removed at runtime while + // requests are being handled, we may need to introduce locking here or + // have the registry return a thread-safe snapshot. + foreach (Tool tool in _toolRegistry.GetAllTools()) + { + count++; + toolsWire.Add(new + { + name = tool.Name, + description = tool.Description, + inputSchema = tool.InputSchema + }); + } + + WriteResult(id, new { tools = toolsWire }); + } + + /// + /// Handles the "tools/call" JSON-RPC method by executing the specified tool with the provided arguments. + /// + /// The request identifier extracted from the incoming JSON-RPC request. Used to correlate the response with the request. + /// The root JSON element of the incoming JSON-RPC request. + /// Cancellation token to signal operation cancellation. + private async Task HandleCallToolAsync(JsonElement? id, JsonElement root, CancellationToken ct) + { + if (!root.TryGetProperty("params", out JsonElement @params) || @params.ValueKind != JsonValueKind.Object) + { + WriteError(id, -32602, "Missing params"); + return; + } + + // If neither params.name (the MCP-standard field for the tool identifier) + // nor the legacy params.tool field is present or non-empty, we cannot tell + // which tool to execute. In that case we log a debug message to STDERR for + // diagnostics and return a JSON-RPC error (-32602 "Missing tool name") to + // the MCP client so it can fix the request payload. + string? toolName = null; + if (@params.TryGetProperty("name", out JsonElement nameEl) && nameEl.ValueKind == JsonValueKind.String) + { + toolName = nameEl.GetString(); + } + else if (@params.TryGetProperty("tool", out JsonElement toolEl) && toolEl.ValueKind == JsonValueKind.String) + { + toolName = toolEl.GetString(); + } + + if (string.IsNullOrWhiteSpace(toolName)) + { + Console.Error.WriteLine("[MCP DEBUG] callTool → missing tool name."); + WriteError(id, -32602, "Missing tool name"); + return; + } + + if (!_toolRegistry.TryGetTool(toolName!, out IMcpTool? tool) || tool is null) + { + Console.Error.WriteLine($"[MCP DEBUG] callTool → tool not found: {toolName}"); + WriteError(id, -32602, $"Tool not found: {toolName}"); + return; + } + + JsonDocument? argsDoc = null; + try + { + 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); + } + else + { + Console.Error.WriteLine($"[MCP DEBUG] callTool → tool: {toolName}, args: "); + } + + // 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 + // helpers that read IHttpContextAccessor will see the role. We also ensure the + // Simulator authentication handler can authenticate the user by flowing the + // Authorization header commonly used in tests/simulator scenarios. + CallToolResult callResult; + IConfiguration? configuration = _serviceProvider.GetService(); + string? stdioRole = configuration?.GetValue("MCP:Role"); + if (!string.IsNullOrWhiteSpace(stdioRole)) + { + IServiceScopeFactory scopeFactory = _serviceProvider.GetRequiredService(); + using IServiceScope scope = scopeFactory.CreateScope(); + IServiceProvider scopedProvider = scope.ServiceProvider; + + // Create a default HttpContext and set the client role header + DefaultHttpContext httpContext = new(); + httpContext.Request.Headers["X-MS-API-ROLE"] = stdioRole; + + // Build a simulator-style identity with the given role + ClaimsIdentity identity = new( + authenticationType: SimulatorAuthenticationDefaults.AUTHENTICATIONSCHEME); + identity.AddClaim(new Claim(ClaimTypes.Role, stdioRole)); + httpContext.User = new ClaimsPrincipal(identity); + + // If IHttpContextAccessor is registered, populate it for downstream code. + IHttpContextAccessor? httpContextAccessor = scopedProvider.GetService(); + if (httpContextAccessor is not null) + { + httpContextAccessor.HttpContext = httpContext; + } + + try + { + // Execute the tool with the scoped service provider so any scoped services resolve correctly. + callResult = await tool.ExecuteAsync(argsDoc, scopedProvider, ct); + } + finally + { + // Clear the accessor's HttpContext to avoid leaking across calls + if (httpContextAccessor is not null) + { + httpContextAccessor.HttpContext = null; + } + } + } + else + { + callResult = await tool.ExecuteAsync(argsDoc, _serviceProvider, ct); + } + + // 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 }); + } + finally + { + argsDoc?.Dispose(); + } + } + + /// + /// Coerces the call result into an array of MCP content blocks. + /// Tools can either return a custom object with a public "Content" property + /// or a raw value; this helper normalizes both patterns into the MCP wire format. + /// + /// The result object returned from a tool execution. + /// An array of content blocks suitable for MCP output. + private static object[] CoerceToMcpContentBlocks(object? callResult) + { + if (callResult is null) + { + return Array.Empty(); + } + + // Prefer a public instance "Content" property if present. + PropertyInfo? prop = callResult.GetType().GetProperty("Content", BindingFlags.Instance | BindingFlags.Public); + + if (prop is not null) + { + object? value = prop.GetValue(callResult); + + if (value is IEnumerable enumerable && value is not string) + { + List list = new(); + foreach (object item in enumerable) + { + if (item is string s) + { + list.Add(new { type = "text", text = s }); + } + else if (item is JsonElement jsonEl) + { + list.Add(new { type = "application/json", data = jsonEl }); + } + else + { + list.Add(item); + } + } + + return list.ToArray(); + } + + if (value is string sContent) + { + return new object[] { new { type = "text", text = sContent } }; + } + + if (value is JsonElement jsonContent) + { + return new object[] { new { type = "application/json", data = jsonContent } }; + } + } + + // If callResult itself is a JsonElement, treat it as application/json. + if (callResult is JsonElement jsonResult) + { + return new object[] { new { type = "application/json", data = jsonResult } }; + } + + // Fallback: serialize to text. + string text = SafeToString(callResult); + return new object[] { new { type = "text", text } }; + } + + /// + /// Safely converts an object to its string representation, preferring JSON serialization for readability. + /// + /// The object to convert to a string. + /// A string representation of the object. + private static string SafeToString(object obj) + { + try + { + // Try JSON first for readability + string json = JsonSerializer.Serialize(obj); + + // If JSON is extremely large, truncate to avoid flooding MCP output. + // 32 KB is large enough to show useful JSON detail for diagnostics + // without flooding MCP output or impacting performance. + const int MAX_JSON_PREVIEW_CHARS = 32 * 1024; // 32 KB + + if (json.Length > MAX_JSON_PREVIEW_CHARS) + { + return string.Concat(json.AsSpan(0, MAX_JSON_PREVIEW_CHARS), $"... [truncated, total length={json.Length} chars]"); + } + + return json; + } + catch + { + return obj.ToString() ?? string.Empty; + } + } + + /// + /// Writes a JSON-RPC result response to the standard output. + /// + /// The request identifier extracted from the incoming JSON-RPC request. Used to correlate the response with the request. + /// The result object to include in the response. + private static void WriteResult(JsonElement? id, object resultObject) + { + var response = new + { + jsonrpc = "2.0", + id = id.HasValue ? GetIdValue(id.Value) : null, + result = resultObject + }; + + string json = JsonSerializer.Serialize(response); + Console.Out.WriteLine(json); + } + + /// + /// Writes a JSON-RPC error response to the standard output. + /// + /// The request identifier extracted from the incoming JSON-RPC request. Used to correlate the response with the request. + /// The error code. + /// The error message. + private static void WriteError(JsonElement? id, int code, string message) + { + var errorObj = new + { + jsonrpc = "2.0", + id = id.HasValue ? GetIdValue(id.Value) : null, + error = new { code, message } + }; + + string json = JsonSerializer.Serialize(errorObj); + Console.Out.WriteLine(json); + } + + /// + /// Extracts the value of a JSON-RPC request identifier. + /// + /// The JSON element representing the request identifier. + /// The extracted identifier value as an object, or null if the identifier is not a primitive type. + private static object? GetIdValue(JsonElement id) + { + return id.ValueKind switch + { + JsonValueKind.String => id.GetString(), + JsonValueKind.Number => id.TryGetInt64(out long l) ? l : + id.TryGetDouble(out double d) ? d : null, + _ => null + }; + } + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/IMcpStdioServer.cs b/src/Azure.DataApiBuilder.Mcp/IMcpStdioServer.cs new file mode 100644 index 0000000000..033e0e3eaa --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/IMcpStdioServer.cs @@ -0,0 +1,7 @@ +namespace Azure.DataApiBuilder.Mcp.Core +{ + public interface IMcpStdioServer + { + Task RunAsync(CancellationToken cancellationToken); + } +} diff --git a/src/Cli/Commands/StartOptions.cs b/src/Cli/Commands/StartOptions.cs index c335c6bcc5..050f410801 100644 --- a/src/Cli/Commands/StartOptions.cs +++ b/src/Cli/Commands/StartOptions.cs @@ -19,12 +19,14 @@ public class StartOptions : Options { private const string LOGLEVEL_HELPTEXT = "Specifies logging level as provided value. For possible values, see: https://go.microsoft.com/fwlink/?linkid=2263106"; - public StartOptions(bool verbose, LogLevel? logLevel, bool isHttpsRedirectionDisabled, string config) + public StartOptions(bool verbose, LogLevel? logLevel, bool isHttpsRedirectionDisabled, bool mcpStdio, string? mcpRole, string config) : base(config) { // When verbose is true we set LogLevel to information. LogLevel = verbose is true ? Microsoft.Extensions.Logging.LogLevel.Information : logLevel; IsHttpsRedirectionDisabled = isHttpsRedirectionDisabled; + McpStdio = mcpStdio; + McpRole = mcpRole; } // SetName defines mutually exclusive sets, ie: can not have @@ -38,6 +40,12 @@ public StartOptions(bool verbose, LogLevel? logLevel, bool isHttpsRedirectionDis [Option("no-https-redirect", Required = false, HelpText = "Disables automatic https redirects.")] public bool IsHttpsRedirectionDisabled { get; } + [Option("mcp-stdio", Required = false, HelpText = "Run Data API Builder in MCP stdio mode while starting the engine.")] + public bool McpStdio { get; } + + [Value(0, MetaName = "role", Required = false, HelpText = "Optional MCP permissions role, e.g. role:anonymous. If omitted, defaults to anonymous.")] + public string? McpRole { get; } + public int Handler(ILogger logger, FileSystemRuntimeConfigLoader loader, IFileSystem fileSystem) { logger.LogInformation("{productName} {version}", PRODUCT_NAME, ProductInfo.GetProductVersion()); @@ -45,7 +53,8 @@ public int Handler(ILogger logger, FileSystemRuntimeConfigLoader loader, IFileSy if (!isSuccess) { - logger.LogError("Failed to start the engine."); + logger.LogError("Failed to start the engine{mode}.", + McpStdio ? " in MCP stdio mode" : string.Empty); } return isSuccess ? CliReturnCode.SUCCESS : CliReturnCode.GENERAL_ERROR; diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs index 7c35335089..1d673c11e3 100644 --- a/src/Cli/ConfigGenerator.cs +++ b/src/Cli/ConfigGenerator.cs @@ -2359,6 +2359,17 @@ public static bool TryStartEngineWithOptions(StartOptions options, FileSystemRun args.Add(Startup.NO_HTTPS_REDIRECT_FLAG); } + // If MCP stdio was requested, append the stdio-specific switches. + if (options.McpStdio) + { + string effectiveRole = string.IsNullOrWhiteSpace(options.McpRole) + ? "anonymous" + : options.McpRole; + + args.Add("--mcp-stdio"); + args.Add(effectiveRole); + } + return Azure.DataApiBuilder.Service.Program.StartEngine(args.ToArray()); } diff --git a/src/Cli/Exporter.cs b/src/Cli/Exporter.cs index 896b485692..e694317cd4 100644 --- a/src/Cli/Exporter.cs +++ b/src/Cli/Exporter.cs @@ -110,7 +110,13 @@ private static async Task ExportGraphQL( } else { - StartOptions startOptions = new(false, LogLevel.None, false, options.Config!); + StartOptions startOptions = new( + verbose: false, + logLevel: LogLevel.None, + isHttpsRedirectionDisabled: false, + config: options.Config!, + mcpStdio: false, + mcpRole: null); Task dabService = Task.Run(() => { diff --git a/src/Service/Program.cs b/src/Service/Program.cs index 1059fd52ff..e0a74bd9d1 100644 --- a/src/Service/Program.cs +++ b/src/Service/Program.cs @@ -1,20 +1,20 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - using System; using System.CommandLine; using System.CommandLine.Parsing; using System.Runtime.InteropServices; +using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; using Azure.DataApiBuilder.Config; using Azure.DataApiBuilder.Service.Exceptions; using Azure.DataApiBuilder.Service.Telemetry; +using Azure.DataApiBuilder.Service.Utilities; using Microsoft.ApplicationInsights; using Microsoft.AspNetCore; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.ApplicationInsights; @@ -33,6 +33,14 @@ public class Program public static void Main(string[] args) { + bool runMcpStdio = McpStdioHelper.ShouldRunMcpStdio(args, out string? mcpRole); + + if (runMcpStdio) + { + Console.OutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + Console.InputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + } + if (!ValidateAspNetCoreUrls()) { Console.Error.WriteLine("Invalid ASPNETCORE_URLS format. e.g.: ASPNETCORE_URLS=\"http://localhost:5000;https://localhost:5001\""); @@ -40,20 +48,26 @@ public static void Main(string[] args) return; } - if (!StartEngine(args)) + if (!StartEngine(args, runMcpStdio, mcpRole)) { Environment.ExitCode = -1; } } - public static bool StartEngine(string[] args) + public static bool StartEngine(string[] args, bool runMcpStdio, string? mcpRole) { - // Unable to use ILogger because this code is invoked before LoggerFactory - // is instantiated. Console.WriteLine("Starting the runtime engine..."); try { - CreateHostBuilder(args).Build().Run(); + IHost host = CreateHostBuilder(args, runMcpStdio, mcpRole).Build(); + + if (runMcpStdio) + { + return McpStdioHelper.RunMcpStdioHost(host); + } + + // Normal web mode + host.Run(); return true; } // Catch exception raised by explicit call to IHostApplicationLifetime.StopApplication() @@ -72,17 +86,28 @@ public static bool StartEngine(string[] args) } } - public static IHostBuilder CreateHostBuilder(string[] args) + // Compatibility overload used by external callers that do not pass the runMcpStdio flag. + public static bool StartEngine(string[] args) + { + bool runMcpStdio = McpStdioHelper.ShouldRunMcpStdio(args, out string? mcpRole); + return StartEngine(args, runMcpStdio, mcpRole: mcpRole); + } + + public static IHostBuilder CreateHostBuilder(string[] args, bool runMcpStdio, string? mcpRole) { return Host.CreateDefaultBuilder(args) .ConfigureAppConfiguration(builder => { AddConfigurationProviders(builder, args); + if (runMcpStdio) + { + McpStdioHelper.ConfigureMcpStdio(builder, mcpRole); + } }) .ConfigureWebHostDefaults(webBuilder => { Startup.MinimumLogLevel = GetLogLevelFromCommandLineArgs(args, out Startup.IsLogLevelOverriddenByCli); - ILoggerFactory loggerFactory = GetLoggerFactoryForLogLevel(Startup.MinimumLogLevel); + ILoggerFactory loggerFactory = GetLoggerFactoryForLogLevel(Startup.MinimumLogLevel, stdio: runMcpStdio); ILogger startupLogger = loggerFactory.CreateLogger(); DisableHttpsRedirectionIfNeeded(args); webBuilder.UseStartup(builder => new Startup(builder.Configuration, startupLogger)); @@ -140,7 +165,14 @@ private static ParseResult GetParseResult(Command cmd, string[] args) /// Telemetry client /// Hot-reloadable log level /// Core Serilog logging pipeline - public static ILoggerFactory GetLoggerFactoryForLogLevel(LogLevel logLevel, TelemetryClient? appTelemetryClient = null, LogLevelInitializer? logLevelInitializer = null, Logger? serilogLogger = null) + /// Whether the logger is for stdio mode + /// ILoggerFactory + public static ILoggerFactory GetLoggerFactoryForLogLevel( + LogLevel logLevel, + TelemetryClient? appTelemetryClient = null, + LogLevelInitializer? logLevelInitializer = null, + Logger? serilogLogger = null, + bool stdio = false) { return LoggerFactory .Create(builder => @@ -229,7 +261,19 @@ public static ILoggerFactory GetLoggerFactoryForLogLevel(LogLevel logLevel, Tele } } - builder.AddConsole(); + // In stdio mode, route console logs to STDERR to keep STDOUT clean for MCP JSON + if (stdio) + { + builder.ClearProviders(); + builder.AddConsole(options => + { + options.LogToStandardErrorThreshold = LogLevel.Trace; + }); + } + else + { + builder.AddConsole(); + } }); } diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index 48a39d31d0..bb164d18e7 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -348,7 +348,16 @@ public void ConfigureServices(IServiceCollection services) return handler; }); - if (runtimeConfig is not null && runtimeConfig.Runtime?.Host?.Mode is HostMode.Development) + bool isMcpStdio = Configuration.GetValue("MCP:StdioMode"); + + if (isMcpStdio) + { + // Explicitly force Simulator when running in MCP stdio mode. + services.AddAuthentication( + defaultScheme: SimulatorAuthenticationDefaults.AUTHENTICATIONSCHEME) + .AddSimulatorAuthentication(); + } + else if (runtimeConfig is not null && runtimeConfig.Runtime?.Host?.Mode is HostMode.Development) { // Development mode implies support for "Hot Reload". The V2 authentication function // wires up all DAB supported authentication providers (schemes) so that at request time, @@ -456,6 +465,8 @@ public void ConfigureServices(IServiceCollection services) services.AddDabMcpServer(configProvider); + services.AddSingleton(); + services.AddControllers(); } diff --git a/src/Service/Utilities/McpStdioHelper.cs b/src/Service/Utilities/McpStdioHelper.cs new file mode 100644 index 0000000000..9e337d0809 --- /dev/null +++ b/src/Service/Utilities/McpStdioHelper.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Azure.DataApiBuilder.Service.Utilities +{ + /// + /// Helper methods for configuring and running MCP in stdio mode. + /// + internal static class McpStdioHelper + { + /// + /// Determines if MCP stdio mode should be run based on command line arguments. + /// + /// The command line arguments. + /// The role for MCP stdio mode, if specified. + /// + public static bool ShouldRunMcpStdio(string[] args, out string? mcpRole) + { + mcpRole = null; + + bool runMcpStdio = Array.Exists( + args, + a => string.Equals(a, "--mcp-stdio", StringComparison.OrdinalIgnoreCase)); + + if (!runMcpStdio) + { + return false; + } + + string? roleArg = Array.Find( + args, + a => a != null && a.StartsWith("role:", StringComparison.OrdinalIgnoreCase)); + + if (!string.IsNullOrEmpty(roleArg)) + { + string roleValue = roleArg[(roleArg.IndexOf(':') + 1)..]; + if (!string.IsNullOrWhiteSpace(roleValue)) + { + mcpRole = roleValue; + } + } + + return true; + } + + /// + /// Configures the IConfigurationBuilder for MCP stdio mode. + /// + /// + /// + public static void ConfigureMcpStdio(IConfigurationBuilder builder, string? mcpRole) + { + builder.AddInMemoryCollection(new Dictionary + { + ["MCP:StdioMode"] = "true", + ["MCP:Role"] = mcpRole ?? "anonymous", + ["Runtime:Host:Authentication:Provider"] = "Simulator" + }); + } + + /// + /// Runs the MCP stdio host. + /// + /// The host to run. + public static bool RunMcpStdioHost(IHost host) + { + host.Start(); + + Mcp.Core.McpToolRegistry registry = + host.Services.GetRequiredService(); + IEnumerable tools = + host.Services.GetServices(); + + foreach (Mcp.Model.IMcpTool tool in tools) + { + _ = tool.GetToolMetadata(); + registry.RegisterTool(tool); + } + + IServiceScopeFactory scopeFactory = + host.Services.GetRequiredService(); + using IServiceScope scope = scopeFactory.CreateScope(); + IHostApplicationLifetime lifetime = + scope.ServiceProvider.GetRequiredService(); + Mcp.Core.IMcpStdioServer stdio = + scope.ServiceProvider.GetRequiredService(); + + stdio.RunAsync(lifetime.ApplicationStopping).GetAwaiter().GetResult(); + host.StopAsync().GetAwaiter().GetResult(); + + return true; + } + } +}