Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
44786b9
fix(mcp): reject ambiguous credential aliases
deepujain Sep 16, 2026
ea8e90c
Merge branch 'main' into deepujain/fix-11895-multi-mcp-policy
deepujain Sep 16, 2026
c7d4638
test(e2e): map MCP alias regression
deepujain Sep 16, 2026
270345c
Merge branch 'main' into deepujain/fix-11895-multi-mcp-policy
deepujain Sep 16, 2026
b3a617f
Merge branch 'main' into deepujain/fix-11895-multi-mcp-policy
deepujain Sep 16, 2026
fea2884
Merge branch 'main' into deepujain/fix-11895-multi-mcp-policy
deepujain Sep 16, 2026
af11f11
test(state): cover lifecycle lock storage branches
deepujain Sep 16, 2026
5a5fe9a
test(mcp): prove alias rejection leaves no state
deepujain Sep 16, 2026
b45a1af
Merge branch 'main' into deepujain/fix-11895-multi-mcp-policy
deepujain Sep 16, 2026
87e72c6
fix(mcp): reject ambiguous migration credentials
deepujain Sep 16, 2026
9fd60ef
Merge remote-tracking branch 'origin/main' into repair-11917-e2e-parity
deepujain Sep 16, 2026
339167b
fix(mcp): include native targets in migration preflight
deepujain Sep 17, 2026
d694da0
merge: update PR 11917 from main
deepujain Sep 17, 2026
0321fb4
fix(mcp): refine credential conflict detection
deepujain Sep 17, 2026
d30b276
merge: update PR 11917 from main
deepujain Sep 17, 2026
6ad3b6e
fix(mcp): recover ambiguous legacy registrations
deepujain Sep 17, 2026
8c7202e
Merge remote-tracking branch 'origin/main' into repair-11917-e2e-parity
deepujain Sep 17, 2026
cffc104
fix(mcp): preflight ambiguous recovery targets
deepujain Sep 17, 2026
f1d6722
fix(mcp): preserve credential target ownership
deepujain Sep 17, 2026
457e2e3
fix(mcp): validate legacy teardown before mutations
deepujain Sep 17, 2026
8bb6ae3
merge: consume trusted growth validation update
deepujain Sep 17, 2026
6e5a82d
fix(mcp): cover rebuild preflight and clarify legacy removal
deepujain Sep 18, 2026
a0e324d
chore: integrate canonical validation dependency
deepujain Sep 18, 2026
bde97ad
test(mcp): refresh reviewed startup runtime integrity pin
deepujain Sep 18, 2026
34d038d
fix(mcp): scope removal to the named source and verify cleanup
deepujain Sep 18, 2026
bd164a3
fix(mcp): preserve legacy ownership until verified removal
deepujain Sep 18, 2026
036ce22
fix(mcp): make owned removal retries safe
deepujain Sep 18, 2026
ed497eb
fix(mcp): retain legacy ownership through registry updates
deepujain Sep 18, 2026
574aceb
fix(mcp): validate cleanup retry identities
deepujain Sep 18, 2026
af40019
merge: update PR 11917 from main
deepujain Sep 18, 2026
1c90adf
fix(mcp): block legacy state before rebuild
deepujain Sep 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions ci/e2e-assertion-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@
"testFileCount": 78,
"liveFileCount": 189,
"direct": {
"expectCalls": 1417,
"matcherAssertions": 1396,
"expectCalls": 1412,
"matcherAssertions": 1391,
"nodeAssertions": 103,
"namedAssertionHelpers": 470,
"namedAssertionHelpers": 468,
"failCalls": 0,
"throwGuards": 71,
"objectFieldAssertions": 186,
"assertionPoints": 2226,
"objectFieldAssertions": 176,
"assertionPoints": 2209,
"generatedProbeBlocks": 96,
"generatedProbeConditions": 250
},
Expand Down Expand Up @@ -82,7 +82,7 @@
"test/e2e/live/managed-image-activation-e2e.test.ts": [0,0,13,30,0],
"test/e2e/live/managed-image-multiarch-startup.test.ts": [1,1,1,7,0],
"test/e2e/live/managed-image-protected-runtime.test.ts": [0,0,42,79,15],
"test/e2e/live/mcp-bridge.test.ts": [48,164,194,499,46],
"test/e2e/live/mcp-bridge.test.ts": [43,147,194,499,46],
"test/e2e/live/messaging-compatible-endpoint.test.ts": [13,19,13,28,2],
"test/e2e/live/messaging-providers.test.ts": [0,11,6,51,12],
"test/e2e/live/model-router-provider-routed-inference.test.ts": [1,2,1,2,0],
Expand Down
9 changes: 7 additions & 2 deletions docs/deployment/set-up-mcp-bridge.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ OpenShell withholds the credential before the binding is active and outside the

Credential keys must still be unique across providers attached to the same sandbox.
NemoClaw rejects managed MCP credential-key reuse and requires a unique least-privilege token and environment key for each server.
It also rejects credential-bound definitions for the same normalized endpoint URL when their credential bindings differ, because OpenShell cannot choose between credentials for otherwise indistinguishable requests.
Entries that intentionally share one credential binding are not ambiguous; otherwise, keep one managed definition for that URL or use distinct endpoint URLs.

A managed MCP provider created by an earlier NemoClaw build can have the profile-less `generic` type and cannot satisfy this binding contract.
NemoClaw refuses to restart or rebuild that registration.
Expand All @@ -123,7 +125,9 @@ This does not expose the raw credential to the sandbox before the request is aut
## Agent Adapters

<AgentOnly variant="openclaw">
OpenClaw writes the native `openclaw.json` `mcp.servers` map in the sandbox. Older managed Mcporter entries are accepted only by the explicit `mcp migrate` path.
OpenClaw writes the native `openclaw.json` `mcp.servers` map in the sandbox.
Use `mcp migrate --apply` to convert older managed Mcporter entries to native configuration.
To discard an entry instead, follow [Remove a Server](manage-mcp-servers#remove-a-server); removal requires proven registry ownership.
</AgentOnly>
<AgentOnly variant="hermes">
Hermes writes managed HTTP entries under `/sandbox/.hermes/config.yaml`.
Expand Down Expand Up @@ -158,7 +162,8 @@ The command carries no MCP traffic or raw service credential, and its payload co
</AgentOnly>
<AgentOnly variant="openclaw">

OpenClaw stores MCP servers in the native `openclaw.json` `mcp.servers` map. Run `nemoclaw <name> mcp migrate` before the first mutation when an older sandbox still contains a managed Mcporter entry. The migration preview calls out that materializing the native entry activates behavior OpenClaw previously did not load.
Before converting an older managed Mcporter entry, run `nemoclaw <name> mcp migrate` to preview the change.
The preview explains that creating the native entry activates behavior OpenClaw previously did not load.

</AgentOnly>
<AgentOnly variant="deepagents">
Expand Down
7 changes: 6 additions & 1 deletion docs/manage-sandboxes/add-mcp-server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ A legacy image stops with migration guidance before it changes a live provider,
Hermes stores MCP servers in its native `config.yaml`. Registration and inspection preserve the stable credential handle from the readiness check in the OpenShell resolver placeholder without comparing it with host-side desired state.
</AgentOnly>
<AgentOnly variant="openclaw">
OpenClaw stores MCP servers in `openclaw.json` under `mcp.servers`. Existing Mcporter entries require the explicit migration described in [Manage MCP Servers](manage-mcp-servers#migrate-an-existing-registration).
OpenClaw stores MCP servers in `openclaw.json` under `mcp.servers`.
To continue using or updating an existing Mcporter entry, follow [Migrate an Existing Registration](manage-mcp-servers#migrate-an-existing-registration).
To remove an entry instead, `$$nemoclaw my-sandbox mcp remove <server>` supports direct removal when its committed registry row proves ownership and the removal preconditions are met.
Refer to [Remove a Server](manage-mcp-servers#remove-a-server).
</AgentOnly>

An interrupted add is retried from the current agent and OpenShell sources. A deterministic provider name lets the retry recognize a partially created provider without a host-side transaction marker. The retry resumes only when the native entry, generated policy, provider identity and credential key, and sandbox attachment form an exact prefix of the requested add; conflicting or ambiguous resources remain unchanged and stop the command.
Expand Down Expand Up @@ -168,6 +171,8 @@ Use a dedicated service name such as `MY_SERVICE_MCP_TOKEN`.
NemoClaw requires exactly one `--env` bearer credential per server.
Use a distinct environment variable for each managed server in the same sandbox because credential keys must be unique across attached providers.
NemoClaw binds each dedicated provider to its generated MCP endpoint, so OpenShell resolves the placeholder only for that endpoint's host, port, and path.
One normalized endpoint URL can have only one credential-bound managed server definition in a sandbox.
If two definitions need different credentials, retain one definition for that URL or configure distinct endpoint URLs so OpenShell can select one credential unambiguously.

If this server uses the profile-less `generic` provider from an earlier NemoClaw build, remove it and add it again with the credential exported.
That provider cannot be bound to an endpoint, so restart and rebuild fail closed until you recreate the registration.
Expand Down
16 changes: 15 additions & 1 deletion docs/manage-sandboxes/manage-mcp-servers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ An existing valid provider can remain ready when a host variable is unset becaus

## Migrate an Existing Registration

Preview legacy Mcporter or Deep Agents projection entries before the first mutation with the cutover release:
To keep using legacy Mcporter or Deep Agents projection entries, preview their conversion to native configuration:

```bash
$$nemoclaw my-sandbox mcp migrate
Expand Down Expand Up @@ -199,6 +199,20 @@ Removing the server deletes the agent-native entry, removes its generated key fr

## Remove a Server

For a legacy registration, removal requires a committed registry row that proves ownership of the named entry.
Removal also requires every other committed legacy row to have an observed legacy entry with the same server name.
If a registry-only row remains, NemoClaw preserves all sources and refuses removal.
Run `$$nemoclaw my-sandbox mcp migrate --apply` when you intend to convert those registrations to native configuration, or explicitly resolve the registry-only rows first.
When these conditions pass, you do not need to migrate the named entry before removing it.
NemoClaw verifies that the named legacy entry is absent before retiring its ownership row and cleaning up policy and provider attachments.
Ownership rows for remaining legacy entries are preserved so you can remove or migrate them later.
If the registry write fails after the agent entry is removed, rerun the same removal command.
The retry verifies that the entry is still absent and compares any remaining policy's recorded registration and provider identity with the committed ownership row before retiring that row.
Provider detachment still requires an exact match with the live provider identity.
NemoClaw preserves unproven entries and refuses removal when the same server exists in both legacy and native configuration.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Remove the named registration:

```bash
$$nemoclaw my-sandbox mcp remove github
```
Expand Down
8 changes: 7 additions & 1 deletion docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2443,6 +2443,8 @@ $$nemoclaw my-assistant mcp list [--json]

Add an MCP Streamable HTTP server to a sandbox. Pass `--url` for the MCP endpoint and the required single `--env KEY` bearer credential for the sandbox-side MCP client. Pass repeatable `--deny-tool <name-or-glob>` options to block matching `tools/call` requests at the OpenShell proxy. The generated deny rules match tool names only and do not inspect arguments. Pass a repeatable `--trusted-private-host <exact-host-or-ip>` option to admit an exact RFC1918, CGNAT, or IPv6 unique local destination for the current command. The declaration must equal the normalized host from `--url`. For managed MCP, use a DNS hostname for an IPv6 unique local address because NemoClaw has not qualified direct IPv6-literal MCP URLs. You can supply exact hosts through `NEMOCLAW_TRUSTED_PRIVATE_HOSTS` instead, and NemoClaw combines the variable with command options. NemoClaw records the resulting exact trust intent and address pins, so restart, rebuild, and restore do not depend on the ambient environment. NemoClaw registers that credential in an OpenShell provider, installs a generated OpenShell `protocol: mcp` policy for the target endpoint, attaches the provider to the running sandbox, and writes only an OpenShell resolver placeholder for the recorded key into the agent configuration. Inline `--env KEY=VALUE` is rejected because it would expose the value in NemoClaw process arguments. Load the variable from a secret manager or masked prompt, export it without recording the value in shell history, and pass only `--env KEY`. All endpoints must use HTTPS. The full URL and path are persisted and displayed, so URLs cannot contain userinfo, query strings, fragments, known secret-shaped path material, percent-escaped or glob-style paths, or port zero. Server names must start with a letter and contain at most 64 letters, digits, hyphens, or underscores, and endpoint hostnames must use canonical lowercase DNS labels. NemoClaw rejects invalid names and endpoints before it writes lifecycle state or changes OpenShell resources. NemoClaw generates a narrow `protocol: mcp` policy for the destination, literal path, adapter binaries, pinned addresses, explicit MCP methods, and a 131,072-byte request-body limit. OpenShell `0.0.116` evaluates that policy before replacing the attached provider placeholder in the allowed request header. NemoClaw imports the endpointless `nemoclaw-mcp-v1` profile and binds the dedicated provider to that endpoint with `credential_binding.provider`. OpenShell withholds the credential before the binding is active and outside the bound host, port, and path. The sandbox client connects directly through OpenShell's existing egress path, and NemoClaw does not run a host-side MCP data-plane bridge, proxy, relay, or listener. After the add commits, NemoClaw freshly verifies the exact generated policy, expected provider attachment, recorded provider ID, `nemoclaw-mcp-v1` type, valid resource version, and exactly one credential key matching the recorded key. If those readiness checks pass, it sends a differential pair of wire-level MCP `initialize` requests from inside the sandbox — one with the placeholder header and one with an unresolvable control bearer — to verify that OpenShell resolves the credential on egress; otherwise it reports an inconclusive `probe skipped` result and sends no request. Neither outcome fails the committed add, and `--no-probe` skips this check. For full setup details, see [Add an MCP Server](../manage-sandboxes/mcp-servers/add-an-mcp-server).

Credential-bound managed definitions may share a normalized endpoint only when they intentionally share one credential binding. NemoClaw rejects the same endpoint with different credential bindings; use a distinct endpoint URL when different credentials are required.

The generated policy is also binary-scoped: the selected agent's adapter binaries are allowed, while an interactive `/usr/bin/curl` request to the same endpoint is denied.
Use `mcp status <server>` to inspect policy readiness and run the supported bounded in-sandbox reachability check.
The command verifies credential resolution only when it reports a verified verdict; other probe outcomes are inconclusive.
Expand Down Expand Up @@ -2572,7 +2574,11 @@ Remove an MCP server from a sandbox.

NemoClaw first removes the named agent-native entry so the agent can no longer use it, then removes the deterministic policy key and detaches the exact live provider when the policy binding, provider identity, type, and credential key agree. The workspace provider and its stored credential are deliberately preserved because current OpenShell provider deletion is name-based. The command reports the retained provider name so an operator can inspect or remove it separately after confirming it is unused.

The command fails closed when it cannot safely identify the source entry. `--force` permits removal of a modified same-name agent entry, but it does not widen policy or provider authority and never deletes a provider. Legacy Mcporter or `.nemoclaw-mcp.json` entries must be converted with `mcp migrate --apply` before ordinary removal.
The command fails closed when it cannot safely identify the source entry. `--force` permits removal of a modified same-name agent entry, but it does not widen policy or provider authority and never deletes a provider. A named legacy Mcporter or `.nemoclaw-mcp.json` entry can be removed directly only when its committed NemoClaw registry row proves ownership. An unproven legacy entry is preserved; use `mcp migrate --apply` when you intend to convert it to native state.

Legacy removal also requires every other committed legacy row to have an observed legacy entry with the same server name.
If a registry-only row remains, NemoClaw preserves all sources and refuses removal.
Run `$$nemoclaw my-assistant mcp migrate --apply` when you intend to convert those registrations to native configuration, or explicitly resolve the registry-only rows first.

```bash
$$nemoclaw my-assistant mcp remove github [--force]
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/troubleshoot-mcp-servers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ NemoClaw disables the native agent entry first and cleans only live resources wh
## Agent MCP Capability Is Missing

If add or restart reports that the OpenClaw native writer, Hermes transaction helper, or Deep Agents native MCP capability v3 is unavailable, rebuild the sandbox onto a current image before retrying.
When an older OpenClaw or Deep Agents sandbox has a legacy MCP file, preview `mcp migrate`, then use `mcp migrate --apply` to convert it. A native definition that conflicts with the legacy definition stops migration before mutation.
When an older OpenClaw or Deep Agents sandbox has a legacy MCP file, preview `mcp migrate`, then use `mcp migrate --apply` when you intend to convert it. To delete one named legacy entry instead, run `mcp remove <server>`; NemoClaw removes it only when a matching committed registry row proves ownership and otherwise preserves it. A native definition that conflicts with the legacy definition stops migration before mutation.

<AgentOnly variant="deepagents">
Migration carries the legacy `.deepagents/.nemoclaw-mcp.json` entries through a normal rebuild into the native `/sandbox/.deepagents/.mcp.json` file.
Expand Down
69 changes: 64 additions & 5 deletions src/lib/actions/sandbox/mcp-bridge-adapter-teardown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ const mocks = vi.hoisted(() => ({
getSandboxOrThrow: vi.fn(),
inspectExactMcpDestroyProvider: vi.fn(),
inspectMcpProvider: vi.fn(),
assertMcpProviderRecoverable: vi.fn(),
preflightMcpEntryTargets: vi.fn(),
detachProvider: vi.fn(),
observeMcpCredentialRevision: vi.fn(),
removeGeneratedPolicy: vi.fn(),
registerAgentAdapterAtCurrentCredentialRevision: vi.fn(),
Expand All @@ -35,13 +38,13 @@ vi.mock("./mcp-bridge-provider-readiness", () => ({
observeMcpCredentialRevision: mocks.observeMcpCredentialRevision,
}));
vi.mock("./mcp-bridge-provider", () => ({
assertMcpProviderRecoverable: vi.fn(),
assertMcpProviderRecoverable: mocks.assertMcpProviderRecoverable,
assertNoProviderCredentialCollisions: vi.fn(),
assertNoRegisteredProviderCredentialCollisions: vi.fn(),
detachProvider: vi.fn(),
detachProvider: mocks.detachProvider,
getMcpProviderInspectionRuntimeSelection: mocks.getMcpProviderInspectionRuntimeSelection,
inspectMcpProvider: mocks.inspectMcpProvider,
preflightMcpEntryTargets: vi.fn(),
preflightMcpEntryTargets: mocks.preflightMcpEntryTargets,
waitForDetachedMcpCredential: vi.fn(),
}));
vi.mock("./mcp-bridge-destroy-preflight", () => ({
Expand All @@ -66,7 +69,8 @@ vi.mock("./mcp-bridge-restart", () => ({
vi.mock("./mcp-bridge-runtime-capabilities", () => ({
assertMcpAdapterTeardownRuntimeCapabilities: vi.fn(),
}));
vi.mock("./mcp-bridge-state", () => ({
vi.mock("./mcp-bridge-state", async (importOriginal) => ({
...(await importOriginal<typeof import("./mcp-bridge-state")>()),
ensureSandboxGatewaySelected: mocks.ensureSandboxGatewaySelected,
getBridgeAdapter: mocks.getBridgeAdapter,
getSandboxAgent: mocks.getSandboxAgent,
Expand All @@ -78,7 +82,10 @@ vi.mock("./mcp-bridge-validation", () => ({
}));

import { scrubManagedMcpAdapterOrThrow } from "./mcp-bridge-adapter-teardown";
import { prepareMcpBridgesForRebuild } from "./mcp-bridge-rebuild";
import {
prepareMcpBridgesForAbsentSandboxRebuild,
prepareMcpBridgesForRebuild,
} from "./mcp-bridge-rebuild";

const sandbox = { agent: "hermes" } as SandboxEntry;
const runtimeSelection = { gatewayName: "nemoclaw-8091", workspace: "default" } as const;
Expand All @@ -95,6 +102,7 @@ const entry: McpSourceEntry = {

describe("MCP adapter teardown rollback", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.ensureSandboxGatewaySelected.mockReset().mockResolvedValue(undefined);
mocks.getMcpProviderInspectionRuntimeSelection.mockReset().mockReturnValue(runtimeSelection);
mocks.getBridgeAdapter.mockReset().mockReturnValue("hermes-config");
Expand All @@ -120,6 +128,57 @@ describe("MCP adapter teardown rollback", () => {
mocks.unregisterAgentAdapter.mockReset().mockReturnValue("removed");
});

it.each([
["present sandbox", prepareMcpBridgesForRebuild],
["absent sandbox", prepareMcpBridgesForAbsentSandboxRebuild],
] as const)(
"rejects conflicting credentials before rebuild preparation with %s",
async (_name, prepare) => {
const conflicting = {
...entry,
server: "github-alias",
env: ["GITHUB_ALIAS_TOKEN"],
providerName: "alpha-mcp-github-alias",
};

await expect(prepare("alpha", [entry, conflicting])).rejects.toThrow(
/cannot safely choose between credentials for an indistinguishable endpoint/,
);

expect(mocks.preflightMcpEntryTargets).not.toHaveBeenCalled();
expect(mocks.ensureSandboxGatewaySelected).not.toHaveBeenCalled();
expect(mocks.assertMcpProviderRecoverable).not.toHaveBeenCalled();
expect(mocks.inspectMcpProvider).not.toHaveBeenCalled();
expect(mocks.inspectExactMcpDestroyProvider).not.toHaveBeenCalled();
expect(mocks.unregisterAgentAdapter).not.toHaveBeenCalled();
expect(mocks.removeGeneratedPolicy).not.toHaveBeenCalled();
expect(mocks.detachProvider).not.toHaveBeenCalled();
},
);

it("preserves distinct credential endpoints in an absent-sandbox rebuild handoff", async () => {
const distinct = {
...entry,
server: "other",
url: "https://other.example.test/mcp/",
env: ["OTHER_TOKEN"],
providerName: "alpha-mcp-other",
};

await expect(
prepareMcpBridgesForAbsentSandboxRebuild("alpha", [entry, distinct]),
).resolves.toMatchObject({
entries: [entry, distinct],
detachedProviderEntries: [],
scrubbedAdapterEntries: [],
});
expect(mocks.preflightMcpEntryTargets).toHaveBeenCalledWith([entry, distinct]);
expect(mocks.assertMcpProviderRecoverable).toHaveBeenCalledTimes(2);
expect(mocks.unregisterAgentAdapter).not.toHaveBeenCalled();
expect(mocks.removeGeneratedPolicy).not.toHaveBeenCalled();
expect(mocks.detachProvider).not.toHaveBeenCalled();
});

it("restores the fresh revision observed after a later rebuild step fails (#10155)", async () => {
mocks.observeMcpCredentialRevision
.mockReset()
Expand Down
Loading
Loading