Skip to content

Say which plane decided the MCP endpoint is on, and on what port (#2389) - #2411

Merged
erikdarlingdata merged 2 commits into
devfrom
fix/2389-mcp-plane-authority
Aug 21, 2026
Merged

Say which plane decided the MCP endpoint is on, and on what port (#2389)#2411
erikdarlingdata merged 2 commits into
devfrom
fix/2389-mcp-plane-authority

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Aug 21, 2026

Copy link
Copy Markdown
Owner

Closes #2389.

What the truth actually is

The first job was to establish which plane owns which field, because the issue's premise is that the same mcp object has two owners and nothing says so. It does. Here is every resolution site:

Field darling.json Store Wins at runtime How
enabled mcp.enabled config.config_service.mcp_enabled Store published?.Enabled ?? config.Mcp.Enabled in the supervisor; the worker publishes on boot and every reload, so the file value applies only in the pre-publish window
port mcp.port config.config_service.mcp_port Store identical shape
network.listen mcp.network.listen File, restart-only ResolveMcpBind(config.Mcp, …) off the host's own DarlingConfig.Load()
network.allowFrom mcp.network.allowFrom File, restart-only same
network.encryptedToken / token mcp.network.* File, restart-only same
postgres.managed (gates exposure) postgres.managed File same

web is the byte-identical twin: web.enabled/web.port store-authoritative, web.network.* file-only.

Two details worth stating because they change how the bug reads. First, the file is not a fallback that lost an argument — SeedServiceRowAsync writes it once with ON CONFLICT DO NOTHING, so on a seeded store it is dead input, and the worker publishes the file values itself when the store read fails, meaning published is non-null in both the healthy and the store-down case. Second, the MCP host calls DarlingConfig.Load() itself and holds that instance for the process lifetime, so the worker's ApplyToConfig never touches the host's copy: the two values genuinely coexist in one process.

Why (a)/(c) and not (b)

Moving mcp.network into the store to match mcp.enabled is not safely achievable, and the DPAPI problem is only half of why. encryptedToken is LocalMachine-scoped with entropy PerformanceMonitor.Darling.v1, so a blob in config_service is undecryptable on any other host that reads that store — and there is no plaintext alternative worth having, because a bind address, a CIDR and a bearer token living in config_service would let a remote admin store connection (the pivot the postgres.network.role warning already names) re-point this listener onto a LAN interface behind a credential of its own choosing. Changing an exposure surface should require touching the host. So the split is kept, and the fix makes it visible and explicit instead.

What changed

The resolution now goes through one shared helper in DarlingHostBinding — the same anti-drift home the bind ladder already lives in — that returns the effective (enabled, port) with its provenance and whether it contradicts the file. published?.Enabled ?? … structurally cannot report which side it took; that is the whole defect, and it is why the fix is at the resolution site rather than in a decision function.

Two surfaces then say what they know. The start line names the plane each half of the bind came from and admits when it is running on file values the control plane has not weighed in on yet — that is the line the operator greps and stops reading, so it had to stop presenting a provisional start as a settled one. And a real disagreement is reported as a warning at the point of override, naming both sides with the key and the column, which one wins, the verb that changes the winning one, and the opposite ownership of the network block. It fires only on the mismatch and only once per distinct state, so a steady disagreement says its piece once per service start instead of every five seconds, and a later re-divergence is still reported.

On the reported box that turns this:

22:58:51 [INFO] Starting MCP server on http://10.149.45.159:5152 (LAN-exposed to …)
22:58:56 [INFO] MCP server disabled via the control plane — stopping (no restart needed)

into this:

[WARN] MCP configuration disagrees across the two planes and the CONTROL PLANE WINS: enabled is true in
       darling.json (mcp.enabled) but false in config.config_service.mcp_enabled. After the first run
       darling.json's mcp.enabled/mcp.port are only the SEED -- change them with --enable-mcp/--disable-mcp
       or the Viewer's Settings, or the file values will keep being ignored. The mcp.network block is the
       OPPOSITE: file-only, restart-only, no store equivalent -- so an exposure block in darling.json is
       live even while the control plane keeps this endpoint off.
[INFO] MCP server disabled via the control plane — stopping (no restart needed)

The port gets the same treatment, per the issue — same shape, and a server on an unexpected port with no explanation is the same defect wearing a different symptom.

The web dashboard shares the resolver rather than getting a parallel copy. One section argument drives the file key, the store column and the CLI verb, which is what keeps the two wordings from drifting.

Three CLI notes had the same defect in miniature: --configure-network's MCP and web notes and PrintNextSteps' two reminders all printed only when the file said the endpoint was disabled, which is exactly backwards — a file that says true while the store says false is the combination that misleads, and they printed nothing there. The wizard holds no store connection and cannot report the effective value, so what it says now is which plane decides and where the file value stops mattering.

I deliberately did not defer the start log until after the first reconcile, which the issue floats as an option. The line is true when it is printed and the server really does bind; suppressing it would trade one misleading log for another, and would also hide a start that failed before the first publish. Naming the provenance fixes the misreading without making the log less complete.

Verification

Windows-only suites can't execute on macOS, so: the solution builds clean (0 warnings), and the pure resolver was run against the shipped build from a throwaway net10.0 harness — 26 assertions covering the unpublished/agreeing/disagreeing cases, both fields, both surfaces and both origin clauses, all green. The source pins were simulated in Python against the fixture copies the tests actually read, and each one was re-run against dev's version of the two hosts to confirm it goes red without the fix rather than passing both ways. The existing pins that parse these same two files (Host-header guard ordering, the firewall check, the identity-derivation ban) were re-simulated and still pass.

Not fixed here

The sweep for resolution sites turned up an adjacent defect of the same family, filed as #2414 rather than folded in: the firewall verbs (--enable-mcp's firewall half and --configure-firewall) name the scoped rule from darling.json's port while the endpoint binds the store's, so changing the port in the Viewer leaves an exposed endpoint with no firewall path and an elevated verb that keeps creating the wrong rule. It needs a store read in the CLI, which is a different change from this one. The warning added here at least makes the precondition visible.

darling.sample.json already described this split correctly, on both the mcp and web blocks. The documentation was right the whole time; the runtime was silent. That is worth saying because it means no doc change would have prevented this.

One mcp object in darling.json has two owners. enabled and port are a
first-run seed that config.config_service overrides forever after; the
network block beside them -- listen, allowFrom, encryptedToken -- is
file-only, with no store equivalent at all. Nothing in the file or the log
distinguished them, so wiring up the MCP on the pgmonitor box produced a
start line naming a LAN bind, and five seconds later a stop line saying the
control plane had disabled it. Both lines were true. Neither said the file
edit was being ignored.

The store still wins; that part was never wrong. What was wrong is that
`published?.Enabled ?? config.Mcp.Enabled` structurally cannot say which
side it took, so a disagreement could only be reconstructed from two INFO
lines with nothing tying either back to the file. The resolution now runs
through a shared helper that carries its provenance, and the two places an
operator actually looks say what they know: the start line names the plane
each half of the bind came from and admits when it is running on file
values the control plane has not weighed in on yet, and a genuine
disagreement is reported as a warning at the point of override, naming both
sides with the key and the column, which one wins, and the verb that
changes the winning one. It fires only on the mismatch and only once per
distinct state, so a steady disagreement says its piece once per start
rather than every five seconds, and a later re-divergence is still
reported.

The port has the identical shape and gets the identical treatment -- a
server on an unexpected port with no explanation is the same defect with a
different symptom.

The network block deliberately stays file-authoritative rather than being
moved into the store to match. The token is DPAPI at LocalMachine scope, so
a blob in config_service is undecryptable on any other host that reads that
store, and there is no plaintext alternative worth having: a bind address,
a CIDR and a bearer token in config_service would let a remote admin store
connection -- the pivot the postgres.network role warning already names --
re-point this listener onto a LAN interface behind a credential of its own
choosing. Exposure should require touching the host. So the split is kept
and disclosed instead, in the warning itself, because the operator whose
file edit is being ignored is exactly the one who needs to know the block
right beside it is live.

The web dashboard is the same defect on the same seam, so it shares the
resolver rather than getting a parallel copy: one section argument drives
the file key, the store column and the CLI verb, which is what stops the
two wordings drifting apart.

Three CLI notes got the same correction. All three printed only when the
FILE said the endpoint was disabled, which is exactly backwards -- a file
that says true while the store says false is the combination that misleads,
and it printed nothing there. The wizard holds no store connection and
cannot report the effective value, so what it says now is which plane
decides and where the file value stops mattering.

The sample config already described this split correctly. The documentation
was right; the runtime was silent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment on lines +323 to +328
return $"{surface} configuration disagrees across the two planes and the CONTROL PLANE WINS: "
+ string.Join("; ", fields)
+ $". After the first run darling.json's {section}.enabled/{section}.port are only the SEED -- change them with "
+ $"--enable-{section}/--disable-{section} or the Viewer's Settings, or the file values will keep being ignored. "
+ $"The {section}.network block is the OPPOSITE: file-only, restart-only, no store equivalent -- so an exposure "
+ "block in darling.json is live even while the control plane keeps this endpoint off.";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The closing sentence is unconditional but only true when toggle.Enabled == false. When the control plane enables an endpoint the file disagrees with (or when only the port differs and both planes agree it's enabled), the endpoint is actually running — this text still claims "the control plane keeps this endpoint off," which is backwards.

Concretely, DescribeToggleOverride_ReportsThePortToo_AndBothFieldsAtOnce in DarlingHostBindingTests.cs builds exactly this case: ResolveEndpointToggle((true, 5199), fileEnabled: false, filePort: 5152)toggle.Enabled == true (control plane turned it on despite the file saying disabled). The generated report — not asserted on by that test — would still end with "...even while the control plane keeps this endpoint off," directly contradicting the state it just described a few sentences earlier ("false in darling.json ... but true in config.config_service.mcp_enabled").

A pure port-only mismatch (both planes agree enabled = true, only the port differs — plausibly the most common real case, e.g. someone changes the port via the Viewer's Settings) hits the same wrong sentence: the endpoint is running, not off.

Since this diagnostic exists specifically to stop operators from being misled about which plane is in control, this closing clause should be conditioned on toggle.Enabled (e.g. only mention "even while the control plane keeps this endpoint off" when !toggle.Enabled, and something like "even while the control plane keeps this endpoint on, exposed per darling.json's network block" otherwise — or just drop the endpoint-state claim entirely and keep the file/store precedence explanation, which is true regardless of state).

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in cfa93d3 — you're right, and the port-only case you named is the sharper half of it: both planes agreeing on enabled while only the port differs is the likeliest real mismatch, and it had no test at all, which is why nothing caught the wrong sentence.

The ownership half is now unconditional — "the control plane cannot change where this endpoint binds or what token it requires" — because that is true in every state. The consequence is emitted only in the state it describes: down, and "it is not what is keeping this endpoint down, and it takes effect as written the moment the control plane enables it"; up, and "it is what this RUNNING endpoint is bound and gated by, and no store setting can move it."

Both enabled-direction tests now assert the endpoint-is-down claim is absent, not just that the right clause is present — a presence-only assertion would have passed on the broken message, which is how it got here.

@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown

Review summary

Scope: purely Darling-side C# (config resolution provenance + diagnostics for MCP/web enabled/port). No T-SQL and no Lite changes — Lite has no equivalent two-plane (darling.json vs config.config_service) precedence, so this isn't a parity gap; nothing needed on the Lite side.

Correctness bug found (left as an inline comment on DarlingHostBinding.cs): DescribeToggleOverride's closing sentence — "...so an exposure block in darling.json is live even while the control plane keeps this endpoint off" — is appended unconditionally, but it's only true when the control plane resolved enabled = false. When the control plane enables an endpoint the file disagrees with, or when only the port differs and both planes agree the endpoint is on, the generated message still claims the endpoint is off while it's actually running. This is directly demonstrated by the PR's own DescribeToggleOverride_ReportsThePortToo_AndBothFieldsAtOnce test case (store enables what the file disables) — the test just doesn't assert on that trailing clause. Given the whole point of this change is to stop operators being misled about which plane controls the endpoint, this is worth fixing before merge.

Everything else checked out:

  • ResolveEndpointToggle/EndpointToggle resolution logic (store wins when published, file is provisional pre-publish) is correct and preserves prior behavior byte-for-byte.
  • The MCP and web host services share the one resolver/description helpers, so there's no drift between the two surfaces.
  • The warning dedup (lastOverrideReport) correctly re-fires on any change of disagreement state, including reverting to a previously-seen state, and goes quiet when the planes agree.
  • CLI wizard notes (DarlingCliCommands.cs) are now unconditional and correctly describe precedence instead of the old backwards "only warn when file says disabled" condition.
  • Test fixture wiring (Fixtures/DarlingMcpHostService.cs / DarlingWebHostService.cs copied via csproj None/Link) exists and is used correctly by the new source-pinning tests.
  • No SQL, no secrets/network/process handling changed here beyond log text.

The override report closed with one unconditional sentence saying the
network block was live "even while the control plane keeps this endpoint
off". That is only true in the direction the issue reported. When the
control plane ENABLES an endpoint the file disables, and in the port-only
mismatch where both planes agree it runs and someone simply moved the port
in the Viewer -- plausibly the commonest real case -- the endpoint is up,
and the sentence contradicted the state the same message had just
described two clauses earlier.

A diagnostic that exists to stop an operator being misled about the
effective state cannot itself misstate it, so the ownership half stays
unconditional, because it is true in every state, and the consequence is
now emitted in the state it describes: down, and the network block is not
what is holding it there; up, and the network block is what it is bound and
gated by.

The port-only mismatch had no test at all, which is why nothing caught
this. It has one now, and both enabled-direction cases assert the
endpoint-is-down claim is absent rather than only asserting what is
present -- an assertion on presence alone would have passed on the broken
message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@erikdarlingdata

Copy link
Copy Markdown
Owner Author

Merge-order note, restated from #2409.

bf0eef5c on fix/2407-azure-utility-db-cannot-cross-databases (PR #2409) carries the same three files this PR changes — DarlingHostBinding.cs, DarlingMcpHostService.cs, DarlingWebHostService.cs — because they were uncommitted #2389 work sitting in the shared checkout when that branch was cut, and a commit -a picked them up. Flagged there with the one-command fix; it's that lane's call, and I haven't touched their branch.

Safe orders:

@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown

Reviewed the diff (99cbeca..0be9b3d, both commits). This is a Darling-only C# change (no T-SQL touched, so the T-SQL style rules don't apply here).

CorrectnessResolveEndpointToggle preserves the exact prior precedence (published?.Enabled ?? config.Mcp.Enabled) byte-for-byte while adding provenance; verified both supervisor loops still null-check config before use and the new resolution code is unreachable when config is null. The dedup logic (lastOverrideReport) correctly resets to null when the planes re-agree, so a later re-divergence is still reported rather than permanently suppressed. DescribeToggleOverride's state-specific closing sentence (down vs. running) is now correctly gated on toggle.Enabled per the second commit's fix — confirmed by the added port-only-mismatch and enabled-flip test cases that assert the absence of the wrong-direction claim, not just the presence of the right one.

Parity — This resolves a Darling-specific defect (the darling.json file vs. config.config_service store split). Lite's Mcp/McpHostService.cs has no equivalent two-plane config resolution (no config_service store, no published/file duality), so there's no Lite counterpart that needs the same fix — not a parity gap.

Security — No secrets in the new log/warning text (only enabled/port values); the network.* (listen/allowFrom/token) file-only boundary is preserved and the reasoning for keeping it out of the store (DPAPI LocalMachine scoping, remote-store-pivot risk) is sound and consistent with the existing postgres.network.role warning precedent.

Style/schema — Column names referenced in the new messages (config.config_service.mcp_enabled/mcp_port/web_enabled/web_port) and CLI flags (--enable-mcp/--disable-mcp/--enable-web/--disable-web) match the actual migration and CLI argument parsing.

No findings. Tests are thorough (26 assertions covering unpublished/agreeing/disagreeing states for both fields and both surfaces, plus source-pinning tests that guard against a future silent reversion to the null-coalesce form).

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant