Skip to content

Commit e83ccd8

Browse files
authored
[MCP] Added support for --mcp-stdio flag to dab start (#2983)
## 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:<name>` 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" } } } `
1 parent 7d76286 commit e83ccd8

File tree

9 files changed

+717
-16
lines changed

9 files changed

+717
-16
lines changed
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
using Microsoft.Extensions.Configuration;
2+
3+
namespace Azure.DataApiBuilder.Mcp.Core
4+
{
5+
/// <summary>
6+
/// Centralized defaults and configuration keys for MCP protocol settings.
7+
/// </summary>
8+
public static class McpProtocolDefaults
9+
{
10+
/// <summary>
11+
/// Default MCP protocol version advertised when no configuration override is provided.
12+
/// </summary>
13+
public const string DEFAULT_PROTOCOL_VERSION = "2025-06-18";
14+
15+
/// <summary>
16+
/// Configuration key used to override the MCP protocol version.
17+
/// </summary>
18+
public const string PROTOCOL_VERSION_CONFIG_KEY = "MCP:ProtocolVersion";
19+
20+
/// <summary>
21+
/// Helper to resolve the effective protocol version from configuration.
22+
/// Falls back to <see cref="DEFAULT_PROTOCOL_VERSION"/> when the key is not set.
23+
/// </summary>
24+
public static string ResolveProtocolVersion(IConfiguration? configuration)
25+
{
26+
return configuration?.GetValue<string>(PROTOCOL_VERSION_CONFIG_KEY) ?? DEFAULT_PROTOCOL_VERSION;
27+
}
28+
}
29+
}
30+

src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs

Lines changed: 486 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
namespace Azure.DataApiBuilder.Mcp.Core
2+
{
3+
public interface IMcpStdioServer
4+
{
5+
Task RunAsync(CancellationToken cancellationToken);
6+
}
7+
}

src/Cli/Commands/StartOptions.cs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,14 @@ public class StartOptions : Options
1919
{
2020
private const string LOGLEVEL_HELPTEXT = "Specifies logging level as provided value. For possible values, see: https://go.microsoft.com/fwlink/?linkid=2263106";
2121

22-
public StartOptions(bool verbose, LogLevel? logLevel, bool isHttpsRedirectionDisabled, string config)
22+
public StartOptions(bool verbose, LogLevel? logLevel, bool isHttpsRedirectionDisabled, bool mcpStdio, string? mcpRole, string config)
2323
: base(config)
2424
{
2525
// When verbose is true we set LogLevel to information.
2626
LogLevel = verbose is true ? Microsoft.Extensions.Logging.LogLevel.Information : logLevel;
2727
IsHttpsRedirectionDisabled = isHttpsRedirectionDisabled;
28+
McpStdio = mcpStdio;
29+
McpRole = mcpRole;
2830
}
2931

3032
// SetName defines mutually exclusive sets, ie: can not have
@@ -38,14 +40,21 @@ public StartOptions(bool verbose, LogLevel? logLevel, bool isHttpsRedirectionDis
3840
[Option("no-https-redirect", Required = false, HelpText = "Disables automatic https redirects.")]
3941
public bool IsHttpsRedirectionDisabled { get; }
4042

43+
[Option("mcp-stdio", Required = false, HelpText = "Run Data API Builder in MCP stdio mode while starting the engine.")]
44+
public bool McpStdio { get; }
45+
46+
[Value(0, MetaName = "role", Required = false, HelpText = "Optional MCP permissions role, e.g. role:anonymous. If omitted, defaults to anonymous.")]
47+
public string? McpRole { get; }
48+
4149
public int Handler(ILogger logger, FileSystemRuntimeConfigLoader loader, IFileSystem fileSystem)
4250
{
4351
logger.LogInformation("{productName} {version}", PRODUCT_NAME, ProductInfo.GetProductVersion());
4452
bool isSuccess = ConfigGenerator.TryStartEngineWithOptions(this, loader, fileSystem);
4553

4654
if (!isSuccess)
4755
{
48-
logger.LogError("Failed to start the engine.");
56+
logger.LogError("Failed to start the engine{mode}.",
57+
McpStdio ? " in MCP stdio mode" : string.Empty);
4958
}
5059

5160
return isSuccess ? CliReturnCode.SUCCESS : CliReturnCode.GENERAL_ERROR;

src/Cli/ConfigGenerator.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2359,6 +2359,17 @@ public static bool TryStartEngineWithOptions(StartOptions options, FileSystemRun
23592359
args.Add(Startup.NO_HTTPS_REDIRECT_FLAG);
23602360
}
23612361

2362+
// If MCP stdio was requested, append the stdio-specific switches.
2363+
if (options.McpStdio)
2364+
{
2365+
string effectiveRole = string.IsNullOrWhiteSpace(options.McpRole)
2366+
? "anonymous"
2367+
: options.McpRole;
2368+
2369+
args.Add("--mcp-stdio");
2370+
args.Add(effectiveRole);
2371+
}
2372+
23622373
return Azure.DataApiBuilder.Service.Program.StartEngine(args.ToArray());
23632374
}
23642375

src/Cli/Exporter.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,13 @@ private static async Task ExportGraphQL(
110110
}
111111
else
112112
{
113-
StartOptions startOptions = new(false, LogLevel.None, false, options.Config!);
113+
StartOptions startOptions = new(
114+
verbose: false,
115+
logLevel: LogLevel.None,
116+
isHttpsRedirectionDisabled: false,
117+
config: options.Config!,
118+
mcpStdio: false,
119+
mcpRole: null);
114120

115121
Task dabService = Task.Run(() =>
116122
{

src/Service/Program.cs

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
1-
// Copyright (c) Microsoft Corporation.
2-
// Licensed under the MIT License.
3-
41
using System;
52
using System.CommandLine;
63
using System.CommandLine.Parsing;
74
using System.Runtime.InteropServices;
5+
using System.Text;
86
using System.Text.RegularExpressions;
97
using System.Threading.Tasks;
108
using Azure.DataApiBuilder.Config;
119
using Azure.DataApiBuilder.Service.Exceptions;
1210
using Azure.DataApiBuilder.Service.Telemetry;
11+
using Azure.DataApiBuilder.Service.Utilities;
1312
using Microsoft.ApplicationInsights;
1413
using Microsoft.AspNetCore;
1514
using Microsoft.AspNetCore.Builder;
1615
using Microsoft.AspNetCore.Hosting;
1716
using Microsoft.Extensions.Configuration;
17+
using Microsoft.Extensions.DependencyInjection;
1818
using Microsoft.Extensions.Hosting;
1919
using Microsoft.Extensions.Logging;
2020
using Microsoft.Extensions.Logging.ApplicationInsights;
@@ -33,27 +33,41 @@ public class Program
3333

3434
public static void Main(string[] args)
3535
{
36+
bool runMcpStdio = McpStdioHelper.ShouldRunMcpStdio(args, out string? mcpRole);
37+
38+
if (runMcpStdio)
39+
{
40+
Console.OutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
41+
Console.InputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
42+
}
43+
3644
if (!ValidateAspNetCoreUrls())
3745
{
3846
Console.Error.WriteLine("Invalid ASPNETCORE_URLS format. e.g.: ASPNETCORE_URLS=\"http://localhost:5000;https://localhost:5001\"");
3947
Environment.ExitCode = -1;
4048
return;
4149
}
4250

43-
if (!StartEngine(args))
51+
if (!StartEngine(args, runMcpStdio, mcpRole))
4452
{
4553
Environment.ExitCode = -1;
4654
}
4755
}
4856

49-
public static bool StartEngine(string[] args)
57+
public static bool StartEngine(string[] args, bool runMcpStdio, string? mcpRole)
5058
{
51-
// Unable to use ILogger because this code is invoked before LoggerFactory
52-
// is instantiated.
5359
Console.WriteLine("Starting the runtime engine...");
5460
try
5561
{
56-
CreateHostBuilder(args).Build().Run();
62+
IHost host = CreateHostBuilder(args, runMcpStdio, mcpRole).Build();
63+
64+
if (runMcpStdio)
65+
{
66+
return McpStdioHelper.RunMcpStdioHost(host);
67+
}
68+
69+
// Normal web mode
70+
host.Run();
5771
return true;
5872
}
5973
// Catch exception raised by explicit call to IHostApplicationLifetime.StopApplication()
@@ -72,17 +86,28 @@ public static bool StartEngine(string[] args)
7286
}
7387
}
7488

75-
public static IHostBuilder CreateHostBuilder(string[] args)
89+
// Compatibility overload used by external callers that do not pass the runMcpStdio flag.
90+
public static bool StartEngine(string[] args)
91+
{
92+
bool runMcpStdio = McpStdioHelper.ShouldRunMcpStdio(args, out string? mcpRole);
93+
return StartEngine(args, runMcpStdio, mcpRole: mcpRole);
94+
}
95+
96+
public static IHostBuilder CreateHostBuilder(string[] args, bool runMcpStdio, string? mcpRole)
7697
{
7798
return Host.CreateDefaultBuilder(args)
7899
.ConfigureAppConfiguration(builder =>
79100
{
80101
AddConfigurationProviders(builder, args);
102+
if (runMcpStdio)
103+
{
104+
McpStdioHelper.ConfigureMcpStdio(builder, mcpRole);
105+
}
81106
})
82107
.ConfigureWebHostDefaults(webBuilder =>
83108
{
84109
Startup.MinimumLogLevel = GetLogLevelFromCommandLineArgs(args, out Startup.IsLogLevelOverriddenByCli);
85-
ILoggerFactory loggerFactory = GetLoggerFactoryForLogLevel(Startup.MinimumLogLevel);
110+
ILoggerFactory loggerFactory = GetLoggerFactoryForLogLevel(Startup.MinimumLogLevel, stdio: runMcpStdio);
86111
ILogger<Startup> startupLogger = loggerFactory.CreateLogger<Startup>();
87112
DisableHttpsRedirectionIfNeeded(args);
88113
webBuilder.UseStartup(builder => new Startup(builder.Configuration, startupLogger));
@@ -140,7 +165,14 @@ private static ParseResult GetParseResult(Command cmd, string[] args)
140165
/// <param name="appTelemetryClient">Telemetry client</param>
141166
/// <param name="logLevelInitializer">Hot-reloadable log level</param>
142167
/// <param name="serilogLogger">Core Serilog logging pipeline</param>
143-
public static ILoggerFactory GetLoggerFactoryForLogLevel(LogLevel logLevel, TelemetryClient? appTelemetryClient = null, LogLevelInitializer? logLevelInitializer = null, Logger? serilogLogger = null)
168+
/// <param name="stdio">Whether the logger is for stdio mode</param>
169+
/// <returns>ILoggerFactory</returns>
170+
public static ILoggerFactory GetLoggerFactoryForLogLevel(
171+
LogLevel logLevel,
172+
TelemetryClient? appTelemetryClient = null,
173+
LogLevelInitializer? logLevelInitializer = null,
174+
Logger? serilogLogger = null,
175+
bool stdio = false)
144176
{
145177
return LoggerFactory
146178
.Create(builder =>
@@ -229,7 +261,19 @@ public static ILoggerFactory GetLoggerFactoryForLogLevel(LogLevel logLevel, Tele
229261
}
230262
}
231263

232-
builder.AddConsole();
264+
// In stdio mode, route console logs to STDERR to keep STDOUT clean for MCP JSON
265+
if (stdio)
266+
{
267+
builder.ClearProviders();
268+
builder.AddConsole(options =>
269+
{
270+
options.LogToStandardErrorThreshold = LogLevel.Trace;
271+
});
272+
}
273+
else
274+
{
275+
builder.AddConsole();
276+
}
233277
});
234278
}
235279

src/Service/Startup.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,16 @@ public void ConfigureServices(IServiceCollection services)
348348
return handler;
349349
});
350350

351-
if (runtimeConfig is not null && runtimeConfig.Runtime?.Host?.Mode is HostMode.Development)
351+
bool isMcpStdio = Configuration.GetValue<bool>("MCP:StdioMode");
352+
353+
if (isMcpStdio)
354+
{
355+
// Explicitly force Simulator when running in MCP stdio mode.
356+
services.AddAuthentication(
357+
defaultScheme: SimulatorAuthenticationDefaults.AUTHENTICATIONSCHEME)
358+
.AddSimulatorAuthentication();
359+
}
360+
else if (runtimeConfig is not null && runtimeConfig.Runtime?.Host?.Mode is HostMode.Development)
352361
{
353362
// Development mode implies support for "Hot Reload". The V2 authentication function
354363
// wires up all DAB supported authentication providers (schemes) so that at request time,
@@ -456,6 +465,8 @@ public void ConfigureServices(IServiceCollection services)
456465

457466
services.AddDabMcpServer(configProvider);
458467

468+
services.AddSingleton<IMcpStdioServer, McpStdioServer>();
469+
459470
services.AddControllers();
460471
}
461472

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using Microsoft.Extensions.Configuration;
4+
using Microsoft.Extensions.DependencyInjection;
5+
using Microsoft.Extensions.Hosting;
6+
7+
namespace Azure.DataApiBuilder.Service.Utilities
8+
{
9+
/// <summary>
10+
/// Helper methods for configuring and running MCP in stdio mode.
11+
/// </summary>
12+
internal static class McpStdioHelper
13+
{
14+
/// <summary>
15+
/// Determines if MCP stdio mode should be run based on command line arguments.
16+
/// </summary>
17+
/// <param name="args"> The command line arguments.</param>
18+
/// <param name="mcpRole"> The role for MCP stdio mode, if specified.</param>
19+
/// <returns></returns>
20+
public static bool ShouldRunMcpStdio(string[] args, out string? mcpRole)
21+
{
22+
mcpRole = null;
23+
24+
bool runMcpStdio = Array.Exists(
25+
args,
26+
a => string.Equals(a, "--mcp-stdio", StringComparison.OrdinalIgnoreCase));
27+
28+
if (!runMcpStdio)
29+
{
30+
return false;
31+
}
32+
33+
string? roleArg = Array.Find(
34+
args,
35+
a => a != null && a.StartsWith("role:", StringComparison.OrdinalIgnoreCase));
36+
37+
if (!string.IsNullOrEmpty(roleArg))
38+
{
39+
string roleValue = roleArg[(roleArg.IndexOf(':') + 1)..];
40+
if (!string.IsNullOrWhiteSpace(roleValue))
41+
{
42+
mcpRole = roleValue;
43+
}
44+
}
45+
46+
return true;
47+
}
48+
49+
/// <summary>
50+
/// Configures the IConfigurationBuilder for MCP stdio mode.
51+
/// </summary>
52+
/// <param name="builder"></param>
53+
/// <param name="mcpRole"></param>
54+
public static void ConfigureMcpStdio(IConfigurationBuilder builder, string? mcpRole)
55+
{
56+
builder.AddInMemoryCollection(new Dictionary<string, string?>
57+
{
58+
["MCP:StdioMode"] = "true",
59+
["MCP:Role"] = mcpRole ?? "anonymous",
60+
["Runtime:Host:Authentication:Provider"] = "Simulator"
61+
});
62+
}
63+
64+
/// <summary>
65+
/// Runs the MCP stdio host.
66+
/// </summary>
67+
/// <param name="host"> The host to run.</param>
68+
public static bool RunMcpStdioHost(IHost host)
69+
{
70+
host.Start();
71+
72+
Mcp.Core.McpToolRegistry registry =
73+
host.Services.GetRequiredService<Mcp.Core.McpToolRegistry>();
74+
IEnumerable<Mcp.Model.IMcpTool> tools =
75+
host.Services.GetServices<Mcp.Model.IMcpTool>();
76+
77+
foreach (Mcp.Model.IMcpTool tool in tools)
78+
{
79+
_ = tool.GetToolMetadata();
80+
registry.RegisterTool(tool);
81+
}
82+
83+
IServiceScopeFactory scopeFactory =
84+
host.Services.GetRequiredService<IServiceScopeFactory>();
85+
using IServiceScope scope = scopeFactory.CreateScope();
86+
IHostApplicationLifetime lifetime =
87+
scope.ServiceProvider.GetRequiredService<IHostApplicationLifetime>();
88+
Mcp.Core.IMcpStdioServer stdio =
89+
scope.ServiceProvider.GetRequiredService<Mcp.Core.IMcpStdioServer>();
90+
91+
stdio.RunAsync(lifetime.ApplicationStopping).GetAwaiter().GetResult();
92+
host.StopAsync().GetAwaiter().GetResult();
93+
94+
return true;
95+
}
96+
}
97+
}

0 commit comments

Comments
 (0)