From 6a4a5465932970fa6794508bc934fb4cf0f615a7 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 12 Aug 2026 02:28:51 +0530 Subject: [PATCH] feat(mcp): per-requester OAuth for MCP servers (#122166) Per-requester OAuth for HTTP MCP servers: in shared channels each trusted sender connects their own account. New config: mcp.servers..oauth.identity ("shared" default, byte-identical behavior) and gateway.publicOrigin (HTTPS except loopback) for the new GET /oauth/mcp/callback served on the Gateway HTTP server. Requester tokens are isolated per (channel, account, sender) in mcp_oauth_stores rows (no schema bump); callbacks correlate through a durable state-keyed one-time index with a 10-minute TTL; per-requester servers are fail-closed out of static/scheduled runtimes; unauthenticated senders get a sign-in link with a portable URL button; MCP config mutations clear requester credentials at the canonical owner. mcp status --json keeps the legacy authStatus fields additively. Proven by a two-user self-hosted-Executor E2E through the real gateway callback. Part of #122034. Co-authored-by: Ayaan Zaidi --- docs/.generated/config-baseline.counts.json | 2 +- docs/.generated/config-baseline.sha256 | 4 +- .../plugin-sdk-api-baseline/account-core.json | 2 +- .../account-helpers.json | 2 +- .../account-resolution.json | 2 +- .../agent-harness-runtime.json | 2 +- .../agent-harness.json | 2 +- .../agent-media-payload.json | 2 +- .../agent-runtime.json | 2 +- .../agent-scope-runtime.json | 2 +- .../allowlist-config-edit.json | 2 +- .../approval-auth-runtime.json | 2 +- .../approval-client-runtime.json | 2 +- .../approval-delivery-runtime.json | 2 +- .../approval-gateway-runtime.json | 2 +- .../approval-handler-adapter-runtime.json | 2 +- .../approval-handler-runtime.json | 2 +- .../approval-native-runtime.json | 2 +- .../approval-runtime.json | 2 +- .../channel-config-helpers.json | 2 +- .../channel-contract.json | 2 +- .../plugin-sdk-api-baseline/channel-core.json | 2 +- .../channel-dm-policy.json | 2 +- .../channel-entry-contract.json | 2 +- .../channel-feedback.json | 2 +- .../channel-inbound-debounce.json | 2 +- .../channel-inbound.json | 2 +- .../channel-ingress-runtime.json | 2 +- .../channel-message.json | 2 +- .../channel-outbound.json | 2 +- .../channel-pairing.json | 2 +- .../channel-plugin-common.json | 2 +- .../channel-policy.json | 2 +- .../channel-reply-pipeline.json | 2 +- .../channel-secret-basic-runtime.json | 2 +- .../channel-secret-runtime.json | 2 +- .../channel-send-result.json | 2 +- .../channel-setup.json | 2 +- .../command-auth-native.json | 2 +- .../plugin-sdk-api-baseline/command-auth.json | 2 +- .../command-detection.json | 2 +- .../command-status.json | 2 +- .../config-contracts.json | 2 +- .../config-mutation.json | 2 +- .../config-runtime.json | 2 +- .../conversation-runtime.json | 2 +- .../plugin-sdk-api-baseline/core.json | 2 +- .../diagnostic-runtime.json | 2 +- .../directory-runtime.json | 2 +- .../plugin-sdk-api-baseline/discord.json | 2 +- .../extension-shared.json | 2 +- .../gateway-runtime.json | 2 +- .../plugin-sdk-api-baseline/health.json | 2 +- .../plugin-sdk-api-baseline/hook-runtime.json | 2 +- .../inbound-reply-dispatch.json | 2 +- .../infra-runtime.json | 2 +- .../plugin-sdk-api-baseline/logging-core.json | 2 +- .../media-local-roots.json | 2 +- .../media-runtime.json | 2 +- .../media-understanding-runtime.json | 2 +- .../media-understanding.json | 2 +- .../meeting-runtime.json | 2 +- .../memory-core-host-engine-foundation.json | 2 +- .../memory-host-core.json | 2 +- .../model-session-runtime.json | 2 +- .../models-provider-runtime.json | 2 +- .../native-command-config-runtime.json | 2 +- .../native-command-registry.json | 2 +- .../plugin-command-runtime.json | 2 +- .../plugin-config-runtime.json | 2 +- .../plugin-sdk-api-baseline/plugin-entry.json | 2 +- .../plugin-runtime.json | 2 +- .../provider-auth.json | 2 +- .../provider-catalog-runtime.json | 2 +- .../question-gateway-runtime.json | 2 +- .../reply-chunking.json | 2 +- .../reply-dispatch-runtime.json | 2 +- .../reply-payload.json | 2 +- .../reply-runtime.json | 2 +- .../plugin-sdk-api-baseline/routing.json | 2 +- .../runtime-config-snapshot.json | 2 +- .../runtime-store.json | 2 +- .../plugin-sdk-api-baseline/runtime.json | 2 +- .../secret-input-runtime.json | 2 +- .../secret-ref-runtime.json | 2 +- .../security-runtime.json | 2 +- .../session-catalog.json | 2 +- .../session-store-runtime.json | 2 +- .../setup-runtime.json | 2 +- .../plugin-sdk-api-baseline/setup.json | 2 +- .../skill-commands-runtime.json | 2 +- .../speech-settings.json | 2 +- .../plugin-sdk-api-baseline/ssrf-policy.json | 2 +- .../plugin-sdk-api-baseline/ssrf-runtime.json | 2 +- .../status-helpers.json | 2 +- .../telegram-account.json | 2 +- .../plugin-sdk-api-baseline/text-runtime.json | 2 +- .../plugin-sdk-api-baseline/tool-plugin.json | 2 +- .../webhook-ingress.json | 2 +- .../webhook-request-guards.json | 2 +- docs/cli/mcp.md | 42 +++- docs/gateway/configuration-reference.md | 12 + .../src/app-server/run-attempt-tool-setup.ts | 1 + .../codex/src/app-server/run-attempt.test.ts | 35 +++ src/agents/agent-bundle-mcp-combined.ts | 2 + src/agents/agent-bundle-mcp-harness.test.ts | 129 ++++++++++ src/agents/agent-bundle-mcp-harness.ts | 35 ++- .../agent-bundle-mcp-manager-install.ts | 90 +++++-- ...ndle-mcp-manager.requester-connect.test.ts | 141 +++++++++++ src/agents/agent-bundle-mcp-manager.ts | 35 ++- src/agents/agent-bundle-mcp-materialize.ts | 12 +- .../agent-bundle-mcp-requester-connect.ts | 189 +++++++++++++++ src/agents/agent-bundle-mcp-runtime-shared.ts | 2 + src/agents/agent-bundle-mcp-runtime.ts | 4 + src/agents/agent-bundle-mcp-types.ts | 9 + src/agents/embedded-agent-runner/run-loop.ts | 9 +- .../run/attempt-result.test.ts | 30 ++- .../run/attempt-result.ts | 18 ++ .../attempt-spawn-workspace.test-support.ts | 1 + .../run/terminal-resolution.ts | 1 + src/agents/embedded-agent-runner/run/types.ts | 2 + src/agents/embedded-agent-runner/types.ts | 2 + ...ent-subscribe.handlers.tools.completion.ts | 5 + ...ded-agent-subscribe.handlers.tools.test.ts | 25 ++ ...embedded-agent-subscribe.handlers.types.ts | 3 + src/agents/embedded-agent-subscribe.ts | 2 + src/agents/mcp-config-mutation.test.ts | 125 ++++++++++ src/agents/mcp-config-mutation.ts | 71 ++++++ src/agents/mcp-connect-action.ts | 21 ++ src/agents/mcp-connection-resolver.ts | 22 +- src/agents/mcp-oauth-identity.ts | 29 ++- src/agents/mcp-oauth-store.test.ts | 120 +++++++++ src/agents/mcp-oauth-store.ts | 158 +++++++++++- src/agents/mcp-oauth.test.ts | 156 ++++++++---- src/agents/mcp-oauth.ts | 189 +++++++++++---- src/agents/mcp-transport-config.ts | 10 +- src/agents/mcp-transport.test.ts | 54 ++++- src/agents/mcp-transport.ts | 21 +- .../reply/agent-runner-result-payloads.ts | 5 + src/auto-reply/reply/commands-mcp.test.ts | 3 + src/auto-reply/reply/commands-mcp.ts | 4 +- .../reply/mcp-connect-channel-action.test.ts | 30 +++ .../reply/mcp-connect-channel-action.ts | 44 ++++ src/claws/lifecycle-mcp-removal.ts | 3 +- src/claws/lifecycle-state.ts | 3 +- src/claws/mcp-update.ts | 2 +- src/claws/mcp.ts | 3 +- src/cli/mcp-cli.login-loopback.test.ts | 10 +- src/cli/mcp-cli.oauth-integration.test.ts | 121 +++++++++- src/cli/mcp-cli.oauth.test.ts | 87 +++++-- src/cli/mcp-cli.test-harness.ts | 15 +- src/cli/mcp-cli.test.ts | 112 +-------- src/cli/mcp-cli.ts | 227 +++++++++--------- src/commands/doctor-config-analysis.test.ts | 61 +++++ src/commands/doctor-config-analysis.ts | 17 ++ src/commands/doctor-config-flow.test.ts | 1 + src/commands/doctor-config-flow.ts | 2 + src/config/mcp-config.test.ts | 8 +- src/config/mcp-config.ts | 84 +++++-- src/config/schema.help.agents.ts | 2 + src/config/schema.help.runtime.ts | 2 + src/config/schema.hints.ts | 1 + src/config/schema.labels.ts | 2 + src/config/schema.test.ts | 121 ++++++++++ src/config/types.gateway.ts | 2 + src/config/types.mcp.ts | 2 + src/config/zod-schema.gateway.ts | 18 ++ src/config/zod-schema.root-support.ts | 67 +++++- src/gateway/mcp-oauth-callback.test.ts | 200 +++++++++++++++ src/gateway/mcp-oauth-callback.ts | 125 ++++++++++ .../server-http.mcp-oauth-callback.test.ts | 177 ++++++++++++++ src/gateway/server-http.ts | 17 +- src/gateway/server-runtime-state.ts | 9 + src/state/openclaw-state-db-contract.ts | 1 + .../openclaw-state-db-schema-additive.ts | 15 ++ src/state/openclaw-state-db.generated.d.ts | 7 + src/state/openclaw-state-schema.sql | 6 + 177 files changed, 3067 insertions(+), 563 deletions(-) create mode 100644 src/agents/agent-bundle-mcp-manager.requester-connect.test.ts create mode 100644 src/agents/agent-bundle-mcp-requester-connect.ts create mode 100644 src/agents/mcp-config-mutation.test.ts create mode 100644 src/agents/mcp-config-mutation.ts create mode 100644 src/agents/mcp-connect-action.ts create mode 100644 src/agents/mcp-oauth-store.test.ts create mode 100644 src/auto-reply/reply/mcp-connect-channel-action.test.ts create mode 100644 src/auto-reply/reply/mcp-connect-channel-action.ts create mode 100644 src/gateway/mcp-oauth-callback.test.ts create mode 100644 src/gateway/mcp-oauth-callback.ts create mode 100644 src/gateway/server-http.mcp-oauth-callback.test.ts diff --git a/docs/.generated/config-baseline.counts.json b/docs/.generated/config-baseline.counts.json index 3b51a1460e0d..3b4290c07751 100644 --- a/docs/.generated/config-baseline.counts.json +++ b/docs/.generated/config-baseline.counts.json @@ -1,5 +1,5 @@ { - "core": 2292, + "core": 2295, "channel": 3716, "plugin": 4040 } diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index f9ebd3e5175c..7fd07ceaacb7 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -08fd7a3d4a966935c40aa26f92ec9f9169d1accfefed7b3cb3b01c35ad0c397e config-baseline.json -68e13b7828f165bf89a95a28c54a9b5b238a4164d8b1d477c9c5c2bd4cf22298 config-baseline.core.json +b0973756164132b2f14542be9af4d48a927abbcf482ad8746742d7391b9f9d36 config-baseline.json +8c3ffcba19ab9f88fa331d24c1e928ac817a1c5453518ccf5c78edc9995880e8 config-baseline.core.json ddcf52b6ca3b83d8a72a74e0808abf4b16ab17b5fc28bfca5856373590913388 config-baseline.channel.json d93639a3d59b9b7ecaa27ff38b844a9ec90ac074c9e53f02930146ed21665c66 config-baseline.plugin.json diff --git a/docs/.generated/plugin-sdk-api-baseline/account-core.json b/docs/.generated/plugin-sdk-api-baseline/account-core.json index 2aca6e71c512..6a424a9e1933 100644 --- a/docs/.generated/plugin-sdk-api-baseline/account-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/account-core.json @@ -1 +1 @@ -{"contentHash":"ce1dc0ef2b719f22f7978f2e4eada4c3a48d29e570d835c133506cb80bac49bf","entrypoint":"account-core","importSpecifier":"openclaw/plugin-sdk/account-core"} +{"contentHash":"74ea0a5fceaa6d9219f2d643174784dff0e56abacb7b456bba9237f6890e825b","entrypoint":"account-core","importSpecifier":"openclaw/plugin-sdk/account-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/account-helpers.json b/docs/.generated/plugin-sdk-api-baseline/account-helpers.json index 462fbfef296b..74655529becb 100644 --- a/docs/.generated/plugin-sdk-api-baseline/account-helpers.json +++ b/docs/.generated/plugin-sdk-api-baseline/account-helpers.json @@ -1 +1 @@ -{"contentHash":"b180542bcec7d2f76ed45cbfd14f0b3e4d37a499c4e383fc0fdc87bd259ee9d3","entrypoint":"account-helpers","importSpecifier":"openclaw/plugin-sdk/account-helpers"} +{"contentHash":"f23af38bfa07c1a52b003a480b9aff1a6e8ada00c47f7eaf7e474c4aa8d9021b","entrypoint":"account-helpers","importSpecifier":"openclaw/plugin-sdk/account-helpers"} diff --git a/docs/.generated/plugin-sdk-api-baseline/account-resolution.json b/docs/.generated/plugin-sdk-api-baseline/account-resolution.json index bb74fd1ca9f7..98fdc16004a2 100644 --- a/docs/.generated/plugin-sdk-api-baseline/account-resolution.json +++ b/docs/.generated/plugin-sdk-api-baseline/account-resolution.json @@ -1 +1 @@ -{"contentHash":"0b4930a77ab3e63bed21a9651a23fb7130f623baf6ce153166cb80a965b0a004","entrypoint":"account-resolution","importSpecifier":"openclaw/plugin-sdk/account-resolution"} +{"contentHash":"2f1582d31bcc2a1d9134997e042280185188077210811984c8a39e9a754330fe","entrypoint":"account-resolution","importSpecifier":"openclaw/plugin-sdk/account-resolution"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json index 4e39869c0d41..37311b5fa037 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json @@ -1 +1 @@ -{"contentHash":"c0e93e0c2140525841e84065e597ee7819420bda66d149d2f5df8e7810ccedf1","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} +{"contentHash":"607bfb12755ddfb5ade112771d24126d09aa47f5a93df3da6ee7a77e60bcebf2","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json index 284815423700..fc3664f07489 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"c299b664efaf168d481e2c793cab8f017c72d9cbf73dbfb08d01417c9ec66a19","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"02d7c4b0ccf053d70fcdae12cd41e6028a025ac4464d9ad01996d825f5558c6f","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-media-payload.json b/docs/.generated/plugin-sdk-api-baseline/agent-media-payload.json index 9aeb1068e050..e6ceb8a16c33 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-media-payload.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-media-payload.json @@ -1 +1 @@ -{"contentHash":"4387e74b1261f632a0e140276c0f8cbbbe848a11f7ad287325538ebbee3b09da","entrypoint":"agent-media-payload","importSpecifier":"openclaw/plugin-sdk/agent-media-payload"} +{"contentHash":"195a863039b6a651c716bf7ea6e6453903fb45a2c09806dcdc1b6605375403ac","entrypoint":"agent-media-payload","importSpecifier":"openclaw/plugin-sdk/agent-media-payload"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json index 06735feebbbd..8e98f9077097 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json @@ -1 +1 @@ -{"contentHash":"b7c8bb7b8bbe40d1d877fb4c9c410663df59e2ceb236838e1564c04f3bd0e0c5","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"} +{"contentHash":"c97e7fbdbd52d6afe181fe0685495582ad5802f3f3fb7e531b2e123ddd4f6a32","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-scope-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-scope-runtime.json index 02697874a15e..7cfe4571a1a7 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-scope-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-scope-runtime.json @@ -1 +1 @@ -{"contentHash":"1f0e51c8b1a58fff13ab7d19806d97bf07fca61a5c7edeb3437cd805775a8320","entrypoint":"agent-scope-runtime","importSpecifier":"openclaw/plugin-sdk/agent-scope-runtime"} +{"contentHash":"7ebe50be2549b7166286b64e28ef320f1de5ca8305a76addbd8871b04d51af77","entrypoint":"agent-scope-runtime","importSpecifier":"openclaw/plugin-sdk/agent-scope-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/allowlist-config-edit.json b/docs/.generated/plugin-sdk-api-baseline/allowlist-config-edit.json index 7d77d675b390..0325ac196125 100644 --- a/docs/.generated/plugin-sdk-api-baseline/allowlist-config-edit.json +++ b/docs/.generated/plugin-sdk-api-baseline/allowlist-config-edit.json @@ -1 +1 @@ -{"contentHash":"c19f8658d0db60b1d3eddfeae9639ca6558592d7d69c6905ab2994b4a572f25b","entrypoint":"allowlist-config-edit","importSpecifier":"openclaw/plugin-sdk/allowlist-config-edit"} +{"contentHash":"7567ce81ce9192aaf2d546c6570f42d9e08f4a170f46734ea8ca88c2643060c1","entrypoint":"allowlist-config-edit","importSpecifier":"openclaw/plugin-sdk/allowlist-config-edit"} diff --git a/docs/.generated/plugin-sdk-api-baseline/approval-auth-runtime.json b/docs/.generated/plugin-sdk-api-baseline/approval-auth-runtime.json index 73ad15348187..41d89cfc36c3 100644 --- a/docs/.generated/plugin-sdk-api-baseline/approval-auth-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/approval-auth-runtime.json @@ -1 +1 @@ -{"contentHash":"2800815b9a0dac84fccd0b946bc938ba31ba3915990b899b0ebce3b80744ed47","entrypoint":"approval-auth-runtime","importSpecifier":"openclaw/plugin-sdk/approval-auth-runtime"} +{"contentHash":"91de2702060fb65adc7ff50e78f7209454581fba4dbe285eb1cea20dba45b17a","entrypoint":"approval-auth-runtime","importSpecifier":"openclaw/plugin-sdk/approval-auth-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/approval-client-runtime.json b/docs/.generated/plugin-sdk-api-baseline/approval-client-runtime.json index 984172fea87d..a6f25ed49dbb 100644 --- a/docs/.generated/plugin-sdk-api-baseline/approval-client-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/approval-client-runtime.json @@ -1 +1 @@ -{"contentHash":"6fef2bb5627a1f9de6bad01c0ba0d15a4fb64a0f3310c26f1c2cbce67088516c","entrypoint":"approval-client-runtime","importSpecifier":"openclaw/plugin-sdk/approval-client-runtime"} +{"contentHash":"4533e6bcac0133c7809df13dc6bee8374441ff0b9b09c4c6dce7752e14871c42","entrypoint":"approval-client-runtime","importSpecifier":"openclaw/plugin-sdk/approval-client-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/approval-delivery-runtime.json b/docs/.generated/plugin-sdk-api-baseline/approval-delivery-runtime.json index 577573875150..bc5a9e3e3fab 100644 --- a/docs/.generated/plugin-sdk-api-baseline/approval-delivery-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/approval-delivery-runtime.json @@ -1 +1 @@ -{"contentHash":"eff85a45e6aed540bfd1ab71c4bf9bc6bf25a90c7010eef24948dc62bd3287b2","entrypoint":"approval-delivery-runtime","importSpecifier":"openclaw/plugin-sdk/approval-delivery-runtime"} +{"contentHash":"14417cf83a13c7597febb9ba14211231876b9b449d89ef964fbc86ab81a73da6","entrypoint":"approval-delivery-runtime","importSpecifier":"openclaw/plugin-sdk/approval-delivery-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/approval-gateway-runtime.json b/docs/.generated/plugin-sdk-api-baseline/approval-gateway-runtime.json index c73d1f78aa85..162bb59f4bf5 100644 --- a/docs/.generated/plugin-sdk-api-baseline/approval-gateway-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/approval-gateway-runtime.json @@ -1 +1 @@ -{"contentHash":"032d42ec6f1452f716773f7cd6e49693b5804264e34f4aad028a2b14bb49c5eb","entrypoint":"approval-gateway-runtime","importSpecifier":"openclaw/plugin-sdk/approval-gateway-runtime"} +{"contentHash":"932de389fe2df73f703c5b77d79c36845e79cb2915d3b66b7756d479c434f24c","entrypoint":"approval-gateway-runtime","importSpecifier":"openclaw/plugin-sdk/approval-gateway-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/approval-handler-adapter-runtime.json b/docs/.generated/plugin-sdk-api-baseline/approval-handler-adapter-runtime.json index a2ba59c230cb..e5f0b4c4fb2b 100644 --- a/docs/.generated/plugin-sdk-api-baseline/approval-handler-adapter-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/approval-handler-adapter-runtime.json @@ -1 +1 @@ -{"contentHash":"a1f10c22e4c909d954f93b7e361313d50afa83b88478f417a1c8780affb682f2","entrypoint":"approval-handler-adapter-runtime","importSpecifier":"openclaw/plugin-sdk/approval-handler-adapter-runtime"} +{"contentHash":"86ea00b5f1f272b84c63ae497b5abfadebdfa51d009eb09ace4e8499696fd4ba","entrypoint":"approval-handler-adapter-runtime","importSpecifier":"openclaw/plugin-sdk/approval-handler-adapter-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/approval-handler-runtime.json b/docs/.generated/plugin-sdk-api-baseline/approval-handler-runtime.json index a20b0f77d581..3e424a4ccf7f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/approval-handler-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/approval-handler-runtime.json @@ -1 +1 @@ -{"contentHash":"7729bb20d4934e9e8ed18c2b4583e3d2d825f7c646db16b70f00481f3558c1ba","entrypoint":"approval-handler-runtime","importSpecifier":"openclaw/plugin-sdk/approval-handler-runtime"} +{"contentHash":"80835d0bb17661d85c9c3315724e37ba3801d69c7f58fc555c15466f35ef651f","entrypoint":"approval-handler-runtime","importSpecifier":"openclaw/plugin-sdk/approval-handler-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/approval-native-runtime.json b/docs/.generated/plugin-sdk-api-baseline/approval-native-runtime.json index 4e4e736c383f..c872ad455552 100644 --- a/docs/.generated/plugin-sdk-api-baseline/approval-native-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/approval-native-runtime.json @@ -1 +1 @@ -{"contentHash":"b084bfaa66fb2fe254d24100c259b20059a7b5b8711cd8d762faf08368661459","entrypoint":"approval-native-runtime","importSpecifier":"openclaw/plugin-sdk/approval-native-runtime"} +{"contentHash":"be74733e90c57f98afd388799824e68532b3d8a5fc46f51abbc0ae6b1a2acfe6","entrypoint":"approval-native-runtime","importSpecifier":"openclaw/plugin-sdk/approval-native-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/approval-runtime.json b/docs/.generated/plugin-sdk-api-baseline/approval-runtime.json index 880d8593d77b..01cdeacd767e 100644 --- a/docs/.generated/plugin-sdk-api-baseline/approval-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/approval-runtime.json @@ -1 +1 @@ -{"contentHash":"e914511f42b05da3103d12b565dbe6317f147663b60845337f6f0f3dec2b6dfb","entrypoint":"approval-runtime","importSpecifier":"openclaw/plugin-sdk/approval-runtime"} +{"contentHash":"bc96dace6be0e69bf7bd8ec89efebacbd507be2c9b5a47844183f6bb3374c56f","entrypoint":"approval-runtime","importSpecifier":"openclaw/plugin-sdk/approval-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-config-helpers.json b/docs/.generated/plugin-sdk-api-baseline/channel-config-helpers.json index 4db355a1e172..7d5f5c505898 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-config-helpers.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-config-helpers.json @@ -1 +1 @@ -{"contentHash":"5998386ad8e299f9080382e5a982bb96ab9f35d3849ecb6de730df8c63044a77","entrypoint":"channel-config-helpers","importSpecifier":"openclaw/plugin-sdk/channel-config-helpers"} +{"contentHash":"f74ea9a2fb50bf48fa29955825135ac63ad387099150cc784b0f5e9cb93fda3c","entrypoint":"channel-config-helpers","importSpecifier":"openclaw/plugin-sdk/channel-config-helpers"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-contract.json index 3d78c4c4b701..5856bca90ceb 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-contract.json @@ -1 +1 @@ -{"contentHash":"d5872fb0a904acd1d4ed830b2597fe578daebaa855f1949bea76db923c35c373","entrypoint":"channel-contract","importSpecifier":"openclaw/plugin-sdk/channel-contract"} +{"contentHash":"f93ca33e4a11459e4def3d6c09463fcbe0f93c6cd6bb967edf7ab6ebd3dbe584","entrypoint":"channel-contract","importSpecifier":"openclaw/plugin-sdk/channel-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index 2e9a31338b05..7c994f73594f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"1c9ffa868f6c5b56d2e2a8ceb067f7d2161760b44f3a2cfbc0f0ad6413c6fc08","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"78ece577895a6a5cc4a5bbe4e2791d72f28ce7f53a349b6faa6eaf1e3bc90c72","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-dm-policy.json b/docs/.generated/plugin-sdk-api-baseline/channel-dm-policy.json index b2eb3eb06988..28a956f0d9a5 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-dm-policy.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-dm-policy.json @@ -1 +1 @@ -{"contentHash":"b2d90f161ca88ef2d5ff35f87f160c772bd784a4ebda91b2ab33bf16b6abd843","entrypoint":"channel-dm-policy","importSpecifier":"openclaw/plugin-sdk/channel-dm-policy"} +{"contentHash":"f25d352d0cdce67f2455b006129d6661d8d254d31774ffc6923e9d4c92eb9250","entrypoint":"channel-dm-policy","importSpecifier":"openclaw/plugin-sdk/channel-dm-policy"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json index 65daf61f619c..b4ae54b4200c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json @@ -1 +1 @@ -{"contentHash":"a569ac63b0cc0ae29e23246a265e1a31bf56d6700d6e934ce917730b6cc5f969","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} +{"contentHash":"dfaac94d7b6044e6b78ea3dd6d93113f2220bebbbbb094837618f1fb065f99ca","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-feedback.json b/docs/.generated/plugin-sdk-api-baseline/channel-feedback.json index b736b84ee352..e15ebc9ca433 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-feedback.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-feedback.json @@ -1 +1 @@ -{"contentHash":"8bc8b784c78a70ee58731aae0be9731c9af01705db00acbafa25de9745a23601","entrypoint":"channel-feedback","importSpecifier":"openclaw/plugin-sdk/channel-feedback"} +{"contentHash":"551b4cd1d6940447dd445cc28f07544ad6f5974d65e241a4816f8d03a69b8f82","entrypoint":"channel-feedback","importSpecifier":"openclaw/plugin-sdk/channel-feedback"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-inbound-debounce.json b/docs/.generated/plugin-sdk-api-baseline/channel-inbound-debounce.json index 620964135a6a..3ad54b9eb962 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-inbound-debounce.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-inbound-debounce.json @@ -1 +1 @@ -{"contentHash":"87ba6e4e9f2f56621f6d52cc262e8b80fada5e338e841439b4d20983e22dfb82","entrypoint":"channel-inbound-debounce","importSpecifier":"openclaw/plugin-sdk/channel-inbound-debounce"} +{"contentHash":"e77cdd92e4f38cdd2a700cfc542402be3aae560c6c0c5c309c39286bdbb61abe","entrypoint":"channel-inbound-debounce","importSpecifier":"openclaw/plugin-sdk/channel-inbound-debounce"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json index c941b9140c67..6b5f9b6370dd 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json @@ -1 +1 @@ -{"contentHash":"57bdf7a00a80ea1e2d06985d3e2989df155f9cd9735b5b190df713abf7184a31","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"} +{"contentHash":"dbbddd5dc44f96642d3885073c6d2939bca17d1ee59c4e65dfd734ccaa115b4b","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-ingress-runtime.json b/docs/.generated/plugin-sdk-api-baseline/channel-ingress-runtime.json index a2de48cd573e..c22007a03688 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-ingress-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-ingress-runtime.json @@ -1 +1 @@ -{"contentHash":"7a69b23fdd7b631ed407d9ae4af55df192d93b61488c81626f041d4199760d95","entrypoint":"channel-ingress-runtime","importSpecifier":"openclaw/plugin-sdk/channel-ingress-runtime"} +{"contentHash":"d98f19fb7df1291cfe33dfe01b99485108dbeff47768eedf3b51a3fd4bce76a0","entrypoint":"channel-ingress-runtime","importSpecifier":"openclaw/plugin-sdk/channel-ingress-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index 38076e1a516b..92e00f489550 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"70fc7f09d590ed889662c78f320eb7d1d87627880084cbc5af1a5442576c1622","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"f7460f72ff844417e3ba3ca35fedb6b9d9f6f9d2cd1c4bb692bf3c7a8cd986fe","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json index 1a78b6d2ae10..e421fde49065 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"9763e0d262d42c5c3d8a938fa7a0b34853c48355c84a6556af283523c35ce250","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"6ddb91ab448c9471d4b96e86a687927ec237aa1fda9ac8d242caecf2863c12d4","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-pairing.json b/docs/.generated/plugin-sdk-api-baseline/channel-pairing.json index b6b5234276b0..027936ea4406 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-pairing.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-pairing.json @@ -1 +1 @@ -{"contentHash":"76c953ab6a8a13215b8a608a8256f544ee9dbb8603f916e276a87ec1f020498d","entrypoint":"channel-pairing","importSpecifier":"openclaw/plugin-sdk/channel-pairing"} +{"contentHash":"07f8d2b5d1186410f5233394028b5df8a5cdbcb7a40011ca089db78e202d3f8f","entrypoint":"channel-pairing","importSpecifier":"openclaw/plugin-sdk/channel-pairing"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json index ce4e1901e5e0..b785909b2ecf 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json @@ -1 +1 @@ -{"contentHash":"1034e6d720b32c0db92a56e02ff10c2d6f790976afb7d5332d548f5eba16321c","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} +{"contentHash":"34f980205f902462ff8068e76142295460b47c6a57242d4355694a0b3b8d6244","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-policy.json b/docs/.generated/plugin-sdk-api-baseline/channel-policy.json index 4d27588c537d..379ad83c5b62 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-policy.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-policy.json @@ -1 +1 @@ -{"contentHash":"350b4986aec115e3674c9dd3712302e7c185dbefc534b65a98a6a84564e65e05","entrypoint":"channel-policy","importSpecifier":"openclaw/plugin-sdk/channel-policy"} +{"contentHash":"2dfa0507e128854c5df2f833e57e56c4cabdf6e32c4d79f26fe21b674b240f69","entrypoint":"channel-policy","importSpecifier":"openclaw/plugin-sdk/channel-policy"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-reply-pipeline.json b/docs/.generated/plugin-sdk-api-baseline/channel-reply-pipeline.json index 9bc98262e0b6..d0ab2becf4fc 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-reply-pipeline.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-reply-pipeline.json @@ -1 +1 @@ -{"contentHash":"369721c067caf33fc86e50c7dd5aec3e8371d2e97e2e3c4305751b79da9e7387","entrypoint":"channel-reply-pipeline","importSpecifier":"openclaw/plugin-sdk/channel-reply-pipeline"} +{"contentHash":"3a94ba6e229f17850208f479e163eccdd0cbd0b01b6638e1feda06ba7c45b79f","entrypoint":"channel-reply-pipeline","importSpecifier":"openclaw/plugin-sdk/channel-reply-pipeline"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-secret-basic-runtime.json b/docs/.generated/plugin-sdk-api-baseline/channel-secret-basic-runtime.json index c6f546c70255..a55bb8be3e16 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-secret-basic-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-secret-basic-runtime.json @@ -1 +1 @@ -{"contentHash":"3b518bb516e462582e9804e7b9007af94d61a23ecd686dc92d9ca5a6d4cc18ba","entrypoint":"channel-secret-basic-runtime","importSpecifier":"openclaw/plugin-sdk/channel-secret-basic-runtime"} +{"contentHash":"e770363ffda93c00f3f1a12ad5b303e42f929b4f891d11e8457711a7fcb94a65","entrypoint":"channel-secret-basic-runtime","importSpecifier":"openclaw/plugin-sdk/channel-secret-basic-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-secret-runtime.json b/docs/.generated/plugin-sdk-api-baseline/channel-secret-runtime.json index 2d6e735558ed..b46159d2a188 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-secret-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-secret-runtime.json @@ -1 +1 @@ -{"contentHash":"d56dd5eb08be4b9397e8e1fa35645b26089575e67f59219760ce4aa23f99a9bd","entrypoint":"channel-secret-runtime","importSpecifier":"openclaw/plugin-sdk/channel-secret-runtime"} +{"contentHash":"b423164cfef742f2d9b7ba377cb7243407da6d32645e8eb85e1c2d6ef09c3714","entrypoint":"channel-secret-runtime","importSpecifier":"openclaw/plugin-sdk/channel-secret-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-send-result.json b/docs/.generated/plugin-sdk-api-baseline/channel-send-result.json index 4550777d0561..84a1a434426b 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-send-result.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-send-result.json @@ -1 +1 @@ -{"contentHash":"69c65bbf59545974200a7c1b9fa683b8999dc085e7a4b7985037e6c1a19a96e0","entrypoint":"channel-send-result","importSpecifier":"openclaw/plugin-sdk/channel-send-result"} +{"contentHash":"24e36948ee19def3102474d3e3ba0964515db1ef0e06eafdc66ebc733828bb13","entrypoint":"channel-send-result","importSpecifier":"openclaw/plugin-sdk/channel-send-result"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-setup.json b/docs/.generated/plugin-sdk-api-baseline/channel-setup.json index 39f861b89cfa..c0075b14a8e8 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-setup.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-setup.json @@ -1 +1 @@ -{"contentHash":"d1c1b4cfacba113aee00bd641dca53d6eb1f572dddd6b6b28695ede2e8c69b38","entrypoint":"channel-setup","importSpecifier":"openclaw/plugin-sdk/channel-setup"} +{"contentHash":"e981991086dc04c69b385d4860acffcb332560368283000ed42b1e4db5a43896","entrypoint":"channel-setup","importSpecifier":"openclaw/plugin-sdk/channel-setup"} diff --git a/docs/.generated/plugin-sdk-api-baseline/command-auth-native.json b/docs/.generated/plugin-sdk-api-baseline/command-auth-native.json index 07d2cd7055eb..296fd604051d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/command-auth-native.json +++ b/docs/.generated/plugin-sdk-api-baseline/command-auth-native.json @@ -1 +1 @@ -{"contentHash":"2c86a1a976f82d74069801758624315dc46020a418f06d565f6f6d0a74bb7cd1","entrypoint":"command-auth-native","importSpecifier":"openclaw/plugin-sdk/command-auth-native"} +{"contentHash":"a5feee467592d71486b388de14c3a08ccaf776c5e31d9355aa49bbfa3dbcb9d1","entrypoint":"command-auth-native","importSpecifier":"openclaw/plugin-sdk/command-auth-native"} diff --git a/docs/.generated/plugin-sdk-api-baseline/command-auth.json b/docs/.generated/plugin-sdk-api-baseline/command-auth.json index fe1c4c23b2e8..00a0238e6004 100644 --- a/docs/.generated/plugin-sdk-api-baseline/command-auth.json +++ b/docs/.generated/plugin-sdk-api-baseline/command-auth.json @@ -1 +1 @@ -{"contentHash":"0e7eb50f296f755cbde5e14e06b754af5e6305202dce138a2d1835ce06ea2509","entrypoint":"command-auth","importSpecifier":"openclaw/plugin-sdk/command-auth"} +{"contentHash":"e0e0ab127fcab6833850a208ecf21cc26bbfda73d4240dcddebae4790ea641db","entrypoint":"command-auth","importSpecifier":"openclaw/plugin-sdk/command-auth"} diff --git a/docs/.generated/plugin-sdk-api-baseline/command-detection.json b/docs/.generated/plugin-sdk-api-baseline/command-detection.json index 6d481bad7338..d7b83fc1ebf2 100644 --- a/docs/.generated/plugin-sdk-api-baseline/command-detection.json +++ b/docs/.generated/plugin-sdk-api-baseline/command-detection.json @@ -1 +1 @@ -{"contentHash":"e95215e8075b8970ba3ec08832014cff45c6ddea219793de01f1e453de8664ed","entrypoint":"command-detection","importSpecifier":"openclaw/plugin-sdk/command-detection"} +{"contentHash":"037ea2f2234b727590643ca08f315041315bce974b0c4c292470abc6f2d3d1fb","entrypoint":"command-detection","importSpecifier":"openclaw/plugin-sdk/command-detection"} diff --git a/docs/.generated/plugin-sdk-api-baseline/command-status.json b/docs/.generated/plugin-sdk-api-baseline/command-status.json index af2d336a4d7d..16da9a3a093e 100644 --- a/docs/.generated/plugin-sdk-api-baseline/command-status.json +++ b/docs/.generated/plugin-sdk-api-baseline/command-status.json @@ -1 +1 @@ -{"contentHash":"76208ba5171dd1ab3bc1fe54cf3deb6e1464e5478934e4c969ec6003e7e47493","entrypoint":"command-status","importSpecifier":"openclaw/plugin-sdk/command-status"} +{"contentHash":"5ad0b6ccf41bee6a0c14d18886815fcfdae46c74f4fe6d4a1b694236f4c8cf9d","entrypoint":"command-status","importSpecifier":"openclaw/plugin-sdk/command-status"} diff --git a/docs/.generated/plugin-sdk-api-baseline/config-contracts.json b/docs/.generated/plugin-sdk-api-baseline/config-contracts.json index 5efff1340608..f4fc18b70bfc 100644 --- a/docs/.generated/plugin-sdk-api-baseline/config-contracts.json +++ b/docs/.generated/plugin-sdk-api-baseline/config-contracts.json @@ -1 +1 @@ -{"contentHash":"50a02811387aa91a8b76acd5f1283a74b97f37ace9f7e0ad7370c828b73fc0cf","entrypoint":"config-contracts","importSpecifier":"openclaw/plugin-sdk/config-contracts"} +{"contentHash":"276229acce4fc27e7eccf3e970b26c05a2f42ba37e82e969c139f997ff681427","entrypoint":"config-contracts","importSpecifier":"openclaw/plugin-sdk/config-contracts"} diff --git a/docs/.generated/plugin-sdk-api-baseline/config-mutation.json b/docs/.generated/plugin-sdk-api-baseline/config-mutation.json index a99f6a7f62eb..c31f04d79e13 100644 --- a/docs/.generated/plugin-sdk-api-baseline/config-mutation.json +++ b/docs/.generated/plugin-sdk-api-baseline/config-mutation.json @@ -1 +1 @@ -{"contentHash":"1deb735b023442072fe21294cf4f94af0e44356fd8591ed6a8a9befafc7c3d67","entrypoint":"config-mutation","importSpecifier":"openclaw/plugin-sdk/config-mutation"} +{"contentHash":"2df969326d93c12f73523987617ebc453577a3610c374eb20529aa78b58759a3","entrypoint":"config-mutation","importSpecifier":"openclaw/plugin-sdk/config-mutation"} diff --git a/docs/.generated/plugin-sdk-api-baseline/config-runtime.json b/docs/.generated/plugin-sdk-api-baseline/config-runtime.json index 527627add23e..a527178afcb4 100644 --- a/docs/.generated/plugin-sdk-api-baseline/config-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/config-runtime.json @@ -1 +1 @@ -{"contentHash":"587ba844fed5f7487a219d52c217e7f17ea60c00a32f7e9480d3652bb406b796","entrypoint":"config-runtime","importSpecifier":"openclaw/plugin-sdk/config-runtime"} +{"contentHash":"f42f280c5fec25a7dae84fa52387525e3ef21c78ac1ee2c0ae19f8e58db829c1","entrypoint":"config-runtime","importSpecifier":"openclaw/plugin-sdk/config-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/conversation-runtime.json b/docs/.generated/plugin-sdk-api-baseline/conversation-runtime.json index 6be10343a0d7..696797fd246c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/conversation-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/conversation-runtime.json @@ -1 +1 @@ -{"contentHash":"3bc769bb7d54a8d369e4922d24b1f7dd48b8a6df296457ee7c5bb241ab330e8b","entrypoint":"conversation-runtime","importSpecifier":"openclaw/plugin-sdk/conversation-runtime"} +{"contentHash":"c48ebc2d528a2f3fb2496828c20b871f72c09dc8636ec8b12e94f9e91199f751","entrypoint":"conversation-runtime","importSpecifier":"openclaw/plugin-sdk/conversation-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index 68e3515f928e..25a8050c7e69 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"ba196a3b1b0ad6f2a8ca074bce4f4dca36e67f48c8f8a3ee77bd2586a1a24b45","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"b06e4402306dbb88968f7f6e03ea05ac04d9b07be18c3d6bd17657ce50db7543","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/diagnostic-runtime.json b/docs/.generated/plugin-sdk-api-baseline/diagnostic-runtime.json index fc06095b1bb4..c321ad2a664d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/diagnostic-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/diagnostic-runtime.json @@ -1 +1 @@ -{"contentHash":"cb6fbd1612e60755c7528a5243cec69c9123904297d2e16a9c716485785be532","entrypoint":"diagnostic-runtime","importSpecifier":"openclaw/plugin-sdk/diagnostic-runtime"} +{"contentHash":"85384810d4e097269845c3d9ac8a11938b37acb4f231a09fa6224265ed76d8a4","entrypoint":"diagnostic-runtime","importSpecifier":"openclaw/plugin-sdk/diagnostic-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/directory-runtime.json b/docs/.generated/plugin-sdk-api-baseline/directory-runtime.json index 8a0f454fb950..a2118d092757 100644 --- a/docs/.generated/plugin-sdk-api-baseline/directory-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/directory-runtime.json @@ -1 +1 @@ -{"contentHash":"f7b6949b179827646d4e047ce4f181550ca8d67bd4537441405d36fb79438556","entrypoint":"directory-runtime","importSpecifier":"openclaw/plugin-sdk/directory-runtime"} +{"contentHash":"a8330c979e92d4d9d4f1f050c7ce7e7d0198e8a25b2caf3706b8596cfc600129","entrypoint":"directory-runtime","importSpecifier":"openclaw/plugin-sdk/directory-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/discord.json b/docs/.generated/plugin-sdk-api-baseline/discord.json index 821b8df01388..8a668609c946 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"e93dde148be09b57b1a2f879fe5008768c41e15885b6ce6a898e0465f78fec68","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"34565f9ed0312937609380d0f4b1ebfb4955a79d0b1fc3644d7c25f143bec5f4","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} diff --git a/docs/.generated/plugin-sdk-api-baseline/extension-shared.json b/docs/.generated/plugin-sdk-api-baseline/extension-shared.json index 4d53bb57add5..b03d22df2d98 100644 --- a/docs/.generated/plugin-sdk-api-baseline/extension-shared.json +++ b/docs/.generated/plugin-sdk-api-baseline/extension-shared.json @@ -1 +1 @@ -{"contentHash":"5125d71bf8c4aeca728c4b8c73b2984247ba7bcc088a45ee29692ff64e3ebf90","entrypoint":"extension-shared","importSpecifier":"openclaw/plugin-sdk/extension-shared"} +{"contentHash":"db169149223eeb4f3d4dbbcf5d8c4111db6dfbddbaa4e7fb4b27f55d35723faa","entrypoint":"extension-shared","importSpecifier":"openclaw/plugin-sdk/extension-shared"} diff --git a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json index 99ef85a5bde1..fa5bd67716b4 100644 --- a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json @@ -1 +1 @@ -{"contentHash":"d83a8d16c27fdc15ee35827799d7a245b2b46e6a5b8b98b1a1b623d466a1dbd4","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} +{"contentHash":"e513691f5c73165100a0a9b820f2c5da0611dc8e901525542fd6539dd3d9c255","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/health.json b/docs/.generated/plugin-sdk-api-baseline/health.json index 684be985e2b5..29792bf5fda6 100644 --- a/docs/.generated/plugin-sdk-api-baseline/health.json +++ b/docs/.generated/plugin-sdk-api-baseline/health.json @@ -1 +1 @@ -{"contentHash":"77489d30eaa98d93c702d335f1d0324b90f0013400875038239075dabba9febe","entrypoint":"health","importSpecifier":"openclaw/plugin-sdk/health"} +{"contentHash":"804f34575cadcf502248f68a2d0539da6a13bd8a4cfcbac7eedf377eb5e100bc","entrypoint":"health","importSpecifier":"openclaw/plugin-sdk/health"} diff --git a/docs/.generated/plugin-sdk-api-baseline/hook-runtime.json b/docs/.generated/plugin-sdk-api-baseline/hook-runtime.json index bea2ab24ced1..05a02df290de 100644 --- a/docs/.generated/plugin-sdk-api-baseline/hook-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/hook-runtime.json @@ -1 +1 @@ -{"contentHash":"909f37120c3e4834b772c5abf720aed63a2fc541ca4e9814360065533e925059","entrypoint":"hook-runtime","importSpecifier":"openclaw/plugin-sdk/hook-runtime"} +{"contentHash":"6066a7ed3f68774a9e108b71a5bedfa002582dfdfa64e53ba8fefecb2310c09f","entrypoint":"hook-runtime","importSpecifier":"openclaw/plugin-sdk/hook-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json index 0f82a976c46d..41e4aa737a52 100644 --- a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json +++ b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json @@ -1 +1 @@ -{"contentHash":"2ce7ba8b43211c18c309837f71105b66b915c7f7cd6a63d892071919d1a1e161","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} +{"contentHash":"141343dce0928e23c1863cc5a4305c1b281adea003beaf2e1853c10b553deb09","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} diff --git a/docs/.generated/plugin-sdk-api-baseline/infra-runtime.json b/docs/.generated/plugin-sdk-api-baseline/infra-runtime.json index c2b37c76ce7b..2c582c8f4fce 100644 --- a/docs/.generated/plugin-sdk-api-baseline/infra-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/infra-runtime.json @@ -1 +1 @@ -{"contentHash":"b590647f65a2e148ba7a1bc207f70adbd3a57990b41d3bf6b10f61ee8f27ab87","entrypoint":"infra-runtime","importSpecifier":"openclaw/plugin-sdk/infra-runtime"} +{"contentHash":"d83147f1ddbab6a62f7afc6ec7bbf5ec960b3abf428e6c9abe4a0f9518b0a8c7","entrypoint":"infra-runtime","importSpecifier":"openclaw/plugin-sdk/infra-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/logging-core.json b/docs/.generated/plugin-sdk-api-baseline/logging-core.json index d5a1cc6183d9..eee7580af4d5 100644 --- a/docs/.generated/plugin-sdk-api-baseline/logging-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/logging-core.json @@ -1 +1 @@ -{"contentHash":"f9661f74dd08b910a8973b64e46cb759a9a729ce76f6c10a639cd00d5282a14c","entrypoint":"logging-core","importSpecifier":"openclaw/plugin-sdk/logging-core"} +{"contentHash":"b8cc5f216a28c2606a2c8439fb5476a8e229459950e9e406c5ac46174c9eee2e","entrypoint":"logging-core","importSpecifier":"openclaw/plugin-sdk/logging-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/media-local-roots.json b/docs/.generated/plugin-sdk-api-baseline/media-local-roots.json index e74ab990f395..0b04748cded4 100644 --- a/docs/.generated/plugin-sdk-api-baseline/media-local-roots.json +++ b/docs/.generated/plugin-sdk-api-baseline/media-local-roots.json @@ -1 +1 @@ -{"contentHash":"dfff39cc053754e24b59df3804369afff03f3331482e26f36f2c477ba21e2ba6","entrypoint":"media-local-roots","importSpecifier":"openclaw/plugin-sdk/media-local-roots"} +{"contentHash":"a1c9573dc582ab69ee317dea444b9eae78da555a44186b66c427fd12e11d7ac9","entrypoint":"media-local-roots","importSpecifier":"openclaw/plugin-sdk/media-local-roots"} diff --git a/docs/.generated/plugin-sdk-api-baseline/media-runtime.json b/docs/.generated/plugin-sdk-api-baseline/media-runtime.json index d0de59f5b45c..bb1c62881182 100644 --- a/docs/.generated/plugin-sdk-api-baseline/media-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/media-runtime.json @@ -1 +1 @@ -{"contentHash":"0c1524e093eb80f9f2bf71e2ae6814b5e521b546d2f8bb381fe278190d0a2932","entrypoint":"media-runtime","importSpecifier":"openclaw/plugin-sdk/media-runtime"} +{"contentHash":"d5e2f7be4ffffaff8ff33cf86761e8e88ccca74b10a9d5cc31ab7cd99012af74","entrypoint":"media-runtime","importSpecifier":"openclaw/plugin-sdk/media-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/media-understanding-runtime.json b/docs/.generated/plugin-sdk-api-baseline/media-understanding-runtime.json index ebbd363aee30..b1879dabaf00 100644 --- a/docs/.generated/plugin-sdk-api-baseline/media-understanding-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/media-understanding-runtime.json @@ -1 +1 @@ -{"contentHash":"24ed026c51d147a2959964e13fd0ef3e43cc44cc808d581f877f818e66ac4c04","entrypoint":"media-understanding-runtime","importSpecifier":"openclaw/plugin-sdk/media-understanding-runtime"} +{"contentHash":"b6ca0750b3c2ada85cec932d08e323e28284566869e2b38d4ae2a849d0947ba5","entrypoint":"media-understanding-runtime","importSpecifier":"openclaw/plugin-sdk/media-understanding-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/media-understanding.json b/docs/.generated/plugin-sdk-api-baseline/media-understanding.json index 0ff007eceba8..841ba9213e68 100644 --- a/docs/.generated/plugin-sdk-api-baseline/media-understanding.json +++ b/docs/.generated/plugin-sdk-api-baseline/media-understanding.json @@ -1 +1 @@ -{"contentHash":"684c061bb0d1112872c9b2d5a00e258d14b4ff8649a26dd0cc67995352d8ca8b","entrypoint":"media-understanding","importSpecifier":"openclaw/plugin-sdk/media-understanding"} +{"contentHash":"92c946421c6f907442685fd0cfb39b42c5a25fda2a44931d7c0fbbaf12245bd9","entrypoint":"media-understanding","importSpecifier":"openclaw/plugin-sdk/media-understanding"} diff --git a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json index aeb5dff4ffc7..5c3f067ec52d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"bebd31387941500e192e07e3f38b20ad389c7641db7f2a08615915019067aeef","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"9c9caf0ad1b5ac92dd564c9a98b1f302855a5506f347a38da114804589bac187","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/memory-core-host-engine-foundation.json b/docs/.generated/plugin-sdk-api-baseline/memory-core-host-engine-foundation.json index 1d0d71ecc38c..35fe3e2ee613 100644 --- a/docs/.generated/plugin-sdk-api-baseline/memory-core-host-engine-foundation.json +++ b/docs/.generated/plugin-sdk-api-baseline/memory-core-host-engine-foundation.json @@ -1 +1 @@ -{"contentHash":"27509d9bbabc547662a45bf258fa432d1e0c3c3f7c22051858d8a67375486e79","entrypoint":"memory-core-host-engine-foundation","importSpecifier":"openclaw/plugin-sdk/memory-core-host-engine-foundation"} +{"contentHash":"c502491a40bd1a579d314e4673c3a3cdba15c6dada8ace82369d1f31b393b9ee","entrypoint":"memory-core-host-engine-foundation","importSpecifier":"openclaw/plugin-sdk/memory-core-host-engine-foundation"} diff --git a/docs/.generated/plugin-sdk-api-baseline/memory-host-core.json b/docs/.generated/plugin-sdk-api-baseline/memory-host-core.json index 4664c99b668c..57f65a014293 100644 --- a/docs/.generated/plugin-sdk-api-baseline/memory-host-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/memory-host-core.json @@ -1 +1 @@ -{"contentHash":"ad875662e95df74808357c234ac36825d1ff729165da941180061591525ff47f","entrypoint":"memory-host-core","importSpecifier":"openclaw/plugin-sdk/memory-host-core"} +{"contentHash":"f771af96c027a82204bb41263fbd702424108bca8eb1670bd85fde4f8c3a0a8c","entrypoint":"memory-host-core","importSpecifier":"openclaw/plugin-sdk/memory-host-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json b/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json index 1cedfab0d773..ec382639cd65 100644 --- a/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json @@ -1 +1 @@ -{"contentHash":"c066691f6e408c7dff36c7f34db64be4166cf59e43ec65ea13924907a351033b","entrypoint":"model-session-runtime","importSpecifier":"openclaw/plugin-sdk/model-session-runtime"} +{"contentHash":"6b7d65e8730f4c592c67c9aa2bf206b0c9b01d272650b5699aabff212c8aa68e","entrypoint":"model-session-runtime","importSpecifier":"openclaw/plugin-sdk/model-session-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/models-provider-runtime.json b/docs/.generated/plugin-sdk-api-baseline/models-provider-runtime.json index 9e1f7413faaf..293aef256b73 100644 --- a/docs/.generated/plugin-sdk-api-baseline/models-provider-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/models-provider-runtime.json @@ -1 +1 @@ -{"contentHash":"dc17bbd6a4f64c5f1a73efe1195ce44ae2320734d40843de9be64c302ec46885","entrypoint":"models-provider-runtime","importSpecifier":"openclaw/plugin-sdk/models-provider-runtime"} +{"contentHash":"56c07204664cddc24532e3f4e07e455e377164308ff85178aaa06f1efd96f532","entrypoint":"models-provider-runtime","importSpecifier":"openclaw/plugin-sdk/models-provider-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/native-command-config-runtime.json b/docs/.generated/plugin-sdk-api-baseline/native-command-config-runtime.json index 9bd23fa2a7b2..05204dd3a77d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/native-command-config-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/native-command-config-runtime.json @@ -1 +1 @@ -{"contentHash":"6c5c89c48ffb76d7e9950ddfa7088f036a54382ac29fb0dff9faa62be2c86c6f","entrypoint":"native-command-config-runtime","importSpecifier":"openclaw/plugin-sdk/native-command-config-runtime"} +{"contentHash":"c3ef6bb24b2e3533b60de73f9196c0cedeb82043586afb5381f0ae97a12ca75f","entrypoint":"native-command-config-runtime","importSpecifier":"openclaw/plugin-sdk/native-command-config-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/native-command-registry.json b/docs/.generated/plugin-sdk-api-baseline/native-command-registry.json index 963945af52d1..717864c519a4 100644 --- a/docs/.generated/plugin-sdk-api-baseline/native-command-registry.json +++ b/docs/.generated/plugin-sdk-api-baseline/native-command-registry.json @@ -1 +1 @@ -{"contentHash":"8a276ed1d2e13e6371a653b8a9ffffdb6a018635dc69d48d3a7e46a2c40bd955","entrypoint":"native-command-registry","importSpecifier":"openclaw/plugin-sdk/native-command-registry"} +{"contentHash":"1a7c4d246a5d9bb91defd483dc2af094199898cc89be05366fd2c7855aa7cc15","entrypoint":"native-command-registry","importSpecifier":"openclaw/plugin-sdk/native-command-registry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-command-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-command-runtime.json index a7e94fb755db..cfea6bae200c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-command-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-command-runtime.json @@ -1 +1 @@ -{"contentHash":"a5dc90e2ddd9593efb159e77de52604f8e62d82a680879d22dbee1bbd0dda4ec","entrypoint":"plugin-command-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-command-runtime"} +{"contentHash":"ff2abc97718e6bdd93ce215eab32e53ca0997b597ed4e387d14d441688ca80db","entrypoint":"plugin-command-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-command-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-config-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-config-runtime.json index 78d28306070c..6a4445a27368 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-config-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-config-runtime.json @@ -1 +1 @@ -{"contentHash":"756175b7ff16cd8456b3c85a87466f077476a9236e098c5545ea8ef31fcff0d2","entrypoint":"plugin-config-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-config-runtime"} +{"contentHash":"b5a2ad221927505a92ff1e4520e14b916ff2ec2a771e7173d0279b85a93bb5a7","entrypoint":"plugin-config-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-config-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index 57037212fc0f..2d332c85afb5 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"eeb102db0465d7f4e0bc12a6aa6dc877faf429304e2670ff51a60f5d5d011124","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"79c5b931cceb7c1be3c95b9ca6e0362bdae5c6d6ee5e5aed97fcbdd9ea1062c1","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json index dec8f06e2105..19d710eff4d4 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"a57d78253ac4d40bc7933506ff65699c4f34e544cc3b107fe6bc29d58e586ad5","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"bc0d9ea4642d666045fe90464c8e5e34c0afc049d20d05823a5ddb4b52ae1c10","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-auth.json b/docs/.generated/plugin-sdk-api-baseline/provider-auth.json index 7b0379d59b9a..b0d86518d103 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-auth.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-auth.json @@ -1 +1 @@ -{"contentHash":"d5d21f2a0883eac72d6642d93306c36e2de0f3851286eb3edbe3a2d9d6122dcc","entrypoint":"provider-auth","importSpecifier":"openclaw/plugin-sdk/provider-auth"} +{"contentHash":"1b51ace8b1e469c12e17dfe749484bbfa624781917e346a431bfe76eb1c286c3","entrypoint":"provider-auth","importSpecifier":"openclaw/plugin-sdk/provider-auth"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json index d516fe10450a..deabac351c92 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json @@ -1 +1 @@ -{"contentHash":"42aa8fb549aac34c624ee63bc948f28cb8e732b256b72745617cca2e431e84be","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} +{"contentHash":"9b6353909b35ab9bc36feb31037a3702e943e0c1749214799a928bf3c1a48035","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/question-gateway-runtime.json b/docs/.generated/plugin-sdk-api-baseline/question-gateway-runtime.json index b903ffc56cee..95a96954174b 100644 --- a/docs/.generated/plugin-sdk-api-baseline/question-gateway-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/question-gateway-runtime.json @@ -1 +1 @@ -{"contentHash":"55bc0be5ce1d700d8b671efccb69ec14a3c942535c2f77d3ed7ca87809dc1ad9","entrypoint":"question-gateway-runtime","importSpecifier":"openclaw/plugin-sdk/question-gateway-runtime"} +{"contentHash":"f379950391cc668ede32800d0dab5dc0a37c1589b4d57abb0790913a15e9aadb","entrypoint":"question-gateway-runtime","importSpecifier":"openclaw/plugin-sdk/question-gateway-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/reply-chunking.json b/docs/.generated/plugin-sdk-api-baseline/reply-chunking.json index 53a8c69ca995..0917d50c75ee 100644 --- a/docs/.generated/plugin-sdk-api-baseline/reply-chunking.json +++ b/docs/.generated/plugin-sdk-api-baseline/reply-chunking.json @@ -1 +1 @@ -{"contentHash":"5263836c817c9391fd5a0efdda50552330dd01e4d6bc9cb4ebba58627c48f098","entrypoint":"reply-chunking","importSpecifier":"openclaw/plugin-sdk/reply-chunking"} +{"contentHash":"9023a0d9a45ae97efb7c59cc90c5afd9cf5acd0644722332a99aa6609c14a7b6","entrypoint":"reply-chunking","importSpecifier":"openclaw/plugin-sdk/reply-chunking"} diff --git a/docs/.generated/plugin-sdk-api-baseline/reply-dispatch-runtime.json b/docs/.generated/plugin-sdk-api-baseline/reply-dispatch-runtime.json index 2cfbc9063122..855c216d81e1 100644 --- a/docs/.generated/plugin-sdk-api-baseline/reply-dispatch-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/reply-dispatch-runtime.json @@ -1 +1 @@ -{"contentHash":"6b712448d5e6af99cee968aea960f12f9e6b385bf3bbb34d925720ebcd252d5f","entrypoint":"reply-dispatch-runtime","importSpecifier":"openclaw/plugin-sdk/reply-dispatch-runtime"} +{"contentHash":"d852e0d74a12f6eae8695aabc3a3659ec447a5289bf8e9e3a91fce7ad24c9f5a","entrypoint":"reply-dispatch-runtime","importSpecifier":"openclaw/plugin-sdk/reply-dispatch-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/reply-payload.json b/docs/.generated/plugin-sdk-api-baseline/reply-payload.json index f9bca1358a5c..a70ba6ffcadd 100644 --- a/docs/.generated/plugin-sdk-api-baseline/reply-payload.json +++ b/docs/.generated/plugin-sdk-api-baseline/reply-payload.json @@ -1 +1 @@ -{"contentHash":"369a0c223cba73be174d9a7ada6dcebff739b15c71c34f767e761b57af667723","entrypoint":"reply-payload","importSpecifier":"openclaw/plugin-sdk/reply-payload"} +{"contentHash":"f421205d77076c2f45e9c15c2f5bab37b3a91a9e21c1d8f945f53e1b0e936e00","entrypoint":"reply-payload","importSpecifier":"openclaw/plugin-sdk/reply-payload"} diff --git a/docs/.generated/plugin-sdk-api-baseline/reply-runtime.json b/docs/.generated/plugin-sdk-api-baseline/reply-runtime.json index d64e541ab2dc..4415972a7738 100644 --- a/docs/.generated/plugin-sdk-api-baseline/reply-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/reply-runtime.json @@ -1 +1 @@ -{"contentHash":"07a6384bfd57e2e318fc5195a74a52d81fc1deb05ec7e6ae770bdd819562975d","entrypoint":"reply-runtime","importSpecifier":"openclaw/plugin-sdk/reply-runtime"} +{"contentHash":"3517a6a2ecc546ffac96bec9cfdaab32a08848c150794149581ca4b3932545eb","entrypoint":"reply-runtime","importSpecifier":"openclaw/plugin-sdk/reply-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/routing.json b/docs/.generated/plugin-sdk-api-baseline/routing.json index 00c42dc0cae8..f4d19e429080 100644 --- a/docs/.generated/plugin-sdk-api-baseline/routing.json +++ b/docs/.generated/plugin-sdk-api-baseline/routing.json @@ -1 +1 @@ -{"contentHash":"ad853e4f7e257b2a206f8e3a551b9e50f36a020db1fd36de65c121bcf24a6d5e","entrypoint":"routing","importSpecifier":"openclaw/plugin-sdk/routing"} +{"contentHash":"dba046330db0bc493ddedbedaa42ef6fcf599fccb472c1f8a8722d3431d15382","entrypoint":"routing","importSpecifier":"openclaw/plugin-sdk/routing"} diff --git a/docs/.generated/plugin-sdk-api-baseline/runtime-config-snapshot.json b/docs/.generated/plugin-sdk-api-baseline/runtime-config-snapshot.json index 97f0c27914e4..a6da2ec83be2 100644 --- a/docs/.generated/plugin-sdk-api-baseline/runtime-config-snapshot.json +++ b/docs/.generated/plugin-sdk-api-baseline/runtime-config-snapshot.json @@ -1 +1 @@ -{"contentHash":"4e9f778169c4e2363dfa5d2f42b5fef117423f0a0bf84b98226e6f75a2a19ce0","entrypoint":"runtime-config-snapshot","importSpecifier":"openclaw/plugin-sdk/runtime-config-snapshot"} +{"contentHash":"efa884588bd728ec21bebf07d2700f1f477f7c4adeb0646190f0b6146d9d167a","entrypoint":"runtime-config-snapshot","importSpecifier":"openclaw/plugin-sdk/runtime-config-snapshot"} diff --git a/docs/.generated/plugin-sdk-api-baseline/runtime-store.json b/docs/.generated/plugin-sdk-api-baseline/runtime-store.json index e0a55fc1a715..cf1a21675c34 100644 --- a/docs/.generated/plugin-sdk-api-baseline/runtime-store.json +++ b/docs/.generated/plugin-sdk-api-baseline/runtime-store.json @@ -1 +1 @@ -{"contentHash":"6deafa205ad659665dc2894386fed7c30743b3a171831bbbc3d6edeaacf6b46f","entrypoint":"runtime-store","importSpecifier":"openclaw/plugin-sdk/runtime-store"} +{"contentHash":"db430e3abd5c061bbb057dcbc5d542d22ce33ee62a07170f8bac5fcbd4c92e8d","entrypoint":"runtime-store","importSpecifier":"openclaw/plugin-sdk/runtime-store"} diff --git a/docs/.generated/plugin-sdk-api-baseline/runtime.json b/docs/.generated/plugin-sdk-api-baseline/runtime.json index 7f4818908d22..75ccb4ac684b 100644 --- a/docs/.generated/plugin-sdk-api-baseline/runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/runtime.json @@ -1 +1 @@ -{"contentHash":"583a581e90003aa4c1f8f0d5e53bde6fad3b795d7006da72c492f56ecacf910e","entrypoint":"runtime","importSpecifier":"openclaw/plugin-sdk/runtime"} +{"contentHash":"98624c94e8f5dd159bca518241ab8afc63e7799d507d2ec0b611247aa3f655d3","entrypoint":"runtime","importSpecifier":"openclaw/plugin-sdk/runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/secret-input-runtime.json b/docs/.generated/plugin-sdk-api-baseline/secret-input-runtime.json index 095e83609bf1..991ab282a877 100644 --- a/docs/.generated/plugin-sdk-api-baseline/secret-input-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/secret-input-runtime.json @@ -1 +1 @@ -{"contentHash":"48269c5717a644527b7fc69f267ca5c578860b9611910d8938352a26aa567aed","entrypoint":"secret-input-runtime","importSpecifier":"openclaw/plugin-sdk/secret-input-runtime"} +{"contentHash":"a202aafe8e27852bfe3aae8ccfcbe7837d3f7ccd97c9b23cf98814c3ac857325","entrypoint":"secret-input-runtime","importSpecifier":"openclaw/plugin-sdk/secret-input-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/secret-ref-runtime.json b/docs/.generated/plugin-sdk-api-baseline/secret-ref-runtime.json index fa942077b3e1..2ee0d696af26 100644 --- a/docs/.generated/plugin-sdk-api-baseline/secret-ref-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/secret-ref-runtime.json @@ -1 +1 @@ -{"contentHash":"05ef79413285ad9a4f8a07bb8f26012098dbb73458b4d90c1990b744a6b4677c","entrypoint":"secret-ref-runtime","importSpecifier":"openclaw/plugin-sdk/secret-ref-runtime"} +{"contentHash":"ad0cf0d38a735859320870835e6b53a7a6f6c47208bbfbbc2bdc48f01c032373","entrypoint":"secret-ref-runtime","importSpecifier":"openclaw/plugin-sdk/secret-ref-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/security-runtime.json b/docs/.generated/plugin-sdk-api-baseline/security-runtime.json index 2827ef3e7495..be8317cb10de 100644 --- a/docs/.generated/plugin-sdk-api-baseline/security-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/security-runtime.json @@ -1 +1 @@ -{"contentHash":"e76844c24ad8fff2b28d960f764f6485ceb295cf139e79f453ff81e586311209","entrypoint":"security-runtime","importSpecifier":"openclaw/plugin-sdk/security-runtime"} +{"contentHash":"ea0c884272571c2494865e709f61fd3a33e0da4e2096c301ab5d292783b923bb","entrypoint":"security-runtime","importSpecifier":"openclaw/plugin-sdk/security-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/session-catalog.json b/docs/.generated/plugin-sdk-api-baseline/session-catalog.json index b12c98cea03c..cc24dece7f7e 100644 --- a/docs/.generated/plugin-sdk-api-baseline/session-catalog.json +++ b/docs/.generated/plugin-sdk-api-baseline/session-catalog.json @@ -1 +1 @@ -{"contentHash":"f82ffb1754104e576ecf21e622fbcc63c70333e8b1962371d4f868c8c3f35ede","entrypoint":"session-catalog","importSpecifier":"openclaw/plugin-sdk/session-catalog"} +{"contentHash":"791e6d3eccf652c146c79a743edaf6bc7cb5b6fdd313dade78faaf329b5507a1","entrypoint":"session-catalog","importSpecifier":"openclaw/plugin-sdk/session-catalog"} diff --git a/docs/.generated/plugin-sdk-api-baseline/session-store-runtime.json b/docs/.generated/plugin-sdk-api-baseline/session-store-runtime.json index 04bb46758cbb..85a3c06a3704 100644 --- a/docs/.generated/plugin-sdk-api-baseline/session-store-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/session-store-runtime.json @@ -1 +1 @@ -{"contentHash":"c9b4b25729f5edb282348b1509c2fe943ba2a9aeacccf5392678033528329ad3","entrypoint":"session-store-runtime","importSpecifier":"openclaw/plugin-sdk/session-store-runtime"} +{"contentHash":"552e06b12f79cbcd09e31fd40be2316546dfeb7a1ea6235b06de4f33db3b721b","entrypoint":"session-store-runtime","importSpecifier":"openclaw/plugin-sdk/session-store-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/setup-runtime.json b/docs/.generated/plugin-sdk-api-baseline/setup-runtime.json index d7510f424e27..4f19e1307574 100644 --- a/docs/.generated/plugin-sdk-api-baseline/setup-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/setup-runtime.json @@ -1 +1 @@ -{"contentHash":"795d413433bdcf36a9b05cd51a029cbf5f38b8f54d488b63e3f2c80ae6eb1999","entrypoint":"setup-runtime","importSpecifier":"openclaw/plugin-sdk/setup-runtime"} +{"contentHash":"bafe3559e5a656effef560996e2a5445c6d7fd8bf94ad4394b422b9396c994ec","entrypoint":"setup-runtime","importSpecifier":"openclaw/plugin-sdk/setup-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/setup.json b/docs/.generated/plugin-sdk-api-baseline/setup.json index 0fe9b523fe73..eebd4aee3db2 100644 --- a/docs/.generated/plugin-sdk-api-baseline/setup.json +++ b/docs/.generated/plugin-sdk-api-baseline/setup.json @@ -1 +1 @@ -{"contentHash":"1ba2b9af6b0760fc93faf00ed89a62050454e28b8b09672b3fa963940b88f0ee","entrypoint":"setup","importSpecifier":"openclaw/plugin-sdk/setup"} +{"contentHash":"af54dee897be0a016bc5844842a0449ac0609a02737495f79a7ac250fc594328","entrypoint":"setup","importSpecifier":"openclaw/plugin-sdk/setup"} diff --git a/docs/.generated/plugin-sdk-api-baseline/skill-commands-runtime.json b/docs/.generated/plugin-sdk-api-baseline/skill-commands-runtime.json index 8715d066ab29..e2fba7e586d9 100644 --- a/docs/.generated/plugin-sdk-api-baseline/skill-commands-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/skill-commands-runtime.json @@ -1 +1 @@ -{"contentHash":"76deac46f52eb2897b0573f3c360257c5e8a2aeb62e2b1134d7c3594c95c43ba","entrypoint":"skill-commands-runtime","importSpecifier":"openclaw/plugin-sdk/skill-commands-runtime"} +{"contentHash":"4339583e3d74b1607bd9585a7824ecb446532010b258b86d1a31343ea0b453e8","entrypoint":"skill-commands-runtime","importSpecifier":"openclaw/plugin-sdk/skill-commands-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/speech-settings.json b/docs/.generated/plugin-sdk-api-baseline/speech-settings.json index 0c3e6422b661..79ef847656a3 100644 --- a/docs/.generated/plugin-sdk-api-baseline/speech-settings.json +++ b/docs/.generated/plugin-sdk-api-baseline/speech-settings.json @@ -1 +1 @@ -{"contentHash":"018c910329ce5d5a988ebbce0cb28e4f18d5ec08f11a3b34572ef340e8e1f29d","entrypoint":"speech-settings","importSpecifier":"openclaw/plugin-sdk/speech-settings"} +{"contentHash":"a9f2ab866e225f38f006ff1aa6eb33b5316454f463aa4f6d6f2fd4ecdec41135","entrypoint":"speech-settings","importSpecifier":"openclaw/plugin-sdk/speech-settings"} diff --git a/docs/.generated/plugin-sdk-api-baseline/ssrf-policy.json b/docs/.generated/plugin-sdk-api-baseline/ssrf-policy.json index 2ad15be1979b..d8b388e663d2 100644 --- a/docs/.generated/plugin-sdk-api-baseline/ssrf-policy.json +++ b/docs/.generated/plugin-sdk-api-baseline/ssrf-policy.json @@ -1 +1 @@ -{"contentHash":"6d24516dfcfd36852bf649ff05e0e5bb38fa874a168788ce94beadeb1e349653","entrypoint":"ssrf-policy","importSpecifier":"openclaw/plugin-sdk/ssrf-policy"} +{"contentHash":"c0619235a9636dc3fc22ab5ac74e7d14a01bf9c40594ced596a17143b6eb90c4","entrypoint":"ssrf-policy","importSpecifier":"openclaw/plugin-sdk/ssrf-policy"} diff --git a/docs/.generated/plugin-sdk-api-baseline/ssrf-runtime.json b/docs/.generated/plugin-sdk-api-baseline/ssrf-runtime.json index 2704e1c0d7b6..499a5a4c03b0 100644 --- a/docs/.generated/plugin-sdk-api-baseline/ssrf-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/ssrf-runtime.json @@ -1 +1 @@ -{"contentHash":"faf8c3ca03beab979f980d7c24ddb5a2725ed05ba4e3ef477263ec4027e8559a","entrypoint":"ssrf-runtime","importSpecifier":"openclaw/plugin-sdk/ssrf-runtime"} +{"contentHash":"77efaea8a61aa4e0dccdffcf2d6e6bb4bcc758587691126c17905ffdf11ddd23","entrypoint":"ssrf-runtime","importSpecifier":"openclaw/plugin-sdk/ssrf-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/status-helpers.json b/docs/.generated/plugin-sdk-api-baseline/status-helpers.json index 7860ba128ab0..0246ce841f5b 100644 --- a/docs/.generated/plugin-sdk-api-baseline/status-helpers.json +++ b/docs/.generated/plugin-sdk-api-baseline/status-helpers.json @@ -1 +1 @@ -{"contentHash":"85010b862301bcaac7c6f17b14c7cee6e192e524ec4898ed96774693dab73a4c","entrypoint":"status-helpers","importSpecifier":"openclaw/plugin-sdk/status-helpers"} +{"contentHash":"a3217185d285139c7c36f69378fb80bd2a8287beeb0a35b7f1fc87d80da88264","entrypoint":"status-helpers","importSpecifier":"openclaw/plugin-sdk/status-helpers"} diff --git a/docs/.generated/plugin-sdk-api-baseline/telegram-account.json b/docs/.generated/plugin-sdk-api-baseline/telegram-account.json index ff30c1d97a3c..c68d808167c0 100644 --- a/docs/.generated/plugin-sdk-api-baseline/telegram-account.json +++ b/docs/.generated/plugin-sdk-api-baseline/telegram-account.json @@ -1 +1 @@ -{"contentHash":"27d2aa201b0e36f656d2f87a491a3e44b871f53ffc344da1caf1a89241dc59e4","entrypoint":"telegram-account","importSpecifier":"openclaw/plugin-sdk/telegram-account"} +{"contentHash":"887bb7b047b997f5eeb948a8fe8f79013580ffc3b62cb6cee10cc017908ee759","entrypoint":"telegram-account","importSpecifier":"openclaw/plugin-sdk/telegram-account"} diff --git a/docs/.generated/plugin-sdk-api-baseline/text-runtime.json b/docs/.generated/plugin-sdk-api-baseline/text-runtime.json index 0a08d62533cc..7291cc51ecf1 100644 --- a/docs/.generated/plugin-sdk-api-baseline/text-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/text-runtime.json @@ -1 +1 @@ -{"contentHash":"42848f114357ea560ab6bd4f695693ce6e2671d45efa57ffde171efd5d25f717","entrypoint":"text-runtime","importSpecifier":"openclaw/plugin-sdk/text-runtime"} +{"contentHash":"41b78aa69035e6b7dc97d62553f964d98065b60604d85d5ce5acd4c4bfc55c8a","entrypoint":"text-runtime","importSpecifier":"openclaw/plugin-sdk/text-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index 37f6f29c8f43..a109659f37d7 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"a4c9dd4fa12d108bb4b54b97201b582185e480de592441391f273c2c92521135","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"b6c16456c26e0be9f02d0e420ee6054a48f529a9288c66e0e31e16b1367b6d5e","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json index 04fef9f1b23b..510ec354ddda 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"f66eb413f310af3189604144311e441609e97bf1b745e617ec434c074c772a65","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"1a2a81855860352fe59cf3162489fa1a93907c16b75d68460b610777e29120a0","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-request-guards.json b/docs/.generated/plugin-sdk-api-baseline/webhook-request-guards.json index f38bde5e8e8b..9943c3c61ddc 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-request-guards.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-request-guards.json @@ -1 +1 @@ -{"contentHash":"1fe8314d4dfa3d7b536f39ac5622ff688f6a20fa6ae8b8fa4770d4851e85a400","entrypoint":"webhook-request-guards","importSpecifier":"openclaw/plugin-sdk/webhook-request-guards"} +{"contentHash":"179917aa0e669336c4b7a6e478e5ff5e4f826bd4ebf9323310a4f539b1272df9","entrypoint":"webhook-request-guards","importSpecifier":"openclaw/plugin-sdk/webhook-request-guards"} diff --git a/docs/cli/mcp.md b/docs/cli/mcp.md index e81133c8f147..70b4c4af1ef1 100644 --- a/docs/cli/mcp.md +++ b/docs/cli/mcp.md @@ -544,7 +544,8 @@ Use `--json` for scripts and dashboards. Field sets can grow over time, so consu "hasClientInformation": true, "hasCodeVerifier": false, "hasDiscoveryState": true, - "hasLastAuthorizationUrl": false + "hasLastAuthorizationUrl": false, + "state": "authorized" }, "requestTimeoutMs": 20000, "connectionTimeoutMs": 5000, @@ -697,18 +698,53 @@ Sensitive values in `url` (userinfo) and `headers` are redacted in logs and stat ### OAuth workflow -OAuth is for HTTP MCP servers that advertise the MCP OAuth flow. Static `Authorization` headers are ignored for a server while `auth: "oauth"` is enabled. Credentials saved by `openclaw mcp login` work with embedded MCP, CLI runners, and the local Codex app-server. +OAuth is for HTTP MCP servers that advertise the MCP OAuth flow. Static `Authorization` headers are ignored for a server while `auth: "oauth"` is enabled. By default, OAuth credentials are shared and operator-managed. Credentials saved by `openclaw mcp login` work with embedded MCP, CLI runners, and the local Codex app-server. Native MCP OAuth sessions live in the owner-only shared SQLite database at `/state/openclaw.sqlite` (`mcp_oauth_stores`). The row can contain access and refresh tokens, dynamic client registration secrets, discovery metadata, and the temporary PKCE verifier. Refresh, login, and logout use the same SQLite lease, so parallel OpenClaw processes cannot consume one refresh token or resurrect a logged-out session. Upgrades from the retired `/mcp-oauth/*.json` store are handled only by `openclaw doctor --fix`. Runtime code never reads, writes, or falls back to those files. -Until credentials are available, OpenClaw omits only that MCP server from the agent runtime instead of failing the agent turn. The operator, or an agent with shell access, can then run `openclaw mcp login ` and use the server on a later turn. +Until shared credentials are available, OpenClaw omits only that MCP server from the agent runtime instead of failing the agent turn. The operator, or an agent with shell access, can then run `openclaw mcp login ` and use the server on a later turn. If a server rejects a token with `insufficient_scope`, OpenClaw preserves the requested scope and asks for `openclaw mcp login ` instead of repeating a refresh that cannot grant new scope. That login starts a new authorization request while keeping the previous token until replacement credentials are saved. When a remote MCP service is already backed by a separate OpenClaw refresh-capable auth profile, you can optionally set `oauth.authProfileId`. OpenClaw refreshes either credential source before runtime projection and passes only the current access token to the downstream MCP client. +Set `oauth.identity: "per-requester"` when every authenticated sender should connect a separate account. Per-requester OAuth requires an HTTP server URL and cannot use `oauth.authProfileId`. Configure `gateway.publicOrigin` as the externally reachable HTTPS origin of the Gateway; HTTP is accepted only for literal loopback hosts (`localhost`, `127.0.0.1`, or `[::1]`) during local development. The provider redirects to `/oauth/mcp/callback` after authorization. + +```json5 +{ + gateway: { + publicOrigin: "https://gateway.example.com", + }, + mcp: { + servers: { + docs: { + url: "https://mcp.example.com/mcp", + transport: "streamable-http", + auth: "oauth", + oauth: { + identity: "per-requester", + scope: "docs.read", + }, + }, + }, + }, +} +``` + +The per-requester flow is sender-driven: + +1. The sender calls a tool from the server before connecting an account. +2. OpenClaw returns a sign-in link for that sender instead of exposing another sender's credentials. +3. The provider redirects through the Gateway callback. After the callback succeeds, the sender retries the tool call with their connected account. + +If `gateway.publicOrigin` is missing, the sign-in result names that setting and `openclaw doctor` reports the same operator fix. `openclaw mcp login` and `openclaw mcp logout` remain operator-only commands for shared credentials; they do not manage per-requester accounts. + +Sign-in links are single-use bearer links: any chat participant who opens one connects their own account to the sender the link was issued for. Use per-requester OAuth in channels where every trusted sender is mutually trusted; a requester-private sign-in handoff is tracked as follow-up work. + +The shared operator flow uses the following commands: + Add or update the server with `auth: "oauth"` and any optional OAuth metadata. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 18721d54074e..2257c355ab97 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -121,6 +121,7 @@ target server during config edits. }, auth: "oauth", oauth: { + identity: "per-requester", // shared | per-requester; default: shared scope: "docs.read", }, sslVerify: true, @@ -156,6 +157,11 @@ target server during config edits. OAuth. Run `openclaw mcp login ` to store tokens under OpenClaw state. - `mcp.servers..oauth`: optional OAuth scope, redirect URL, and client metadata URL overrides. +- `mcp.servers..oauth.identity`: credential ownership. Omit it or set + `"shared"` for operator-managed credentials; set `"per-requester"` to isolate + credentials for each authenticated sender. Per-requester OAuth requires an + HTTP server URL, cannot use `oauth.authProfileId`, and requires + `gateway.publicOrigin` for its callback. - `mcp.servers..sslVerify`, `clientCert`, `clientKey`: HTTP TLS controls for private endpoints and mutual TLS. - `mcp.servers..toolFilter`: optional per-server tool selection. `include` @@ -569,6 +575,7 @@ See [Plugins](/tools/plugin). mode: "local", // local | remote port: 18789, bind: "loopback", + publicOrigin: "https://gateway.example.com", auth: { mode: "token", // none | token | password | trusted-proxy token: "your-token", @@ -656,6 +663,11 @@ See [Plugins](/tools/plugin). - `mode`: `local` (run gateway) or `remote` (connect to remote gateway). Gateway refuses to start unless `local`. - `port`: single multiplexed port for WS + HTTP. Precedence: `--port` > `OPENCLAW_GATEWAY_PORT` > `gateway.port` > `18789`. +- `publicOrigin`: optional externally reachable HTTPS origin of the Gateway, + without a path, query, or credentials. HTTP is accepted only for literal + loopback hosts (`localhost`, `127.0.0.1`, or `[::1]`) during local development. + Per-requester MCP OAuth requires this value and uses + `/oauth/mcp/callback` as its callback URL. - `bind`: `auto`, `loopback` (default), `lan` (`0.0.0.0`), `tailnet` (Tailscale IPv4 when available, otherwise loopback), or `custom` (one IPv4 address). A resolved `tailnet` address and any `custom` address other than `127.0.0.1` or `0.0.0.0` require `127.0.0.1` on the same port for same-host clients; startup fails if either listener cannot bind. Non-loopback exposure remains limited to the selected interface. - **Legacy bind aliases**: use bind mode values in `gateway.bind` (`auto`, `loopback`, `lan`, `tailnet`, `custom`), not host aliases (`0.0.0.0`, `127.0.0.1`, `localhost`, `::`, `::1`). - **Docker note**: the default `loopback` bind listens on `127.0.0.1` inside the container. With Docker bridge networking (`-p 18789:18789`), traffic arrives on `eth0`, so the gateway is unreachable. Use `--network host`, or set `bind: "lan"` (or `bind: "custom"` with `customBindHost: "0.0.0.0"`) to listen on all interfaces. diff --git a/extensions/codex/src/app-server/run-attempt-tool-setup.ts b/extensions/codex/src/app-server/run-attempt-tool-setup.ts index 1694db372452..7a5e7ea4cc11 100644 --- a/extensions/codex/src/app-server/run-attempt-tool-setup.ts +++ b/extensions/codex/src/app-server/run-attempt-tool-setup.ts @@ -338,6 +338,7 @@ export async function prepareCodexAttemptTools(runtime: CodexAttemptRuntime) { agentDir: policyContext.agentDir, cfg: params.config, manifestRegistry: bundleManifestRegistry, + toolOverrides: codexMcpToolOverrides, requesterSenderId: params.senderId, agentAccountId: params.agentAccountId, messageChannel: params.messageChannel ?? params.messageProvider, diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index 8feb7d9efd88..262b60123962 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -123,6 +123,9 @@ import { const agentHarnessRuntimeMocks = vi.hoisted(() => ({ forceModelToolsUnsupported: false, skipRequesterScopedMcpMaterialization: false, + requesterScopedMcpCalls: [] as Array<{ + toolOverrides?: { mcpServers?: Record }; + }>, })); vi.mock("openclaw/plugin-sdk/agent-harness-runtime", async (importOriginal) => { @@ -136,6 +139,7 @@ vi.mock("openclaw/plugin-sdk/agent-harness-runtime", async (importOriginal) => { materializeRequesterScopedMcpToolsForHarnessRun: async ( ...args: Parameters ) => { + agentHarnessRuntimeMocks.requesterScopedMcpCalls.push(args[0]); if (agentHarnessRuntimeMocks.skipRequesterScopedMcpMaterialization) { return undefined; } @@ -1066,6 +1070,7 @@ setupRunAttemptTestHooks(); beforeEach(() => { agentHarnessRuntimeMocks.forceModelToolsUnsupported = false; agentHarnessRuntimeMocks.skipRequesterScopedMcpMaterialization = false; + agentHarnessRuntimeMocks.requesterScopedMcpCalls.length = 0; }); describe("runCodexAppServerAttempt", () => { @@ -5648,6 +5653,36 @@ describe("runCodexAppServerAttempt", () => { expect(turnParams?.approvalsReviewer).toBe("auto_review"); expect(turnParams?.serviceTier).toBe("priority"); }); + + it("forwards Codex agent exclusions to requester-scoped MCP materialization", async () => { + const { sessionFile, workspaceDir } = createRunPaths(); + const harness = createStartedThreadHarness(); + agentHarnessRuntimeMocks.skipRequesterScopedMcpMaterialization = true; + const params = createParams(sessionFile, workspaceDir); + params.senderId = "sender-a"; + params.config = { + ...params.config, + mcp: { + servers: { + calendar: { + url: "https://calendar.example.com/mcp", + auth: "oauth", + oauth: { identity: "per-requester" }, + codex: { agents: ["other-agent"] }, + }, + }, + }, + }; + + const run = runCodexAppServerAttempt(params); + await completeStartedRun(run, harness.waitForMethod, harness.completeTurn); + + expect(agentHarnessRuntimeMocks.requesterScopedMcpCalls).toContainEqual( + expect.objectContaining({ + toolOverrides: { mcpServers: { calendar: false } }, + }), + ); + }); it("fails before client startup when a successor generation hides a private supervision binding", async () => { const { sessionFile, workspaceDir } = createRunPaths(); const sessionKey = "agent:main:supervised-stale-generation"; diff --git a/src/agents/agent-bundle-mcp-combined.ts b/src/agents/agent-bundle-mcp-combined.ts index 1085d6f043b9..d17aa62051ac 100644 --- a/src/agents/agent-bundle-mcp-combined.ts +++ b/src/agents/agent-bundle-mcp-combined.ts @@ -89,6 +89,7 @@ export function createCombinedSessionMcpRuntime(params: { let mergedSourceCatalogs: ReadonlyArray | null = null; let catalogInFlight: Promise | undefined; const serverOwner = new Map(); + const requesterConnect = parts.find((part) => part.requesterConnect)?.requesterConnect; const rememberServerOwners = (catalog: McpToolCatalog, owner: SessionMcpRuntime) => { for (const serverName of Object.keys(catalog.servers)) { @@ -158,6 +159,7 @@ export function createCombinedSessionMcpRuntime(params: { workspaceDir: params.workspaceDir, agentDir: params.agentDir, configFingerprint: parts.map((part) => part.configFingerprint).join(":"), + ...(requesterConnect ? { requesterConnect } : {}), isRequesterScopedServer(serverName) { // Owner map is populated by the catalog load that exposed the tool. return serverOwner.get(serverName)?.requesterScope !== undefined; diff --git a/src/agents/agent-bundle-mcp-harness.test.ts b/src/agents/agent-bundle-mcp-harness.test.ts index b28bb0ae1b19..28684099ec8d 100644 --- a/src/agents/agent-bundle-mcp-harness.test.ts +++ b/src/agents/agent-bundle-mcp-harness.test.ts @@ -6,6 +6,8 @@ import { getMcpAppViewLease } from "./mcp-ui-resource.js"; import { testing as mcpUiResourceTesting } from "./mcp-ui-resource.test-support.js"; const MCP_APP_RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; +const startAuthorization = vi.hoisted(() => vi.fn()); +const readCredentialsStatus = vi.hoisted(() => vi.fn()); const mocks = vi.hoisted(() => { type Runtime = SessionMcpRuntime; @@ -73,10 +75,20 @@ vi.mock("./agent-bundle-mcp-runtime.js", async (importOriginal) => { }; }); +vi.mock("./mcp-oauth.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readMcpOAuthCredentialsStatus: readCredentialsStatus, + startMcpOAuthAuthorization: startAuthorization, + }; +}); + import { materializeRequesterScopedMcpToolsForHarnessRunCore, materializeStaticMcpToolsForScheduledHarnessRunCore, } from "./agent-bundle-mcp-harness.js"; +import { createRequesterMcpConnect } from "./agent-bundle-mcp-requester-connect.js"; function makeRuntime(params: { sessionId: string; requesterSenderId: string }): SessionMcpRuntime { const serverName = "user-mail"; @@ -144,12 +156,44 @@ function makeRuntime(params: { sessionId: string; requesterSenderId: string }): }; } +async function makeConnectRuntime(params: { + sessionId: string; + requesterSenderId: string; + publicOrigin?: string; +}): Promise { + const runtime = makeRuntime(params); + const catalog = { version: 1, generatedAt: 0, servers: {}, tools: [] }; + runtime.peekCatalog = () => catalog; + runtime.getCatalog = async () => catalog; + runtime.requesterConnect = await createRequesterMcpConnect({ + serverNames: new Set(["calendar"]), + mcpServers: { + calendar: { + url: "https://mcp.example/rpc", + auth: "oauth", + oauth: { identity: "per-requester" }, + }, + }, + safeServerNamesByServer: new Map([["calendar", "calendar"]]), + requesterScope: { + requesterSenderId: params.requesterSenderId, + messageChannel: "telegram", + agentAccountId: "bot", + }, + cfg: params.publicOrigin ? { gateway: { publicOrigin: params.publicOrigin } } : undefined, + configFingerprint: "connect-fingerprint", + }); + return runtime; +} + beforeEach(() => { mocks.reset(); mocks.getOrCreateRequesterScopedMcpRuntime.mockClear(); mocks.getOrCreateSessionMcpRuntime.mockReset(); mocks.rememberAdvertisedScopedMcpCatalog.mockClear(); mocks.getAdvertisedScopedMcpCatalog.mockClear(); + readCredentialsStatus.mockReset().mockResolvedValue({ state: "unauthenticated" }); + startAuthorization.mockReset(); }); describe("materializeStaticMcpToolsForScheduledHarnessRunCore", () => { @@ -530,6 +574,91 @@ describe("materializeRequesterScopedMcpToolsForHarnessRunCore", () => { expect(mocks.rememberAdvertisedScopedMcpCatalog).not.toHaveBeenCalled(); }); + it("bootstraps a requester connect tool without starting OAuth during materialization", async () => { + mocks.setResolveImpl(async (params) => + makeConnectRuntime({ + sessionId: params.sessionId, + requesterSenderId: params.requesterSenderId ?? "alice", + publicOrigin: "https://gateway.example", + }), + ); + startAuthorization.mockResolvedValue({ + status: "redirect", + authorizationUrl: "https://auth.example/authorize?state=opaque", + redirectUrl: "https://gateway.example/oauth/mcp/callback", + state: "opaque", + }); + const result = await materializeRequesterScopedMcpToolsForHarnessRunCore({ + sessionId: "session-connect", + workspaceDir: "/workspace", + requesterSenderId: "alice", + messageChannel: "telegram", + agentAccountId: "bot", + cfg: { + gateway: { publicOrigin: "https://gateway.example" }, + mcp: { + servers: { + calendar: { + url: "https://mcp.example/rpc", + auth: "oauth", + oauth: { identity: "per-requester" }, + }, + }, + }, + }, + }); + + expect(result?.tools.map((tool) => tool.name)).toEqual(["calendar__connect"]); + expect(startAuthorization).not.toHaveBeenCalled(); + const connect = await result!.tools[0]!.execute("connect", {}); + expect(connect).toMatchObject({ + details: { + mcpConnect: { + serverName: "calendar", + authorizationUrl: "https://auth.example/authorize?state=opaque", + }, + }, + }); + expect(startAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ principal: "requester", serverName: "calendar" }), + expect.objectContaining({ url: "https://mcp.example/rpc" }), + { redirectUrl: "https://gateway.example/oauth/mcp/callback" }, + ); + expect(mocks.rememberAdvertisedScopedMcpCatalog).not.toHaveBeenCalled(); + await result!.dispose(); + }); + + it("returns a bounded operator fix when the public origin is missing", async () => { + mocks.setResolveImpl(async (params) => + makeConnectRuntime({ + sessionId: params.sessionId, + requesterSenderId: params.requesterSenderId ?? "alice", + }), + ); + const result = await materializeRequesterScopedMcpToolsForHarnessRunCore({ + sessionId: "session-no-origin", + workspaceDir: "/workspace", + requesterSenderId: "alice", + cfg: { + mcp: { + servers: { + calendar: { + url: "https://mcp.example/rpc", + auth: "oauth", + oauth: { identity: "per-requester" }, + }, + }, + }, + }, + }); + + const connect = await result!.tools[0]!.execute("connect", {}); + expect(connect.details).toMatchObject({ status: "error" }); + expect(connect.content[0]).toMatchObject({ text: expect.stringContaining("publicOrigin") }); + expect(startAuthorization).not.toHaveBeenCalled(); + await result!.dispose(); + }); + it("releases the live runtime when pre-return catalog publication fails", async () => { const runtime = makeRuntime({ sessionId: "session-cleanup", requesterSenderId: "authed" }); mocks.setResolveImpl(async () => runtime); diff --git a/src/agents/agent-bundle-mcp-harness.ts b/src/agents/agent-bundle-mcp-harness.ts index a2ff2b9f25ee..e26ca3fcfa2d 100644 --- a/src/agents/agent-bundle-mcp-harness.ts +++ b/src/agents/agent-bundle-mcp-harness.ts @@ -7,6 +7,7 @@ import { buildBundleMcpToolsFromCatalog, materializeBundleMcpToolsForRun, } from "./agent-bundle-mcp-materialize.js"; +import { mergeMcpConnectCatalog } from "./agent-bundle-mcp-requester-connect.js"; import { getAdvertisedScopedMcpCatalog, getOrCreateRequesterScopedMcpRuntime, @@ -14,7 +15,7 @@ import { rememberAdvertisedScopedMcpCatalog, retireSessionMcpRuntime, } from "./agent-bundle-mcp-runtime.js"; -import type { McpToolCatalog } from "./agent-bundle-mcp-types.js"; +import type { McpToolCatalog, RequesterMcpConnect } from "./agent-bundle-mcp-types.js"; import { resolveConversationCapabilityProfile, type ConversationCapabilityProfileParams, @@ -91,6 +92,7 @@ type MaterializeRequesterScopedMcpToolsForHarnessRunParams = { agentDir?: string; cfg?: OpenClawConfig; manifestRegistry?: Pick; + toolOverrides?: Pick; requesterSenderId?: string | null; agentAccountId?: string | null; messageChannel?: string | null; @@ -148,11 +150,17 @@ function applyHarnessToolPolicy( function buildCatalogTools( catalog: McpToolCatalog, params: MaterializeRequesterScopedMcpToolsForHarnessRunParams, + requesterConnect?: RequesterMcpConnect, ): AnyAgentTool[] { return buildBundleMcpToolsFromCatalog({ catalog, reservedToolNames: params.reservedToolNames ? Array.from(params.reservedToolNames) : undefined, - createExecute: (tool) => async () => notConnectedToolResult(tool.serverName, tool.toolName), + createExecute: (tool) => { + return ( + requesterConnect?.createExecute(tool.serverName) ?? + (async () => notConnectedToolResult(tool.serverName, tool.toolName)) + ); + }, }); } @@ -262,23 +270,31 @@ export async function materializeRequesterScopedMcpToolsForHarnessRunCore( agentDir: params.agentDir, cfg: params.cfg, manifestRegistry: params.manifestRegistry, + toolOverrides: params.toolOverrides, requesterSenderId: params.requesterSenderId, agentAccountId: params.agentAccountId, messageChannel: params.messageChannel, }); let liveRuntime: Awaited> | undefined; + let liveCatalog: McpToolCatalog | undefined; try { if (scopedRuntime) { liveRuntime = await materializeBundleMcpToolsForRun({ runtime: scopedRuntime, reservedToolNames: params.reservedToolNames, }); - const catalog = scopedRuntime.peekCatalog() ?? (await scopedRuntime.getCatalog()); - rememberAdvertisedScopedMcpCatalog(params.sessionId, catalog); + liveCatalog = scopedRuntime.peekCatalog() ?? (await scopedRuntime.getCatalog()); + if (liveCatalog.tools.length > 0) { + rememberAdvertisedScopedMcpCatalog(params.sessionId, liveCatalog); + } } - const advertisedCatalog = getAdvertisedScopedMcpCatalog(params.sessionId); + const advertisedCatalog = + getAdvertisedScopedMcpCatalog(params.sessionId) ?? + (liveCatalog + ? mergeMcpConnectCatalog(liveCatalog, scopedRuntime?.requesterConnect) + : undefined); if (!advertisedCatalog || advertisedCatalog.tools.length === 0) { await liveRuntime?.dispose(); return undefined; @@ -287,10 +303,11 @@ export async function materializeRequesterScopedMcpToolsForHarnessRunCore( const reservedToolNames = params.reservedToolNames ? Array.from(params.reservedToolNames) : undefined; - const advertisedTools = buildCatalogTools(advertisedCatalog, { - ...params, - reservedToolNames, - }); + const advertisedTools = buildCatalogTools( + advertisedCatalog, + { ...params, reservedToolNames }, + scopedRuntime?.requesterConnect, + ); const liveByName = new Map((liveRuntime?.tools ?? []).map((tool) => [tool.name, tool])); // Live tools supply execution; advertised catalog supplies the stable name/schema surface. const tools = advertisedTools.map((tool) => liveByName.get(tool.name) ?? tool); diff --git a/src/agents/agent-bundle-mcp-manager-install.ts b/src/agents/agent-bundle-mcp-manager-install.ts index a1dd26a2e74c..8713beccc81a 100644 --- a/src/agents/agent-bundle-mcp-manager-install.ts +++ b/src/agents/agent-bundle-mcp-manager-install.ts @@ -1,10 +1,16 @@ import type { SessionToolOverrides } from "../config/sessions/types.js"; /** Session MCP runtime manager install path: static get-or-create + requester resolve/install. */ import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { BundleMcpServerConfig } from "../plugins/bundle-mcp.js"; import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; import type { SessionMcpRuntimeManagerLifecycle } from "./agent-bundle-mcp-manager-lifecycle.js"; +import { createRequesterMcpConnect } from "./agent-bundle-mcp-requester-connect.js"; import { loadSessionMcpConfig } from "./agent-bundle-mcp-runtime-config.js"; -import type { SessionMcpRequesterScope, SessionMcpRuntime } from "./agent-bundle-mcp-types.js"; +import type { + RequesterMcpConnect, + SessionMcpRequesterScope, + SessionMcpRuntime, +} from "./agent-bundle-mcp-types.js"; import { allowMcpAppModelContext, revokeMcpAppModelContext } from "./mcp-app-model-context.js"; import { hashMcpResolvedConnections, @@ -28,6 +34,7 @@ type RuntimeEntryParams = { connectionOverrides?: ReadonlyMap; redactConnectionServerNames?: ReadonlySet; requesterScope?: SessionMcpRequesterScope; + requesterConnect?: RequesterMcpConnect; configFingerprint?: string; toolOverrides?: Pick; }; @@ -43,8 +50,9 @@ type SessionMcpRuntimeManagerInstall = { cfg?: OpenClawConfig; manifestRegistry?: Pick; idleTtlMs: number; - requesterScopedServerNames: readonly string[]; - scopedNameSet: ReadonlySet; + oauthRequesterNameSet: ReadonlySet; + mcpServers: Record; + resolverRequesterServerNames: readonly string[]; safeServerNamesByServer: ReadonlyMap; fullScopedFingerprint: string; requesterSenderId: string; @@ -65,6 +73,15 @@ const matchesStaticReuse = (params: { params.candidate.agentDir === params.agentDir && params.candidate.configFingerprint === params.configFingerprint; +function requesterRuntimeFingerprint( + configFingerprint: string, + requesterConnect?: RequesterMcpConnect, +): string { + return requesterConnect + ? `${configFingerprint}:${requesterConnect.configFingerprint}` + : configFingerprint; +} + export function createSessionMcpRuntimeManagerInstall( lifecycle: SessionMcpRuntimeManagerLifecycle, ): SessionMcpRuntimeManagerInstall { @@ -152,6 +169,7 @@ export function createSessionMcpRuntimeManagerInstall( connectionOverrides: params.connectionOverrides, redactConnectionServerNames: params.redactConnectionServerNames, requesterScope: params.requesterScope, + requesterConnect: params.requesterConnect, configFingerprint: nextFingerprint, toolOverrides: params.toolOverrides, }), @@ -175,10 +193,7 @@ export function createSessionMcpRuntimeManagerInstall( } }; - /** - * Install or reuse a requester runtime for already-resolved connections. - * Must run inside runExclusiveOnRuntimeKey for this runtimeKey. - */ + /** Install or reuse one requester runtime. Must run under its runtime-key lock. */ const installRequesterRuntime = async (params: { runtimeKey: string; sessionId: string; @@ -189,22 +204,27 @@ export function createSessionMcpRuntimeManagerInstall( manifestRegistry?: Pick; idleTtlMs: number; safeServerNamesByServer: ReadonlyMap; + includeServerNames: ReadonlySet; + requesterConnect?: RequesterMcpConnect; connectionOverrides: Map; redactConnectionServerNames: ReadonlySet; requesterScope: SessionMcpRequesterScope; toolOverrides?: Pick; }): Promise => { - const resolvedNameSet = new Set(params.connectionOverrides.keys()); const { fingerprint: resolvedFingerprint } = loadSessionMcpConfig({ workspaceDir: params.workspaceDir, cfg: params.cfg, logDiagnostics: false, manifestRegistry: params.manifestRegistry, - includeServerNames: resolvedNameSet, + includeServerNames: params.includeServerNames, redactConnectionServerNames: params.redactConnectionServerNames, safeServerNamesByServer: params.safeServerNamesByServer, toolOverrides: params.toolOverrides, }); + const runtimeFingerprint = requesterRuntimeFingerprint( + resolvedFingerprint, + params.requesterConnect, + ); const connectionHash = hashMcpResolvedConnections(params.connectionOverrides); const existing = store.runtimesBySessionId.get(params.runtimeKey); const meta = store.connectionMetaByRuntimeKey.get(params.runtimeKey); @@ -214,7 +234,7 @@ export function createSessionMcpRuntimeManagerInstall( matchesStaticReuse({ workspaceDir: params.workspaceDir, agentDir: params.agentDir, - configFingerprint: resolvedFingerprint, + configFingerprint: runtimeFingerprint, candidate: existing, }) ) { @@ -242,12 +262,13 @@ export function createSessionMcpRuntimeManagerInstall( cfg: params.cfg, manifestRegistry: params.manifestRegistry, idleTtlMs: params.idleTtlMs, - includeServerNames: resolvedNameSet, + includeServerNames: params.includeServerNames, safeServerNamesByServer: params.safeServerNamesByServer, connectionOverrides: params.connectionOverrides, redactConnectionServerNames: params.redactConnectionServerNames, requesterScope: params.requesterScope, - configFingerprint: resolvedFingerprint, + requesterConnect: params.requesterConnect, + configFingerprint: runtimeFingerprint, toolOverrides: params.toolOverrides, }); store.connectionMetaByRuntimeKey.set(params.runtimeKey, { @@ -275,8 +296,9 @@ export function createSessionMcpRuntimeManagerInstall( cfg?: OpenClawConfig; manifestRegistry?: Pick; idleTtlMs: number; - requesterScopedServerNames: readonly string[]; - scopedNameSet: ReadonlySet; + oauthRequesterNameSet: ReadonlySet; + mcpServers: Record; + resolverRequesterServerNames: readonly string[]; safeServerNamesByServer: ReadonlyMap; fullScopedFingerprint: string; requesterSenderId: string; @@ -285,6 +307,32 @@ export function createSessionMcpRuntimeManagerInstall( requesterScope: SessionMcpRequesterScope; toolOverrides?: Pick; }): Promise => { + const requesterConnect = await createRequesterMcpConnect({ + serverNames: params.oauthRequesterNameSet, + mcpServers: params.mcpServers, + safeServerNamesByServer: params.safeServerNamesByServer, + requesterScope: params.requesterScope, + cfg: params.cfg, + configFingerprint: params.fullScopedFingerprint, + }); + const expectedLiveNameSet = new Set([ + ...(requesterConnect?.authorizedServerNames ?? []), + ...params.resolverRequesterServerNames, + ]); + const { fingerprint: expectedLiveFingerprint } = loadSessionMcpConfig({ + workspaceDir: params.workspaceDir, + cfg: params.cfg, + logDiagnostics: false, + manifestRegistry: params.manifestRegistry, + includeServerNames: expectedLiveNameSet, + redactConnectionServerNames: new Set(params.resolverRequesterServerNames), + safeServerNamesByServer: params.safeServerNamesByServer, + toolOverrides: params.toolOverrides, + }); + const scopedFingerprint = requesterRuntimeFingerprint( + expectedLiveFingerprint, + requesterConnect, + ); const existing = store.runtimesBySessionId.get(params.runtimeKey); const meta = store.connectionMetaByRuntimeKey.get(params.runtimeKey); const revalidateMs = resolveMcpConnectionRevalidateMs(); @@ -299,7 +347,7 @@ export function createSessionMcpRuntimeManagerInstall( matchesStaticReuse({ workspaceDir: params.workspaceDir, agentDir: params.agentDir, - configFingerprint: params.fullScopedFingerprint, + configFingerprint: scopedFingerprint, candidate: existing, }) ) { @@ -310,12 +358,16 @@ export function createSessionMcpRuntimeManagerInstall( } const connectionOverrides = await resolveRequesterScopedMcpConnections({ - serverNames: params.requesterScopedServerNames, + serverNames: params.resolverRequesterServerNames, requesterSenderId: params.requesterSenderId, agentAccountId: params.agentAccountId, messageChannel: params.messageChannel, }); - if (connectionOverrides.size === 0) { + const activeNameSet = new Set([ + ...(requesterConnect?.authorizedServerNames ?? []), + ...connectionOverrides.keys(), + ]); + if (activeNameSet.size === 0 && !requesterConnect) { // Empty re-resolution revokes cached scoped credentials. // Leases do not block: this is an authorization boundary. if ( @@ -336,8 +388,10 @@ export function createSessionMcpRuntimeManagerInstall( manifestRegistry: params.manifestRegistry, idleTtlMs: params.idleTtlMs, safeServerNamesByServer: params.safeServerNamesByServer, + includeServerNames: activeNameSet, + requesterConnect, connectionOverrides, - redactConnectionServerNames: params.scopedNameSet, + redactConnectionServerNames: new Set(params.resolverRequesterServerNames), requesterScope: params.requesterScope, toolOverrides: params.toolOverrides, }); diff --git a/src/agents/agent-bundle-mcp-manager.requester-connect.test.ts b/src/agents/agent-bundle-mcp-manager.requester-connect.test.ts new file mode 100644 index 000000000000..9539c4096ba0 --- /dev/null +++ b/src/agents/agent-bundle-mcp-manager.requester-connect.test.ts @@ -0,0 +1,141 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createSessionMcpRuntimeManager } from "./agent-bundle-mcp-manager.js"; +import { materializeBundleMcpToolsForRun } from "./agent-bundle-mcp-materialize.js"; +import type { CreateSessionMcpRuntime } from "./agent-bundle-mcp-runtime-shared.js"; +import type { McpToolCatalog, SessionMcpRuntime } from "./agent-bundle-mcp-types.js"; + +const oauthStatus = vi.hoisted(() => vi.fn()); +const startAuthorization = vi.hoisted(() => vi.fn()); + +vi.mock("./mcp-oauth.js", () => ({ + readMcpOAuthCredentialsStatus: oauthStatus, + startMcpOAuthAuthorization: startAuthorization, +})); + +function createTestRuntime(params: Parameters[0]): SessionMcpRuntime { + const includesCalendar = params.includeServerNames?.has("calendar") === true; + const catalog: McpToolCatalog = includesCalendar + ? { + version: 1, + generatedAt: 1, + servers: { + calendar: { + serverName: "calendar", + safeServerName: "calendar", + launchSummary: "calendar", + toolCount: 1, + }, + }, + tools: [ + { + serverName: "calendar", + safeServerName: "calendar", + toolName: "events", + description: "List events", + fallbackDescription: "List events", + inputSchema: { type: "object", properties: {} }, + }, + ], + } + : { version: 1, generatedAt: 1, servers: {}, tools: [] }; + let lastUsedAt = Date.now(); + return { + sessionId: params.sessionId, + sessionKey: params.sessionKey, + workspaceDir: params.workspaceDir, + agentDir: params.agentDir, + configFingerprint: params.configFingerprint ?? "test", + requesterScope: params.requesterScope, + requesterConnect: params.requesterConnect, + createdAt: Date.now(), + get lastUsedAt() { + return lastUsedAt; + }, + getCatalog: async () => catalog, + peekCatalog: () => catalog, + markUsed: () => { + lastUsedAt = Date.now(); + }, + callTool: async (_serverName, toolName) => ({ + content: [{ type: "text", text: `called:${toolName}` }], + isError: false, + }), + dispose: async () => {}, + }; +} + +describe("requester MCP connect runtime", () => { + let manager: ReturnType; + const created: Array[0]> = []; + + beforeEach(() => { + oauthStatus.mockReset().mockResolvedValue({ state: "unauthenticated" }); + startAuthorization.mockReset().mockResolvedValue({ + status: "redirect", + authorizationUrl: "https://auth.example/authorize?state=opaque", + redirectUrl: "https://gateway.example/oauth/mcp/callback", + state: "opaque", + }); + created.length = 0; + manager = createSessionMcpRuntimeManager({ + createRuntime: (params) => { + created.push(params); + return createTestRuntime(params); + }, + }); + }); + + afterEach(async () => { + await manager.disposeAll(); + }); + + it("materializes connect before authorization and real tools on the next message", async () => { + const request = { + sessionId: "session-connect", + workspaceDir: "/workspace", + requesterSenderId: "alice", + messageChannel: "telegram", + agentAccountId: "bot", + cfg: { + gateway: { publicOrigin: "https://gateway.example" }, + mcp: { + servers: { + calendar: { + url: "https://mcp.example/rpc", + transport: "streamable-http" as const, + auth: "oauth" as const, + oauth: { identity: "per-requester" as const }, + }, + }, + }, + }, + }; + + const disconnectedRuntime = await manager.getOrCreate(request); + const disconnected = await materializeBundleMcpToolsForRun({ + runtime: disconnectedRuntime, + }); + expect(disconnected.tools.map((tool) => tool.name)).toEqual(["calendar__connect"]); + expect(created.find((params) => params.requesterScope)?.includeServerNames).toEqual(new Set()); + expect(startAuthorization).not.toHaveBeenCalled(); + await expect(disconnected.tools[0]!.execute("connect", {})).resolves.toMatchObject({ + details: { + mcpConnect: { + serverName: "calendar", + authorizationUrl: "https://auth.example/authorize?state=opaque", + }, + }, + }); + await disconnected.dispose(); + + oauthStatus.mockResolvedValue({ state: "authorized" }); + const connectedRuntime = await manager.getOrCreate(request); + const connected = await materializeBundleMcpToolsForRun({ runtime: connectedRuntime }); + + expect(connected.tools.map((tool) => tool.name)).toEqual(["calendar__events"]); + expect(created.findLast((params) => params.requesterScope)?.includeServerNames).toEqual( + new Set(["calendar"]), + ); + await connected.dispose(); + }); +}); diff --git a/src/agents/agent-bundle-mcp-manager.ts b/src/agents/agent-bundle-mcp-manager.ts index dd2900b85208..c2f6284c3365 100644 --- a/src/agents/agent-bundle-mcp-manager.ts +++ b/src/agents/agent-bundle-mcp-manager.ts @@ -1,5 +1,6 @@ /** Session MCP runtime manager: get-or-create and requester-scoped install orchestration. */ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import type { BundleMcpServerConfig } from "../plugins/bundle-mcp.js"; import { createCombinedSessionMcpRuntime, isCombinedSessionMcpRuntime, @@ -52,12 +53,16 @@ export function createSessionMcpRuntimeManager( const materializeRequesterScopedRuntime = async ( params: Parameters[0] & { idleTtlMs: number; - requesterScopedServerNames: readonly string[]; + mcpServers: Record; + oauthRequesterServerNames: readonly string[]; + resolverRequesterServerNames: readonly string[]; scopedNameSet: ReadonlySet; safeServerNamesByServer: ReadonlyMap; requesterSenderId: string; }, ) => { + const oauthRequesterNameSet = new Set(params.oauthRequesterServerNames); + const resolverRequesterNameSet = new Set(params.resolverRequesterServerNames); const agentAccountId = normalizeOptionalString(params.agentAccountId); const messageChannel = normalizeOptionalString(params.messageChannel); const runtimeKey = buildMcpRequesterRuntimeCacheKey({ @@ -72,7 +77,7 @@ export function createSessionMcpRuntimeManager( logDiagnostics: false, manifestRegistry: params.manifestRegistry, includeServerNames: params.scopedNameSet, - redactConnectionServerNames: params.scopedNameSet, + redactConnectionServerNames: resolverRequesterNameSet, safeServerNamesByServer: params.safeServerNamesByServer, toolOverrides: params.toolOverrides, }).fingerprint; @@ -81,6 +86,7 @@ export function createSessionMcpRuntimeManager( ...params, runtimeKey, fullScopedFingerprint, + oauthRequesterNameSet, agentAccountId, messageChannel, requesterScope: { @@ -115,9 +121,12 @@ export function createSessionMcpRuntimeManager( const safeServerNamesByServer = assignSafeServerNames( Object.keys(fullConfig.loaded.mcpServers), ); - const { staticServers, requesterScopedServerNames } = partitionMcpServersByConnectionScope( - fullConfig.loaded.mcpServers, - ); + const { + staticServers, + requesterScopedServerNames, + oauthRequesterServerNames, + resolverRequesterServerNames, + } = partitionMcpServersByConnectionScope(fullConfig.loaded.mcpServers); const hasRequesterScoped = requesterScopedServerNames.length > 0; if (!hasRequesterScoped) { @@ -176,7 +185,9 @@ export function createSessionMcpRuntimeManager( const { runtimeKey, runtime: scopedRuntime } = await materializeRequesterScopedRuntime({ ...params, idleTtlMs, - requesterScopedServerNames, + mcpServers: fullConfig.loaded.mcpServers, + oauthRequesterServerNames, + resolverRequesterServerNames, scopedNameSet, safeServerNamesByServer, requesterSenderId, @@ -236,9 +247,11 @@ export function createSessionMcpRuntimeManager( manifestRegistry: params.manifestRegistry, toolOverrides: params.toolOverrides, }); - const { requesterScopedServerNames } = partitionMcpServersByConnectionScope( - fullConfig.loaded.mcpServers, - ); + const { + requesterScopedServerNames, + oauthRequesterServerNames, + resolverRequesterServerNames, + } = partitionMcpServersByConnectionScope(fullConfig.loaded.mcpServers); if (requesterScopedServerNames.length === 0) { return undefined; } @@ -249,7 +262,9 @@ export function createSessionMcpRuntimeManager( const { runtimeKey, runtime } = await materializeRequesterScopedRuntime({ ...params, idleTtlMs, - requesterScopedServerNames, + mcpServers: fullConfig.loaded.mcpServers, + oauthRequesterServerNames, + resolverRequesterServerNames, scopedNameSet, safeServerNamesByServer, requesterSenderId, diff --git a/src/agents/agent-bundle-mcp-materialize.ts b/src/agents/agent-bundle-mcp-materialize.ts index 235779b65072..a506542a46b6 100644 --- a/src/agents/agent-bundle-mcp-materialize.ts +++ b/src/agents/agent-bundle-mcp-materialize.ts @@ -12,6 +12,7 @@ import { normalizeReservedToolNames, TOOL_NAME_SEPARATOR, } from "./agent-bundle-mcp-names.js"; +import { mergeMcpConnectCatalog } from "./agent-bundle-mcp-requester-connect.js"; import type { BundleMcpToolRuntime, McpCatalogTool, @@ -476,10 +477,17 @@ export async function materializeBundleMcpToolsForRun(params: { const reservedToolNames = params.reservedToolNames ? Array.from(params.reservedToolNames) : undefined; + const materializedCatalog = mergeMcpConnectCatalog(catalog, params.runtime.requesterConnect); const tools = buildBundleMcpToolsFromCatalog({ - catalog, + catalog: materializedCatalog, reservedToolNames, createExecute: (tool) => async (toolCallId: string, input: unknown) => { + if (!Object.hasOwn(catalog.servers, tool.serverName)) { + const connect = params.runtime.requesterConnect?.createExecute(tool.serverName); + if (connect) { + return await connect(toolCallId, input); + } + } params.runtime.markUsed(); const result = await params.runtime.callTool(tool.serverName, tool.toolName, input); const agentResult = toAgentToolResult({ @@ -561,7 +569,7 @@ export async function materializeBundleMcpToolsForRun(params: { : undefined, }); const appTools = buildAppToolPolicyProjections({ - catalog, + catalog: materializedCatalog, modelTools: tools, reservedToolNames, }); diff --git a/src/agents/agent-bundle-mcp-requester-connect.ts b/src/agents/agent-bundle-mcp-requester-connect.ts new file mode 100644 index 000000000000..4a5d3f9d3dc1 --- /dev/null +++ b/src/agents/agent-bundle-mcp-requester-connect.ts @@ -0,0 +1,189 @@ +import { Type } from "typebox"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { BundleMcpServerConfig } from "../plugins/bundle-mcp.js"; +import type { + McpToolCatalog, + RequesterMcpConnect, + SessionMcpRequesterScope, +} from "./agent-bundle-mcp-types.js"; +import { requesterMcpOAuthIdentity } from "./mcp-oauth-identity.js"; +import { readMcpOAuthCredentialsStatus, startMcpOAuthAuthorization } from "./mcp-oauth.js"; +import { resolveMcpTransportConfig } from "./mcp-transport-config.js"; +import type { AgentToolResult } from "./runtime/index.js"; + +type RequesterOAuthServer = Extract< + NonNullable>, + { kind: "http" } +>; + +async function connectRequesterOAuthServer(params: { + serverName: string; + server: RequesterOAuthServer; + requesterScope: SessionMcpRequesterScope; + publicOrigin?: string; +}): Promise> { + if (!params.publicOrigin) { + const message = + `MCP server "${params.serverName}" needs requester sign-in, but gateway.publicOrigin is not configured. ` + + "Ask the operator to set the public Gateway HTTP(S) origin."; + return { + content: [{ type: "text", text: message }], + details: { status: "error", error: message, mcpServer: params.serverName }, + }; + } + const result = await startMcpOAuthAuthorization( + requesterMcpOAuthIdentity(params.serverName, params.server.url, params.requesterScope), + params.server, + { redirectUrl: new URL("/oauth/mcp/callback", params.publicOrigin).href }, + ); + if (result.status === "authorized") { + return { + content: [ + { + type: "text", + text: `MCP server "${params.serverName}" is connected. Its tools become available on the next message.`, + }, + ], + details: { mcpServer: params.serverName }, + }; + } + return { + content: [ + { + type: "text", + text: + `Connect MCP server "${params.serverName}" at ${result.authorizationUrl}\n` + + "After sign-in completes, the server's tools become available on the next message.", + }, + ], + details: { + mcpConnect: { serverName: params.serverName, authorizationUrl: result.authorizationUrl }, + }, + }; +} + +function buildRequesterConnectCatalog( + servers: ReadonlyMap, + safeServerNamesByServer: ReadonlyMap, +): McpToolCatalog { + const entries = [...servers.entries()]; + return { + version: 1, + generatedAt: Date.now(), + servers: Object.fromEntries( + entries.map(([serverName]) => [ + serverName, + { + serverName, + safeServerName: safeServerNamesByServer.get(serverName), + launchSummary: "Requester OAuth", + toolCount: 1, + }, + ]), + ), + tools: entries.map(([serverName]) => ({ + serverName, + safeServerName: safeServerNamesByServer.get(serverName) ?? serverName, + toolName: "connect", + description: `Connect your ${serverName} account.`, + fallbackDescription: `Connect your ${serverName} account.`, + inputSchema: Type.Object({}), + })), + }; +} + +/** Builds the per-message requester sign-in surface without opening MCP transports. */ +export async function createRequesterMcpConnect(params: { + serverNames: ReadonlySet; + mcpServers: Record; + safeServerNamesByServer: ReadonlyMap; + requesterScope: SessionMcpRequesterScope; + cfg?: OpenClawConfig; + configFingerprint: string; +}): Promise { + const servers = new Map(); + const authorizedServerNames: string[] = []; + for (const serverName of [...params.serverNames].toSorted((a, b) => a.localeCompare(b))) { + const resolved = resolveMcpTransportConfig(serverName, params.mcpServers[serverName], { + logWarnings: false, + }); + if ( + resolved?.kind !== "http" || + resolved.auth !== "oauth" || + resolved.oauth?.identity !== "per-requester" + ) { + continue; + } + servers.set(serverName, resolved); + const status = await readMcpOAuthCredentialsStatus( + requesterMcpOAuthIdentity(serverName, resolved.url, params.requesterScope), + ); + if (status.state === "authorized") { + authorizedServerNames.push(serverName); + } + } + if (servers.size === 0) { + return undefined; + } + const configFingerprint = JSON.stringify({ + config: params.configFingerprint, + authorizedServerNames, + publicOrigin: params.cfg?.gateway?.publicOrigin, + }); + return { + catalog: buildRequesterConnectCatalog(servers, params.safeServerNamesByServer), + authorizedServerNames, + configFingerprint, + createExecute(serverName) { + const server = servers.get(serverName); + return server + ? async () => + await connectRequesterOAuthServer({ + serverName, + server, + requesterScope: params.requesterScope, + publicOrigin: params.cfg?.gateway?.publicOrigin, + }) + : undefined; + }, + }; +} + +/** Adds transient connect entries only for servers absent from the live catalog. */ +export function mergeMcpConnectCatalog( + liveCatalog: McpToolCatalog, + requesterConnect?: RequesterMcpConnect, +): McpToolCatalog { + if (!requesterConnect) { + return liveCatalog; + } + const missingServerNames = new Set( + Object.keys(requesterConnect.catalog.servers).filter( + (serverName) => !Object.hasOwn(liveCatalog.servers, serverName), + ), + ); + if (missingServerNames.size === 0) { + return liveCatalog; + } + return { + ...liveCatalog, + generatedAt: Math.max(liveCatalog.generatedAt, requesterConnect.catalog.generatedAt), + servers: { + ...liveCatalog.servers, + ...Object.fromEntries( + Object.entries(requesterConnect.catalog.servers).filter(([serverName]) => + missingServerNames.has(serverName), + ), + ), + }, + tools: [ + ...liveCatalog.tools, + ...requesterConnect.catalog.tools.filter((tool) => missingServerNames.has(tool.serverName)), + ].toSorted( + (left, right) => + left.safeServerName.localeCompare(right.safeServerName) || + left.toolName.localeCompare(right.toolName) || + left.serverName.localeCompare(right.serverName), + ), + }; +} diff --git a/src/agents/agent-bundle-mcp-runtime-shared.ts b/src/agents/agent-bundle-mcp-runtime-shared.ts index d8b4cba8dcee..96259adc7423 100644 --- a/src/agents/agent-bundle-mcp-runtime-shared.ts +++ b/src/agents/agent-bundle-mcp-runtime-shared.ts @@ -3,6 +3,7 @@ import type { SessionToolOverrides } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; import type { + RequesterMcpConnect, SessionMcpRequesterScope, SessionMcpRuntime, SessionMcpRuntimeManager, @@ -43,6 +44,7 @@ export type CreateSessionMcpRuntime = (params: { connectionOverrides?: ReadonlyMap; redactConnectionServerNames?: ReadonlySet; requesterScope?: SessionMcpRequesterScope; + requesterConnect?: RequesterMcpConnect; configFingerprint?: string; toolOverrides?: Pick; }) => SessionMcpRuntime; diff --git a/src/agents/agent-bundle-mcp-runtime.ts b/src/agents/agent-bundle-mcp-runtime.ts index 047d0f6fa259..a5bad14af2e6 100644 --- a/src/agents/agent-bundle-mcp-runtime.ts +++ b/src/agents/agent-bundle-mcp-runtime.ts @@ -51,6 +51,7 @@ import type { McpServerCatalog, McpToolCatalog, McpToolCatalogDiagnostic, + RequesterMcpConnect, SessionMcpRequesterScope, SessionMcpRuntime, SessionMcpRuntimeManager, @@ -400,6 +401,7 @@ export function createSessionMcpRuntime(params: { connectionOverrides?: ReadonlyMap; redactConnectionServerNames?: ReadonlySet; requesterScope?: SessionMcpRequesterScope; + requesterConnect?: RequesterMcpConnect; configFingerprint?: string; toolOverrides?: Pick; }): SessionMcpRuntime { @@ -695,6 +697,7 @@ export function createSessionMcpRuntime(params: { cfg: params.cfg, agentDir: params.agentDir, prepareDataDir: dataDirOwnership?.dataDir, + requesterScope: params.requesterScope, }); if (!resolved) { continue; @@ -1047,6 +1050,7 @@ export function createSessionMcpRuntime(params: { agentDir: params.agentDir, configFingerprint, ...(params.requesterScope ? { requesterScope: params.requesterScope } : {}), + ...(params.requesterConnect ? { requesterConnect: params.requesterConnect } : {}), // A runtime partition hosts either only static or only requester-scoped servers. isRequesterScopedServer: () => params.requesterScope !== undefined, mcpAppsEnabled, diff --git a/src/agents/agent-bundle-mcp-types.ts b/src/agents/agent-bundle-mcp-types.ts index d02656029178..988c37b2d6c1 100644 --- a/src/agents/agent-bundle-mcp-types.ts +++ b/src/agents/agent-bundle-mcp-types.ts @@ -74,6 +74,14 @@ export type McpToolCatalog = { diagnostics?: readonly McpToolCatalogDiagnostic[]; }; +/** Transient requester sign-in surface kept outside the remembered live catalog. */ +export type RequesterMcpConnect = { + catalog: McpToolCatalog; + authorizedServerNames: readonly string[]; + configFingerprint: string; + createExecute: (serverName: string) => AnyAgentTool["execute"] | undefined; +}; + export type McpToolCatalogDiagnostic = { serverName: string; safeServerName: string; @@ -101,6 +109,7 @@ export type SessionMcpRuntime = { configFingerprint: string; /** Present when this runtime is keyed by requester-scoped connection identity. */ requesterScope?: SessionMcpRequesterScope; + requesterConnect?: RequesterMcpConnect; /** * True when the named server's connection is requester-scoped. App views for * such servers stay fail-closed: views outlive the requester-authenticated diff --git a/src/agents/embedded-agent-runner/run-loop.ts b/src/agents/embedded-agent-runner/run-loop.ts index 1ca3c628cd81..ff3d91d3c1bc 100644 --- a/src/agents/embedded-agent-runner/run-loop.ts +++ b/src/agents/embedded-agent-runner/run-loop.ts @@ -17,7 +17,6 @@ import { selectContextEngineForTranscriptHost, } from "../harness/context-engine-logical-turn.js"; import { drainPendingContextEngineTurnsBeforeRun } from "../harness/context-engine-turn-attempt.js"; -import type { McpAppChannelView } from "../mcp-ui-resource.js"; import { runAgentCleanupStep } from "../run-cleanup-timeout.js"; import { resolveToolLoopDetectionConfig } from "../tool-loop-detection-config.js"; import { normalizeUsage } from "../usage.js"; @@ -33,6 +32,7 @@ import { prepareAndDispatchEmbeddedRunAttempt } from "./run/attempt-dispatch-pre import { normalizeEmbeddedRunAttempt } from "./run/attempt-normalization.js"; import { forgetPromptBuildDrainCacheForRun } from "./run/attempt-prompt-helpers.js"; import { recoverEmbeddedRunAttempt } from "./run/attempt-recovery.js"; +import { createMcpAttemptCarryover } from "./run/attempt-result.js"; import { hasCodexAppServerRecoveryRetryBudget } from "./run/codex-app-server-recovery.js"; import { createEmbeddedRunCompactionRuntime } from "./run/compaction-runtime.js"; import { createEmbeddedRunContextRecoveryState } from "./run/context-recovery-state.js"; @@ -318,7 +318,7 @@ export async function runPreparedEmbeddedLoop( }); let authRetryPending = false; let accumulatedReplayState = createEmbeddedRunReplayState(); - let latestMcpAppChannelView: McpAppChannelView | undefined; + const mcpAttemptCarryover = createMcpAttemptCarryover(); while (true) { refreshPreparedRuntimeSnapshot(); if (isRunRetryBudgetExhausted(runRetryBudget)) { @@ -391,10 +391,7 @@ export async function runPreparedEmbeddedLoop( }); startupStagesEmitted = dispatch.startupStagesEmitted; const { dispatchedAttempt, runtimePlan } = dispatch; - // Preserve the newest launch target before normalization can request an early retry. - latestMcpAppChannelView = - dispatchedAttempt.rawAttempt.latestMcpAppChannelView ?? latestMcpAppChannelView; - dispatchedAttempt.rawAttempt.latestMcpAppChannelView = latestMcpAppChannelView; + mcpAttemptCarryover.apply(dispatchedAttempt.rawAttempt); const normalizedAttempt = await normalizeEmbeddedRunAttempt({ runInput: admittedRunInput, preparedRuntime, diff --git a/src/agents/embedded-agent-runner/run/attempt-result.test.ts b/src/agents/embedded-agent-runner/run/attempt-result.test.ts index d1fc9e130c53..7c0d2b0f5207 100644 --- a/src/agents/embedded-agent-runner/run/attempt-result.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-result.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { completeEmbeddedAttemptResult } from "./attempt-result.js"; +import { completeEmbeddedAttemptResult, createMcpAttemptCarryover } from "./attempt-result.js"; function completeResult(params?: { latestMcpAppChannelView?: { viewId: string }; @@ -42,6 +42,7 @@ function completeResult(params?: { getLastCompactionTokensAfter: () => undefined, getLastToolError: () => undefined, getLatestMcpAppChannelView: () => params?.latestMcpAppChannelView, + getLatestMcpConnectAction: () => undefined, getMessagingToolSentMediaUrls: () => [], getMessagingToolSentTargets: () => [], getMessagingToolSentTexts: () => [], @@ -77,6 +78,33 @@ function completeResult(params?: { } describe("attempt result projection", () => { + it("carries the newest MCP presentation state across retry attempts", () => { + const carryover = createMcpAttemptCarryover(); + const first = { + latestMcpAppChannelView: { viewId: "view-first" }, + latestMcpConnectAction: { + serverName: "calendar", + authorizationUrl: "https://auth.example/first", + }, + }; + const retry: Parameters[0] = {}; + const latest = { + latestMcpAppChannelView: { viewId: "view-latest" }, + latestMcpConnectAction: { + serverName: "calendar", + authorizationUrl: "https://auth.example/latest", + }, + }; + + carryover.apply(first); + carryover.apply(retry); + carryover.apply(latest); + + expect(retry).toEqual(first); + expect(latest.latestMcpAppChannelView.viewId).toBe("view-latest"); + expect(latest.latestMcpConnectAction.authorizationUrl).toBe("https://auth.example/latest"); + }); + it("keeps completed client tool calls in reserved source order", () => { expect( completeResult({ diff --git a/src/agents/embedded-agent-runner/run/attempt-result.ts b/src/agents/embedded-agent-runner/run/attempt-result.ts index ff985f2e2ae2..7601879f431c 100644 --- a/src/agents/embedded-agent-runner/run/attempt-result.ts +++ b/src/agents/embedded-agent-runner/run/attempt-result.ts @@ -33,6 +33,22 @@ type EmbeddedAttemptSubscription = ReturnType; type HookRunner = ReturnType; +/** Keeps presentation state sticky while retry attempts replace their result object. */ +export function createMcpAttemptCarryover() { + let latestMcpAppChannelView: EmbeddedRunAttemptResult["latestMcpAppChannelView"]; + let latestMcpConnectAction: EmbeddedRunAttemptResult["latestMcpConnectAction"]; + return { + apply( + attempt: Pick, + ): void { + latestMcpAppChannelView = attempt.latestMcpAppChannelView ?? latestMcpAppChannelView; + attempt.latestMcpAppChannelView = latestMcpAppChannelView; + latestMcpConnectAction = attempt.latestMcpConnectAction ?? latestMcpConnectAction; + attempt.latestMcpConnectAction = latestMcpConnectAction; + }, + }; +} + export type EmbeddedAttemptClientToolCallSlot = { toolCallId: string; name: string; @@ -158,6 +174,7 @@ export function completeEmbeddedAttemptResult( getLastCompactionTokensAfter, getLastToolError, getLatestMcpAppChannelView, + getLatestMcpConnectAction, getMessagingToolSentMediaUrls, getMessagingToolSentTargets, getMessagingToolSentTexts, @@ -383,6 +400,7 @@ export function completeEmbeddedAttemptResult( bootstrapPromptWarningSignature: input.bootstrapPromptWarning.signature, assistantTexts, latestMcpAppChannelView: getLatestMcpAppChannelView(), + latestMcpConnectAction: getLatestMcpConnectAction(), lastAssistantTextMessageIndex: getLastAssistantTextMessageIndex(), toolMetas: toolMetasNormalized, acceptedSessionSpawns, diff --git a/src/agents/embedded-agent-runner/run/attempt-spawn-workspace.test-support.ts b/src/agents/embedded-agent-runner/run/attempt-spawn-workspace.test-support.ts index c334c2e731bd..bd43014ac263 100644 --- a/src/agents/embedded-agent-runner/run/attempt-spawn-workspace.test-support.ts +++ b/src/agents/embedded-agent-runner/run/attempt-spawn-workspace.test-support.ts @@ -130,6 +130,7 @@ function createSubscriptionMock(): SubscriptionMock { getCurrentAttemptAssistant: () => undefined, getLastAssistantTextMessageIndex: () => undefined, getLatestMcpAppChannelView: () => undefined, + getLatestMcpConnectAction: () => undefined, toolMetas: [] as Array<{ toolName: string; meta?: string; asyncStarted?: boolean }>, runToolLifecycle: async (toolParams: { execute: () => Promise }) => await toolParams.execute(), diff --git a/src/agents/embedded-agent-runner/run/terminal-resolution.ts b/src/agents/embedded-agent-runner/run/terminal-resolution.ts index 4e0756b762f5..9fc501936976 100644 --- a/src/agents/embedded-agent-runner/run/terminal-resolution.ts +++ b/src/agents/embedded-agent-runner/run/terminal-resolution.ts @@ -648,6 +648,7 @@ function completeEmbeddedRun( export function copyAttemptDeliveryState(attempt: EmbeddedRunAttemptResult) { return { latestMcpAppChannelView: attempt.latestMcpAppChannelView, + latestMcpConnectAction: attempt.latestMcpConnectAction, didSendViaMessagingTool: attempt.didSendViaMessagingTool, didDeliverSourceReplyViaMessageTool: attempt.didDeliverSourceReplyViaMessageTool === true, didSendDeterministicApprovalPrompt: attempt.didSendDeterministicApprovalPrompt, diff --git a/src/agents/embedded-agent-runner/run/types.ts b/src/agents/embedded-agent-runner/run/types.ts index 07b7f7f2be14..87628d013cfc 100644 --- a/src/agents/embedded-agent-runner/run/types.ts +++ b/src/agents/embedded-agent-runner/run/types.ts @@ -21,6 +21,7 @@ import type { MessagingToolSourceReplyPayload, } from "../../embedded-agent-messaging.types.js"; import type { AgentHarnessRuntimeArtifactBinding } from "../../harness/runtime-artifact.types.js"; +import type { McpConnectAction } from "../../mcp-connect-action.js"; import type { McpAppChannelView } from "../../mcp-ui-resource.js"; import type { PreparedModelRuntimeSnapshot } from "../../prepared-model-runtime.js"; import type { AgentRunTimeoutPhase } from "../../run-timeout-attribution.js"; @@ -269,6 +270,7 @@ export type EmbeddedRunAttemptResult = { beforeAgentFinalizeRevisionReason?: string; assistantTexts: string[]; latestMcpAppChannelView?: McpAppChannelView; + latestMcpConnectAction?: McpConnectAction; lastAssistantTextMessageIndex?: number; toolMetas: Array<{ toolName: string; diff --git a/src/agents/embedded-agent-runner/types.ts b/src/agents/embedded-agent-runner/types.ts index 0de3fa1b15c5..d01df6c6fa8d 100644 --- a/src/agents/embedded-agent-runner/types.ts +++ b/src/agents/embedded-agent-runner/types.ts @@ -14,6 +14,7 @@ import type { MessagingToolSend, MessagingToolSourceReplyPayload, } from "../embedded-agent-messaging.types.js"; +import type { McpConnectAction } from "../mcp-connect-action.js"; import type { McpAppChannelView } from "../mcp-ui-resource.js"; import type { FallbackAttempt } from "../model-fallback.types.js"; import type { AgentRunTimeoutPhase } from "../run-timeout-attribution.js"; @@ -218,6 +219,7 @@ export type EmbeddedAgentRunMeta = { export type EmbeddedAgentRunResult = { latestMcpAppChannelView?: McpAppChannelView; + latestMcpConnectAction?: McpConnectAction; payloads?: Array<{ text?: string; mediaUrl?: string; diff --git a/src/agents/embedded-agent-subscribe.handlers.tools.completion.ts b/src/agents/embedded-agent-subscribe.handlers.tools.completion.ts index 89e1d61c021a..ca8117645e7f 100644 --- a/src/agents/embedded-agent-subscribe.handlers.tools.completion.ts +++ b/src/agents/embedded-agent-subscribe.handlers.tools.completion.ts @@ -84,6 +84,7 @@ import { sanitizeToolResult, } from "./embedded-agent-subscribe.tools.js"; import { parseExecApprovalResultText } from "./exec-approval-result.js"; +import { readMcpConnectAction } from "./mcp-connect-action.js"; import { readMcpAppChannelView } from "./mcp-ui-resource.js"; import type { AgentEvent } from "./runtime/index.js"; import { @@ -124,6 +125,10 @@ export async function handleToolExecutionEnd( // A later successful app result supersedes the earlier launch target. ctx.state.latestMcpAppChannelView = channelView; } + const connectAction = readMcpConnectAction(result); + if (connectAction) { + ctx.state.latestMcpConnectAction = connectAction; + } } try { ctx.params.onAgentToolResult?.({ diff --git a/src/agents/embedded-agent-subscribe.handlers.tools.test.ts b/src/agents/embedded-agent-subscribe.handlers.tools.test.ts index 981ab81f940a..dea503e53532 100644 --- a/src/agents/embedded-agent-subscribe.handlers.tools.test.ts +++ b/src/agents/embedded-agent-subscribe.handlers.tools.test.ts @@ -1275,6 +1275,31 @@ describe("handleToolExecutionEnd MCP App channel view tracking", () => { }); }); +describe("handleToolExecutionEnd MCP connect action tracking", () => { + it("retains only a successful HTTP(S) connect action", async () => { + const { ctx } = createTestContext(); + + await endTool(ctx, { + toolName: "mcp_connect", + toolCallId: "mcp-connect", + isError: false, + result: { + details: { + mcpConnect: { + serverName: "calendar", + authorizationUrl: "https://auth.example/authorize?state=opaque", + }, + }, + }, + }); + + expect(ctx.state.latestMcpConnectAction).toEqual({ + serverName: "calendar", + authorizationUrl: "https://auth.example/authorize?state=opaque", + }); + }); +}); + describe("handleToolExecutionEnd sessions_spawn terminal success tracking", () => { it("records accepted sessions_spawn identifiers", async () => { const { ctx } = createTestContext(); diff --git a/src/agents/embedded-agent-subscribe.handlers.types.ts b/src/agents/embedded-agent-subscribe.handlers.types.ts index 8785e8b96acf..e870e82f924a 100644 --- a/src/agents/embedded-agent-subscribe.handlers.types.ts +++ b/src/agents/embedded-agent-subscribe.handlers.types.ts @@ -25,6 +25,7 @@ import type { SubscribeEmbeddedAgentSessionParams, } from "./embedded-agent-subscribe.types.js"; import type { ThinkingTagStreamState } from "./embedded-agent-utils.js"; +import type { McpConnectAction } from "./mcp-connect-action.js"; import type { McpAppChannelView } from "./mcp-ui-resource.js"; import type { AgentRunTimeoutPhase } from "./run-timeout-attribution.js"; import type { AgentMessage } from "./runtime/index.js"; @@ -105,6 +106,7 @@ export type EmbeddedAgentSubscribeState = { assistantTurnCount: number; lastToolError?: ToolErrorSummary; latestMcpAppChannelView?: McpAppChannelView; + latestMcpConnectAction?: McpConnectAction; blockReplyBreak: "text_end" | "message_end"; reasoningMode: ReasoningLevel; @@ -348,6 +350,7 @@ type ToolHandlerState = Pick< | "itemCompletedCount" | "lastToolError" | "latestMcpAppChannelView" + | "latestMcpConnectAction" | "pendingMessagingTargets" | "pendingMessagingTexts" | "pendingMessagingMediaUrls" diff --git a/src/agents/embedded-agent-subscribe.ts b/src/agents/embedded-agent-subscribe.ts index 6c4436a9424a..0e167bf68e54 100644 --- a/src/agents/embedded-agent-subscribe.ts +++ b/src/agents/embedded-agent-subscribe.ts @@ -1503,6 +1503,8 @@ export function subscribeEmbeddedAgentSession(params: SubscribeEmbeddedAgentSess getAcceptedSessionSpawns: () => state.acceptedSessionSpawns.slice(), getLatestMcpAppChannelView: () => state.latestMcpAppChannelView ? { ...state.latestMcpAppChannelView } : undefined, + getLatestMcpConnectAction: () => + state.latestMcpConnectAction ? { ...state.latestMcpConnectAction } : undefined, runToolLifecycle: async (toolParams: { toolName: string; toolCallId: string; diff --git a/src/agents/mcp-config-mutation.test.ts b/src/agents/mcp-config-mutation.test.ts new file mode 100644 index 000000000000..c00c78392d4b --- /dev/null +++ b/src/agents/mcp-config-mutation.test.ts @@ -0,0 +1,125 @@ +import path from "node:path"; +import { withTempHome } from "openclaw/plugin-sdk/test-env"; +import { afterEach, describe, expect, it } from "vitest"; +import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; +import { + setConfiguredMcpServer, + unsetConfiguredMcpServer, + updateConfiguredMcpServer, + updateConfiguredMcpServerTools, +} from "./mcp-config-mutation.js"; +import { operatorMcpOAuthIdentity, requesterMcpOAuthIdentity } from "./mcp-oauth-identity.js"; +import { + readMcpOAuthPendingAuthorization, + readMcpOAuthStore, + updateMcpOAuthStore, + writeMcpOAuthPendingAuthorization, +} from "./mcp-oauth-store.js"; + +const SERVER_URL = "https://mcp.example.com/rpc"; +const PER_REQUESTER_SERVER = { + url: SERVER_URL, + transport: "streamable-http", + auth: "oauth", + oauth: { identity: "per-requester" }, +}; + +function seedOAuthState(name: string) { + const operator = operatorMcpOAuthIdentity(name, SERVER_URL); + const requester = requesterMcpOAuthIdentity(name, SERVER_URL, { + requesterSenderId: "alice", + messageChannel: "telegram", + }); + for (const identity of [operator, requester]) { + updateMcpOAuthStore(identity.storeKey, (store) => ({ + ...store, + tokens: { access_token: identity.principal, token_type: "Bearer" }, + })); + writeMcpOAuthPendingAuthorization(identity.storeKey, `${identity.principal}-state`); + } + return { operator, requester }; +} + +afterEach(() => { + closeOpenClawStateDatabaseForTest(); +}); + +async function withMcpConfigHome(run: () => Promise): Promise { + await withTempHome( + async () => { + closeOpenClawStateDatabaseForTest(); + try { + await run(); + } finally { + closeOpenClawStateDatabaseForTest(); + } + }, + { + prefix: "openclaw-mcp-config-oauth-", + skipSessionCleanup: true, + env: { + OPENCLAW_CONFIG_PATH: undefined, + OPENCLAW_STATE_DIR: (home) => path.join(home, ".openclaw"), + }, + }, + ); +} + +describe("configured MCP OAuth cleanup", () => { + it.each([ + { + name: "set replacement", + mutate: (serverName: string) => + setConfiguredMcpServer({ + name: serverName, + server: { command: "uvx", args: ["replacement-mcp"] }, + }), + expected: { operator: undefined, requester: undefined }, + }, + { + name: "unset", + mutate: (serverName: string) => unsetConfiguredMcpServer({ name: serverName }), + expected: { operator: undefined, requester: undefined }, + }, + { + name: "identity flip", + mutate: (serverName: string) => + updateConfiguredMcpServer({ + name: serverName, + update: (server) => ({ ...server, oauth: {} }), + }), + expected: { operator: "operator", requester: undefined }, + }, + { + name: "tool update", + mutate: (serverName: string) => + updateConfiguredMcpServerTools({ + name: serverName, + tools: { include: ["search"] }, + }), + expected: { operator: "operator", requester: "requester" }, + }, + ])("applies cleanup after $name", async ({ mutate, expected }) => { + await withMcpConfigHome(async () => { + const serverName = "fixture"; + const initial = await setConfiguredMcpServer({ + name: serverName, + server: PER_REQUESTER_SERVER, + }); + expect(initial.ok).toBe(true); + const { operator, requester } = seedOAuthState(serverName); + + const result = await mutate(serverName); + + expect(result.ok).toBe(true); + expect(readMcpOAuthStore(operator.storeKey).tokens?.access_token).toBe(expected.operator); + expect(readMcpOAuthStore(requester.storeKey).tokens?.access_token).toBe(expected.requester); + expect(readMcpOAuthPendingAuthorization("operator-state")).toBe( + expected.operator ? operator.storeKey : undefined, + ); + expect(readMcpOAuthPendingAuthorization("requester-state")).toBe( + expected.requester ? requester.storeKey : undefined, + ); + }); + }); +}); diff --git a/src/agents/mcp-config-mutation.ts b/src/agents/mcp-config-mutation.ts new file mode 100644 index 000000000000..771077e378af --- /dev/null +++ b/src/agents/mcp-config-mutation.ts @@ -0,0 +1,71 @@ +/** Canonical configured-MCP mutations with OAuth credential lifecycle cleanup. */ +import { asNullableRecord } from "@openclaw/normalization-core/record-coerce"; +import { mcpConfigInternal } from "../config/mcp-config.js"; +import { operatorMcpOAuthIdentity } from "./mcp-oauth-identity.js"; +import { clearMcpOAuthRequesters, clearMcpOAuthServer } from "./mcp-oauth.js"; +import { resolveMcpTransportConfig } from "./mcp-transport-config.js"; + +function hasOAuthAuth(server: unknown): boolean { + return asNullableRecord(server)?.auth === "oauth"; +} + +function hasRequesterIdentity(server: unknown): boolean { + return ( + hasOAuthAuth(server) && + asNullableRecord(asNullableRecord(server)?.oauth)?.identity === "per-requester" + ); +} + +async function clearReplacedMcpOAuth(mutation: { + name: string; + previous?: Record; + next?: Record; +}): Promise { + if (!hasOAuthAuth(mutation.previous)) { + return; + } + const previous = resolveMcpTransportConfig(mutation.name, mutation.previous); + if (previous?.kind !== "http") { + return; + } + const next = hasOAuthAuth(mutation.next) + ? resolveMcpTransportConfig(mutation.name, mutation.next) + : undefined; + if (next?.kind === "http" && next.url === previous.url) { + const wasRequester = hasRequesterIdentity(mutation.previous); + const isRequester = hasRequesterIdentity(mutation.next); + if (wasRequester === isRequester) { + return; + } + if (wasRequester) { + // The operator row becomes the shared destination; only requester rows are stale. + await clearMcpOAuthRequesters(operatorMcpOAuthIdentity(mutation.name, previous.url)); + return; + } + } + await clearMcpOAuthServer(operatorMcpOAuthIdentity(mutation.name, previous.url)); +} + +export function setConfiguredMcpServer( + params: Parameters[0], +): ReturnType { + return mcpConfigInternal.set(params, clearReplacedMcpOAuth); +} + +export function unsetConfiguredMcpServer( + params: Parameters[0], +): ReturnType { + return mcpConfigInternal.unset(params, clearReplacedMcpOAuth); +} + +export function updateConfiguredMcpServer( + params: Parameters[0], +): ReturnType { + return mcpConfigInternal.update(params, clearReplacedMcpOAuth); +} + +export function updateConfiguredMcpServerTools( + params: Parameters[0], +): ReturnType { + return mcpConfigInternal.updateTools(params, clearReplacedMcpOAuth); +} diff --git a/src/agents/mcp-connect-action.ts b/src/agents/mcp-connect-action.ts new file mode 100644 index 000000000000..ca71197473d2 --- /dev/null +++ b/src/agents/mcp-connect-action.ts @@ -0,0 +1,21 @@ +import { asOptionalRecord as asRecord } from "@openclaw/normalization-core/record-coerce"; + +export type McpConnectAction = { + serverName: string; + authorizationUrl: string; +}; + +export function readMcpConnectAction(result: unknown): McpConnectAction | undefined { + const connect = asRecord(asRecord(asRecord(result)?.details)?.mcpConnect); + const serverName = typeof connect?.serverName === "string" ? connect.serverName.trim() : ""; + const authorizationUrl = + typeof connect?.authorizationUrl === "string" ? connect.authorizationUrl.trim() : ""; + if (!serverName || !URL.canParse(authorizationUrl)) { + return undefined; + } + const protocol = new URL(authorizationUrl).protocol; + if (protocol !== "http:" && protocol !== "https:") { + return undefined; + } + return { serverName, authorizationUrl }; +} diff --git a/src/agents/mcp-connection-resolver.ts b/src/agents/mcp-connection-resolver.ts index 694fbe920dbc..dfb5313d3a2f 100644 --- a/src/agents/mcp-connection-resolver.ts +++ b/src/agents/mcp-connection-resolver.ts @@ -154,19 +154,32 @@ function listMcpServerConnectionResolversByServerName(): Map< return new Map([...byName.entries()].toSorted(([a], [b]) => a.localeCompare(b))); } -/** Partition loaded MCP servers into static vs requester-scoped by registered resolvers. */ +/** Partition loaded MCP servers into static vs requester-scoped connections. */ export function partitionMcpServersByConnectionScope(mcpServers: Record): { staticServers: Record; requesterScopedServerNames: string[]; + oauthRequesterServerNames: string[]; + resolverRequesterServerNames: string[]; } { const resolvers = listMcpServerConnectionResolversByServerName(); const staticServerEntries: Array<[string, T]> = []; const requesterScopedServerNames: string[] = []; + const oauthRequesterServerNames: string[] = []; + const resolverRequesterServerNames: string[] = []; for (const [serverName, rawServer] of Object.entries(mcpServers).toSorted(([a], [b]) => a.localeCompare(b), )) { + const oauth = isRecord(rawServer) && isRecord(rawServer.oauth) ? rawServer.oauth : undefined; + if (isRecord(rawServer) && rawServer.auth === "oauth" && oauth?.identity === "per-requester") { + // Config-declared requester OAuth must stay out of anonymous/static runs. + // Resolver lookup here would erase OAuth and could expose a shared connection. + requesterScopedServerNames.push(serverName); + oauthRequesterServerNames.push(serverName); + continue; + } if (resolvers.has(serverName)) { requesterScopedServerNames.push(serverName); + resolverRequesterServerNames.push(serverName); continue; } staticServerEntries.push([serverName, rawServer]); @@ -174,7 +187,12 @@ export function partitionMcpServersByConnectionScope(mcpServers: Record Promise): Promise { + await withBaseTempHome(async () => { + try { + await run(); + } finally { + closeOpenClawStateDatabaseForTest(); + } + }); +} + +describe("MCP OAuth pending authorization store", () => { + it("lazily creates durable exact-state correlation without changing schema version", async () => { + await withTempHome(async () => { + const database = openOpenClawStateDatabase().db; + expect( + database + .prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?") + .get("mcp_oauth_pending_authorizations"), + ).toBeUndefined(); + + // Public callback lookups are read-only: an unknown state must not + // create the lazy table or any shared state. + expect(readMcpOAuthPendingAuthorization("unknown-state")).toBeUndefined(); + expect( + database + .prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?") + .get("mcp_oauth_pending_authorizations"), + ).toBeUndefined(); + + const store = operatorMcpOAuthIdentity("Pending", "https://pending.example.com/mcp"); + writeMcpOAuthPendingAuthorization(store.storeKey, "first-state"); + expect( + database + .prepare("SELECT strict FROM pragma_table_list WHERE name = ?") + .get("mcp_oauth_pending_authorizations"), + ).toEqual({ strict: 1 }); + expect(database.prepare("PRAGMA user_version").get()).toEqual({ + user_version: OPENCLAW_STATE_SCHEMA_VERSION, + }); + expect(readMcpOAuthPendingAuthorization("first-state")).toBe(store.storeKey); + + writeMcpOAuthPendingAuthorization(store.storeKey, "second-state"); + expect(readMcpOAuthPendingAuthorization("first-state")).toBeUndefined(); + expect(readMcpOAuthPendingAuthorization("second-state")).toBe(store.storeKey); + expect(consumeOAuthState(store.storeKey, "other-state")).toBe(false); + expect(consumeOAuthState(store.storeKey, "second-state")).toBe(true); + expect(consumeOAuthState(store.storeKey, "second-state")).toBe(false); + + clearMcpOAuthStore(store.storeKey); + expect(readMcpOAuthPendingAuthorization("second-state")).toBeUndefined(); + }); + }); + + it("uses exact state lookup and clears one requester prefix", async () => { + await withTempHome(async () => { + const database = openOpenClawStateDatabase().db; + writeMcpOAuthPendingAuthorization("schema-install", "schema-install-state"); + expect(consumeOAuthState("schema-install", "schema-install-state")).toBe(true); + const insertPending = database.prepare( + "INSERT INTO mcp_oauth_pending_authorizations (state, store_key, create_time) VALUES (?, ?, ?)", + ); + const insertStore = database.prepare( + "INSERT INTO mcp_oauth_stores (store_key, format_version, store_json, updated_at) VALUES (?, 1, ?, ?)", + ); + database.exec("BEGIN"); + try { + for (let index = 0; index < 1_000; index += 1) { + insertPending.run(`seed-${index}`, `unrelated-${index}`, index); + insertStore.run( + `stored-${index}`, + 'invalid-json-with-"lastAuthorizationUrl"-marker', + index, + ); + } + database.exec("COMMIT"); + } catch (error) { + database.exec("ROLLBACK"); + throw error; + } + expect(readMcpOAuthPendingAuthorization("absent-state")).toBeUndefined(); + + // A copied sign-in link dies after the pending-state TTL, even unclaimed. + insertPending.run("expired-state", "expired-store", Date.now() - 11 * 60 * 1000); + insertPending.run("fresh-foreign-state", "fresh-foreign-store", Date.now()); + expect(readMcpOAuthPendingAuthorization("expired-state")).toBeUndefined(); + expect(consumeOAuthState("expired-store", "expired-state")).toBe(false); + + writeMcpOAuthPendingAuthorization("server-r-requester-a", "requester-a-state"); + expect( + database + .prepare("SELECT state FROM mcp_oauth_pending_authorizations WHERE state = ?") + .get("expired-state"), + ).toBeUndefined(); + expect(readMcpOAuthPendingAuthorization("fresh-foreign-state")).toBe("fresh-foreign-store"); + writeMcpOAuthPendingAuthorization("server-r-requester-b", "requester-b-state"); + writeMcpOAuthPendingAuthorization("other-r-requester", "other-state"); + deleteMcpOAuthPendingAuthorizationsByPrefix("server-r-"); + + expect(readMcpOAuthPendingAuthorization("requester-a-state")).toBeUndefined(); + expect(readMcpOAuthPendingAuthorization("requester-b-state")).toBeUndefined(); + expect(readMcpOAuthPendingAuthorization("other-state")).toBe("other-r-requester"); + }); + }); +}); diff --git a/src/agents/mcp-oauth-store.ts b/src/agents/mcp-oauth-store.ts index c31d742e7c3d..a11cfb56609f 100644 --- a/src/agents/mcp-oauth-store.ts +++ b/src/agents/mcp-oauth-store.ts @@ -18,6 +18,7 @@ import { getNodeSqliteKysely, } from "../infra/kysely-sync.js"; import { withOpenClawStateDatabaseReadOnly } from "../state/openclaw-state-db-readonly.js"; +import { ensureMcpOAuthPendingSchema } from "../state/openclaw-state-db-schema-additive.js"; import { tableExists } from "../state/openclaw-state-db-schema-helpers.js"; import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; import { @@ -26,10 +27,14 @@ import { } from "../state/openclaw-state-db.js"; import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js"; -type McpOAuthDatabase = Pick; +type McpOAuthDatabase = Pick< + OpenClawStateKyselyDatabase, + "mcp_oauth_pending_authorizations" | "mcp_oauth_stores" +>; const MCP_OAUTH_STORE_FORMAT_VERSION = 1; const UNINITIALIZED_STORE_FIELDS = new Set(["credentialState", "pendingAuthorizationChallenge"]); +const pendingSchemaDatabases = new WeakSet(); type McpOAuthAuthorizationChallenge = { resourceMetadataUrl?: string; @@ -252,6 +257,154 @@ export function readMcpOAuthStoreReadOnly(storeKey: string): McpOAuthStore { }); } +/** List canonical store keys matching one server/principal prefix without creating state. */ +export function listMcpOAuthStoreKeysByPrefix(prefix: string): string[] { + const databasePath = resolveOpenClawStateSqlitePath(); + if (!fs.existsSync(databasePath)) { + return []; + } + return withOpenClawStateDatabaseReadOnly(({ db }) => { + if (!tableExists(db, "mcp_oauth_stores")) { + return []; + } + const rows = executeSqliteQuerySync( + db, + getNodeSqliteKysely(db) + .selectFrom("mcp_oauth_stores") + .select("store_key") + .orderBy("store_key", "asc"), + ).rows; + return rows.map((row) => row.store_key).filter((storeKey) => storeKey.startsWith(prefix)); + }); +} + +function ensurePendingSchema(database: DatabaseSync): void { + if (pendingSchemaDatabases.has(database)) { + return; + } + ensureMcpOAuthPendingSchema(database); + pendingSchemaDatabases.add(database); +} + +function runPendingWrite(run: (database: DatabaseSync) => T): T { + ensurePendingSchema(openOpenClawStateDatabase().db); + return runOpenClawStateWriteTransaction(({ db }) => run(db)); +} + +function deletePendingForStore( + database: DatabaseSync, + storeKey: string, + assertOwnedInTransaction?: (database: DatabaseSync) => void, +): void { + assertOwnedInTransaction?.(database); + executeSqliteQuerySync( + database, + getNodeSqliteKysely(database) + .deleteFrom("mcp_oauth_pending_authorizations") + .where("store_key", "=", storeKey), + ); +} + +/** + * Sign-in links are channel-visible bearer state; a bounded lifetime caps how + * long a copied link stays completable. Enforced at lookup AND claim. + */ +const MCP_OAUTH_PENDING_STATE_TTL_MS = 10 * 60 * 1000; + +/** Resolve one OAuth callback state without scanning credential JSON. */ +export function readMcpOAuthPendingAuthorization(state: string): string | undefined { + // Public unauthenticated callback path: must stay read-only. Table creation + // belongs to start-authorization; an unknown state must not write anything. + const databasePath = resolveOpenClawStateSqlitePath(); + if (!fs.existsSync(databasePath)) { + return undefined; + } + return withOpenClawStateDatabaseReadOnly(({ db }) => { + if (!tableExists(db, "mcp_oauth_pending_authorizations")) { + return undefined; + } + return executeSqliteQueryTakeFirstSync( + db, + getNodeSqliteKysely(db) + .selectFrom("mcp_oauth_pending_authorizations") + .select("store_key") + .where("state", "=", state) + .where("create_time", ">", Date.now() - MCP_OAUTH_PENDING_STATE_TTL_MS), + )?.store_key; + }); +} + +/** Claim one exact unexpired callback state while its store lease is still owned. */ +export function consumeOAuthState( + storeKey: string, + state: string, + assertOwnedInTransaction?: (database: DatabaseSync) => void, +): boolean { + return runPendingWrite((database) => { + assertOwnedInTransaction?.(database); + return ( + executeSqliteQuerySync( + database, + getNodeSqliteKysely(database) + .deleteFrom("mcp_oauth_pending_authorizations") + .where("store_key", "=", storeKey) + .where("state", "=", state) + // Expired rows are unclaimable; supersede/clear paths delete them. + .where("create_time", ">", Date.now() - MCP_OAUTH_PENDING_STATE_TTL_MS), + ).numAffectedRows === 1n + ); + }); +} + +/** Replace one store's pending callback state after OAuth persisted its session. */ +export function writeMcpOAuthPendingAuthorization( + storeKey: string, + state: string, + assertOwnedInTransaction?: (database: DatabaseSync) => void, +): void { + runPendingWrite((database) => { + const now = Date.now(); + assertOwnedInTransaction?.(database); + executeSqliteQuerySync( + database, + getNodeSqliteKysely(database) + .deleteFrom("mcp_oauth_pending_authorizations") + .where("create_time", "<=", now - MCP_OAUTH_PENDING_STATE_TTL_MS), + ); + deletePendingForStore(database, storeKey); + executeSqliteQuerySync( + database, + getNodeSqliteKysely(database) + .insertInto("mcp_oauth_pending_authorizations") + .values({ state, store_key: storeKey, create_time: now }), + ); + }); +} + +/** Delete callback correlation for one settled or cleared OAuth store. */ +export function deleteMcpOAuthPendingAuthorization( + storeKey: string, + assertOwnedInTransaction?: (database: DatabaseSync) => void, +): void { + runPendingWrite((database) => { + deletePendingForStore(database, storeKey, assertOwnedInTransaction); + }); +} + +/** Delete callback correlation for every requester store under one server key prefix. */ +export function deleteMcpOAuthPendingAuthorizationsByPrefix(prefix: string): void { + runPendingWrite((database) => { + // Requester store-key grammar excludes SQL wildcard bytes; changing it without + // escaping here could clear unrelated principals. + executeSqliteQuerySync( + database, + getNodeSqliteKysely(database) + .deleteFrom("mcp_oauth_pending_authorizations") + .where("store_key", "like", `${prefix}%`), + ); + }); +} + function replaceMcpOAuthStore( database: DatabaseSync, storeKey: string, @@ -302,7 +455,8 @@ export function clearMcpOAuthStore( ): void { // Explicit provenance distinguishes logout from challenge-only bootstrap state. // Doctor imports retired credentials only into an `uninitialized` row. - runOpenClawStateWriteTransaction(({ db }) => { + runPendingWrite((db) => { replaceMcpOAuthStore(db, storeKey, { credentialState: "cleared" }, assertOwnedInTransaction); + deletePendingForStore(db, storeKey, assertOwnedInTransaction); }); } diff --git a/src/agents/mcp-oauth.test.ts b/src/agents/mcp-oauth.test.ts index aa1e9f74129c..2916e37f525c 100644 --- a/src/agents/mcp-oauth.test.ts +++ b/src/agents/mcp-oauth.test.ts @@ -1,4 +1,3 @@ -// Covers MCP OAuth token persistence, isolation, and noninteractive behavior. import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; @@ -6,29 +5,76 @@ import path from "node:path"; import { withTempHome as withBaseTempHome } from "openclaw/plugin-sdk/test-env"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { vi } from "vitest"; +import type { McpServerConfig } from "../config/types.mcp.js"; +import { handleMcpOAuthCallback } from "../gateway/mcp-oauth-callback.js"; +import { createRequest, createResponse } from "../gateway/server-http.test-harness.js"; import { closeOpenClawStateDatabaseForTest, openOpenClawStateDatabase, } from "../state/openclaw-state-db.js"; import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js"; import { getFreePort } from "../test-utils/ports.js"; -import { operatorMcpOAuthIdentity, type McpOAuthIdentity } from "./mcp-oauth-identity.js"; +import { + operatorMcpOAuthIdentity, + requesterMcpOAuthIdentity, + type McpOAuthIdentity, +} from "./mcp-oauth-identity.js"; import { createMcpOAuthClientProvider } from "./mcp-oauth-provider.js"; +import { readMcpOAuthPendingAuthorization as readPending } from "./mcp-oauth-store.js"; import { readMcpOAuthStore, updateMcpOAuthStore } from "./mcp-oauth-store.js"; import { clearMcpOAuthCredentials, + clearMcpOAuthServer, completeMcpOAuthAuthorization, + countMcpOAuthPrincipals, readMcpOAuthCredentialsStatus, recordMcpOAuthAuthorizationRequired, resolveMcpOAuthAccessToken, startMcpOAuthAuthorization, } from "./mcp-oauth.js"; +import { resolveMcpTransportConfig } from "./mcp-transport-config.js"; const authMock = vi.hoisted(() => vi.fn()); const ROTATED_ACCESS = "gateway-token"; const LEGACY_ACCESS = "example"; const REMOTE_IDENTITY = operatorMcpOAuthIdentity("Remote Docs", "https://mcp.example.com/mcp"); const CALENDLY_IDENTITY = operatorMcpOAuthIdentity("Calendly", "https://mcp.calendly.com/"); +const REQUESTER_SCOPE = { messageChannel: "telegram", agentAccountId: "bot" } as const; + +function requesterIdentity(serverName: string, serverUrl: string, requesterSenderId: string) { + return requesterMcpOAuthIdentity(serverName, serverUrl, { + ...REQUESTER_SCOPE, + requesterSenderId, + }); +} + +async function saveAccessToken(identity: McpOAuthIdentity, accessToken: string): Promise { + await createMcpOAuthClientProvider({ identity }).saveTokens({ + access_token: accessToken, + token_type: "Bearer", + expires_in: 3600, + }); +} + +async function runGatewayOAuthCallback(params: { + serverName: string; + server: McpServerConfig; + code: string; + state: string; +}) { + const response = createResponse(); + await handleMcpOAuthCallback( + createRequest({ + path: `/oauth/mcp/callback?code=${params.code}&state=${params.state}`, + }), + response.res, + { + config: { mcp: { servers: { [params.serverName]: params.server } } }, + log: { warn: vi.fn() }, + }, + ); + return response; +} function resolvedOAuthConfig(identity: McpOAuthIdentity) { return { @@ -231,8 +277,7 @@ describe("MCP OAuth provider", () => { scope: "docs.write", }); await expect(readMcpOAuthCredentialsStatus(REMOTE_IDENTITY)).resolves.toMatchObject({ - hasTokens: true, - requiresAuthorization: true, + state: "requires-authorization", }); const storeKey = REMOTE_IDENTITY.storeKey; @@ -637,12 +682,7 @@ describe("MCP OAuth provider", () => { await withTempHome( async () => { await expect(readMcpOAuthCredentialsStatus(REMOTE_IDENTITY)).resolves.toEqual({ - hasTokens: false, - requiresAuthorization: false, - hasClientInformation: false, - hasCodeVerifier: false, - hasDiscoveryState: false, - hasLastAuthorizationUrl: false, + state: "unauthenticated", }); await expect(fs.stat(resolveOpenClawStateSqlitePath())).rejects.toMatchObject({ code: "ENOENT", @@ -732,26 +772,35 @@ describe("MCP OAuth provider", () => { ); }); - it("isolates token state by configured server URL", async () => { + it("isolates, counts, and clears requester credentials by configured server", async () => { await withTempHome( async () => { - const first = createMcpOAuthClientProvider({ - identity: REMOTE_IDENTITY, - }); - const second = createMcpOAuthClientProvider({ - identity: operatorMcpOAuthIdentity("Remote Docs", "https://other.example.com/mcp"), - }); - await first.saveTokens({ access_token: "access", token_type: "Bearer" }); + const serverUrl = "https://mcp.example.com/shared"; + const alice = requesterIdentity("Shared", serverUrl, "alice"); + const bob = requesterIdentity("Shared", serverUrl, "bob"); + const other = requesterIdentity("Shared", "https://other.example.com/mcp", "alice"); + await saveAccessToken(alice, "alice-token"); + await saveAccessToken(bob, "bob-token"); + await saveAccessToken(other, "other-token"); - expect(second.tokens()).toBeUndefined(); + closeOpenClawStateDatabaseForTest(); + await expect(resolveMcpOAuthAccessToken({ identity: alice })).resolves.toBe("alice-token"); + await expect(resolveMcpOAuthAccessToken({ identity: bob })).resolves.toBe("bob-token"); + expect(alice.storeKey).not.toBe(bob.storeKey); + expect(countMcpOAuthPrincipals(operatorMcpOAuthIdentity("Shared", serverUrl))).toBe(2); + + await clearMcpOAuthServer(operatorMcpOAuthIdentity("Shared", serverUrl)); + for (const identity of [alice, bob]) { + await expect(readMcpOAuthCredentialsStatus(identity)).resolves.toEqual({ + state: "unauthenticated", + }); + } + await expect(resolveMcpOAuthAccessToken({ identity: other })).resolves.toBe("other-token"); }, { - prefix: "openclaw-mcp-oauth-url-", + prefix: "openclaw-mcp-oauth-requesters-", skipSessionCleanup: true, - env: { - OPENCLAW_CONFIG_PATH: undefined, - OPENCLAW_STATE_DIR: undefined, - }, + env: { OPENCLAW_CONFIG_PATH: undefined, OPENCLAW_STATE_DIR: undefined }, }, ); }); @@ -868,8 +917,6 @@ describe("MCP OAuth provider", () => { }); it("does not start hidden authorization flows without an authorization callback", async () => { - // Normal agent/tool execution must not open browser auth flows implicitly; - // operators use the explicit mcp login command instead. await withTempHome( async () => { const provider = createMcpOAuthClientProvider({ @@ -927,11 +974,20 @@ describe("MCP OAuth provider", () => { >("@modelcontextprotocol/sdk/client/auth.js"); authMock.mockImplementation(realAuth); const fixture = await startAuthorizationServer(await getFreePort()); - const identity = operatorMcpOAuthIdentity("fixture", `${fixture.issuer}/mcp`); - const config = { - ...resolvedOAuthConfig(identity), - oauth: { redirectUrl: "http://127.0.0.1:8989/oauth/callback" }, + const rawServer = { + url: `${fixture.issuer}/mcp`, + transport: "streamable-http" as const, + auth: "oauth" as const, + oauth: { + identity: "per-requester" as const, + redirectUrl: "https://gateway.example.com/oauth/mcp/callback", + }, }; + const config = resolveMcpTransportConfig("fixture", rawServer); + if (config?.kind !== "http") { + throw new Error("expected HTTP MCP OAuth config"); + } + const identity = requesterIdentity("fixture", config.url, "sender-a"); try { const first = await startMcpOAuthAuthorization(identity, config, {}); if (first.status !== "redirect") { @@ -942,43 +998,57 @@ describe("MCP OAuth provider", () => { lastAuthorizationUrl: first.authorizationUrl, redirectUrl: first.redirectUrl, }); - closeOpenClawStateDatabaseForTest(); - await expect( - completeMcpOAuthAuthorization(identity, config, { - code: authorizationCode(first.authorizationUrl), - }), - ).resolves.toBe("authorized"); + const callbacks = await Promise.all( + [0, 1].map(() => + runGatewayOAuthCallback({ + serverName: "fixture", + server: rawServer, + code: authorizationCode(first.authorizationUrl), + state: first.state, + }), + ), + ); + expect(callbacks.map(({ res }) => res.statusCode).toSorted((a, b) => a - b)).toEqual([ + 200, 404, + ]); + expect(readMcpOAuthStore(identity.storeKey)).toMatchObject({ + tokens: { access_token: expect.any(String) }, + }); expect(readMcpOAuthStore(identity.storeKey)).not.toHaveProperty("codeVerifier"); - const second = await startMcpOAuthAuthorization(identity, config, {}); + const secondIdentity = requesterIdentity("fixture", config.url, "sender-b"); + const second = await startMcpOAuthAuthorization(secondIdentity, config, {}); if (second.status !== "redirect") { throw new Error("expected second MCP OAuth redirect"); } await expect( - completeMcpOAuthAuthorization(identity, config, { code: "wrong-code" }), + completeMcpOAuthAuthorization(secondIdentity, config, { code: "wrong-code" }), ).rejects.toThrow(); - expect(readMcpOAuthStore(identity.storeKey)).toMatchObject({ + expect(readMcpOAuthStore(secondIdentity.storeKey)).toMatchObject({ lastAuthorizationUrl: second.authorizationUrl, redirectUrl: second.redirectUrl, codeVerifier: expect.any(String), }); + expect(readMcpOAuthStore(secondIdentity.storeKey)).not.toHaveProperty("tokens"); - const third = await startMcpOAuthAuthorization(identity, config, {}); + const third = await startMcpOAuthAuthorization(secondIdentity, config, {}); if (third.status !== "redirect") { throw new Error("expected third MCP OAuth redirect"); } expect(third.authorizationUrl).not.toBe(second.authorizationUrl); + expect(readPending(second.state)).toBeUndefined(); + expect(readPending(third.state)).toBe(secondIdentity.storeKey); await expect( - completeMcpOAuthAuthorization(identity, config, { + completeMcpOAuthAuthorization(secondIdentity, config, { code: authorizationCode(second.authorizationUrl), }), ).rejects.toThrow(); - expect(readMcpOAuthStore(identity.storeKey).lastAuthorizationUrl).toBe( + expect(readMcpOAuthStore(secondIdentity.storeKey).lastAuthorizationUrl).toBe( third.authorizationUrl, ); await expect( - completeMcpOAuthAuthorization(identity, config, { + completeMcpOAuthAuthorization(secondIdentity, config, { code: authorizationCode(third.authorizationUrl), }), ).resolves.toBe("authorized"); diff --git a/src/agents/mcp-oauth.ts b/src/agents/mcp-oauth.ts index 890e2eaba13e..74c7ff268eae 100644 --- a/src/agents/mcp-oauth.ts +++ b/src/agents/mcp-oauth.ts @@ -11,7 +11,7 @@ import { withoutMcpAuthorizationHeader, withSameOriginMcpHttpHeaders, } from "./mcp-http-fetch.js"; -import type { McpOAuthIdentity } from "./mcp-oauth-identity.js"; +import { requesterMcpOAuthStoreKeyPrefix, type McpOAuthIdentity } from "./mcp-oauth-identity.js"; import { bindMcpOAuthLeaseAssertion, createMcpOAuthClientProvider, @@ -20,9 +20,14 @@ import { } from "./mcp-oauth-provider.js"; import { clearMcpOAuthStore, + consumeOAuthState, + deleteMcpOAuthPendingAuthorization, + deleteMcpOAuthPendingAuthorizationsByPrefix, + listMcpOAuthStoreKeysByPrefix, readMcpOAuthStore, readMcpOAuthStoreReadOnly, updateMcpOAuthStore, + writeMcpOAuthPendingAuthorization, type McpOAuthStore, } from "./mcp-oauth-store.js"; import type { resolveMcpTransportConfig } from "./mcp-transport-config.js"; @@ -38,15 +43,12 @@ type McpOAuthAuthorizationStartResult = | { status: "authorized" } | { status: "redirect"; authorizationUrl: string; redirectUrl: string; state: string }; -/** Persisted OAuth credential presence and authorization state for one MCP server. */ -export type McpOAuthCredentialsStatus = { - hasTokens: boolean; - requiresAuthorization: boolean; - hasClientInformation: boolean; - hasCodeVerifier: boolean; - hasDiscoveryState: boolean; - hasLastAuthorizationUrl: boolean; -}; +/** Persisted OAuth authorization state for one principal and MCP server. */ +export type McpOAuthPrincipalStatus = + | { state: "authorized"; expiresAt?: number } + | { state: "requires-authorization" } + | { state: "pending-authorization" } + | { state: "unauthenticated" }; const LOCALHOST_REDIRECT_URL = "http://localhost:8989/oauth/callback"; const TOKEN_EXPIRY_SKEW_MS = 30_000; @@ -291,24 +293,64 @@ export async function recordMcpOAuthAuthorizationRequired(params: { /** Deletes one OAuth session without racing an in-flight refresh or login. */ export async function clearMcpOAuthCredentials(identity: McpOAuthIdentity): Promise { - await withMcpOAuthLease(identity.storeKey, async (lease) => { - clearMcpOAuthStore(identity.storeKey, bindMcpOAuthLeaseAssertion(lease)); + await clearMcpOAuthStoreKey(identity.storeKey); +} + +async function clearMcpOAuthStoreKey(storeKey: string): Promise { + await withMcpOAuthLease(storeKey, async (lease) => { + clearMcpOAuthStore(storeKey, bindMcpOAuthLeaseAssertion(lease)); }); } +/** Clear operator and requester credentials bound to one configured server URL. */ +export async function clearMcpOAuthServer(identity: McpOAuthIdentity): Promise { + await clearMcpOAuthStoreKey(identity.storeKey); + await clearMcpOAuthRequesters(identity); +} + +/** Clear requester credentials without changing the operator row for this server URL. */ +export async function clearMcpOAuthRequesters(identity: McpOAuthIdentity): Promise { + const prefix = requesterMcpOAuthStoreKeyPrefix(identity.serverName, identity.serverUrl); + const requesterKeys = listMcpOAuthStoreKeysByPrefix(prefix); + for (const storeKey of requesterKeys) { + await clearMcpOAuthStoreKey(storeKey); + } + deleteMcpOAuthPendingAuthorizationsByPrefix(prefix); +} + +/** Count authorized requester principals for one configured server URL. */ +export function countMcpOAuthPrincipals(identity: McpOAuthIdentity): number { + const prefix = requesterMcpOAuthStoreKeyPrefix(identity.serverName, identity.serverUrl); + return listMcpOAuthStoreKeysByPrefix(prefix).filter( + (storeKey) => readMcpOAuthStoreReadOnly(storeKey).tokens !== undefined, + ).length; +} + /** Reads stored OAuth credential presence without exposing values or creating state. */ export async function readMcpOAuthCredentialsStatus( identity: McpOAuthIdentity, -): Promise { +): Promise { const store = readMcpOAuthStoreReadOnly(identity.storeKey); - return { - hasTokens: Boolean(store.tokens), - requiresAuthorization: store.pendingAuthorizationChallenge?.requiresAuthorization === true, - hasClientInformation: Boolean(store.clientInformation), - hasCodeVerifier: Boolean(store.codeVerifier), - hasDiscoveryState: Boolean(store.discoveryState), - hasLastAuthorizationUrl: Boolean(store.lastAuthorizationUrl), - }; + if (store.pendingAuthorizationChallenge?.requiresAuthorization === true) { + return { state: "requires-authorization" }; + } + if (store.tokens) { + return { + state: "authorized", + ...(store.tokenExpiresAt === undefined ? {} : { expiresAt: store.tokenExpiresAt }), + }; + } + if ( + store.clientInformation || + store.codeVerifier || + store.discoveryState || + store.lastAuthorizationUrl || + store.redirectUrl || + store.pendingAuthorizationChallenge + ) { + return { state: "pending-authorization" }; + } + return { state: "unauthenticated" }; } function buildMcpOAuthAuthorizationFetch(config: ResolvedHttpMcpTransportConfig): FetchLike { @@ -412,6 +454,7 @@ export async function startMcpOAuthAuthorization( if (!authorizationUrl || !pending.codeVerifier || !pending.redirectUrl || !state) { throw new Error("MCP OAuth authorization session was not persisted."); } + writeMcpOAuthPendingAuthorization(storeKey, state, bindMcpOAuthLeaseAssertion(lease)); return { status: "redirect", authorizationUrl, redirectUrl: pending.redirectUrl, state }; }); } @@ -423,36 +466,78 @@ export async function completeMcpOAuthAuthorization( ): Promise<"authorized"> { const storeKey = identity.storeKey; return await withMcpOAuthLease<"authorized">(storeKey, async (lease) => { - const store = readMcpOAuthStore(storeKey); - if (!store.codeVerifier || !store.redirectUrl) { - throw new Error("Missing MCP OAuth authorization session. Run the login flow again."); - } - const pendingChallenge = store.pendingAuthorizationChallenge; - await runMcpOAuthAuthorizationAttempt( - { - identity, - config: { ...config.oauth, redirectUrl: store.redirectUrl }, - fetchFn: buildMcpOAuthAuthorizationFetch(config), - authorizationCode: input.code, - resourceMetadataUrl: pendingChallenge?.resourceMetadataUrl - ? new URL(pendingChallenge.resourceMetadataUrl) - : undefined, - scope: normalizeOptionalString(pendingChallenge?.scope), - suppressStoredTokens: pendingChallenge?.requiresAuthorization === true, - }, - lease, - ); - updateMcpOAuthStore( - storeKey, - (current) => { - const next = { ...current }; - delete next.codeVerifier; - delete next.lastAuthorizationUrl; - delete next.redirectUrl; - return next; - }, - bindMcpOAuthLeaseAssertion(lease), - ); - return "authorized"; + return await completeMcpOAuthAuthorizationUnderLease(identity, config, input, lease); + }); +} + +function readMcpOAuthAuthorizationState(authorizationUrl: string | undefined): string | undefined { + if (!authorizationUrl) { + return undefined; + } + try { + return normalizeOptionalString(new URL(authorizationUrl).searchParams.get("state")); + } catch { + return undefined; + } +} + +async function completeMcpOAuthAuthorizationUnderLease( + identity: McpOAuthIdentity, + config: ResolvedHttpMcpTransportConfig, + input: { code: string }, + lease: OpenClawStateLeaseContext, +): Promise<"authorized"> { + const storeKey = identity.storeKey; + const store = readMcpOAuthStore(storeKey); + if (!store.codeVerifier || !store.redirectUrl) { + throw new Error("Missing MCP OAuth authorization session. Run the login flow again."); + } + const pendingChallenge = store.pendingAuthorizationChallenge; + await runMcpOAuthAuthorizationAttempt( + { + identity, + config: { ...config.oauth, redirectUrl: store.redirectUrl }, + fetchFn: buildMcpOAuthAuthorizationFetch(config), + authorizationCode: input.code, + resourceMetadataUrl: pendingChallenge?.resourceMetadataUrl + ? new URL(pendingChallenge.resourceMetadataUrl) + : undefined, + scope: normalizeOptionalString(pendingChallenge?.scope), + suppressStoredTokens: pendingChallenge?.requiresAuthorization === true, + }, + lease, + ); + const assertLeaseOwned = bindMcpOAuthLeaseAssertion(lease); + updateMcpOAuthStore( + storeKey, + (current) => { + const next = { ...current }; + delete next.codeVerifier; + delete next.lastAuthorizationUrl; + delete next.redirectUrl; + return next; + }, + assertLeaseOwned, + ); + deleteMcpOAuthPendingAuthorization(storeKey, assertLeaseOwned); + return "authorized"; +} + +/** Claims one callback state and completes its exchange under the same store lease. */ +export async function completeOAuthCallback( + identity: McpOAuthIdentity, + config: ResolvedHttpMcpTransportConfig, + input: { code: string; state: string }, +): Promise<"authorized" | "expired"> { + return await withMcpOAuthLease(identity.storeKey, async (lease) => { + const assertLeaseOwned = bindMcpOAuthLeaseAssertion(lease); + if (!consumeOAuthState(identity.storeKey, input.state, assertLeaseOwned)) { + return "expired"; + } + const store = readMcpOAuthStore(identity.storeKey); + if (readMcpOAuthAuthorizationState(store.lastAuthorizationUrl) !== input.state) { + return "expired"; + } + return await completeMcpOAuthAuthorizationUnderLease(identity, config, input, lease); }); } diff --git a/src/agents/mcp-transport-config.ts b/src/agents/mcp-transport-config.ts index 37e2ee4436fb..92de90a0db33 100644 --- a/src/agents/mcp-transport-config.ts +++ b/src/agents/mcp-transport-config.ts @@ -15,6 +15,7 @@ import { resolveHttpMcpServerLaunchConfig, type HttpMcpTransportType, } from "./mcp-http.js"; +import type { McpOAuthConfig } from "./mcp-oauth-provider.js"; import { describeStdioMcpServerLaunchConfig, resolveStdioMcpServerLaunchConfig, @@ -39,13 +40,18 @@ type ResolvedStdioMcpTransportConfig = ResolvedBaseMcpTransportConfig & { cwd?: string; }; +type ResolvedMcpOAuthConfig = McpOAuthConfig & { + identity?: "shared" | "per-requester"; + authProfileId?: unknown; +}; + type ResolvedHttpMcpTransportConfig = ResolvedBaseMcpTransportConfig & { kind: "http"; transportType: HttpMcpTransportType; url: string; headers?: Record; auth?: "oauth"; - oauth?: Record; + oauth?: ResolvedMcpOAuthConfig; sslVerify?: boolean; clientCert?: string; clientKey?: string; @@ -174,7 +180,7 @@ function resolveHttpTransportConfig( (rawServer as { oauth?: unknown }).oauth && typeof (rawServer as { oauth?: unknown }).oauth === "object" && !Array.isArray((rawServer as { oauth?: unknown }).oauth) - ? { oauth: (rawServer as { oauth: Record }).oauth } + ? { oauth: (rawServer as { oauth: ResolvedMcpOAuthConfig }).oauth } : {}), ...(getBooleanField(rawServer, ["sslVerify"]) !== undefined ? { sslVerify: getBooleanField(rawServer, ["sslVerify"]) } diff --git a/src/agents/mcp-transport.test.ts b/src/agents/mcp-transport.test.ts index ed9205cce380..d9bd6a817ac7 100644 --- a/src/agents/mcp-transport.test.ts +++ b/src/agents/mcp-transport.test.ts @@ -1,5 +1,7 @@ // Covers MCP HTTP transport redirects, SSRF guardrails, and auth/TLS handoff. import { beforeEach, describe, expect, it, vi } from "vitest"; +import { partitionMcpServersByConnectionScope } from "./mcp-connection-resolver.js"; +import type { McpOAuthIdentity } from "./mcp-oauth-identity.js"; import { resolveMcpTransport } from "./mcp-transport.js"; type StreamableTransportOptions = { @@ -17,7 +19,9 @@ const { } = vi.hoisted(() => ({ lookupMock: vi.fn(), runtimeFetchMock: vi.fn(), - oauthBearerMock: vi.fn((params: { fetchFn: unknown }) => params.fetchFn), + oauthBearerMock: vi.fn( + (params: { fetchFn: unknown; identity: McpOAuthIdentity }) => params.fetchFn, + ), streamableTransportConstructorMock: vi.fn(), sseTransportConstructorMock: vi.fn(), })); @@ -328,6 +332,54 @@ describe("resolveMcpTransport", () => { ); }); + it("selects distinct requester OAuth identities for the same configured server", () => { + const server = { + url: "https://mcp.example.com/mcp", + transport: "streamable-http", + auth: "oauth", + oauth: { identity: "per-requester" }, + }; + for (const requesterSenderId of ["alice", "bob"]) { + resolveMcpTransport("probe", server, { + requesterScope: { + messageChannel: "telegram", + agentAccountId: "bot", + requesterSenderId, + }, + }); + } + + const identities = oauthBearerMock.mock.calls.slice(-2).map(([params]) => params.identity); + expect(identities.map((identity) => identity.principal)).toEqual(["requester", "requester"]); + expect(identities[0]?.storeKey).not.toBe(identities[1]?.storeKey); + expect(identities.map((identity) => identity.serverUrl)).toEqual([ + "https://mcp.example.com/mcp", + "https://mcp.example.com/mcp", + ]); + + const partition = partitionMcpServersByConnectionScope({ + shared: { command: "true" }, + calendar: server, + }); + expect(Object.keys(partition.staticServers)).toEqual(["shared"]); + expect(partition.requesterScopedServerNames).toEqual(["calendar"]); + expect(partition.oauthRequesterServerNames).toEqual(["calendar"]); + expect(partition.resolverRequesterServerNames).toEqual([]); + }); + + it("does not create an operator transport for per-requester OAuth", () => { + const transport = resolveMcpTransport("probe", { + url: "https://mcp.example.com/mcp", + transport: "streamable-http", + auth: "oauth", + oauth: { identity: "per-requester" }, + }); + + expect(transport).toBeNull(); + expect(oauthBearerMock).not.toHaveBeenCalled(); + expect(streamableTransportConstructorMock).not.toHaveBeenCalled(); + }); + it("keeps OAuth runtime headers scoped to the MCP resource origin", async () => { runtimeFetchMock.mockImplementation(async () => new Response("ok")); diff --git a/src/agents/mcp-transport.ts b/src/agents/mcp-transport.ts index bfd22bf52462..ed176808a0b6 100644 --- a/src/agents/mcp-transport.ts +++ b/src/agents/mcp-transport.ts @@ -13,6 +13,7 @@ import type { FetchLike, Transport } from "@modelcontextprotocol/sdk/shared/tran import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { logDebug } from "../logger.js"; +import type { SessionMcpRequesterScope } from "./agent-bundle-mcp-types.js"; import { resolveMcpAuthProfileId, withMcpAuthProfileBearer } from "./mcp-auth-profile.js"; import { buildMcpHttpFetch, @@ -20,7 +21,7 @@ import { withSameOriginMcpHttpHeaders, } from "./mcp-http-fetch.js"; import { withMcpOAuthBearer } from "./mcp-oauth-fetch.js"; -import { operatorMcpOAuthIdentity } from "./mcp-oauth-identity.js"; +import { operatorMcpOAuthIdentity, requesterMcpOAuthIdentity } from "./mcp-oauth-identity.js"; import { OpenClawStdioClientTransport } from "./mcp-stdio-transport.js"; import { resolveMcpTransportConfig } from "./mcp-transport-config.js"; @@ -92,7 +93,12 @@ function buildSseEventSourceFetch( export function resolveMcpTransport( serverName: string, rawServer: unknown, - options?: { cfg?: OpenClawConfig; agentDir?: string; prepareDataDir?: string }, + options?: { + cfg?: OpenClawConfig; + agentDir?: string; + prepareDataDir?: string; + requesterScope?: SessionMcpRequesterScope; + }, ): ResolvedMcpTransport | null { const resolved = resolveMcpTransportConfig(serverName, rawServer); if (!resolved) { @@ -118,7 +124,16 @@ export function resolveMcpTransport( }; } const authProfileId = resolveMcpAuthProfileId(rawServer); - const oauthIdentity = operatorMcpOAuthIdentity(serverName, resolved.url); + const requesterScope = options?.requesterScope; + let oauthIdentity; + if (resolved.oauth?.identity === "per-requester") { + if (!requesterScope) { + return null; + } + oauthIdentity = requesterMcpOAuthIdentity(serverName, resolved.url, requesterScope); + } else { + oauthIdentity = operatorMcpOAuthIdentity(serverName, resolved.url); + } // The SDK reuses one fetch for OAuth and long-lived SSE/streamable bodies. // Per-RPC deadlines belong to client calls, not this transport fetch. const baseFetch = buildMcpHttpFetch({ diff --git a/src/auto-reply/reply/agent-runner-result-payloads.ts b/src/auto-reply/reply/agent-runner-result-payloads.ts index dddb4d300806..c48376e407b6 100644 --- a/src/auto-reply/reply/agent-runner-result-payloads.ts +++ b/src/auto-reply/reply/agent-runner-result-payloads.ts @@ -38,6 +38,7 @@ import type { accountAgentTurn } from "./agent-runner-result-accounting.js"; import type { FinalizeReplyAgentRunInput } from "./agent-runner-result.types.js"; import { resolveResponseUsageLine } from "./agent-runner-usage-line.js"; import { attachMcpAppChannelAction } from "./mcp-app-channel-action.js"; +import { attachMcpConnectChannelAction } from "./mcp-connect-channel-action.js"; import { normalizeReplyPayload } from "./normalize-reply.js"; import { resolveOriginMessageTo } from "./origin-routing.js"; import { createReplyToModeFilterForChannel } from "./reply-threading.js"; @@ -413,6 +414,10 @@ export async function prepareReplyAgentPayloads(state: { sessionKey, view: runResult.latestMcpAppChannelView, }); + replyPayloads = attachMcpConnectChannelAction({ + payloads: replyPayloads, + action: runResult.latestMcpConnectAction, + }); const hasVisibleReplyPayload = replyPayloads.some( (payload) => diff --git a/src/auto-reply/reply/commands-mcp.test.ts b/src/auto-reply/reply/commands-mcp.test.ts index da79becc9602..382b85af4804 100644 --- a/src/auto-reply/reply/commands-mcp.test.ts +++ b/src/auto-reply/reply/commands-mcp.test.ts @@ -20,6 +20,9 @@ vi.mock("../../config/mcp-config.js", () => ({ config: {}, mcpServers: Object.fromEntries(mcpServers), })), +})); + +vi.mock("../../agents/mcp-config-mutation.js", () => ({ setConfiguredMcpServer: vi.fn(async ({ name, server }) => { mcpServers.set(name, { ...(server as Record) }); return { diff --git a/src/auto-reply/reply/commands-mcp.ts b/src/auto-reply/reply/commands-mcp.ts index 1a6c850b93c7..93a568fea8fb 100644 --- a/src/auto-reply/reply/commands-mcp.ts +++ b/src/auto-reply/reply/commands-mcp.ts @@ -1,10 +1,10 @@ /** Handles /mcp commands for showing and mutating configured MCP servers. */ import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { - listConfiguredMcpServers, setConfiguredMcpServer, unsetConfiguredMcpServer, -} from "../../config/mcp-config.js"; +} from "../../agents/mcp-config-mutation.js"; +import { listConfiguredMcpServers } from "../../config/mcp-config.js"; import { redactSensitiveArgv } from "../../config/redact-argv.js"; import { REDACTED_SENTINEL, redactConfigObject } from "../../config/redact-snapshot.js"; import { buildConfigSchemaCore } from "../../config/schema.js"; diff --git a/src/auto-reply/reply/mcp-connect-channel-action.test.ts b/src/auto-reply/reply/mcp-connect-channel-action.test.ts new file mode 100644 index 000000000000..79dbddcbd9ba --- /dev/null +++ b/src/auto-reply/reply/mcp-connect-channel-action.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { renderMessagePresentationFallbackText } from "../../interactive/payload.js"; +import { attachMcpConnectChannelAction } from "./mcp-connect-channel-action.js"; + +describe("attachMcpConnectChannelAction", () => { + it("adds one portable URL action to the final visible reply", () => { + const payloads = attachMcpConnectChannelAction({ + payloads: [{ text: "progress", isStatusNotice: true }, { text: "Sign in to continue." }], + action: { + serverName: "calendar", + authorizationUrl: "https://auth.example/authorize?state=opaque", + }, + }); + + expect(renderMessagePresentationFallbackText(payloads[1]!)).toBe( + "Sign in to continue.\n\n- Connect calendar: https://auth.example/authorize?state=opaque", + ); + }); + + it("preserves payloads without an action or eligible terminal reply", () => { + const payloads = [{ text: "failed", isError: true }]; + expect(attachMcpConnectChannelAction({ payloads })).toBe(payloads); + expect( + attachMcpConnectChannelAction({ + payloads, + action: { serverName: "calendar", authorizationUrl: "https://auth.example/authorize" }, + }), + ).toBe(payloads); + }); +}); diff --git a/src/auto-reply/reply/mcp-connect-channel-action.ts b/src/auto-reply/reply/mcp-connect-channel-action.ts new file mode 100644 index 000000000000..f0550a364560 --- /dev/null +++ b/src/auto-reply/reply/mcp-connect-channel-action.ts @@ -0,0 +1,44 @@ +import type { McpConnectAction } from "../../agents/mcp-connect-action.js"; +import { isReplyPayloadStatusNotice } from "../reply-payload.js"; +import type { ReplyPayload } from "../types.js"; + +function isEligibleTerminalPayload(payload: ReplyPayload): boolean { + return Boolean( + payload.text?.trim() && + payload.isError !== true && + payload.isReasoning !== true && + payload.isCommentary !== true && + !isReplyPayloadStatusNotice(payload), + ); +} + +export function attachMcpConnectChannelAction(params: { + payloads: ReplyPayload[]; + action?: McpConnectAction; +}): ReplyPayload[] { + if (!params.action) { + return params.payloads; + } + const index = params.payloads.findLastIndex(isEligibleTerminalPayload); + if (index < 0) { + return params.payloads; + } + const block = { + type: "buttons" as const, + buttons: [ + { + label: `Connect ${params.action.serverName}`, + action: { type: "url" as const, url: params.action.authorizationUrl }, + }, + ], + }; + const payloads = params.payloads.slice(); + const payload = payloads[index]!; + payloads[index] = { + ...payload, + presentation: payload.presentation + ? { ...payload.presentation, blocks: [...payload.presentation.blocks, block] } + : { blocks: [block] }, + }; + return payloads; +} diff --git a/src/claws/lifecycle-mcp-removal.ts b/src/claws/lifecycle-mcp-removal.ts index 5d912a9f74bb..2e64d31e58b4 100644 --- a/src/claws/lifecycle-mcp-removal.ts +++ b/src/claws/lifecycle-mcp-removal.ts @@ -1,5 +1,6 @@ +import { unsetConfiguredMcpServer } from "../agents/mcp-config-mutation.js"; import { normalizeConfiguredMcpServers } from "../config/mcp-config-normalize.js"; -import { listConfiguredMcpServers, unsetConfiguredMcpServer } from "../config/mcp-config.js"; +import { listConfiguredMcpServers } from "../config/mcp-config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js"; import { ClawRemoveError } from "./lifecycle-delete-support.js"; diff --git a/src/claws/lifecycle-state.ts b/src/claws/lifecycle-state.ts index 41f45340b1b9..a7d1351e5947 100644 --- a/src/claws/lifecycle-state.ts +++ b/src/claws/lifecycle-state.ts @@ -1,7 +1,8 @@ import { createHash } from "node:crypto"; import { stableStringify } from "@openclaw/normalization-core"; +import { unsetConfiguredMcpServer } from "../agents/mcp-config-mutation.js"; import { getRuntimeConfig } from "../config/config.js"; -import { listConfiguredMcpServers, unsetConfiguredMcpServer } from "../config/mcp-config.js"; +import { listConfiguredMcpServers } from "../config/mcp-config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { closeOpenClawAgentDatabaseByPath, diff --git a/src/claws/mcp-update.ts b/src/claws/mcp-update.ts index 13b2801cf3f2..c7e0ee92a81d 100644 --- a/src/claws/mcp-update.ts +++ b/src/claws/mcp-update.ts @@ -1,5 +1,5 @@ +import { setConfiguredMcpServer, unsetConfiguredMcpServer } from "../agents/mcp-config-mutation.js"; import { normalizeConfiguredMcpServers } from "../config/mcp-config-normalize.js"; -import { setConfiguredMcpServer, unsetConfiguredMcpServer } from "../config/mcp-config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js"; import { diff --git a/src/claws/mcp.ts b/src/claws/mcp.ts index bfd46921b4a0..06285cfcf7d0 100644 --- a/src/claws/mcp.ts +++ b/src/claws/mcp.ts @@ -1,7 +1,8 @@ import { createHash } from "node:crypto"; import { stableStringify } from "@openclaw/normalization-core"; +import { setConfiguredMcpServer } from "../agents/mcp-config-mutation.js"; import { canonicalizeConfiguredMcpServer } from "../config/mcp-config-normalize.js"; -import { listConfiguredMcpServers, setConfiguredMcpServer } from "../config/mcp-config.js"; +import { listConfiguredMcpServers } from "../config/mcp-config.js"; import { openOpenClawStateDatabase, runOpenClawStateWriteTransaction, diff --git a/src/cli/mcp-cli.login-loopback.test.ts b/src/cli/mcp-cli.login-loopback.test.ts index c1e3b405c2cd..8da27aa6b158 100644 --- a/src/cli/mcp-cli.login-loopback.test.ts +++ b/src/cli/mcp-cli.login-loopback.test.ts @@ -33,7 +33,10 @@ vi.mock("../runtime.js", () => ({ defaultRuntime: mocks.runtime })); vi.mock("../mcp/channel-server.js", () => ({ serveOpenClawChannelMcp: vi.fn() })); vi.mock("../agents/mcp-oauth.js", () => ({ clearMcpOAuthCredentials: vi.fn(), + clearMcpOAuthRequesters: vi.fn(), + clearMcpOAuthServer: vi.fn(), completeMcpOAuthAuthorization: mocks.completeMcpOAuthAuthorization, + countMcpOAuthPrincipals: vi.fn(() => 0), readMcpOAuthCredentialsStatus: mocks.readMcpOAuthCredentialsStatus, startMcpOAuthAuthorization: mocks.startMcpOAuthAuthorization, })); @@ -94,12 +97,7 @@ describe("mcp login loopback callback", () => { program = new Command().exitOverride(); registerMcpCli(program); mocks.readMcpOAuthCredentialsStatus.mockResolvedValue({ - hasTokens: false, - requiresAuthorization: false, - hasClientInformation: false, - hasCodeVerifier: false, - hasDiscoveryState: false, - hasLastAuthorizationUrl: false, + state: "unauthenticated", }); }); diff --git a/src/cli/mcp-cli.oauth-integration.test.ts b/src/cli/mcp-cli.oauth-integration.test.ts index ba74782e1900..62e7699263fb 100644 --- a/src/cli/mcp-cli.oauth-integration.test.ts +++ b/src/cli/mcp-cli.oauth-integration.test.ts @@ -3,10 +3,19 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { createServer } from "node:http"; import { Command } from "commander"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { operatorMcpOAuthIdentity } from "../agents/mcp-oauth-identity.js"; +import { + operatorMcpOAuthIdentity, + requesterMcpOAuthIdentity, +} from "../agents/mcp-oauth-identity.js"; +import { + readMcpOAuthPendingAuthorization, + updateMcpOAuthStore, + writeMcpOAuthPendingAuthorization, +} from "../agents/mcp-oauth-store.js"; import { readMcpOAuthCredentialsStatus } from "../agents/mcp-oauth.js"; import { withTempHome } from "../config/home-env.test-harness.js"; import { defaultRuntime } from "../runtime.js"; +import { withOpenClawStateDatabaseReadOnly } from "../state/openclaw-state-db-readonly.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { getFreePort } from "../test-utils/ports.js"; import { registerMcpCli } from "./mcp-cli.js"; @@ -122,6 +131,112 @@ afterEach(() => { }); describe("mcp login OAuth integration", () => { + it("keeps per-requester list, status, and doctor probes read only", async () => { + await withTempHome(`openclaw-mcp-read-only-${randomUUID()}-`, async () => { + const logs: string[] = []; + const json: unknown[] = []; + vi.spyOn(defaultRuntime, "log").mockImplementation((line) => logs.push(String(line))); + vi.spyOn(defaultRuntime, "writeJson").mockImplementation((value) => json.push(value)); + vi.spyOn(defaultRuntime, "exit").mockImplementation(() => undefined); + const program = new Command().exitOverride(); + registerMcpCli(program); + await program.parseAsync( + [ + "mcp", + "set", + "fixture", + JSON.stringify({ + url: "https://mcp.example.com/rpc", + transport: "streamable-http", + auth: "oauth", + oauth: { identity: "per-requester" }, + }), + ], + { from: "user" }, + ); + logs.length = 0; + + await program.parseAsync(["mcp", "list"], { from: "user" }); + expect(logs).toContain("- fixture (0 connected principals)"); + logs.length = 0; + await program.parseAsync(["mcp", "status", "--json"], { from: "user" }); + expect(json.at(-1)).toMatchObject({ + servers: [{ name: "fixture", connectedPrincipals: 0 }], + }); + json.length = 0; + await expect( + program.parseAsync(["mcp", "doctor", "--probe", "--json"], { from: "user" }), + ).rejects.toThrow("MCP doctor found errors"); + expect(json.at(-1)).toMatchObject({ servers: [{ name: "fixture" }] }); + withOpenClawStateDatabaseReadOnly(({ db }) => { + expect(db.prepare("SELECT count(*) AS count FROM mcp_oauth_stores").get()).toEqual({ + count: 0, + }); + expect( + db + .prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?") + .get("mcp_oauth_pending_authorizations"), + ).toBeUndefined(); + }); + }); + }); + + it("clears requester credentials when the same URL changes to shared OAuth", async () => { + await withTempHome(`openclaw-mcp-identity-flip-${randomUUID()}-`, async () => { + const serverUrl = "https://mcp.example.com/rpc"; + const program = new Command().exitOverride(); + registerMcpCli(program); + await program.parseAsync( + [ + "mcp", + "set", + "fixture", + JSON.stringify({ + url: serverUrl, + transport: "streamable-http", + auth: "oauth", + oauth: { identity: "per-requester" }, + }), + ], + { from: "user" }, + ); + const operator = operatorMcpOAuthIdentity("fixture", serverUrl); + const requester = requesterMcpOAuthIdentity("fixture", serverUrl, { + requesterSenderId: "alice", + messageChannel: "telegram", + }); + for (const identity of [operator, requester]) { + updateMcpOAuthStore(identity.storeKey, (store) => ({ + ...store, + tokens: { access_token: identity.principal, token_type: "Bearer" }, + })); + } + writeMcpOAuthPendingAuthorization(requester.storeKey, "requester-state"); + + await program.parseAsync( + [ + "mcp", + "set", + "fixture", + JSON.stringify({ + url: serverUrl, + transport: "streamable-http", + auth: "oauth", + }), + ], + { from: "user" }, + ); + + await expect(readMcpOAuthCredentialsStatus(requester)).resolves.toEqual({ + state: "unauthenticated", + }); + await expect(readMcpOAuthCredentialsStatus(operator)).resolves.toEqual({ + state: "authorized", + }); + expect(readMcpOAuthPendingAuthorization("requester-state")).toBeUndefined(); + }); + }); + it("captures the browser callback, persists tokens, and closes the port", async () => { await withTempHome(`openclaw-mcp-login-${randomUUID()}-`, async () => { const oauthPort = await getFreePort(); @@ -167,9 +282,7 @@ describe("mcp login OAuth integration", () => { operatorMcpOAuthIdentity("fixture", `${fixture.issuer}/mcp`), ), ).resolves.toMatchObject({ - hasTokens: true, - hasCodeVerifier: false, - hasLastAuthorizationUrl: false, + state: "authorized", }); expect(fixture.exchange()).toMatchObject({ tokenRedirectUri: redirectUrl, diff --git a/src/cli/mcp-cli.oauth.test.ts b/src/cli/mcp-cli.oauth.test.ts index 44324620f34b..3d8428102849 100644 --- a/src/cli/mcp-cli.oauth.test.ts +++ b/src/cli/mcp-cli.oauth.test.ts @@ -4,9 +4,12 @@ import { withTempHome } from "../config/home-env.test-harness.js"; import { cleanupMcpCliTestState, clearMcpOAuthCredentials, + countMcpOAuthPrincipals, completeMcpOAuthAuthorization, createWorkspace, + lastErrorLine, lastLogLine, + mockError, mockLog, readMcpOAuthCredentialsStatus, resetMcpCliTestState, @@ -27,12 +30,7 @@ describe("mcp cli OAuth", () => { const workspaceDir = await createWorkspace(); vi.spyOn(process, "cwd").mockReturnValue(workspaceDir); readMcpOAuthCredentialsStatus.mockResolvedValueOnce({ - hasTokens: true, - requiresAuthorization: false, - hasClientInformation: true, - hasCodeVerifier: false, - hasDiscoveryState: true, - hasLastAuthorizationUrl: true, + state: "authorized", }); await runMcpCommand([ @@ -49,12 +47,8 @@ describe("mcp cli OAuth", () => { name: "docs", auth: "oauth", authStatus: { - hasTokens: true, - requiresAuthorization: false, - hasClientInformation: true, - hasCodeVerifier: false, - hasDiscoveryState: true, - hasLastAuthorizationUrl: true, + hasTokens: false, + state: "authorized", }, }); }); @@ -65,12 +59,7 @@ describe("mcp cli OAuth", () => { const workspaceDir = await createWorkspace(); vi.spyOn(process, "cwd").mockReturnValue(workspaceDir); readMcpOAuthCredentialsStatus.mockResolvedValue({ - hasTokens: true, - requiresAuthorization: true, - hasClientInformation: true, - hasCodeVerifier: false, - hasDiscoveryState: true, - hasLastAuthorizationUrl: true, + state: "requires-authorization", }); await runMcpCommand([ @@ -85,7 +74,7 @@ describe("mcp cli OAuth", () => { const statusLines = mockLog.mock.calls.map((call) => String(call[0])); expect(statusLines).toContain("- docs: streamable-http oauth authorization-required"); - expect(statusLines).toContain(" oauth: tokens=yes authorization=required client=yes"); + expect(statusLines).toContain(" oauth: requires-authorization"); mockLog.mockClear(); await runMcpCommand(["mcp", "doctor", "--json"]); @@ -109,6 +98,35 @@ describe("mcp cli OAuth", () => { }); }); + it("shows connected requester principals in list and status output", async () => { + await withTempHome("openclaw-cli-mcp-home-", async () => { + const workspaceDir = await createWorkspace(); + vi.spyOn(process, "cwd").mockReturnValue(workspaceDir); + countMcpOAuthPrincipals.mockReturnValue(2); + + await runMcpCommand([ + "mcp", + "set", + "calendar", + '{"url":"https://mcp.example.com","transport":"streamable-http","auth":"oauth","oauth":{"identity":"per-requester"}}', + ]); + mockLog.mockClear(); + + await runMcpCommand(["mcp", "list"]); + expect(mockLog.mock.calls.map(([line]) => String(line))).toContain( + "- calendar (2 connected principals)", + ); + + mockLog.mockClear(); + await runMcpCommand(["mcp", "status", "--json"]); + expect(JSON.parse(lastLogLine()).servers[0]).toMatchObject({ + name: "calendar", + connectedPrincipals: 2, + }); + expect(readMcpOAuthCredentialsStatus).not.toHaveBeenCalled(); + }); + }); + it("configures enablement, timeouts, and OAuth login", async () => { await withTempHome("openclaw-cli-mcp-home-", async () => { const workspaceDir = await createWorkspace(); @@ -178,6 +196,37 @@ describe("mcp cli OAuth", () => { }); }); + it("rejects operator login and logout for per-requester OAuth", async () => { + await withTempHome("openclaw-cli-mcp-home-", async () => { + const workspaceDir = await createWorkspace(); + vi.spyOn(process, "cwd").mockReturnValue(workspaceDir); + await runMcpCommand([ + "mcp", + "set", + "calendar", + '{"url":"https://mcp.example.com","transport":"streamable-http","auth":"oauth","oauth":{"identity":"per-requester"}}', + ]); + mockError.mockClear(); + completeMcpOAuthAuthorization.mockClear(); + clearMcpOAuthCredentials.mockClear(); + + await expect(runMcpCommand(["mcp", "login", "calendar", "--code", "abc123"])).rejects.toThrow( + "__exit__:1", + ); + expect(lastErrorLine()).toBe( + 'MCP server "calendar" uses per-requester OAuth. Senders connect from the channel via the MCP connect flow.', + ); + expect(completeMcpOAuthAuthorization).not.toHaveBeenCalled(); + + mockError.mockClear(); + await expect(runMcpCommand(["mcp", "logout", "calendar"])).rejects.toThrow("__exit__:1"); + expect(lastErrorLine()).toBe( + 'MCP server "calendar" uses per-requester OAuth. Remove or replace the server to clear requester credentials.', + ); + expect(clearMcpOAuthCredentials).not.toHaveBeenCalled(); + }); + }); + it("clears stored OAuth credentials after auth is removed", async () => { await withTempHome("openclaw-cli-mcp-home-", async () => { const workspaceDir = await createWorkspace(); diff --git a/src/cli/mcp-cli.test-harness.ts b/src/cli/mcp-cli.test-harness.ts index cfe42685df19..53faf98c66ec 100644 --- a/src/cli/mcp-cli.test-harness.ts +++ b/src/cli/mcp-cli.test-harness.ts @@ -23,8 +23,11 @@ const mocks = vi.hoisted(() => { runtime, serveOpenClawChannelMcp: vi.fn(), clearMcpOAuthCredentials: vi.fn(), + clearMcpOAuthRequesters: vi.fn(), + clearMcpOAuthServer: vi.fn(), completeMcpOAuthAuthorization: vi.fn(), readMcpOAuthCredentialsStatus: vi.fn(), + countMcpOAuthPrincipals: vi.fn(), startMcpOAuthAuthorization: vi.fn(), createSessionMcpRuntimeOverride: undefined as CreateSessionMcpRuntime | undefined, }; @@ -36,6 +39,7 @@ export const serveOpenClawChannelMcp = mocks.serveOpenClawChannelMcp; export const clearMcpOAuthCredentials = mocks.clearMcpOAuthCredentials; export const completeMcpOAuthAuthorization = mocks.completeMcpOAuthAuthorization; export const readMcpOAuthCredentialsStatus = mocks.readMcpOAuthCredentialsStatus; +export const countMcpOAuthPrincipals = mocks.countMcpOAuthPrincipals; vi.mock("../runtime.js", () => ({ defaultRuntime: mocks.runtime, @@ -47,8 +51,11 @@ vi.mock("../mcp/channel-server.js", () => ({ vi.mock("../agents/mcp-oauth.js", () => ({ clearMcpOAuthCredentials: mocks.clearMcpOAuthCredentials, + clearMcpOAuthRequesters: mocks.clearMcpOAuthRequesters, + clearMcpOAuthServer: mocks.clearMcpOAuthServer, completeMcpOAuthAuthorization: mocks.completeMcpOAuthAuthorization, readMcpOAuthCredentialsStatus: mocks.readMcpOAuthCredentialsStatus, + countMcpOAuthPrincipals: mocks.countMcpOAuthPrincipals, startMcpOAuthAuthorization: mocks.startMcpOAuthAuthorization, })); @@ -100,13 +107,9 @@ export function resetMcpCliTestState(): void { vi.clearAllMocks(); mocks.createSessionMcpRuntimeOverride = undefined; readMcpOAuthCredentialsStatus.mockResolvedValue({ - hasTokens: false, - requiresAuthorization: false, - hasClientInformation: false, - hasCodeVerifier: false, - hasDiscoveryState: false, - hasLastAuthorizationUrl: false, + state: "unauthenticated", }); + countMcpOAuthPrincipals.mockReturnValue(0); } export async function cleanupMcpCliTestState(): Promise { diff --git a/src/cli/mcp-cli.test.ts b/src/cli/mcp-cli.test.ts index f08daead73cf..630abae54706 100644 --- a/src/cli/mcp-cli.test.ts +++ b/src/cli/mcp-cli.test.ts @@ -6,7 +6,6 @@ import { createDeferred } from "../../test/helpers/promise.js"; import { withTempHome } from "../config/home-env.test-harness.js"; import { cleanupMcpCliTestState, - clearMcpOAuthCredentials, createWorkspace, lastErrorLine, lastLogLine, @@ -446,11 +445,7 @@ describe("mcp cli", () => { readMcpOAuthCredentialsStatus.mockImplementation(async () => { await checksBlocked.promise; return { - hasTokens: false, - hasClientInformation: false, - hasCodeVerifier: false, - hasDiscoveryState: false, - hasLastAuthorizationUrl: false, + state: "unauthenticated", }; }); @@ -633,111 +628,6 @@ describe("mcp cli", () => { }); }); - it("clears stored OAuth credentials when auth is cleared", async () => { - await withTempHome("openclaw-cli-mcp-home-", async () => { - const workspaceDir = await createWorkspace(); - vi.spyOn(process, "cwd").mockReturnValue(workspaceDir); - - await runMcpCommand([ - "mcp", - "set", - "docs", - '{"url":"https://mcp.example.com","transport":"streamable-http","auth":"oauth"}', - ]); - await runMcpCommand(["mcp", "configure", "docs", "--clear-auth"]); - - expect(clearMcpOAuthCredentials).toHaveBeenCalledWith( - expect.objectContaining({ - serverName: "docs", - serverUrl: "https://mcp.example.com", - }), - ); - - mockLog.mockClear(); - await runMcpCommand(["mcp", "show", "docs", "--json"]); - expect(JSON.parse(lastLogLine())).not.toHaveProperty("auth"); - }); - }); - - it("clears stored OAuth credentials when an MCP server is removed", async () => { - await withTempHome("openclaw-cli-mcp-home-", async () => { - const workspaceDir = await createWorkspace(); - vi.spyOn(process, "cwd").mockReturnValue(workspaceDir); - - await runMcpCommand([ - "mcp", - "set", - "docs", - '{"url":"https://mcp.example.com","transport":"streamable-http","auth":"oauth"}', - ]); - await runMcpCommand(["mcp", "unset", "docs"]); - - expect(clearMcpOAuthCredentials).toHaveBeenCalledWith( - expect.objectContaining({ - serverName: "docs", - serverUrl: "https://mcp.example.com", - }), - ); - }); - }); - - it("clears stored OAuth credentials when set replaces an OAuth server", async () => { - await withTempHome("openclaw-cli-mcp-home-", async () => { - const workspaceDir = await createWorkspace(); - vi.spyOn(process, "cwd").mockReturnValue(workspaceDir); - - await runMcpCommand([ - "mcp", - "set", - "docs", - '{"url":"https://mcp.example.com","transport":"streamable-http","auth":"oauth"}', - ]); - clearMcpOAuthCredentials.mockClear(); - await runMcpCommand(["mcp", "set", "docs", '{"command":"uvx","args":["docs-mcp"]}']); - - expect(clearMcpOAuthCredentials).toHaveBeenCalledWith( - expect.objectContaining({ - serverName: "docs", - serverUrl: "https://mcp.example.com", - }), - ); - }); - }); - - it("clears stored OAuth credentials when add changes an OAuth server URL", async () => { - await withTempHome("openclaw-cli-mcp-home-", async () => { - const workspaceDir = await createWorkspace(); - vi.spyOn(process, "cwd").mockReturnValue(workspaceDir); - - await runMcpCommand([ - "mcp", - "set", - "docs", - '{"url":"https://mcp.example.com","transport":"streamable-http","auth":"oauth"}', - ]); - clearMcpOAuthCredentials.mockClear(); - await runMcpCommand([ - "mcp", - "add", - "docs", - "--url", - "https://other.example.com", - "--transport", - "streamable-http", - "--auth", - "oauth", - "--no-probe", - ]); - - expect(clearMcpOAuthCredentials).toHaveBeenCalledWith( - expect.objectContaining({ - serverName: "docs", - serverUrl: "https://mcp.example.com", - }), - ); - }); - }); - it("removes pure disabled tombstones when enabling MCP servers", async () => { await withTempHome("openclaw-cli-mcp-home-", async () => { const workspaceDir = await createWorkspace(); diff --git a/src/cli/mcp-cli.ts b/src/cli/mcp-cli.ts index bca8620d3737..813dd78ce091 100644 --- a/src/cli/mcp-cli.ts +++ b/src/cli/mcp-cli.ts @@ -12,23 +12,25 @@ import { import { Command } from "commander"; import { buildBundleMcpToolsFromCatalog } from "../agents/agent-bundle-mcp-materialize.js"; import { createSessionMcpRuntime } from "../agents/agent-bundle-mcp-runtime.js"; -import { operatorMcpOAuthIdentity } from "../agents/mcp-oauth-identity.js"; import { - clearMcpOAuthCredentials, - completeMcpOAuthAuthorization, - readMcpOAuthCredentialsStatus, - startMcpOAuthAuthorization, - type McpOAuthCredentialsStatus, -} from "../agents/mcp-oauth.js"; -import { resolveMcpTransportConfig } from "../agents/mcp-transport-config.js"; -import { parseConfigValue } from "../auto-reply/reply/config-value.js"; -import { - listConfiguredMcpServers, setConfiguredMcpServer, unsetConfiguredMcpServer, updateConfiguredMcpServer, updateConfiguredMcpServerTools, -} from "../config/mcp-config.js"; +} from "../agents/mcp-config-mutation.js"; +import { operatorMcpOAuthIdentity } from "../agents/mcp-oauth-identity.js"; +import { readMcpOAuthStoreReadOnly } from "../agents/mcp-oauth-store.js"; +import { + clearMcpOAuthCredentials, + completeMcpOAuthAuthorization, + countMcpOAuthPrincipals, + readMcpOAuthCredentialsStatus, + startMcpOAuthAuthorization, + type McpOAuthPrincipalStatus, +} from "../agents/mcp-oauth.js"; +import { resolveMcpTransportConfig } from "../agents/mcp-transport-config.js"; +import { parseConfigValue } from "../auto-reply/reply/config-value.js"; +import { listConfiguredMcpServers } from "../config/mcp-config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { formatErrorMessage } from "../infra/errors.js"; import { @@ -116,50 +118,22 @@ function parseOAuthConfig(opts: { return Object.keys(oauth).length > 0 ? oauth : undefined; } -async function clearMcpOAuthCredentialsForConfiguredServer( - name: string, - server: unknown, -): Promise { - const resolved = resolveMcpTransportConfig(name, server); - if (resolved?.kind === "http") { - await clearMcpOAuthCredentials(operatorMcpOAuthIdentity(name, resolved.url)); - } -} - -function hasOAuthAuth(server: unknown): boolean { - return ( - typeof server === "object" && server !== null && "auth" in server && server.auth === "oauth" - ); -} - -async function clearStaleMcpOAuthCredentialsForReplacement(params: { - name: string; - previous: unknown; - next: unknown; -}): Promise { - // Replacing an OAuth HTTP server should not leave credentials bound to the old URL. - if (!hasOAuthAuth(params.previous)) { - return; - } - const previousResolved = resolveMcpTransportConfig(params.name, params.previous); - if (previousResolved?.kind !== "http") { - return; - } - const nextResolved = hasOAuthAuth(params.next) - ? resolveMcpTransportConfig(params.name, params.next) - : undefined; - if (nextResolved?.kind === "http" && nextResolved.url === previousResolved.url) { - return; - } - await clearMcpOAuthCredentials(operatorMcpOAuthIdentity(params.name, previousResolved.url)); -} - function setOptionalField(target: Record, key: string, value: unknown): void { if (value !== undefined) { target[key] = value; } } +/** Documented `mcp status --json` shape: legacy booleans stay additive to `state`. */ +type McpStatusAuthStatusJson = McpOAuthPrincipalStatus & { + hasTokens: boolean; + requiresAuthorization: boolean; + hasClientInformation: boolean; + hasCodeVerifier: boolean; + hasDiscoveryState: boolean; + hasLastAuthorizationUrl: boolean; +}; + type McpStatusEntry = { name: string; configured: true; @@ -171,7 +145,8 @@ type McpStatusEntry = { connectionTimeoutMs?: number; supportsParallelToolCalls?: boolean; auth?: unknown; - authStatus?: McpOAuthCredentialsStatus; + authStatus?: McpStatusAuthStatusJson; + connectedPrincipals?: number; toolFilter?: unknown; codex?: unknown; }; @@ -319,23 +294,25 @@ async function collectMcpDoctorIssues(params: { } if (resolved?.kind === "http") { if (server.auth === "oauth") { - const authStatus = await readMcpOAuthCredentialsStatus( - operatorMcpOAuthIdentity(name, resolved.url), - ); - if (authStatus.requiresAuthorization) { - issues.push( - issue( - "warning", - `OAuth credentials require additional authorization; run ${formatCliCommand(`openclaw mcp login ${name}`)}`, - ), - ); - } else if (!authStatus.hasTokens) { - issues.push( - issue( - "warning", - `OAuth credentials are not authorized; run ${formatCliCommand(`openclaw mcp login ${name}`)}`, - ), + if (asRecord(server.oauth)?.identity !== "per-requester") { + const authStatus = await readMcpOAuthCredentialsStatus( + operatorMcpOAuthIdentity(name, resolved.url), ); + if (authStatus.state === "requires-authorization") { + issues.push( + issue( + "warning", + `OAuth credentials require additional authorization; run ${formatCliCommand(`openclaw mcp login ${name}`)}`, + ), + ); + } else if (authStatus.state !== "authorized") { + issues.push( + issue( + "warning", + `OAuth credentials are not authorized; run ${formatCliCommand(`openclaw mcp login ${name}`)}`, + ), + ); + } } const headers = asRecord(server.headers); if (headers && "Authorization" in headers) { @@ -430,6 +407,21 @@ async function probeMcpServerIssue(params: { } } +function countConnectedMcpPrincipals( + name: string, + server: Record, +): number | undefined { + const resolved = resolveMcpTransportConfig(name, server); + if ( + server.auth !== "oauth" || + resolved?.kind !== "http" || + asRecord(server.oauth)?.identity !== "per-requester" + ) { + return undefined; + } + return countMcpOAuthPrincipals(operatorMcpOAuthIdentity(name, resolved.url)); +} + async function buildMcpStatusEntries( servers: Record>, ): Promise { @@ -454,10 +446,27 @@ async function buildMcpStatusEntries( if (server.auth) { entry.auth = server.auth; } - if (server.auth === "oauth" && resolved?.kind === "http") { - entry.authStatus = await readMcpOAuthCredentialsStatus( - operatorMcpOAuthIdentity(name, resolved.url), - ); + if ( + server.auth === "oauth" && + resolved?.kind === "http" && + asRecord(server.oauth)?.identity !== "per-requester" + ) { + const identity = operatorMcpOAuthIdentity(name, resolved.url); + // Documented `mcp status --json` contract: the six legacy authStatus + // booleans stay for existing scripts; `state` is the additive shape. + const store = readMcpOAuthStoreReadOnly(identity.storeKey); + entry.authStatus = { + hasTokens: Boolean(store.tokens), + requiresAuthorization: + store.pendingAuthorizationChallenge?.requiresAuthorization === true, + hasClientInformation: Boolean(store.clientInformation), + hasCodeVerifier: Boolean(store.codeVerifier), + hasDiscoveryState: Boolean(store.discoveryState), + hasLastAuthorizationUrl: Boolean(store.lastAuthorizationUrl), + ...(await readMcpOAuthCredentialsStatus(identity)), + }; + } else { + entry.connectedPrincipals = countConnectedMcpPrincipals(name, server); } return entry; }), @@ -660,7 +669,8 @@ export function registerMcpCli(program: Command) { printJson(loaded.mcpServers); return; } - const names = Object.keys(loaded.mcpServers).toSorted(); + const entries = Object.entries(loaded.mcpServers).toSorted(([a], [b]) => a.localeCompare(b)); + const names = entries.map(([name]) => name); if (names.length === 0) { defaultRuntime.log( `No OpenClaw-managed MCP servers configured in ${loaded.path}. Add one with ${formatCliCommand('openclaw mcp set \'{"command":"uvx","args":["context7-mcp"]}\'')}.`, @@ -669,8 +679,13 @@ export function registerMcpCli(program: Command) { return; } defaultRuntime.log(`OpenClaw-managed MCP servers (${loaded.path}):`); - for (const name of names) { - defaultRuntime.log(`- ${name}`); + for (const [name, server] of entries) { + const connectedPrincipals = countConnectedMcpPrincipals(name, server); + const connected = + connectedPrincipals === undefined + ? "" + : ` (${connectedPrincipals} connected principal${connectedPrincipals === 1 ? "" : "s"})`; + defaultRuntime.log(`- ${name}${connected}`); } defaultRuntime.log(""); defaultRuntime.log(OPENCLAW_MCP_REGISTRY_SCOPE_NOTE); @@ -727,14 +742,21 @@ export function registerMcpCli(program: Command) { for (const entry of status) { const transport = entry.enabled ? (entry.transport ?? "invalid") : "disabled"; const auth = entry.auth === "oauth" ? " oauth" : ""; - const oauth = entry.authStatus?.requiresAuthorization - ? " authorization-required" - : entry.authStatus?.hasTokens - ? " authorized" - : ""; + const oauth = + entry.authStatus?.state === "requires-authorization" + ? " authorization-required" + : entry.authStatus?.state === "authorized" + ? " authorized" + : ""; const filters = entry.toolFilter ? " tool-filtered" : ""; const parallel = entry.supportsParallelToolCalls ? " parallel" : ""; - defaultRuntime.log(`- ${entry.name}: ${transport}${auth}${oauth}${filters}${parallel}`); + const connected = + entry.connectedPrincipals === undefined + ? "" + : ` ${entry.connectedPrincipals}-principal${entry.connectedPrincipals === 1 ? "" : "s"}-connected`; + defaultRuntime.log( + `- ${entry.name}: ${transport}${auth}${oauth}${connected}${filters}${parallel}`, + ); if (opts.verbose) { defaultRuntime.log(` launch: ${entry.launch ?? "n/a"}`); defaultRuntime.log( @@ -742,7 +764,9 @@ export function registerMcpCli(program: Command) { ); if (entry.auth === "oauth") { defaultRuntime.log( - ` oauth: tokens=${entry.authStatus?.hasTokens ? "yes" : "no"} authorization=${entry.authStatus?.requiresAuthorization ? "required" : entry.authStatus?.hasTokens ? "ready" : "missing"} client=${entry.authStatus?.hasClientInformation ? "yes" : "no"}`, + entry.connectedPrincipals === undefined + ? ` oauth: ${entry.authStatus?.state ?? "unauthenticated"}` + : ` oauth: per-requester, connected principals: ${entry.connectedPrincipals}`, ); } if (entry.toolFilter) { @@ -1026,7 +1050,6 @@ export function registerMcpCli(program: Command) { if (!loaded.ok) { fail(loaded.error); } - const current = loaded.mcpServers[name]; const shouldProbe = opts.probe !== false && server.enabled !== false && server.auth !== "oauth"; if (shouldProbe) { @@ -1040,11 +1063,6 @@ export function registerMcpCli(program: Command) { if (!result.ok) { fail(result.error); } - await clearStaleMcpOAuthCredentialsForReplacement({ - name, - previous: current, - next: server, - }); defaultRuntime.log(`Saved MCP server "${name}" to ${result.path}.`); if (server.auth === "oauth") { defaultRuntime.log( @@ -1064,20 +1082,10 @@ export function registerMcpCli(program: Command) { if (parsed.error) { fail(parsed.error); } - const loaded = await listConfiguredMcpServers(); - if (!loaded.ok) { - fail(loaded.error); - } - const current = loaded.mcpServers[name]; const result = await setConfiguredMcpServer({ name, server: parsed.value }); if (!result.ok) { fail(result.error); } - await clearStaleMcpOAuthCredentialsForReplacement({ - name, - previous: current, - next: parsed.value, - }); defaultRuntime.log(`Saved MCP server "${name}" to ${result.path}.`); }); @@ -1175,7 +1183,6 @@ export function registerMcpCli(program: Command) { ); } const next = { ...current }; - const clearOAuthCredentials = opts.clearAuth; if (opts.enable) { delete next.enabled; } @@ -1269,9 +1276,6 @@ export function registerMcpCli(program: Command) { if (!result.ok) { fail(result.error); } - if (clearOAuthCredentials) { - await clearMcpOAuthCredentialsForConfiguredServer(name, current); - } defaultRuntime.log(`Removed disabled MCP override for "${name}" in ${result.path}.`); return; } @@ -1287,9 +1291,6 @@ export function registerMcpCli(program: Command) { `No MCP server named "${name}" in ${result.path}. Run ${formatCliCommand("openclaw mcp list")} to see configured servers.`, ); } - if (clearOAuthCredentials) { - await clearMcpOAuthCredentialsForConfiguredServer(name, current); - } defaultRuntime.log(`Updated MCP server "${name}" in ${result.path}.`); }, ); @@ -1310,6 +1311,11 @@ export function registerMcpCli(program: Command) { `No MCP server named "${name}" in ${loaded.path}. Run ${formatCliCommand("openclaw mcp list")} to see configured servers.`, ); } + if (asRecord(server.oauth)?.identity === "per-requester") { + fail( + `MCP server "${name}" uses per-requester OAuth. Senders connect from the channel via the MCP connect flow.`, + ); + } if (server.auth !== "oauth") { fail(`MCP server "${name}" is not configured with auth: "oauth".`); } @@ -1395,6 +1401,11 @@ export function registerMcpCli(program: Command) { `No MCP server named "${name}" in ${loaded.path}. Run ${formatCliCommand("openclaw mcp list")} to see configured servers.`, ); } + if (asRecord(server.oauth)?.identity === "per-requester") { + fail( + `MCP server "${name}" uses per-requester OAuth. Remove or replace the server to clear requester credentials.`, + ); + } const resolved = resolveMcpTransportConfig(name, server); if (!resolved || resolved.kind !== "http") { fail(`MCP server "${name}" needs a valid HTTP transport for OAuth logout.`); @@ -1420,11 +1431,6 @@ export function registerMcpCli(program: Command) { .description("Remove one OpenClaw-managed MCP server") .argument("", "MCP server name") .action(async (name: string) => { - const loaded = await listConfiguredMcpServers(); - if (!loaded.ok) { - fail(loaded.error); - } - const current = loaded.mcpServers[name]; const result = await unsetConfiguredMcpServer({ name }); if (!result.ok) { fail(result.error); @@ -1434,9 +1440,6 @@ export function registerMcpCli(program: Command) { `No MCP server named "${name}" in ${result.path}. Run ${formatCliCommand("openclaw mcp list")} to see configured servers.`, ); } - if (current) { - await clearMcpOAuthCredentialsForConfiguredServer(name, current); - } defaultRuntime.log(`Removed MCP server "${name}" from ${result.path}.`); }); diff --git a/src/commands/doctor-config-analysis.test.ts b/src/commands/doctor-config-analysis.test.ts index 7be705167200..326a7ac1bbb6 100644 --- a/src/commands/doctor-config-analysis.test.ts +++ b/src/commands/doctor-config-analysis.test.ts @@ -6,6 +6,7 @@ import { OpenClawSchema } from "../config/zod-schema.js"; import { formatConfigKeyPath, noteImplicitFallbackClobberWarnings, + noteMcpOriginWarning, noteOpencodeProviderOverrides, noteSandboxOriginProxyWarning, resolveConfigPathTarget, @@ -497,3 +498,63 @@ describe("noteSandboxOriginProxyWarning", () => { expect(warningsFor({} as OpenClawConfig)).toHaveLength(0); }); }); + +describe("noteMcpOriginWarning", () => { + function warningsFor(cfg: OpenClawConfig): string[] { + noteMock.mockClear(); + noteMcpOriginWarning(cfg); + return noteMock.mock.calls.map((call) => String(call[0])); + } + + it("warns for per-requester MCP OAuth without a public Gateway origin", () => { + const warnings = warningsFor({ + mcp: { + servers: { + docs: { + url: "https://mcp.example.com", + auth: "oauth", + oauth: { identity: "per-requester" }, + }, + }, + }, + }); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("gateway.publicOrigin is not set"); + expect(warnings[0]).toContain("senders can complete MCP sign-in"); + }); + + it("stays silent when the public origin is configured", () => { + expect( + warningsFor({ + gateway: { publicOrigin: "https://gateway.example.com" }, + mcp: { + servers: { + docs: { + url: "https://mcp.example.com", + auth: "oauth", + oauth: { identity: "per-requester" }, + }, + }, + }, + }), + ).toHaveLength(0); + }); + + it("stays silent for shared or absent MCP OAuth identity", () => { + expect( + warningsFor({ + mcp: { + servers: { + shared: { + url: "https://shared.example.com", + auth: "oauth", + oauth: { identity: "shared" }, + }, + implicit: { url: "https://implicit.example.com", auth: "oauth" }, + }, + }, + }), + ).toHaveLength(0); + expect(warningsFor({})).toHaveLength(0); + }); +}); diff --git a/src/commands/doctor-config-analysis.ts b/src/commands/doctor-config-analysis.ts index 23afa70d75b8..6b860a149d05 100644 --- a/src/commands/doctor-config-analysis.ts +++ b/src/commands/doctor-config-analysis.ts @@ -272,3 +272,20 @@ export function noteSandboxOriginProxyWarning(cfg: OpenClawConfig): void { "Doctor warnings", ); } + +/** Warns when per-requester MCP OAuth cannot build a public callback URL. */ +export function noteMcpOriginWarning(cfg: OpenClawConfig): void { + const hasPerRequesterOAuth = Object.values(cfg.mcp?.servers ?? {}).some( + (server) => server.oauth?.identity === "per-requester", + ); + if (!hasPerRequesterOAuth || cfg.gateway?.publicOrigin) { + return; + } + note( + [ + '- An MCP server uses oauth.identity "per-requester", but gateway.publicOrigin is not set.', + " Set gateway.publicOrigin to the externally reachable Gateway origin so senders can complete MCP sign-in.", + ].join("\n"), + "Doctor warnings", + ); +} diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index 6153303c8dd4..b8804e96d3cb 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -1553,6 +1553,7 @@ vi.mock("./doctor-config-analysis.js", () => { noteImplicitFallbackClobberWarnings: noteImplicitFallbackClobberWarningsMock, noteIncludeConfinementWarning: vi.fn(), noteOpencodeProviderOverrides: vi.fn(), + noteMcpOriginWarning: vi.fn(), noteSandboxOriginProxyWarning: vi.fn(), resolveConfigPathTarget, stripUnknownConfigKeys: vi.fn((config: Record) => { diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index b546334043f1..f8981bac4e55 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -12,6 +12,7 @@ import { callGateway } from "../gateway/call.js"; import type { RuntimeEnv } from "../runtime.js"; import { noteImplicitFallbackClobberWarnings, + noteMcpOriginWarning, noteOpencodeProviderOverrides, noteSandboxOriginProxyWarning, } from "./doctor-config-analysis.js"; @@ -547,6 +548,7 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { }); noteImplicitFallbackClobberWarnings(cfg); noteSandboxOriginProxyWarning(cfg); + noteMcpOriginWarning(cfg); return { cfg, diff --git a/src/config/mcp-config.test.ts b/src/config/mcp-config.test.ts index f256585ca52f..6631baaa941b 100644 --- a/src/config/mcp-config.test.ts +++ b/src/config/mcp-config.test.ts @@ -3,13 +3,11 @@ import fs from "node:fs/promises"; import path from "node:path"; import { withTempHome } from "openclaw/plugin-sdk/test-env"; import { describe, expect, it, vi } from "vitest"; -import { - listConfiguredMcpServers, - setConfiguredMcpServer, - unsetConfiguredMcpServer, -} from "./mcp-config.js"; +import { listConfiguredMcpServers, mcpConfigInternal } from "./mcp-config.js"; import { REDACTED_SENTINEL } from "./redact-snapshot.js"; +const { set: setConfiguredMcpServer, unset: unsetConfiguredMcpServer } = mcpConfigInternal; + function validationOk(raw: unknown) { return { ok: true as const, config: raw, warnings: [] }; } diff --git a/src/config/mcp-config.ts b/src/config/mcp-config.ts index 378cfd4d13f0..c33f616320ee 100644 --- a/src/config/mcp-config.ts +++ b/src/config/mcp-config.ts @@ -33,6 +33,12 @@ type ConfigMcpWriteResult = | ConfigMcpFailure; type LoadedConfigMcpServers = Extract; +type McpConfigMutation = { + name: string; + previous?: Record; + next?: Record; +}; +type McpConfigMutationHook = (mutation: McpConfigMutation) => Promise; /** Include/exclude tool selection stored for a configured MCP server. */ type McpServerToolSelection = { @@ -118,6 +124,7 @@ async function commitConfiguredMcpServers(params: { errorLabel: string; success?: { removed?: boolean; updated?: boolean }; independentlyOwnedName?: string; + mutation?: { name: string; onCommitted?: McpConfigMutationHook }; }): Promise { const next = structuredClone(params.loaded.config); if (Object.keys(params.servers).length > 0) { @@ -142,6 +149,15 @@ async function commitConfiguredMcpServers(params: { nextConfig: validated.config, baseHash: params.loaded.baseHash, }); + if (params.mutation?.onCommitted) { + const previous = params.loaded.mcpServers[params.mutation.name]; + const nextServer = params.servers[params.mutation.name]; + await params.mutation.onCommitted({ + name: params.mutation.name, + ...(previous ? { previous } : {}), + ...(nextServer ? { next: nextServer } : {}), + }); + } if (params.independentlyOwnedName) { markClawMcpServerIndependentlyOwned(params.independentlyOwnedName); } @@ -159,6 +175,7 @@ async function updateConfiguredMcpServerConfig(params: { update: (server: Record) => Record; errorLabel: string; recordIndependentOwner?: boolean; + onCommitted?: McpConfigMutationHook; }): Promise { const name = params.name.trim(); if (!name) { @@ -182,18 +199,23 @@ async function updateConfiguredMcpServerConfig(params: { errorLabel: params.errorLabel, success: { updated: true }, independentlyOwnedName: params.recordIndependentOwner === false ? undefined : name, + mutation: { name, onCommitted: params.onCommitted }, }); } -export async function updateConfiguredMcpServerTools(params: { - name: string; - tools: McpServerToolSelection | null; - recordIndependentOwner?: boolean; -}): Promise { +async function updateConfiguredMcpServerTools( + params: { + name: string; + tools: McpServerToolSelection | null; + recordIndependentOwner?: boolean; + }, + onCommitted?: McpConfigMutationHook, +): Promise { return updateConfiguredMcpServerConfig({ name: params.name, recordIndependentOwner: params.recordIndependentOwner, errorLabel: "tool selection update", + onCommitted, update: (server) => { if (params.tools === null) { delete server.toolFilter; @@ -214,26 +236,33 @@ export async function updateConfiguredMcpServerTools(params: { }); } -export async function updateConfiguredMcpServer(params: { - name: string; - update: (server: Record) => Record; - recordIndependentOwner?: boolean; -}): Promise { +async function updateConfiguredMcpServer( + params: { + name: string; + update: (server: Record) => Record; + recordIndependentOwner?: boolean; + }, + onCommitted?: McpConfigMutationHook, +): Promise { return updateConfiguredMcpServerConfig({ name: params.name, recordIndependentOwner: params.recordIndependentOwner, errorLabel: "configure", + onCommitted, update: (server) => canonicalizeConfiguredMcpServer(params.update(server)), }); } -export async function setConfiguredMcpServer(params: { - name: string; - server: unknown; - createOnly?: boolean; - recordIndependentOwner?: boolean; - expectedServer?: Record; -}): Promise { +async function setConfiguredMcpServer( + params: { + name: string; + server: unknown; + createOnly?: boolean; + recordIndependentOwner?: boolean; + expectedServer?: Record; + }, + onCommitted?: McpConfigMutationHook, +): Promise { const name = params.name.trim(); if (!name) { return { ok: false, path: "", error: "MCP server name is required." }; @@ -309,13 +338,17 @@ export async function setConfiguredMcpServer(params: { servers, errorLabel: "set", independentlyOwnedName: params.recordIndependentOwner === false ? undefined : name, + mutation: { name, onCommitted }, }); } -export async function unsetConfiguredMcpServer(params: { - name: string; - expectedServer?: Record; -}): Promise { +async function unsetConfiguredMcpServer( + params: { + name: string; + expectedServer?: Record; + }, + onCommitted?: McpConfigMutationHook, +): Promise { const name = params.name.trim(); if (!name) { return { ok: false, path: "", error: "MCP server name is required." }; @@ -350,5 +383,14 @@ export async function unsetConfiguredMcpServer(params: { servers, errorLabel: "unset", success: { removed: true }, + mutation: { name, onCommitted }, }); } + +/** Low-level config writers; production mutations must use the agents-owned lifecycle facade. */ +export const mcpConfigInternal = { + set: setConfiguredMcpServer, + unset: unsetConfiguredMcpServer, + update: updateConfiguredMcpServer, + updateTools: updateConfiguredMcpServerTools, +}; diff --git a/src/config/schema.help.agents.ts b/src/config/schema.help.agents.ts index ca2b6ae21f1a..217a3fe58971 100644 --- a/src/config/schema.help.agents.ts +++ b/src/config/schema.help.agents.ts @@ -221,6 +221,8 @@ export const AGENT_FIELD_HELP: Record = { "Exact MCP tool names or simple '*' globs to expose from this server. When omitted, all server tools remain eligible unless excluded.", "mcp.servers.*.toolFilter.exclude": "Exact MCP tool names or simple '*' globs to hide from this server.", + "mcp.servers.*.oauth.identity": + 'OAuth credential ownership for this server. Omit this field or use "shared" for operator-managed credentials; use "per-requester" to let each authenticated sender connect their own account.', "mcp.servers.*.oauth.authProfileId": "Refresh-capable auth profile id used to inject the current bearer token into this remote MCP server. When set, OpenClaw resolves and refreshes the profile at runtime and does not project refresh material downstream.", "mcp.servers.*.codex.agents": diff --git a/src/config/schema.help.runtime.ts b/src/config/schema.help.runtime.ts index d55076d71f16..d1c581232e34 100644 --- a/src/config/schema.help.runtime.ts +++ b/src/config/schema.help.runtime.ts @@ -198,6 +198,8 @@ export const RUNTIME_FIELD_HELP: Record = { 'Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled. Setting ["*"] means allow any browser origin and should be avoided outside tightly controlled local testing.', "gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback": "DANGEROUS toggle that enables Host-header based origin fallback for Control UI/WebChat websocket checks. This mode is supported when your deployment intentionally relies on Host-header origin policy; explicit gateway.controlUi.allowedOrigins remains the recommended hardened default.", + "gateway.publicOrigin": + "Externally reachable HTTPS origin of the Gateway. HTTP is allowed only for localhost, 127.0.0.1, or [::1]. Per-requester MCP OAuth uses it to build the callback URL at /oauth/mcp/callback; configure the reverse proxy or tunnel to route that path to the Gateway.", "mcp.apps": "MCP Apps UI support. When enabled, configured MCP servers may provide interactive HTML views for their tool results.", "mcp.apps.enabled": diff --git a/src/config/schema.hints.ts b/src/config/schema.hints.ts index 68ea9dc2abab..46c918555b0e 100644 --- a/src/config/schema.hints.ts +++ b/src/config/schema.hints.ts @@ -96,6 +96,7 @@ const SECTION_DOCS_URLS = { const SECTIONS_WITHOUT_DOCS = ["$schema", "meta", "attachments"] as const; const FIELD_PLACEHOLDERS: Record = { + "gateway.publicOrigin": "https://gateway.example.com", "gateway.remote.url": "ws://host:18789", "gateway.remote.tlsFingerprint": "sha256:ab12cd34…", "gateway.remote.sshTarget": "user@host", diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 4c252022bc03..7f641be891c9 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -107,6 +107,7 @@ export const FIELD_LABELS: Record = { "gateway.mode": "Gateway Mode", "gateway.bind": "Gateway Bind Mode", "gateway.customBindHost": "Gateway Custom Bind Host", + "gateway.publicOrigin": "Gateway Public Origin", "gateway.controlUi": "Control UI", "gateway.controlUi.enabled": "Control UI Enabled", "gateway.cliAgents": "CLI Agents", @@ -659,6 +660,7 @@ export const FIELD_LABELS: Record = { "mcp.servers.*.enabled": "MCP Server Enabled", "mcp.servers.*.auth": "MCP Server Auth", "mcp.servers.*.oauth": "MCP OAuth", + "mcp.servers.*.oauth.identity": "MCP OAuth Identity", "mcp.servers.*.oauth.authProfileId": "MCP OAuth Auth Profile", "mcp.servers.*.oauth.scope": "MCP OAuth Scope", "mcp.servers.*.oauth.redirectUrl": "MCP OAuth Redirect URL", diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 5b5be2dc6b1b..83f6d00d27fe 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -410,6 +410,127 @@ describe("config schema", () => { ).toThrow(); }); + it("validates MCP OAuth credential identity", () => { + for (const identity of ["shared", "per-requester"] as const) { + expect( + OpenClawSchema.safeParse({ + mcp: { + servers: { + docs: { + url: "https://mcp.example.com/mcp", + auth: "oauth", + oauth: { identity }, + }, + }, + }, + }).success, + ).toBe(true); + } + + const missingAuth = OpenClawSchema.safeParse({ + mcp: { + servers: { + docs: { + url: "https://mcp.example.com/mcp", + oauth: { identity: "per-requester" }, + }, + }, + }, + }); + expect(missingAuth.success).toBe(false); + if (missingAuth.success) { + throw new Error("Expected per-requester OAuth without auth mode to fail validation"); + } + expect(missingAuth.error.issues).toContainEqual( + expect.objectContaining({ + message: 'oauth.identity "per-requester" requires auth: "oauth"', + path: ["mcp", "servers", "docs", "oauth", "identity"], + }), + ); + + expect( + OpenClawSchema.safeParse({ + mcp: { + servers: { + docs: { + url: "https://mcp.example.com/mcp", + auth: "oauth", + oauth: { identity: "per-requester", authProfileId: "docs:mcp" }, + }, + }, + }, + }).success, + ).toBe(false); + expect( + OpenClawSchema.safeParse({ + mcp: { + servers: { + docs: { + command: "docs-mcp", + auth: "oauth", + oauth: { identity: "per-requester" }, + }, + }, + }, + }).success, + ).toBe(false); + // URL plus command resolves stdio and would strand the server silently. + expect( + OpenClawSchema.safeParse({ + mcp: { + servers: { + docs: { + url: "https://mcp.example.com/mcp", + command: "docs-mcp", + auth: "oauth", + oauth: { identity: "per-requester" }, + }, + }, + }, + }).success, + ).toBe(false); + expect( + OpenClawSchema.safeParse({ + mcp: { + servers: { + docs: { + url: "https://mcp.example.com/mcp", + transport: "stdio", + auth: "oauth", + oauth: { identity: "per-requester" }, + }, + }, + }, + }).success, + ).toBe(false); + }); + + it("requires a bare HTTPS Gateway public origin except on loopback", () => { + for (const publicOrigin of [ + "https://gateway.example.com", + "https://gateway.example.com:443", + "http://localhost:80", + "http://localhost:18789/", + "http://127.0.0.1:18789", + "http://[::1]:18789", + ]) { + expect(OpenClawSchema.safeParse({ gateway: { publicOrigin } }).success).toBe(true); + } + // Built via URL so no credential-shaped literal lands in source (secret scanners). + const userinfoOrigin = new URL("https://gateway.example.com"); + userinfoOrigin.username = "operator"; + for (const publicOrigin of [ + "https://gateway.example.com/path", + "https://gateway.example.com?query=1", + "https://gateway.example.com/#fragment", + "http://gateway.example.com", + userinfoOrigin.href, + "data:text/html,hello", + ]) { + expect(OpenClawSchema.safeParse({ gateway: { publicOrigin } }).success).toBe(false); + } + }); + it("accepts stdio transport for command-bearing MCP servers", () => { const result = OpenClawSchema.safeParse({ mcp: { diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index 1644a14696d8..56c8a7830f22 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -557,6 +557,8 @@ export type GatewayConfig = { bind?: GatewayBindMode; /** Custom IPv4 address for bind="custom" mode. IPv6-only BYOH requires an IPv4 sidecar or proxy. */ customBindHost?: string; + /** Externally reachable HTTPS origin for Gateway callback routes; HTTP only on loopback. */ + publicOrigin?: string; controlUi?: GatewayControlUiConfig; cliAgents?: GatewayCliAgentsConfig; terminal?: GatewayTerminalConfig; diff --git a/src/config/types.mcp.ts b/src/config/types.mcp.ts index ed5d65a5d804..676b754a2772 100644 --- a/src/config/types.mcp.ts +++ b/src/config/types.mcp.ts @@ -46,6 +46,8 @@ export type McpServerConfig = { auth?: "oauth"; /** Optional OAuth client metadata overrides for HTTP MCP servers. */ oauth?: { + /** Credential ownership for this server. Defaults to shared operator credentials. */ + identity?: "shared" | "per-requester"; /** Refresh-capable auth profile used to inject the current bearer token. */ authProfileId?: string; scope?: string; diff --git a/src/config/zod-schema.gateway.ts b/src/config/zod-schema.gateway.ts index 92076ed6db78..6ad87d565cce 100644 --- a/src/config/zod-schema.gateway.ts +++ b/src/config/zod-schema.gateway.ts @@ -14,6 +14,7 @@ import { GatewayRemoteConfigSchema, ResponsesEndpointUrlFetchShape, TailscaleServiceNameSchema, + validateHttpOrigin, } from "./zod-schema.root-support.js"; import { sensitive } from "./zod-schema.sensitive.js"; @@ -27,6 +28,15 @@ const OperatorScopeSchema = z.enum([ TALK_SCOPE, TALK_SECRETS_SCOPE, ]); +const GATEWAY_HTTP_LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]); + +function validateGatewayPublicOrigin(value: string): boolean { + if (!validateHttpOrigin(value)) { + return false; + } + const url = new URL(value); + return url.protocol === "https:" || GATEWAY_HTTP_LOOPBACK_HOSTS.has(url.hostname); +} export const GatewayConfigSchema = z .strictObject({ @@ -42,6 +52,14 @@ export const GatewayConfigSchema = z ]) .optional(), customBindHost: z.string().optional(), + publicOrigin: z + .string() + .url() + .refine( + validateGatewayPublicOrigin, + "gateway.publicOrigin must be a bare HTTPS origin; HTTP is allowed only for localhost, 127.0.0.1, or [::1]", + ) + .optional(), controlUi: z .strictObject({ // Shipped legacy input. Doctor removes it after recording migration state. diff --git a/src/config/zod-schema.root-support.ts b/src/config/zod-schema.root-support.ts index d8f8b70b998a..0c30171664f1 100644 --- a/src/config/zod-schema.root-support.ts +++ b/src/config/zod-schema.root-support.ts @@ -274,6 +274,7 @@ const McpServerSchema = z auth: z.literal("oauth").optional(), oauth: z .strictObject({ + identity: z.enum(["shared", "per-requester"]).optional(), authProfileId: z.string().trim().min(1).optional(), scope: z.string().trim().min(1).optional(), redirectUrl: HttpUrlSchema.optional(), @@ -344,6 +345,39 @@ const McpServerSchema = z path: ["disabled"], }); } + if (data.oauth?.identity === "per-requester") { + if (data.auth !== "oauth") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'oauth.identity "per-requester" requires auth: "oauth"', + path: ["oauth", "identity"], + }); + } + if (data.oauth.authProfileId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'oauth.authProfileId cannot be used with oauth.identity "per-requester"', + path: ["oauth", "authProfileId"], + }); + } + if (!data.url) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'oauth.identity "per-requester" requires an HTTP server URL', + path: ["oauth", "identity"], + }); + } + // Command precedence would resolve stdio and strand the server: partitioned + // out of the static runtime with no requester sign-in path. + if (data.command !== undefined || data.transport === "stdio") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'oauth.identity "per-requester" cannot be combined with a command or "stdio" transport', + path: ["oauth", "identity"], + }); + } + } // transport "stdio" requires a non-empty command — URL-only servers must use "sse" or "streamable-http" if ( data.transport === "stdio" && @@ -394,6 +428,22 @@ function createMcpServersSchema(serverNameSchema: z.ZodType) { ); } +export function validateHttpOrigin(value: string): boolean { + try { + const url = new URL(value); + return ( + (url.protocol === "http:" || url.protocol === "https:") && + url.pathname === "/" && + !url.search && + !url.hash && + !url.username && + !url.password + ); + } catch { + return false; + } +} + export const McpConfigSchema = z .strictObject({ servers: createMcpServersSchema(McpServerNameSchema).optional(), @@ -403,19 +453,10 @@ export const McpConfigSchema = z sandboxOrigin: z .string() .url() - .refine((value) => { - try { - const url = new URL(value); - return ( - (url.protocol === "http:" || url.protocol === "https:") && - url.origin === value.replace(/\/$/u, "") && - !url.username && - !url.password - ); - } catch { - return false; - } - }, "sandboxOrigin must be an HTTP(S) origin without a path, query, or credentials") + .refine( + validateHttpOrigin, + "sandboxOrigin must be an HTTP(S) origin without a path, query, or credentials", + ) .optional(), sandboxPort: z.number().int().min(1).max(65535).optional(), }) diff --git a/src/gateway/mcp-oauth-callback.test.ts b/src/gateway/mcp-oauth-callback.test.ts new file mode 100644 index 000000000000..e45512e68bf6 --- /dev/null +++ b/src/gateway/mcp-oauth-callback.test.ts @@ -0,0 +1,200 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { requesterMcpOAuthStoreKeyPrefix } from "../agents/mcp-oauth-identity.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; + +const mocks = vi.hoisted(() => ({ + complete: vi.fn(), + readPending: vi.fn(), + readStore: vi.fn(), +})); + +vi.mock("../agents/mcp-oauth.js", () => ({ + completeOAuthCallback: mocks.complete, +})); +vi.mock("../agents/mcp-oauth-store.js", () => ({ + readMcpOAuthPendingAuthorization: mocks.readPending, + readMcpOAuthStore: mocks.readStore, +})); + +import { handleMcpOAuthCallback } from "./mcp-oauth-callback.js"; +import { createRequest, createResponse } from "./server-http.test-harness.js"; + +const SERVER_URL = "https://calendar.example.com/mcp"; +const STORE_KEY = `${requesterMcpOAuthStoreKeyPrefix("calendar", SERVER_URL)}fedcba9876543210`; +const AUTHORIZATION_URL = + "https://accounts.example.com/authorize?state=state-1234567890&client_id=openclaw"; + +function callbackConfig(serverName = "calendar"): OpenClawConfig { + return { + mcp: { + servers: { + [serverName]: { + url: SERVER_URL, + transport: "streamable-http", + auth: "oauth", + oauth: { identity: "per-requester" }, + }, + }, + }, + }; +} + +function pendingStore() { + return { + codeVerifier: "verifier", + lastAuthorizationUrl: AUTHORIZATION_URL, + redirectUrl: "https://gateway.example.com/oauth/mcp/callback", + }; +} + +async function dispatch( + path: string, + options?: { config?: OpenClawConfig; method?: string }, +): Promise<{ + handled: boolean; + response: ReturnType; + warn: ReturnType; +}> { + const response = createResponse(); + const warn = vi.fn(); + const handled = await handleMcpOAuthCallback( + createRequest({ path, method: options?.method }), + response.res, + { config: options?.config ?? callbackConfig(), log: { warn } }, + ); + return { handled, response, warn }; +} + +beforeEach(() => { + mocks.complete.mockReset().mockResolvedValue("authorized"); + mocks.readPending.mockReset().mockReturnValue(STORE_KEY); + mocks.readStore.mockReset().mockReturnValue(pendingStore()); +}); + +describe("Gateway MCP OAuth callback", () => { + it("completes the requester row selected by exact OAuth state", async () => { + const result = await dispatch( + "/oauth/mcp/callback?code=authorization-code&state=state-1234567890", + ); + + expect(result.handled).toBe(true); + expect(result.response.res.statusCode).toBe(200); + expect(result.response.getBody()).toContain("You're connected."); + expect(result.response.setHeader).toHaveBeenCalledWith("Cache-Control", "no-store"); + expect(mocks.readPending).toHaveBeenCalledWith("state-1234567890"); + expect(mocks.readStore).toHaveBeenCalledWith(STORE_KEY); + expect(mocks.complete).toHaveBeenCalledWith( + { + storeKey: STORE_KEY, + principal: "requester", + serverName: "calendar", + serverUrl: SERVER_URL, + }, + expect.objectContaining({ kind: "http", url: SERVER_URL }), + { code: "authorization-code", state: "state-1234567890" }, + ); + }); + + it("rejects a callback whose state was consumed concurrently", async () => { + mocks.complete.mockResolvedValue("expired"); + + const result = await dispatch( + "/oauth/mcp/callback?code=authorization-code&state=state-1234567890", + ); + + expect(result.response.res.statusCode).toBe(404); + expect(result.response.getBody()).toContain("expired or was already used"); + }); + + it("rejects unknown and replayed states with the same generic page", async () => { + mocks.readPending.mockReturnValue(undefined); + + const unknown = await dispatch( + "/oauth/mcp/callback?code=authorization-code&state=unknown-state", + ); + const replay = await dispatch( + "/oauth/mcp/callback?code=authorization-code&state=state-1234567890", + ); + + for (const result of [unknown, replay]) { + expect(result.handled).toBe(true); + expect(result.response.res.statusCode).toBe(404); + expect(result.response.getBody()).toContain("expired or was already used"); + } + expect(mocks.readStore).not.toHaveBeenCalled(); + expect(mocks.complete).not.toHaveBeenCalled(); + }); + + it("rejects correlation when the OAuth store no longer owns the state", async () => { + mocks.readStore.mockReturnValue({ + ...pendingStore(), + lastAuthorizationUrl: "https://accounts.example.com/authorize?state=replaced-state", + }); + + const result = await dispatch( + "/oauth/mcp/callback?code=authorization-code&state=state-1234567890", + ); + + expect(result.response.res.statusCode).toBe(404); + expect(result.response.getBody()).toContain("expired or was already used"); + expect(mocks.complete).not.toHaveBeenCalled(); + }); + + it("renders the retry path for provider errors without exchanging a code", async () => { + const result = await dispatch( + "/oauth/mcp/callback?error=access_denied&error_description=nope&state=state-1234567890", + ); + + expect(result.response.res.statusCode).toBe(400); + expect(result.response.getBody()).toContain("Ask the bot to connect again."); + expect(result.response.getBody()).not.toContain("nope"); + expect(mocks.complete).not.toHaveBeenCalled(); + }); + + it("fails generically when the configured server no longer owns the row", async () => { + const result = await dispatch( + "/oauth/mcp/callback?code=authorization-code&state=state-1234567890", + { config: callbackConfig("renamed") }, + ); + + expect(result.response.res.statusCode).toBe(404); + expect(result.response.getBody()).toContain("expired or was already used"); + expect(mocks.complete).not.toHaveBeenCalled(); + }); + + it("does not expose authorization-code exchange failures", async () => { + mocks.complete.mockRejectedValue(new Error("invalid_grant for secret-code")); + + const result = await dispatch("/oauth/mcp/callback?code=wrong-code&state=state-1234567890"); + + expect(result.response.res.statusCode).toBe(400); + expect(result.response.getBody()).toContain("Ask the bot to connect again."); + expect(result.response.getBody()).not.toContain("invalid_grant"); + expect(result.response.getBody()).not.toContain("wrong-code"); + expect(result.warn).toHaveBeenCalledOnce(); + }); + + it("leaves other methods and paths unclaimed", async () => { + const wrongMethod = await dispatch( + "/oauth/mcp/callback?code=authorization-code&state=state-1234567890", + { method: "POST" }, + ); + const wrongPath = await dispatch("/oauth/other?code=authorization-code&state=state-1234567890"); + + expect(wrongMethod.handled).toBe(false); + expect(wrongPath.handled).toBe(false); + expect(mocks.readPending).not.toHaveBeenCalled(); + }); + + it("bounds the callback query before reading durable state", async () => { + const result = await dispatch( + `/oauth/mcp/callback?code=${"x".repeat(8 * 1024)}&state=state-1234567890`, + ); + + expect(result.handled).toBe(true); + expect(result.response.res.statusCode).toBe(400); + expect(mocks.readPending).not.toHaveBeenCalled(); + expect(mocks.readStore).not.toHaveBeenCalled(); + expect(mocks.complete).not.toHaveBeenCalled(); + }); +}); diff --git a/src/gateway/mcp-oauth-callback.ts b/src/gateway/mcp-oauth-callback.ts new file mode 100644 index 000000000000..fd285dba471e --- /dev/null +++ b/src/gateway/mcp-oauth-callback.ts @@ -0,0 +1,125 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { requesterMcpOAuthStoreKeyPrefix } from "../agents/mcp-oauth-identity.js"; +import { readMcpOAuthPendingAuthorization, readMcpOAuthStore } from "../agents/mcp-oauth-store.js"; +import { completeOAuthCallback } from "../agents/mcp-oauth.js"; +import { resolveMcpTransportConfig } from "../agents/mcp-transport-config.js"; +import { normalizeConfiguredMcpServers } from "../config/mcp-config-normalize.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { formatErrorMessage } from "../infra/errors.js"; + +const MCP_OAUTH_CALLBACK_PATH = "/oauth/mcp/callback"; +const MCP_OAUTH_CALLBACK_MAX_URL_BYTES = 8 * 1024; +const CONNECTED_HTML = + 'Account connected

You\'re connected.

Return to the chat.

'; +const RETRY_HTML = + 'Sign-in incomplete

Sign-in wasn\'t completed.

Ask the bot to connect again.

'; +const EXPIRED_HTML = + 'Sign-in link expired

This sign-in link expired or was already used.

Ask the bot to connect again.

'; + +type CallbackLog = Pick; + +function respondHtml(res: ServerResponse, status: number, body: string): void { + res.statusCode = status; + res.setHeader("Cache-Control", "no-store"); + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(body); +} + +function readPendingState(lastAuthorizationUrl: string): string | undefined { + try { + return new URL(lastAuthorizationUrl).searchParams.get("state")?.trim() || undefined; + } catch { + return undefined; + } +} + +function isPerRequesterServer(server: Record): boolean { + const oauth = isRecord(server.oauth) ? server.oauth : undefined; + return server.enabled !== false && server.auth === "oauth" && oauth?.identity === "per-requester"; +} + +/** Completes one requester MCP OAuth redirect using durable state correlation. */ +export async function handleMcpOAuthCallback( + req: IncomingMessage, + res: ServerResponse, + params: { config: OpenClawConfig; log: CallbackLog }, +): Promise { + if (req.method !== "GET") { + return false; + } + const rawUrl = req.url ?? "/"; + const url = new URL(rawUrl, "http://localhost"); + if (url.pathname !== MCP_OAUTH_CALLBACK_PATH) { + return false; + } + const configuredServers = Object.entries( + normalizeConfiguredMcpServers(params.config.mcp?.servers), + ) + .toSorted(([left], [right]) => left.localeCompare(right)) + .flatMap(([serverName, rawServer]) => { + if (!isPerRequesterServer(rawServer)) { + return []; + } + const resolved = resolveMcpTransportConfig(serverName, rawServer, { logWarnings: false }); + return resolved?.kind === "http" && resolved.auth === "oauth" + ? [{ serverName, resolved }] + : []; + }); + if (configuredServers.length === 0) { + return false; + } + if (Buffer.byteLength(rawUrl, "utf8") > MCP_OAUTH_CALLBACK_MAX_URL_BYTES) { + respondHtml(res, 400, RETRY_HTML); + return true; + } + + const state = url.searchParams.get("state")?.trim(); + const storeKey = state ? readMcpOAuthPendingAuthorization(state) : undefined; + const pending = storeKey ? readMcpOAuthStore(storeKey) : undefined; + if (!storeKey || !state || readPendingState(pending?.lastAuthorizationUrl ?? "") !== state) { + respondHtml(res, 404, EXPIRED_HTML); + return true; + } + + const configuredServer = configuredServers.find(({ serverName, resolved }) => + storeKey.startsWith(requesterMcpOAuthStoreKeyPrefix(serverName, resolved.url)), + ); + if (!configuredServer) { + respondHtml(res, 404, EXPIRED_HTML); + return true; + } + if (url.searchParams.has("error")) { + respondHtml(res, 400, RETRY_HTML); + return true; + } + const code = url.searchParams.get("code")?.trim(); + if (!code) { + respondHtml(res, 400, RETRY_HTML); + return true; + } + + try { + const result = await completeOAuthCallback( + { + storeKey, + principal: "requester", + serverName: configuredServer.serverName, + serverUrl: configuredServer.resolved.url, + }, + configuredServer.resolved, + { code, state }, + ); + if (result === "expired") { + respondHtml(res, 404, EXPIRED_HTML); + return true; + } + respondHtml(res, 200, CONNECTED_HTML); + } catch (error) { + params.log.warn( + `MCP OAuth callback failed for server "${configuredServer.serverName}": ${formatErrorMessage(error)}`, + ); + respondHtml(res, 400, RETRY_HTML); + } + return true; +} diff --git a/src/gateway/server-http.mcp-oauth-callback.test.ts b/src/gateway/server-http.mcp-oauth-callback.test.ts new file mode 100644 index 000000000000..72bba7decede --- /dev/null +++ b/src/gateway/server-http.mcp-oauth-callback.test.ts @@ -0,0 +1,177 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + resetGatewayWorkAdmission, + tryBeginGatewaySuspendAdmission, +} from "../process/gateway-work-admission.js"; +import { handleMcpOAuthCallback } from "./mcp-oauth-callback.js"; +import { + AUTH_TOKEN, + createRequest, + createResponse, + dispatchRequest, + withGatewayServer, +} from "./server-http.test-harness.js"; + +beforeEach(() => resetGatewayWorkAdmission()); +afterEach(() => resetGatewayWorkAdmission()); + +describe("Gateway MCP OAuth callback route", () => { + it("serves the exact GET route before authenticated plugin catch-alls", async () => { + const callback = vi.fn(async (_req: IncomingMessage, res: ServerResponse) => { + res.statusCode = 200; + res.end("connected"); + return true; + }); + const plugin = vi.fn(async () => false); + + await withGatewayServer({ + prefix: "mcp-oauth-callback-route", + resolvedAuth: AUTH_TOKEN, + overrides: { + handleMcpOAuthCallbackRequest: callback, + handlePluginRequest: plugin, + }, + run: async (server) => { + const response = createResponse(); + await dispatchRequest( + server, + createRequest({ + path: "/oauth/mcp/callback?code=code&state=state", + method: "GET", + }), + response.res, + ); + + expect(response.res.statusCode).toBe(200); + expect(response.getBody()).toBe("connected"); + expect(callback).toHaveBeenCalledOnce(); + expect(plugin).not.toHaveBeenCalled(); + }, + }); + }); + + it("claims the callback before a hooks path that overlaps /oauth", async () => { + const callback = vi.fn(async (_req: IncomingMessage, res: ServerResponse) => { + res.statusCode = 200; + res.end("connected"); + return true; + }); + // Simulates hooks.path "/oauth": a prefix-claiming hooks handler that would + // otherwise 405 the provider redirect. + const hooks = vi.fn(async (_req: IncomingMessage, res: ServerResponse) => { + res.statusCode = 405; + res.end(); + return true; + }); + + await withGatewayServer({ + prefix: "mcp-oauth-callback-hooks-overlap", + resolvedAuth: AUTH_TOKEN, + overrides: { + handleMcpOAuthCallbackRequest: callback, + handleHooksRequest: hooks, + }, + run: async (server) => { + const response = createResponse(); + await dispatchRequest( + server, + createRequest({ + path: "/oauth/mcp/callback?code=code&state=state", + method: "GET", + }), + response.res, + ); + + expect(response.res.statusCode).toBe(200); + expect(callback).toHaveBeenCalledOnce(); + expect(hooks).not.toHaveBeenCalled(); + }, + }); + }); + + it("leaves wrong methods and paths outside the callback stage", async () => { + const callback = vi.fn(async () => false); + + await withGatewayServer({ + prefix: "mcp-oauth-callback-unclaimed", + resolvedAuth: AUTH_TOKEN, + overrides: { handleMcpOAuthCallbackRequest: callback }, + run: async (server) => { + for (const request of [ + createRequest({ path: "/oauth/mcp/callback", method: "POST" }), + createRequest({ path: "/oauth/other", method: "GET" }), + ]) { + const response = createResponse(); + await dispatchRequest(server, request, response.res); + expect(response.res.statusCode).toBe(404); + } + expect(callback).not.toHaveBeenCalled(); + }, + }); + }); + + it("falls through to plugin routing when requester OAuth is not configured", async () => { + const callback = vi.fn((req: IncomingMessage, res: ServerResponse) => + handleMcpOAuthCallback(req, res, { config: {}, log: { warn: vi.fn() } }), + ); + const plugin = vi.fn(async (_req: IncomingMessage, res: ServerResponse) => { + res.statusCode = 200; + res.end("plugin"); + return true; + }); + + await withGatewayServer({ + prefix: "mcp-oauth-callback-plugin-fallthrough", + resolvedAuth: AUTH_TOKEN, + overrides: { + handleMcpOAuthCallbackRequest: callback, + handlePluginRequest: plugin, + shouldEnforcePluginGatewayAuth: () => false, + }, + run: async (server) => { + const response = createResponse(); + await dispatchRequest( + server, + createRequest({ path: "/oauth/mcp/callback?code=code&state=state", method: "GET" }), + response.res, + ); + + expect(response.res.statusCode).toBe(200); + expect(response.getBody()).toBe("plugin"); + expect(callback).toHaveBeenCalledOnce(); + expect(plugin).toHaveBeenCalledOnce(); + }, + }); + }); + + it("rejects callback work after Gateway admission closes", async () => { + const callback = vi.fn(async () => false); + const suspension = tryBeginGatewaySuspendAdmission(() => {}); + expect(suspension?.commit()).toBe(true); + + try { + await withGatewayServer({ + prefix: "mcp-oauth-callback-admission", + resolvedAuth: AUTH_TOKEN, + overrides: { handleMcpOAuthCallbackRequest: callback }, + run: async (server) => { + const response = createResponse(); + await dispatchRequest( + server, + createRequest({ path: "/oauth/mcp/callback?code=code&state=state" }), + response.res, + ); + + expect(response.res.statusCode).toBe(503); + expect(JSON.parse(response.getBody())).toMatchObject({ + error: { code: "gateway_unavailable" }, + }); + expect(callback).not.toHaveBeenCalled(); + }, + }); + } finally { + suspension?.release(); + } + }); +}); diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 8c1c1240e930..3daacb523957 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -91,6 +91,7 @@ type PluginHttpRequestHandler = ( ) => Promise; type WatchNodeHttpRequestHandler = (req: IncomingMessage, res: ServerResponse) => Promise; +type McpOAuthCallbackHandler = (req: IncomingMessage, res: ServerResponse) => Promise; type PluginHttpUpgradeHandler = ( req: IncomingMessage, @@ -331,6 +332,7 @@ export function createGatewayHttpServer(opts: { openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig; strictTransportSecurityHeader?: string; handleHooksRequest: HooksRequestHandler; + handleMcpOAuthCallbackRequest?: McpOAuthCallbackHandler; handleWatchNodeRequest?: WatchNodeHttpRequestHandler; handlePluginRequest?: PluginHttpRequestHandler; shouldEnforcePluginGatewayAuth?: (pathContext: PluginRoutePathContext) => boolean; @@ -467,10 +469,6 @@ export function createGatewayHttpServer(opts: { getReadiness, ), }, - { - name: "hooks", - run: () => handleHooksRequest(req, res), - }, ]; const addRequestStage = ( name: string, @@ -491,6 +489,17 @@ export function createGatewayHttpServer(opts: { run: GatewayHttpRequestStage["run"], ) => addRequestStage(name, enabled, run, true); + // Before hooks: an operator hooks.path of "/oauth" would otherwise claim + // this exact GET and 405 every provider redirect. The claim is exact-path + // and config-gated, so preceding hooks cannot shadow any hook route. + addAdmittedStage( + "mcp-oauth-callback", + req.method === "GET" && + scopedRequestPath === "/oauth/mcp/callback" && + Boolean(opts.handleMcpOAuthCallbackRequest), + () => opts.handleMcpOAuthCallbackRequest?.(req, res) ?? false, + ); + addRequestStage("hooks", true, () => handleHooksRequest(req, res)); addAdmittedStage( "watch-node", Boolean(opts.handleWatchNodeRequest) && scopedRequestPath.startsWith("/api/nodes/watch/"), diff --git a/src/gateway/server-runtime-state.ts b/src/gateway/server-runtime-state.ts index fba39e3a33e7..d8f847a4a0a6 100644 --- a/src/gateway/server-runtime-state.ts +++ b/src/gateway/server-runtime-state.ts @@ -163,6 +163,14 @@ export async function createGatewayHttpTransport(params: { }); }; + const handleMcpOAuthCallbackRequest = async (req: IncomingMessage, res: ServerResponse) => { + const { handleMcpOAuthCallback } = await import("./mcp-oauth-callback.js"); + return await handleMcpOAuthCallback(req, res, { + config: loadRuntimeConfig(), + log: params.log, + }); + }; + let loadedPluginRequestHandler: GatewayPluginRequestHandler | null = null; let loadedPluginUpgradeHandler: GatewayPluginUpgradeHandler | null = null; const handlePluginRequest: GatewayPluginRequestHandler = async ( @@ -266,6 +274,7 @@ export async function createGatewayHttpTransport(params: { strictTransportSecurityHeader: params.strictTransportSecurityHeader, handleWatchNodeRequest: params.handleWatchNodeRequest, handleHooksRequest, + handleMcpOAuthCallbackRequest, handlePluginRequest, shouldEnforcePluginGatewayAuth, resolvePluginNodeCapabilityRoute, diff --git a/src/state/openclaw-state-db-contract.ts b/src/state/openclaw-state-db-contract.ts index 5fe6f2f4e14c..be2109e52f21 100644 --- a/src/state/openclaw-state-db-contract.ts +++ b/src/state/openclaw-state-db-contract.ts @@ -10,6 +10,7 @@ export const OPENCLAW_STATE_STRICT_SCHEMA_VERSION = 3; export const FIRST_USE_STATE_TABLES = [ "cron_job_runtime_authorities", "execution_identity_contexts", + "mcp_oauth_pending_authorizations", "operator_approval_execution_identities", ] as const; export const FIRST_USE_STATE_INDEXES = ["execution_identity_contexts_run_created_idx"] as const; diff --git a/src/state/openclaw-state-db-schema-additive.ts b/src/state/openclaw-state-db-schema-additive.ts index 4f3eb7820f0d..c3eb963770c8 100644 --- a/src/state/openclaw-state-db-schema-additive.ts +++ b/src/state/openclaw-state-db-schema-additive.ts @@ -21,6 +21,9 @@ import { OPENCLAW_STATE_SCHEMA_SQL } from "./openclaw-state-schema.js"; const SECRET_STORE_SCHEMA_START = "CREATE TABLE IF NOT EXISTS secret_store_entries ("; const SECRET_STORE_SCHEMA_END = "ON secret_store_entries (scope_kind, scope_id, name) WHERE deleted_at_ms IS NULL;"; +const MCP_OAUTH_PENDING_SCHEMA_START = + "CREATE TABLE IF NOT EXISTS mcp_oauth_pending_authorizations ("; +const MCP_OAUTH_PENDING_SCHEMA_END = "\n) STRICT;"; function secretStoreSchemaSql(): string { const start = OPENCLAW_STATE_SCHEMA_SQL.indexOf(SECRET_STORE_SCHEMA_START); @@ -37,6 +40,18 @@ export function ensureSecretStoreSchema(database: DatabaseSync): void { database.exec(secretStoreSchemaSql()); // sqlite-allow-raw -- Canonical additive DDL only. } +/** Lazily install durable MCP OAuth callback correlation on first feature use. */ +export function ensureMcpOAuthPendingSchema(database: DatabaseSync): void { + const start = OPENCLAW_STATE_SCHEMA_SQL.indexOf(MCP_OAUTH_PENDING_SCHEMA_START); + const endMarkerStart = OPENCLAW_STATE_SCHEMA_SQL.indexOf(MCP_OAUTH_PENDING_SCHEMA_END, start); + if (start < 0 || endMarkerStart < start) { + throw new Error("OpenClaw MCP OAuth pending schema marker is missing."); + } + database.exec( + OPENCLAW_STATE_SCHEMA_SQL.slice(start, endMarkerStart + MCP_OAUTH_PENDING_SCHEMA_END.length), + ); // sqlite-allow-raw -- Canonical additive DDL only. +} + export function ensureAgentDeletionJournalSchema(database: DatabaseSync): void { database.exec(` CREATE TABLE IF NOT EXISTS agent_deletion_journal ( diff --git a/src/state/openclaw-state-db.generated.d.ts b/src/state/openclaw-state-db.generated.d.ts index 4264a31eebce..a14073ac6ac5 100644 --- a/src/state/openclaw-state-db.generated.d.ts +++ b/src/state/openclaw-state-db.generated.d.ts @@ -810,6 +810,12 @@ export interface ManagedOutgoingImageRecords { updated_at: string | null; } +export interface McpOauthPendingAuthorizations { + create_time: number; + state: string; + store_key: string; +} + export interface McpOauthStores { format_version: number; store_json: string; @@ -1700,6 +1706,7 @@ export interface DB { installed_plugin_index: InstalledPluginIndex; macos_port_guardian_records: MacosPortGuardianRecords; managed_outgoing_image_records: ManagedOutgoingImageRecords; + mcp_oauth_pending_authorizations: McpOauthPendingAuthorizations; mcp_oauth_stores: McpOauthStores; media_blobs: MediaBlobs; meeting_transcript_sessions: MeetingTranscriptSessions; diff --git a/src/state/openclaw-state-schema.sql b/src/state/openclaw-state-schema.sql index ab7de1a6da44..e03767b99d40 100644 --- a/src/state/openclaw-state-schema.sql +++ b/src/state/openclaw-state-schema.sql @@ -17,6 +17,12 @@ CREATE TABLE IF NOT EXISTS mcp_oauth_stores ( updated_at INTEGER NOT NULL ) STRICT; +CREATE TABLE IF NOT EXISTS mcp_oauth_pending_authorizations ( + state TEXT NOT NULL PRIMARY KEY, + store_key TEXT NOT NULL, + create_time INTEGER NOT NULL +) STRICT; + CREATE TABLE IF NOT EXISTS diagnostic_events ( scope TEXT NOT NULL, event_key TEXT NOT NULL,