Skip to content

[MCP] Initialize metadata providers in stdio mode - #3784

Open
Christos Despotakis (despotak) wants to merge 4 commits into
Azure:mainfrom
despotak:fix/mcp-stdio-metadata-inference
Open

[MCP] Initialize metadata providers in stdio mode#3784
Christos Despotakis (despotak) wants to merge 4 commits into
Azure:mainfrom
despotak:fix/mcp-stdio-metadata-inference

Conversation

@despotak

Copy link
Copy Markdown

Why make this change?

Closes #3783.

Summary of the linked issue: in every 2.1.x build, dab start --mcp-stdio registers entities but never infers their database objects, so every MCP tool call fails with Database object for entity '<name>' has not been inferred. while the identical config serves the same entity correctly over REST. 2.0.12 is unaffected.

version published --mcp-stdio REST
2.0.12 (GA) 2026-08-20 PASS PASS
2.1.0-rc 2026-08-11 FAIL PASS
2.1.3-rc 2026-08-25 FAIL PASS

Related: #3676, #3675 (the change this regressed from), and #3430 — see the note at the bottom.

What is this change?

Schema inference is reachable only through the ASP.NET Core startup path, which stdio mode skips:

  1. Program.csStartEngine returns before the host is started:
    IHost host = CreateHostBuilder(args, runMcpStdio, mcpRole).Build();
    
    if (runMcpStdio)
    {
        return McpStdioHelper.RunMcpStdioHost(host);   // returns here
    }
    
    host.Run();   // normal web mode
  2. McpStdioHelper.RunMcpStdioHost — initialises the tool registry and nothing else, so Startup.Configure never runs.
  3. Startup.cs:822Configure is the only place that calls PerformOnConfigChangeAsync(app) (line 866).
  4. Startup.cs:1431PerformOnConfigChangeAsync is the only caller of IMetadataProviderFactory.InitializeAsync().

So entity names reach the tool registry from config, while IMetadataProviderFactory is never initialised and no entity receives a database object. That is exactly the observed split: tools/list succeeds, every tool call fails.

This is a side effect of #3676 "Avoid starting web host in MCP stdio mode". That change was right — stdio mode should not bind an HTTP port — but PerformOnConfigChangeAsync did more than serve HTTP, and nothing took over its metadata-initialisation duty on the stdio path.

The fix is 10 lines: RunMcpStdioHost resolves IMetadataProviderFactory from DI and initialises it before registering tools. Program.cs is untouched, and #3676's behaviour is preserved — the existing assertions that StartAsync/StopAsync are never called still pass.

How was this tested?

  • Integration Tests
  • Unit Tests

RunMcpStdioHost_DoesNotStartWebHost gains a stub IMetadataProviderFactory and an assertion that InitializeAsync is called exactly once. Its original assertions are unchanged and still pass, so this cannot silently re-introduce the web host.

Also verified by hand against SQL Server, on main built from source with the pinned SDK (10.0.302), before and after the patch:

before after
describe_entities, 1-entity config has not been inferred success (1 entity)
describe_entities, 28-entity config has not been inferred success (28 entities)
read_records on a table error success, rows returned
REST GET /api/<entity> HTTP 200 HTTP 200 (unchanged)

dotnet format --verify-no-changes on both touched files exits 0.

Sample Request(s)

Driving the stdio server by hand, so no MCP client is involved:

printf '%s\n' \
 '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}' \
 '{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}' \
 '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"describe_entities","arguments":{}}}' \
 | dab start --mcp-stdio

Before:

{"toolName":"describe_entities","status":"error",
 "error":{"type":"DataApiBuilderError",
          "message":"Database object for entity 'MyTable' has not been inferred."}}

After:

{"entities":[{"name":"MyTable","description":"","fields":[],"permissions":["READ"]}],
 "count":1,"status":"success"}

One note for reviewers

#3430 reports that stdio blocks the initialize response until introspection finishes (~17 s against a remote instance with 53 entities). Restoring inference on this path necessarily brings that latency back — it was only absent because inference was not happening at all. If you would prefer inference to run asynchronously after initialize returns, that is a larger change and I am happy to rework this accordingly; this PR deliberately restores correctness first.


Investigated and written with Claude Code (Νύξ) 🌑 — the version bracket, the REST/stdio control and the root-cause trace were worked out together. Reviewed and submitted by me.

dab start --mcp-stdio returns from Program.StartEngine before host.Run(), so
Startup.Configure never executes -- and with it PerformOnConfigChangeAsync, the
only caller of IMetadataProviderFactory.InitializeAsync(). Entity names reach the
tool registry from config, but no entity ever receives a database object, so every
MCP tool call fails with:

    Database object for entity '<name>' has not been inferred.

The identical configuration serves the same entity correctly over REST, because
the web path does call host.Run().

This is a side effect of Azure#3676 (Avoid starting web host in MCP stdio mode). That
change was correct in itself -- stdio mode should not bind an HTTP port -- but
PerformOnConfigChangeAsync did more than serve HTTP, and nothing took over its
metadata-initialization duty on the stdio path.

RunMcpStdioHost now initializes the metadata providers itself, before registering
tools. The existing assertions that StartAsync and StopAsync are never called
still hold, so Azure#3676 is preserved; the unit test gains a stub factory and an
assertion that InitializeAsync is invoked exactly once.

Verified against SQL Server: describe_entities and read_records both succeed on a
one-entity and a twenty-eight-entity configuration, and REST is unchanged.

Fixes Azure#3783

Co-authored-by: Νύξ (Nyx) 🌑 <noreply@anthropic.com>
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

@despotak

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Restores schema inference for MCP stdio mode by explicitly initializing the metadata providers on the stdio startup path (which bypasses ASP.NET Core Startup.Configure). This addresses the 2.1.x regression where tools were registered from config but all tool calls failed because database objects were never inferred.

Changes:

  • Initialize IMetadataProviderFactory inside McpStdioHelper.RunMcpStdioHost before registering MCP tools.
  • Extend the existing unit test to assert IMetadataProviderFactory.InitializeAsync() is invoked exactly once in stdio mode.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
src/Service/Utilities/McpStdioHelper.cs Initializes metadata providers during stdio startup so entities have inferred database objects before tool calls.
src/Service.Tests/UnitTests/McpStdioHelperTests.cs Adds a stub IMetadataProviderFactory and asserts InitializeAsync() is called once without starting the web host.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/Service/Utilities/McpStdioHelper.cs Outdated
Comment on lines +87 to +88
Core.Services.MetadataProviders.IMetadataProviderFactory metadataProviderFactory =
host.Services.GetRequiredService<Core.Services.MetadataProviders.IMetadataProviderFactory>();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit- just import using Azure.DataApiBuilder.Core.Services.MetadataProviders instead of calling the same multiple times.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 6b2fea1 — and I applied it to the rest of the method too: importing Azure.DataApiBuilder.Mcp.Core and .Mcp.Model removes seven more qualifications (McpToolRegistry ×3, IMcpTool ×2, IMcpStdioServer ×2). Those sit on lines this PR did not introduce, so happy to drop that hunk if you would rather the diff stayed strictly on the lines it added.

// "Database object for entity '<name>' has not been inferred."
Core.Services.MetadataProviders.IMetadataProviderFactory metadataProviderFactory =
host.Services.GetRequiredService<Core.Services.MetadataProviders.IMetadataProviderFactory>();
metadataProviderFactory.InitializeAsync().GetAwaiter().GetResult();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since this inside a try/finally that only disposes the host, so any initialization exception propagates raw out of RunMcpStdioHost (which otherwise returns bool). Consider wrapping it so a metadata-inference failure produces a clear, logged error over the stdio channel rather than an unhandled exception

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 6b2fea1. The catch went on the existing outer try rather than around the initialization alone, taking "any initialization exception" literally — and that turned out to matter: GetRequiredService<IMetadataProviderFactory>() activates MetadataProviderFactory, whose constructor calls ConfigureMetadataProviders()RuntimeConfigProvider.GetConfig(), so a missing or unparseable config file throws one line above a narrower guard.

It reports on stderr rather than through ILogger, because stdio clears every logging provider and the remaining McpLoggerProvider stays disabled until the client sends logging/setLevel — which cannot happen before the JSON-RPC loop runs. Full reasoning and the before/after measurement are in the comment on the PR.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The change is small, directly addresses the reported regression in stdio mode, and is covered by a focused unit test that preserves the existing “does not start web host” guarantees.

Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

RunMcpStdioHost had a try/finally that only disposed the host, so any exception
propagated out of a method whose contract is a bool. The catch goes on the
existing outer try rather than around the metadata initialization alone, because
the resolution one line above it is where the commonest failures land:
GetRequiredService<IMetadataProviderFactory>() activates MetadataProviderFactory,
whose constructor calls ConfigureMetadataProviders() -> RuntimeConfigProvider
.GetConfig(), which throws "Runtime config isn't setup." for a missing or
unparseable config file. Guarding the initialization alone would have left that
untouched.

This follows Startup.PerformOnConfigChangeAsync: report, return false. Program
.Main already maps false to ExitCode -1, the stdio analogue of that path's
hostLifetime.StopApplication(), so no caller changes. The catch spans the stdio
loop as well as startup, which is why the message says "run the MCP stdio host"
rather than naming a phase. OperationCanceledException is filtered out so a
normal shutdown is not relabelled as a failure; it continues to reach
Program.StartEngine's dedicated handler unchanged.

The report goes to stderr rather than through ILogger because no logger can
reach anyone at that point: stdio clears every provider and leaves
McpLoggerProvider, whose McpLogger stays disabled until the client sends
logging/setLevel, which cannot happen before the JSON-RPC loop runs. Writing a
notifications/message frame by hand would precede the initialize response the
server contracts to send first.

stderr itself may be suppressed. --mcp-stdio defaults to LogLevel.None, at which
Program points both console streams at TextWriter.Null for "ZERO output", which
is why the existing "Unable to launch the runtime" message is never seen in that
mode either. This reports anyway, on the view that a refusal to run is not log
output and an exit code alone is not diagnosable; it writes to the standard error
stream directly rather than installing a replacement writer that would outlive
the call. stdout is untouched and stays reserved for JSON-RPC.

Measured against the built engine with a missing config file, default log level:

    before:  exit 255, stdout 0 bytes, stderr 0 bytes
    after :  exit 255, stdout 0 bytes, stderr 2635 bytes

Imports Azure.DataApiBuilder.Core.Services.MetadataProviders so the factory type
is not fully qualified twice, and extends the same treatment to the rest of the
method: importing Azure.DataApiBuilder.Mcp.Core and .Mcp.Model removes seven
further qualifications of McpToolRegistry, IMcpTool and IMcpStdioServer. Those
seven sit on lines this PR did not introduce and can be dropped if the reviewer
would rather the diff stayed on the lines it added.

Both new tests were verified to fail when only the catch is reverted.

Co-Authored-By: Νύξ (Nyx, AI) 🌑 <nyx@despotak.is>
@despotak

Copy link
Copy Markdown
Author

Both addressed in the follow-up commit.

Nit: done — using Azure.DataApiBuilder.Core.Services.MetadataProviders;, and the two
fully-qualified mentions collapse to IMetadataProviderFactory. I applied it to the rest of the
method too: importing Azure.DataApiBuilder.Mcp.Core and .Mcp.Model removes seven more
qualifications. Those sit on lines this PR did not introduce, so happy to drop that hunk if you would
rather the diff stayed strictly on the lines it added.

Error handling: the catch sits on the existing outer try, taking your wording literally, and
that turned out to matter. GetRequiredService<IMetadataProviderFactory>() is not a cheap container
lookup: it activates MetadataProviderFactory, whose constructor calls ConfigureMetadataProviders()
RuntimeConfigProvider.GetConfig(), which throws "Runtime config isn't setup." for a missing or
unparseable config file — so a missing config path and malformed JSON both throw one line above a
guard placed around the initialization alone.

It follows Startup.PerformOnConfigChangeAsync (Startup.cs:1494-1498): report, return false.
Program.Main already maps false to ExitCode -1, the stdio analogue of that path's
hostLifetime.StopApplication(), so nothing changes for callers. The filter
when (ex is not OperationCanceledException) leaves cancellation to StartEngine's own
catch (TaskCanceledException) rather than relabelling a normal shutdown as a failure. The catch
spans the stdio loop as well as startup, which is why the message names the host rather than a phase.

On the channel: the report goes to stderr, because the logging pipeline cannot carry it this
early. Stdio mode clears every provider (Program.cs:189) and leaves McpLoggerProvider, which stays
disabled until the client sends logging/setLevel (McpStdioServer.cs:387, McpLogger.cs:65-67) —
impossible before stdio.RunAsync() runs. A hand-written notifications/message frame would precede
the initialize response, which McpStdioServer documents itself as sending first (the <remarks>
at McpStdioServer.cs:176-180). stdout is untouched.

Worth flagging: today this failure is silent, not just raw. --mcp-stdio defaults to
LogLevel.None (Program.cs:261-265), and at that level Program.cs:111-114 points both console
streams at TextWriter.Null — so StartEngine's existing Unable to launch the runtime due to: {ex}
is discarded too. Measured against the built engine, missing config file, default log level:

before:  exit 255, stdout 0 bytes, stderr 0 bytes
after :  exit 255, stdout 0 bytes, stderr 2635 bytes

Tests: two new, beside the existing one, both proven red by reverting only the catch. Locally
dotnet format --verify-no-changes is clean and the unit suite is 2419/2419.

Two things I left out to keep the diff to what you asked for, happy to add either: folding the stderr
handling into a shared helper next to McpStdioServer.RestoreStderrIfNeeded, and putting the config
file path in the message the way Startup.cs:870-872 does.

@souvikghosh04

Copy link
Copy Markdown
Contributor

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 6 pipeline(s).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Review In Progress

Development

Successfully merging this pull request may close these issues.

[Bug]: --mcp-stdio never infers database objects for configured entities in 2.1.x (works in 2.0.12, works over REST)

5 participants