Azure / Azure/data-api-builder

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

Open
#3,783 0 comments 1 reaction 0 assignees View on GitHub
Dominant language
C#
Stars
1.5k
Forks
370
Avg merge
3d 17h
Merged PRs (30d)
8

Description

## Describe the bug

In every `2.1.x` build, `dab start --mcp-stdio` **registers entities but never infers their database objects**. The MCP handshake succeeds, `tools/list` returns the full tool surface, and then every tool call fails:

```
Database object for entity 'MyTable' has not been inferred.
```

`read_records` reports the same condition differently:

```
Entity 'MyTable' is not defined in the configuration.
```

The same config, on the same machine, in the same minute, works perfectly over REST.

This does not reproduce on `2.0.12`.

## Repro

`dab-config.json` — one entity, nothing exotic:

```json
{
"data-source": {
"database-type": "mssql",
"connection-string": "@env('MY_CONN')"
},
"runtime": {
"rest": { "enabled": true, "path": "/api" },
"graphql": { "enabled": false },
"host": {
"mode": "development",
"cors": { "origins": [], "allow-credentials": false },
"authentication": { "provider": "StaticWebApps" }
},
"mcp": {
"enabled": true,
"dml-tools": {
"describe-entities": true,
"create-record": false,
"update-record": false,
"delete-record": false,
"execute-entity": true
}
}
},
"entities": {
"MyTable": {
"source": { "object": "dbo.MyTable", "type": "table" },
"permissions": [{ "role": "anonymous", "actions": ["read"] }]
}
}
}
```

(`$schema` is omitted deliberately — the repro behaves the same with no `$schema`, with the
`v1.7.93` URL, and with a `v2.1.3-rc` URL, so it is not a factor.)

**Fails** — drive the stdio server by hand, so no MCP client is involved:

```bash
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
```

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

**Works** — identical config, identical connection string, same shell:

```bash
dab start # REST mode
curl "http://localhost:5000/api/MyTable?\$first=1"
```

`HTTP 200`, real rows, all columns.

## Expected behaviour

`--mcp-stdio` should infer database objects for configured entities, exactly as REST mode does.

## Version bracket

| 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 |

Same machine, same config, same database, same connection string throughout.

## What we ruled out

- **Config shape** — the minimal one-entity config above fails identically to our real 28-entity config.
- **Config discovery** — an explicit `--config` with an absolute path changes nothing. Running from a directory containing *no* config produces no response at all, which is a different failure, so the file is definitely being found and parsed.
- **Timing / lazy init** — waiting 8 s between `initialize` and the tool call makes no difference. (We checked this because of #3430.)
- **The MCP client** — the repro above is raw JSON-RPC piped into stdin, no client involved.
- **Connectivity, credentials, permissions** — REST serves the same entity from the same connection string; an independent SQL client sees the table and rows as the same login.

## Root cause

Confirmed by reading `main`, not inferred from behaviour. Schema inference is reachable **only** through the ASP.NET Core startup path, which stdio mode skips:

**1. `src/Service/Program.cs` — `StartEngine` returns before the host is ever started**

```csharp
IHost host = CreateHostBuilder(args, runMcpStdio, mcpRole).Build();

if (runMcpStdio)
{
return McpStdioHelper.RunMcpStdioHost(host); // <-- returns here
}

// Normal web mode
host.Run();
```

**2. `src/Service/Utilities/McpStdioHelper.cs` — `RunMcpStdioHost` initialises tools, and only tools**

```csharp
Mcp.Core.McpToolRegistry.InitializeAndRegisterTools(tools, registry, host.Services);
```

No `host.StartAsync()`, so `Startup.Configure` never runs.

**3. `src/Service/Startup.cs:822` — `Configure` is where inference is triggered**

```csharp
public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ...)
...
// line 866
isRuntimeReady = PerformOnConfigChangeAsync(app).Result;
```

**4. `src/Service/Startup.cs:1431` — and that is the only caller of `InitializeAsync`**

```csharp
private async Task PerformOnConfigChangeAsync(IApplicationBuilder app)
{
...
IMetadataProviderFactory sqlMetadataProviderFactory =
app.ApplicationServices.GetRequiredService();
await sqlMetadataProviderFactory.InitializeAsync(); // <-- schema inference
```

So in stdio mode the tool registry knows the entity names (they come from config), while `IMetadataProviderFactory` is never initialised and no entity ever gets 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"** (merged 2026-07-09, fixing #3675). 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-initialisation duty on the stdio path.

### Suggested fix

Have `RunMcpStdioHost` initialise the metadata provider before entering the JSON-RPC loop — either by calling `IMetadataProviderFactory.InitializeAsync()` directly from DI, or by extracting the non-HTTP portion of `PerformOnConfigChangeAsync` (config validation + metadata init) into something both paths call. `RunMcpStdioHost` already resolves services from `host.Services`, so the plumbing is there.

Worth noting for whoever picks this up: #3430 reports that stdio blocks the `initialize` response until introspection finishes. Restoring inference on this path will bring that latency back, so the two probably want solving together.

## Environment

- **DAB:** 2.1.0-rc and 2.1.3-rc fail; 2.0.12 works
- **Install:** `dotnet tool install -g microsoft.dataapibuilder --version `
- **.NET:** 8.0.30 runtime + ASP.NET Core 8.0.30 (for 2.0.x); 10.0.11 (for 2.1.x)
- **OS:** CachyOS Linux (Arch), x64, kernel 7.2.0
- **Database:** on-premises SQL Server over TCP on a non-default port (not Azure SQL)
- **Connection string:** `Encrypt=True;TrustServerCertificate=True`
- **MCP client:** none — raw JSON-RPC on stdin

## Side note

`--prerelease` currently resolves to `2.1.3-rc`, so anyone following the common `dotnet tool install -g microsoft.dataapibuilder --prerelease` advice lands on a build where MCP stdio cannot serve any entity. `--version 2.0.12` is the working install today.

---

*Investigated and written with Claude Code (**Νύξ**) 🌑 — version bracketing, the REST/stdio control and the root-cause trace were worked out together over one session. A fix is on the way as a PR.*

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.