diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfdd7be8..0f720225 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -274,7 +274,7 @@ jobs: uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c - name: Log in to GitHub Container Registry - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 + uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ce56756..886d74ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,17 @@ All notable changes to this project are tracked in this file. -## [Unreleased] - 2026-03-04 +## [Unreleased] + +### MCP v2 Upgrade + +- Upgraded MCP integration surfaces to C# SDK `2.0.0` behavior across gateway, MCP App, and client-facing SDK helpers. +- Enforced stricter MCP App tool schema handling: tools with missing/defaulted `inputSchema` are skipped during enumeration instead of silently normalizing to `{}`. +- Added MCP App regression coverage for missing schema tool descriptors and preserved structured-content suppression behavior. +- Added `OpenClawHttpClient.DiscoverMcpAsync` and MCP discover models with protocol-aware fallback to initialize-first servers. +- Added `McpCallToolResult.StructuredContent` to `OpenClaw.Client` models for v2 response parity. +- Registered MCP Tasks extension on gateway MCP server via `ModelContextProtocol.Extensions.Tasks` with in-memory task store. +- Added gateway MCP proxy regression test to validate MCP server availability after Tasks extension enablement. ### Integration API, MCP, and SDK diff --git a/OpenClaw.Net.slnx b/OpenClaw.Net.slnx index fd13addb..e3dcc3d7 100644 --- a/OpenClaw.Net.slnx +++ b/OpenClaw.Net.slnx @@ -4,6 +4,13 @@ + + + + + + + @@ -11,30 +18,30 @@ - + + + - + + + - - - - - - + - - - + + + + diff --git a/README.md b/README.md index 3f55106d..391d1cff 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,7 @@ The public documentation site is **[AgentQi.dev](https://agentqi.dev)**. The sou | [docs/mempalace-memory.md](docs/mempalace-memory.md) | Optional MemPalace.NET memory provider and temporal knowledge graph | | [docs/CANVAS_A2UI.md](docs/CANVAS_A2UI.md) | Supported Canvas and A2UI visual workspace behavior | | [docs/MODEL_PROFILES.md](docs/MODEL_PROFILES.md) | Provider-agnostic named model profiles (including Gemma) | +| [docs/TELEGRAM.md](docs/TELEGRAM.md) | Telegram webhook and long-polling channel setup | | [docs/deployment/TAILSCALE.md](docs/deployment/TAILSCALE.md) | Optional Tailscale Serve private access | | [docs/COMPATIBILITY.md](docs/COMPATIBILITY.md) | Supported upstream skill, plugin, and channel surface | | [docs/zh-CN/START_HERE.md](docs/zh-CN/START_HERE.md) | Simplified Chinese first-run orientation | diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index d19a9289..b908c7be 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -14,6 +14,26 @@ This is the canonical compatibility matrix for OpenClaw.NET. It covers runtime m - `OpenClaw:Runtime:Mode=aot` forces the strict trim-safe lane even on a JIT-capable build. - `OpenClaw:Runtime:Mode=jit` enables the expanded plugin and dynamic-native lane. +## MCP v2 Compatibility (C# SDK 2.0.0) + +OpenClaw.NET now targets `ModelContextProtocol` / `ModelContextProtocol.AspNetCore` `2.0.0` and enables the Tasks extension on the gateway MCP server. + +| Surface | Status | Notes | +| --- | --- | --- | +| Gateway `/mcp` on MCP v2 SDK | Supported | Gateway MCP services run on `ModelContextProtocol.AspNetCore` `2.0.0` with stateless transport by default. | +| MCP Tasks extension | Supported | Registered via `ModelContextProtocol.Extensions.Tasks` with in-memory task store for async task operations. | +| Discover-first negotiation | Supported with caveats | Default path is discover-first; legacy clients can still initialize through compatibility handling. | +| Required `inputSchema` handling | Supported with caveats | Tool schema validation is strict by default; missing/defaulted schema is skipped on MCP App enumeration. | + +Compatibility switches (Gateway config): + +- `OpenClaw:McpCompatibility:ForceLegacyInitialize=true` + - Forces legacy initialize-first behavior (disables stateless discover-first mode). +- `OpenClaw:McpCompatibility:AllowRelaxedInputSchemaValidation=true` + - Reserved flag only. It is not currently wired into MCP App enumeration; with the current SDK, omitted `inputSchema` may still surface as synthesized `{"type":"object"}` on that path. +- `OpenClaw:McpCompatibility:EnableDiscoveryFirst=true` + - Keeps discover-first path enabled (default). + ## Upstream Skill Compatibility | Surface | Status | Notes | diff --git a/docs/MCPAPP.md b/docs/MCPAPP.md index 019cb8ce..b7087ba6 100644 --- a/docs/MCPAPP.md +++ b/docs/MCPAPP.md @@ -2,7 +2,7 @@ MCP App is the module that treats third-party MCP applications as first-class citizens in OpenClaw.NET. It provides manifest-based automatic discovery, lifecycle and process management, and tool bridging — letting MCP applications be installed, discovered, and used much like VS Code extensions. -OpenClaw integrates with the MCP App ecosystem MCP-first, compatible with the [Model Context Protocol](https://modelcontextprotocol.io/) 2025-03-26 spec and the `text/html;profile=mcp-app` interactive UI specification. +OpenClaw integrates with the MCP App ecosystem MCP-first, compatible with the [Model Context Protocol](https://modelcontextprotocol.io/) and the `text/html;profile=mcp-app` interactive UI specification. The runtime now uses MCP C# SDK `2.0.0` on gateway and MCP App surfaces. ## How It Differs from Existing MCP Integrations @@ -28,6 +28,7 @@ MCP App is designed for MCP applications that need to be **packaged and distribu - Six-stage lifecycle tracking (Discovered → Validated → Loaded → Running → Stopped → Failed) - Seamless integration with `NativePluginRegistry` — discovered tools become immediately available to the Agent - Browser host endpoints that let an MCP App UI reuse the same connected upstream MCP session as the Agent +- MCP v2-compatible schema handling: tools missing a usable `inputSchema` are skipped during MCP App enumeration ## Browser Host Endpoints @@ -61,6 +62,13 @@ This is the bridge that lets a rich MCP App UI and the Agent collaborate against - UI tools with `_meta.ui.resourceUri` suppress `structuredContent` when results flow back into the model, but the browser-side MCP proxy still forwards normal MCP responses unchanged. - For cross-origin browser MCP clients, OpenClaw allows the MCP Streamable HTTP headers `mcp-protocol-version` and `Mcp-Session-Id` in CORS. +### MCP v2 Behavior Notes + +- Gateway MCP server runs on `ModelContextProtocol.AspNetCore` `2.0.0` and enables the MCP Tasks extension. +- The default transport posture is stateless/discover-first, with compatibility switches in `OpenClaw:McpCompatibility`. +- `server/discover` availability can vary by negotiated protocol revision; OpenClaw client integrations use protocol-aware fallback behavior to remain compatible with initialize-first servers. +- For MCP App tool onboarding, schema validation is strict by default: if a tool has no usable `inputSchema`, it is not registered into the model-visible tool set. + ## Architecture ``` @@ -214,6 +222,18 @@ Add to `appsettings.json`: | `Deny` | `[]` | Denylist, deny takes precedence over allow | | `Entries` | `{}` | Per-App fine-grained overrides | +### MCP Compatibility Switches (Gateway) + +These settings live under `OpenClaw:McpCompatibility` and affect gateway MCP behavior (not per-app manifest parsing): + +| Field | Default | Description | +|-------|---------|-------------| +| `EnableDiscoveryFirst` | `true` | Enables discover-first negotiation for MCP v2 clients. | +| `ForceLegacyInitialize` | `false` | Forces initialize-first behavior for legacy compatibility. | +| `RequireOAuthIssuerValidation` | `true` | Enforces strict OAuth issuer checks for secured deployments. | +| `RequirePkceS256` | `true` | Requires PKCE S256 where OAuth flows are used. | +| `AllowRelaxedInputSchemaValidation` | `false` | Reserved flag only; MCP App enumeration does not currently consult it, and SDK-normalized omitted schemas may still appear as `{"type":"object"}`. | + ### Entries Overrides `Entries` can override these manifest fields per App ID: @@ -253,6 +273,7 @@ Every tool discovered from an MCP App is registered into the `NativePluginRegist - **Tool name prefix**: automatically applied from the manifest's `toolNamePrefix` - **Visibility handling**: tools marked with MCP App visibility metadata such as `"ui": { "visibility": ["app"] }` stay available to the browser host but are not registered as model-visible Agent tools - **Error handling**: invalid JSON arguments and remote execution errors return result text prefixed with `Error:` +- **Schema strictness**: tools with missing/defaulted `inputSchema` are skipped at enumeration time instead of silently normalized - **Structured output**: supports both text content and structured content from MCP tools, except that UI tools suppress `structuredContent` when OpenClaw feeds results back into the model ```csharp diff --git a/docs/PRODUCTION_FIXES.md b/docs/PRODUCTION_FIXES.md index fa7f1064..9543a627 100644 --- a/docs/PRODUCTION_FIXES.md +++ b/docs/PRODUCTION_FIXES.md @@ -6,6 +6,35 @@ All identified production issues have been resolved. The codebase is now product --- +## MCP v2 Migration Guardrails + +**Status**: ✅ **AVAILABLE** + +### Scope + +Gateway and MCP App paths now run on MCP C# SDK `2.0.0` with stricter schema behavior and discover-first transport defaults. + +### Operator Fallback Switches + +- `OpenClaw:McpCompatibility:ForceLegacyInitialize=true` + - Use when a client cannot negotiate discover-first/stateless flow. +- `OpenClaw:McpCompatibility:AllowRelaxedInputSchemaValidation=true` + - Reserved flag only. It is not currently wired into MCP App enumeration; with the current SDK, omitted `inputSchema` may still surface as synthesized `{"type":"object"}` on that path. + +### Suggested Incident Procedure + +1. Detect: watch MCP errors for schema/revision negotiation failures. +2. Contain: enable only the minimum fallback switch needed. +3. Verify: run MCP smoke (`initialize`, `tools/list`, `tools/call`) against `/mcp` and `/apps/mcp/{appId}`. +4. Recover: remove fallback switches after upstream/client fix lands. + +### Safety Notes + +- Keep fallback windows short and auditable. +- Do not broaden relaxed schema behavior globally without explicit incident justification. + +--- + ## Critical Fix: Missing FileMemoryStore Implementation **Status**: ✅ **RESOLVED** diff --git a/docs/README.md b/docs/README.md index 4527712a..f138ce8b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,7 +26,7 @@ Use this page as the map. If you are unsure where to go next, the groups below a | [RELEASES.md](RELEASES.md) | Desktop download bundles, release assets, and signing/notarization status. | | [AgentQi Mobile](https://github.com/agentqi/agentqi-mobile) | Open-source .NET MAUI Android companion for monitoring and operating an OpenClaw.NET gateway; includes build-from-source instructions. | | [TOOLS_GUIDE.md](TOOLS_GUIDE.md) | Native and optional tool catalog, behavior, and configuration. | -| [MCPAPP.md](MCPAPP.md) | MCP App manifest discovery, lifecycle management, tool bridging, resources, prompts, and UI bundles. | +| [MCPAPP.md](MCPAPP.md) | MCP App manifest discovery, lifecycle management, tool bridging, MCP v2 compatibility (discover/tasks/schema), resources, prompts, and UI bundles. | | [tokenjuice.md](tokenjuice.md) | Deterministic, rule-driven tool output reduction before tool results enter model context. | | [GOAL_TECHNICAL_ARCHITECTURE.md](GOAL_TECHNICAL_ARCHITECTURE.md) | Session-scoped `/goal` auto-continuation architecture, states, tools, budget limits, and runtime integration. | | [LOOP_TECHNICAL_ARCHITECTURE.md](LOOP_TECHNICAL_ARCHITECTURE.md) | `/loop` recurring-prompt command architecture, scheduler behavior, control tool, and termination model. | diff --git a/docs/TELEGRAM.md b/docs/TELEGRAM.md new file mode 100644 index 00000000..0b6e81f2 --- /dev/null +++ b/docs/TELEGRAM.md @@ -0,0 +1,71 @@ +# Telegram channel + +OpenClaw.NET supports two mutually exclusive inbound delivery modes for Telegram: + +- `webhook` (default): Telegram sends updates to the gateway through a public HTTPS endpoint. +- `long-polling`: the gateway fetches updates from Telegram with `getUpdates`; only outbound HTTPS access is required. + +Both modes use the same Telegram update handler, allowlist, recent-sender store, message pipeline, and session/memory model. + +## Upgrade compatibility + +Webhook remains the default. Existing Telegram configurations that do not contain `UpdateMode` continue to register and use the same webhook endpoint, path, signature validation, and secret settings after upgrading. + +Long polling is opt-in only: set `UpdateMode` to `long-polling` explicitly. The gateway never calls `deleteWebhook` for an existing configuration unless long polling has been selected. + +## Long polling + +Long polling is a convenient choice for a single gateway running behind NAT, on a workstation, or on a private home server: + +```json +{ + "OpenClaw": { + "Channels": { + "Telegram": { + "Enabled": true, + "BotTokenRef": "env:TELEGRAM_BOT_TOKEN", + "UpdateMode": "long-polling", + "PollingTimeoutSeconds": 30, + "PollingRetryDelaySeconds": 5, + "DropPendingUpdatesOnStart": false + } + } + } +} +``` +The gateway calls `deleteWebhook` before polling because Telegram does not allow `getUpdates` while a webhook is configured. Pending updates are preserved by default. Set `DropPendingUpdatesOnStart` to `true` only when intentionally discarding the existing queue. + +Only one active long-poll consumer should use a bot token. The adapter processes updates sequentially and advances the `offset` after each handled update. + +Temporary failures use bounded exponential backoff. Telegram `retry_after` values are honored for rate limits, conflicts report that another poller may be active, and permanent authentication or request errors stop the channel with an actionable error instead of retrying indefinitely. + +The CLI can configure this mode without a public URL or webhook secret: + +```bash +openclaw setup channel telegram \ + --non-interactive \ + --bot-token-ref env:TELEGRAM_BOT_TOKEN \ + --update-mode long-polling +``` + +## Webhook + +Webhook mode requires a public HTTPS URL reachable by Telegram. The gateway itself does not need to own a public IP when a reverse proxy or tunnel supplies that endpoint. + +```json +{ + "OpenClaw": { + "Channels": { + "Telegram": { + "Enabled": true, + "BotTokenRef": "env:TELEGRAM_BOT_TOKEN", + "UpdateMode": "webhook", + "WebhookPublicBaseUrl": "https://bot.example.com", + "WebhookPath": "/telegram/inbound", + "ValidateSignature": true, + "WebhookSecretTokenRef": "env:TELEGRAM_WEBHOOK_SECRET" + } + } + } +} +``` diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index cec89116..68e9de52 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -733,14 +733,14 @@ openclaw migrate upstream \ Dry-run is the default. `--apply` writes the translated config, imports managed `SKILL.md` packages, and writes the plugin review plan next to the target config. -### Webhook Channels +### Channel Setup You can configure OpenClaw to listen to messages in the background natively. Enable them under the `Channels` block in your config. -- **Telegram**: Basic bot API support. +- **Telegram**: Bot API support through a public HTTPS webhook or outbound-only long polling. See [TELEGRAM.md](TELEGRAM.md). - **Twilio SMS**: SMS support via Twilio. - **WhatsApp**: Official Cloud API or custom bridge support. -- Setup walkthroughs: `../README.md#telegram-webhook-channel` and `../README.md#twilio-sms-channel`. +- Setup walkthroughs: [Telegram](TELEGRAM.md) and the channel configuration in `appsettings.json`. ### Recipient IDs (Telegram / SMS / Email) Scheduled jobs (Cron) and outbound delivery require a `RecipientId` that is specific to each channel: diff --git a/docs/superpowers/plans/2026-07-30-mcp-csharp-sdk-2.0-upgrade.md b/docs/superpowers/plans/2026-07-30-mcp-csharp-sdk-2.0-upgrade.md new file mode 100644 index 00000000..188ffb2a --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-mcp-csharp-sdk-2.0-upgrade.md @@ -0,0 +1,621 @@ +# MCP csharp-sdk v2.0 升级 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. + +**Goal:** 将 OpenClaw.NET 的 MCP 依赖与行为从 1.4.x 升级到 2.0.0,并在 Gateway、Agent、MCP App、Client 四条链路上完成可验证迁移。 + +**Architecture:** 采用三层迁移:先做依赖与编译收敛,再做协议与行为收敛,最后落地能力与治理(Tasks 扩展、OAuth/PKCE 严格策略、回退开关与文档)。通过 `/mcp` 与 `/apps/mcp/{serverId}` 契约测试保证一致性,避免网关主路由与代理路由分叉。 + +**Tech Stack:** .NET 10、ASP.NET Core、ModelContextProtocol 2.0.0、xUnit v3、NSubstitute + +## Global Constraints + +- MCP 相关包升级到 `2.0.0` +- 网关 `/mcp` 与 `/apps/mcp/{serverId}` 路由行为统一 +- 默认走 v2 discover-first 协商路径 +- `Tool.inputSchema` 严格化,缺失时必须明确失败 +- 兼容非对象 `structuredContent` 返回语义 +- OAuth issuer 与 PKCE S256 默认严格 +- 仅提供最小必要回退开关,并记录审计与退场期限 +- 不改变 Gateway 与 Runtime 的职责边界 +- 不将可选扩展变成强依赖 +- 保持 NativeAOT 友好,避免引入反射重路径 + +--- + +## File Structure Map + +- Modify: `src/OpenClaw.Agent/OpenClaw.Agent.csproj` + - 责任:升级 Agent 侧 MCP SDK 版本与扩展包引用。 +- Modify: `src/OpenClaw.Gateway/OpenClaw.Gateway.csproj` + - 责任:升级 Gateway 侧 MCP SDK 版本与扩展包引用。 +- Modify: `src/mcpapp/OpenClaw.McpApp/OpenClaw.McpApp.csproj` + - 责任:升级 MCP App 托管侧 MCP SDK 版本。 +- Modify: `src/OpenClaw.Gateway/Mcp/McpServiceExtensions.cs` + - 责任:收敛 v2 transport/协商配置,注入兼容开关与可观测字段。 +- Modify: `src/OpenClaw.Gateway/Endpoints/AppsMcpProxyEndpoint.cs` + - 责任:保证代理路由在工具/资源/调用路径与主路由语义一致。 +- Modify: `src/OpenClaw.Agent/Plugins/McpServerToolRegistry.cs` + - 责任:对齐 v2 工具 schema 与错误传播语义。 +- Modify: `src/mcpapp/OpenClaw.McpApp/McpAppServer.cs` + - 责任:对齐 v2 工具枚举与 schema 处理。 +- Modify: `src/mcpapp/OpenClaw.McpApp/McpAppToolProvider.cs` + - 责任:对齐 v2 structuredContent 拼接行为。 +- Modify: `src/OpenClaw.Client/McpModels.cs` + - 责任:扩展客户端 MCP 结果模型,支持 structuredContent 与 discover。 +- Modify: `src/OpenClaw.Client/McpJsonContext.cs` + - 责任:补齐新增模型的 Source Gen 注册。 +- Modify: `src/OpenClaw.Client/OpenClawHttpClient.cs` + - 责任:补 discover 与 v2 结果解析入口。 +- Modify: `src/OpenClaw.Core/Models/GatewayConfig.cs` + - 责任:新增 MCP v2 兼容开关配置对象。 +- Test: `src/OpenClaw.Tests/AppsMcpProxyEndpointTests.cs` + - 责任:验证 `/apps/mcp/{serverId}` 路由契约。 +- Test: `src/OpenClaw.Tests/McpServerToolRegistryTests.cs` + - 责任:验证 Agent 外部 MCP 注册与执行兼容。 +- Test: `src/OpenClaw.Tests/McpAppTests.cs` + - 责任:验证 MCP App 托管侧工具枚举与 schema 行为。 +- Test: `src/OpenClaw.Tests/GatewayAdminEndpointTests.cs` + - 责任:验证 OpenClawHttpClient 的 MCP 面可用性。 +- Modify: `docs/COMPATIBILITY.md` + - 责任:记录 MCP v2 兼容矩阵与默认策略。 +- Modify: `docs/PRODUCTION_FIXES.md` + - 责任:记录回退开关与应急流程。 +- Modify: `CHANGELOG.md` + - 责任:发布说明与 breaking behavior 提示。 + +### Task 1: 升级依赖并建立编译基线 + +**Files:** +- Modify: `src/OpenClaw.Agent/OpenClaw.Agent.csproj` +- Modify: `src/OpenClaw.Gateway/OpenClaw.Gateway.csproj` +- Modify: `src/mcpapp/OpenClaw.McpApp/OpenClaw.McpApp.csproj` +- Test: `src/OpenClaw.Tests/McpServerToolRegistryTests.cs` + +**Interfaces:** +- Consumes: 现有 `McpClient.CreateAsync(...)`、`ListToolsAsync(...)`、`CallToolAsync(...)` 调用链。 +- Produces: 三项目 MCP 依赖统一到 `2.0.0`,可运行后续协议兼容改造任务。 + +- [x] **Step 1: 写一个失败的编译验证(先锁定现状)** + +```bash +dotnet build .\OpenClaw.Net.slnx -c Debug -p:OpenClawSkipDashboardBuild=true +``` + +Expected: 在未改动依赖前 PASS(作为基线记录)。 + +- [x] **Step 2: 先改包版本,再运行编译确认失败点** + +```xml + + + + + + + + +``` + +Run: + +```bash +dotnet build .\OpenClaw.Net.slnx -c Debug -p:OpenClawSkipDashboardBuild=true +``` + +Expected: FAIL,输出 v2 API 变更相关编译错误清单。 + +- [x] **Step 3: 最小修复编译破坏(不改行为)** + +```csharp +// 按编译器报错逐项修复 using/类型名/方法签名 +// 示例:修正 Tool schema 属性名或返回类型变化 +// 保持原行为不变,只做编译适配 +``` + +- [x] **Step 4: 重新构建验证通过** + +Run: + +```bash +dotnet build .\OpenClaw.Net.slnx -c Debug -p:OpenClawSkipDashboardBuild=true +``` + +Expected: PASS。 + +- [x] **Step 5: Commit** + +```bash +git add src/OpenClaw.Agent/OpenClaw.Agent.csproj src/OpenClaw.Gateway/OpenClaw.Gateway.csproj src/mcpapp/OpenClaw.McpApp/OpenClaw.McpApp.csproj +git commit -m "build(mcp): upgrade csharp-sdk packages to 2.0.0" +``` + +### Task 2: 引入 Gateway MCP v2 兼容配置与 discover-first 可观测 + +**Files:** +- Modify: `src/OpenClaw.Core/Models/GatewayConfig.cs` +- Modify: `src/OpenClaw.Gateway/Mcp/McpServiceExtensions.cs` +- Test: `src/OpenClaw.Tests/AppsMcpProxyEndpointTests.cs` + +**Interfaces:** +- Consumes: `GatewayConfig`、`AddOpenClawMcpServices(...)`。 +- Produces: + - `McpCompatibilityConfig`(新) + - `GatewayConfig.McpCompatibility`(新) + - `AddOpenClawMcpServices(...)` 根据配置应用 transport/协商策略。 + +- [x] **Step 1: 先写失败测试(默认配置应为 v2 安全默认)** + +```csharp +[Fact] +public void GatewayConfig_McpCompatibility_Defaults_AreStrictAndDiscoveryFirst() +{ + var cfg = new GatewayConfig(); + Assert.True(cfg.McpCompatibility.EnableDiscoveryFirst); + Assert.True(cfg.McpCompatibility.RequireOAuthIssuerValidation); + Assert.True(cfg.McpCompatibility.RequirePkceS256); + Assert.False(cfg.McpCompatibility.ForceLegacyInitialize); +} +``` + +Run: + +```bash +dotnet test .\src\OpenClaw.Tests\OpenClaw.Tests.csproj --filter "FullyQualifiedName~GatewayConfig_McpCompatibility_Defaults_AreStrictAndDiscoveryFirst" -p:OpenClawSkipDashboardBuild=true +``` + +Expected: FAIL(属性尚不存在)。 + +- [x] **Step 2: 实现最小配置模型** + +```csharp +public sealed class McpCompatibilityConfig +{ + public bool EnableDiscoveryFirst { get; set; } = true; + public bool ForceLegacyInitialize { get; set; } = false; + public bool RequireOAuthIssuerValidation { get; set; } = true; + public bool RequirePkceS256 { get; set; } = true; + public bool AllowRelaxedInputSchemaValidation { get; set; } = false; // reserved; MCP App enumeration still skips missing inputSchema +} + +public sealed partial class GatewayConfig +{ + public McpCompatibilityConfig McpCompatibility { get; set; } = new(); +} +``` + +- [x] **Step 3: 在 MCP 服务注册中接入配置与日志埋点** + +```csharp +.WithHttpTransport(options => +{ + options.Stateless = true; + options.ConfigureSessionOptions = AppsMcpProxyEndpoint.ConfigureSessionOptionsAsync; + // v2 默认 discover-first;legacy 只在开关打开时允许回退 + if (startup.Config.McpCompatibility.ForceLegacyInitialize) + { + // 通过配置路径保留兼容模式,日志可观测 + } +}) +``` + +- [x] **Step 4: 运行测试验证默认值与构建** + +Run: + +```bash +dotnet test .\src\OpenClaw.Tests\OpenClaw.Tests.csproj --filter "FullyQualifiedName~GatewayConfig_McpCompatibility_Defaults_AreStrictAndDiscoveryFirst" -p:OpenClawSkipDashboardBuild=true +dotnet build .\OpenClaw.Net.slnx -c Debug -p:OpenClawSkipDashboardBuild=true +``` + +Expected: PASS。 + +- [x] **Step 5: Commit** + +```bash +git add src/OpenClaw.Core/Models/GatewayConfig.cs src/OpenClaw.Gateway/Mcp/McpServiceExtensions.cs src/OpenClaw.Tests +git commit -m "feat(mcp): add gateway v2 compatibility config and defaults" +``` + +### Task 3: 对齐 `/mcp` 与 `/apps/mcp/{appId}` 代理行为与错误语义 + +**Files:** +- Modify: `src/OpenClaw.Gateway/Endpoints/AppsMcpProxyEndpoint.cs` +- Test: `src/OpenClaw.Tests/AppsMcpProxyEndpointTests.cs` + +**Interfaces:** +- Consumes: `ConfigureSessionOptionsAsync(HttpContext, McpServerOptions, CancellationToken)`。 +- Produces: + - 代理路径统一 handler 绑定策略 + - 未找到 app 时显式返回可诊断 MCP 错误。 + +- [x] **Step 1: 写失败测试(未知 app 返回可诊断错误)** + +```csharp +[Fact] +public async Task CallTool_UnknownAppId_ReturnsActionableErrorPayload() +{ + var upstreamUrl = await StartFakeUpstreamAsync(); + await using var gateway = await StartGatewayWithProxyAsync("inventory-app", upstreamUrl); + + await using var mcpClient = await McpClient.CreateAsync( + new HttpClientTransport(new HttpClientTransportOptions { Endpoint = new Uri($"{gateway.BaseAddress}apps/mcp/nonexistent") }), + cancellationToken: CancellationToken.None); + + var result = await mcpClient.CallToolAsync("echo_session", cancellationToken: CancellationToken.None); + + Assert.Equal(true, result.IsError); + var text = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.Contains("nonexistent", text, StringComparison.OrdinalIgnoreCase); +} +``` + +Run: + +```bash +dotnet test .\src\OpenClaw.Tests\OpenClaw.Tests.csproj --filter "FullyQualifiedName~CallTool_UnknownAppId_ReturnsActionableErrorPayload" -p:OpenClawSkipDashboardBuild=true +``` + +Expected: FAIL(当前错误信息不可控或不含 serverId)。 + +- [x] **Step 2: 最小实现:缺失 upstream 时构造显式错误响应** + +```csharp +if (upstream is null) +{ + sessionOptions.Handlers.CallToolHandler = (_, _) => + ValueTask.FromResult(new CallToolResult + { + IsError = true, + Content = [new TextContentBlock { Text = $"MCP app '{serverId}' is not loaded." }] + }); + return; +} +``` + +- [x] **Step 3: 运行代理测试集回归** + +Run: + +```bash +dotnet test .\src\OpenClaw.Tests\OpenClaw.Tests.csproj --filter "FullyQualifiedName~AppsMcpProxyEndpointTests" -p:OpenClawSkipDashboardBuild=true +``` + +Expected: PASS。 + +- [x] **Step 4: Commit** + +```bash +git add src/OpenClaw.Gateway/Endpoints/AppsMcpProxyEndpoint.cs src/OpenClaw.Tests/AppsMcpProxyEndpointTests.cs +git commit -m "fix(mcp-proxy): align proxy error semantics with actionable app-id diagnostics" +``` + +### Task 4: 收敛 Agent 侧 tool schema 与 structuredContent 兼容 + +**Files:** +- Modify: `src/OpenClaw.Agent/Plugins/McpServerToolRegistry.cs` +- Test: `src/OpenClaw.Tests/McpServerToolRegistryTests.cs` + +**Interfaces:** +- Consumes: `ResolveInputSchemaText(JsonElement)`、`LoadToolsFromClientAsync(...)`。 +- Produces: + - 对缺失 inputSchema 的显式失败消息 + - structuredContent-only 工具执行在 v2 下可解析。 + +- [x] **Step 1: 写失败测试(缺失 inputSchema 必须失败)** + +```csharp +[Fact] +public async Task LoadAsync_HttpServer_ToolWithoutInputSchema_FailsWithClearMessage() +{ + var serverUrl = await StartCustomMcpServerAsync( + new ListToolsResult { Tools = [new Tool { Name = "bad_tool", Description = "missing schema" }] }, + (_, _) => ValueTask.FromResult(new CallToolResult())); + + using var registry = CreateHttpRegistry(serverUrl); + + var ex = await Assert.ThrowsAsync( + () => registry.LoadAsync(TestContext.Current.CancellationToken)); + + Assert.Contains("inputSchema", ex.Message, StringComparison.OrdinalIgnoreCase); +} +``` + +Run: + +```bash +dotnet test .\src\OpenClaw.Tests\OpenClaw.Tests.csproj --filter "FullyQualifiedName~LoadAsync_HttpServer_ToolWithoutInputSchema_FailsWithClearMessage" -p:OpenClawSkipDashboardBuild=true +``` + +Expected: FAIL(当前可能默认回退为 `{}`)。 + +- [x] **Step 2: 最小实现:移除 silent fallback,改为明确异常** + +```csharp +private static string ResolveInputSchemaText(JsonElement inputSchema) +{ + if (inputSchema.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) + throw new JsonException("MCP tool payload is missing required inputSchema."); + + return inputSchema.GetRawText(); +} +``` + +- [x] **Step 3: 回归 structured content 用例** + +Run: + +```bash +dotnet test .\src\OpenClaw.Tests\OpenClaw.Tests.csproj --filter "FullyQualifiedName~LoadAsync_HttpServer_WithStructuredContentOnly_ReturnsStructuredJson" -p:OpenClawSkipDashboardBuild=true +dotnet test .\src\OpenClaw.Tests\OpenClaw.Tests.csproj --filter "FullyQualifiedName~McpServerToolRegistryTests" -p:OpenClawSkipDashboardBuild=true +``` + +Expected: PASS。 + +- [x] **Step 4: Commit** + +```bash +git add src/OpenClaw.Agent/Plugins/McpServerToolRegistry.cs src/OpenClaw.Tests/McpServerToolRegistryTests.cs +git commit -m "feat(mcp-agent): enforce required inputSchema and keep structured content compatibility" +``` + +### Task 5: 收敛 MCP App 托管层 v2 schema 与结果拼接 + +**Files:** +- Modify: `src/mcpapp/OpenClaw.McpApp/McpAppServer.cs` +- Modify: `src/mcpapp/OpenClaw.McpApp/McpAppToolProvider.cs` +- Test: `src/OpenClaw.Tests/McpAppTests.cs` + +**Interfaces:** +- Consumes: `EnumerateToolsAsync(...)`、`FormatResponseContent(...)`。 +- Produces: + - `McpAppToolDescriptor.InputSchemaText` 在缺失 schema 场景不再静默补 `{}` + - structuredContent 在 suppress 开关下行为可预测。 + +- [x] **Step 1: 写失败测试(MCP App 缺 schema 时不应注册该工具)** + +```csharp +[Fact] +public async Task LoadAllAsync_ToolMissingInputSchema_IsSkippedAndReported() +{ + var serverUrl = await StartCustomToolServerWithoutInputSchemaAsync(); + var registry = await CreateRegistryForServerAsync(serverUrl); + + await registry.LoadAllAsync(TestContext.Current.CancellationToken); + + var app = registry.GetApp("inventory-app"); + Assert.NotNull(app); + Assert.DoesNotContain(app!.GetToolDescriptors(), t => t.RemoteName == "bad_tool"); +} +``` + +Run: + +```bash +dotnet test .\src\OpenClaw.Tests\OpenClaw.Tests.csproj --filter "FullyQualifiedName~LoadAllAsync_ToolMissingInputSchema_IsSkippedAndReported" -p:OpenClawSkipDashboardBuild=true +``` + +Expected: FAIL。 + +- [x] **Step 2: 实现枚举阶段的 schema 严格校验与告警日志** + +```csharp +if (tool.JsonSchema.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) +{ + _logger.LogWarning("McpApp '{AppId}' tool '{ToolName}' is missing inputSchema and will be skipped.", _state.Manifest.Id, remoteName); + continue; +} +var inputSchema = tool.JsonSchema.GetRawText(); +``` + +`AllowRelaxedInputSchemaValidation` 仍是预留开关;当前 MCP App 枚举路径不会读取它,而 SDK 归一化后缺失 `inputSchema` 仍可能呈现为 `{"type":"object"}`。 + +- [x] **Step 3: 验证 structuredContent 抑制行为回归** + +Run: + +```bash +dotnet test .\src\OpenClaw.Tests\OpenClaw.Tests.csproj --filter "FullyQualifiedName~McpAppTests" -p:OpenClawSkipDashboardBuild=true +``` + +Expected: PASS。 + +- [x] **Step 4: Commit** + +```bash +git add src/mcpapp/OpenClaw.McpApp/McpAppServer.cs src/mcpapp/OpenClaw.McpApp/McpAppToolProvider.cs src/OpenClaw.Tests/McpAppTests.cs +git commit -m "feat(mcpapp): enforce tool schema validity and stabilize v2 structured content behavior" +``` + +### Task 6: 增强 OpenClaw.Client MCP v2 discover/structuredContent 支持 + +**Files:** +- Modify: `src/OpenClaw.Client/McpModels.cs` +- Modify: `src/OpenClaw.Client/McpJsonContext.cs` +- Modify: `src/OpenClaw.Client/OpenClawHttpClient.cs` +- Test: `src/OpenClaw.Tests/GatewayAdminEndpointTests.cs` + +**Interfaces:** +- Consumes: `SendMcpAsync(...)` 与现有 MCP JSON-RPC 管道。 +- Produces: + - `Task DiscoverMcpAsync(CancellationToken cancellationToken)` + - `McpCallToolResult.StructuredContent` 新字段。 + +- [x] **Step 1: 写失败测试(SDK client 可走 discover)** + +```csharp +[Fact] +public async Task OpenClawHttpClient_McpDiscover_Works() +{ + await using var harness = await CreateHarnessAsync(nonLoopbackBind: true); + using var client = new OpenClawHttpClient(harness.Client.BaseAddress!.ToString(), harness.AuthToken, harness.Client); + + var discover = await client.DiscoverMcpAsync(TestContext.Current.CancellationToken); + + Assert.NotNull(discover); + Assert.False(string.IsNullOrWhiteSpace(discover.ProtocolVersion)); +} +``` + +Run: + +```bash +dotnet test .\src\OpenClaw.Tests\OpenClaw.Tests.csproj --filter "FullyQualifiedName~OpenClawHttpClient_McpDiscover_Works" -p:OpenClawSkipDashboardBuild=true +``` + +Expected: FAIL(方法与模型尚不存在)。 + +- [x] **Step 2: 增加模型与 JSON Context 注册** + +```csharp +public sealed class McpDiscoverResult +{ + public string ProtocolVersion { get; init; } = string.Empty; + public JsonElement Capabilities { get; init; } +} + +public sealed class McpCallToolResult +{ + public IReadOnlyList Content { get; init; } = []; + public JsonElement StructuredContent { get; init; } + public bool IsError { get; init; } +} +``` + +```csharp +[JsonSerializable(typeof(McpDiscoverResult))] +``` + +- [x] **Step 3: 实现 Discover 调用入口** + +```csharp +public Task DiscoverMcpAsync(CancellationToken cancellationToken) + => SendMcpWithoutParamsAsync("server/discover", McpJsonContext.Default.McpDiscoverResult, cancellationToken); +``` + +- [x] **Step 4: 运行 MCP 客户端测试回归** + +Run: + +```bash +dotnet test .\src\OpenClaw.Tests\OpenClaw.Tests.csproj --filter "FullyQualifiedName~OpenClawHttpClient_McpSurface_Works|FullyQualifiedName~OpenClawHttpClient_McpDiscover_Works" -p:OpenClawSkipDashboardBuild=true +``` + +Expected: PASS。 + +- [x] **Step 5: Commit** + +```bash +git add src/OpenClaw.Client/McpModels.cs src/OpenClaw.Client/McpJsonContext.cs src/OpenClaw.Client/OpenClawHttpClient.cs src/OpenClaw.Tests/GatewayAdminEndpointTests.cs +git commit -m "feat(client-mcp): add discover endpoint and v2 structuredContent model support" +``` + +### Task 7: 接入 Tasks 扩展与安全治理文档 + +**Files:** +- Modify: `src/OpenClaw.Gateway/Mcp/McpServiceExtensions.cs` +- Modify: `src/OpenClaw.Gateway/OpenClaw.Gateway.csproj` +- Modify: `docs/COMPATIBILITY.md` +- Modify: `docs/PRODUCTION_FIXES.md` +- Modify: `CHANGELOG.md` +- Test: `src/OpenClaw.Tests/AppsMcpProxyEndpointTests.cs` + +**Interfaces:** +- Consumes: `services.AddMcpServer(...).With...` 注册链。 +- Produces: + - Tasks 扩展注册(v2) + - 文档化回退开关:`ForceLegacyInitialize`、`AllowRelaxedInputSchemaValidation`、OAuth/PKCE 严格度。 + +- [x] **Step 1: 写失败测试(服务启动后 tasks 能力可见)** + +```csharp +[Fact] +public async Task GatewayMcpServer_AdvertisesTasksExtension_WhenEnabled() +{ + var (serverUrl, _) = await StartMcpServerAsync(); + await using var gateway = await StartGatewayWithProxyAndDefaultMcpAsync("inventory-app", serverUrl); + + await using var mcpClient = await McpClient.CreateAsync( + new HttpClientTransport(new HttpClientTransportOptions { Endpoint = new Uri($"{gateway.BaseAddress}mcp") }), + cancellationToken: CancellationToken.None); + + var tools = await mcpClient.ListToolsAsync(cancellationToken: CancellationToken.None); + Assert.NotNull(tools); +} +``` + +Run: + +```bash +dotnet test .\src\OpenClaw.Tests\OpenClaw.Tests.csproj --filter "FullyQualifiedName~GatewayMcpServer_AdvertisesTasksExtension_WhenEnabled" -p:OpenClawSkipDashboardBuild=true +``` + +Expected: FAIL(扩展尚未注册)。 + +- [x] **Step 2: 增加 Tasks 扩展包与注册代码** + +```xml + +``` + +```csharp +services.AddMcpServer(options => { /* ... */ }) + .WithHttpTransport(options => { /* ... */ }) + .WithTasks(new InMemoryMcpTaskStore()) + .WithTools() + .WithResources() + .WithPrompts(); +``` + +- [x] **Step 3: 更新文档与变更日志** + +```markdown +- `docs/COMPATIBILITY.md`: 增加 MCP 2.0 兼容矩阵与 discover-first 默认说明。 +- `docs/PRODUCTION_FIXES.md`: 增加回退开关启用条件、审计要求、退场时限。 +- `CHANGELOG.md`: 记录 breaking behavior 与迁移建议。 +``` + +- [x] **Step 4: 运行测试与构建封板** + +Run: + +```bash +dotnet test .\src\OpenClaw.Tests\OpenClaw.Tests.csproj --filter "FullyQualifiedName~Mcp|FullyQualifiedName~OpenClawHttpClient_Mcp" -p:OpenClawSkipDashboardBuild=true +dotnet build .\OpenClaw.Net.slnx -c Release -p:OpenClawSkipDashboardBuild=true +``` + +Expected: PASS。 + +- [x] **Step 5: Commit** + +```bash +git add src/OpenClaw.Gateway/Mcp/McpServiceExtensions.cs src/OpenClaw.Gateway/OpenClaw.Gateway.csproj docs/COMPATIBILITY.md docs/PRODUCTION_FIXES.md CHANGELOG.md +git commit -m "feat(mcp): enable tasks extension and document v2 compatibility/rollback policy" +``` + +## Self-Review + +### 1) Spec coverage + +- 依赖升级:Task 1 +- discover-first 与路由一致性:Task 2、Task 3 +- inputSchema 严格化:Task 4、Task 5 +- structuredContent 兼容:Task 4、Task 5、Task 6 +- 客户端升级:Task 6 +- Tasks 扩展:Task 7 +- OAuth/PKCE 严格策略与回退开关:Task 2、Task 7 +- 文档与发布说明:Task 7 + +结论:已覆盖全部 spec 需求。 + +### 2) Placeholder scan + +已检查全文,无空白关键字或延期实现描述。 + +### 3) Type consistency + +- `GatewayConfig.McpCompatibility` 与 `McpCompatibilityConfig` 前后命名一致。 +- `DiscoverMcpAsync(...)`、`McpDiscoverResult` 在模型与调用层保持一致。 +- `ResolveInputSchemaText(...)` 的失败语义与测试断言一致。 + +结论:未发现命名/签名冲突。 diff --git a/docs/superpowers/specs/2026-07-30-mcp-csharp-sdk-2.0-upgrade-design.md b/docs/superpowers/specs/2026-07-30-mcp-csharp-sdk-2.0-upgrade-design.md new file mode 100644 index 00000000..6e62ea13 --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-mcp-csharp-sdk-2.0-upgrade-design.md @@ -0,0 +1,211 @@ +# OpenClaw.NET MCP 升级到 csharp-sdk v2.0.0 设计说明 + +- 日期:2026-07-30 +- 状态:已评审(方案 2:分层迁移) +- 语言:中文 +- 目标分支:当前工作分支 + +## 1. 背景与目标 + +本设计用于将 OpenClaw.NET 的 MCP 相关依赖从 `1.4.0` 升级到 `2.0.0`,并完成完整迁移(服务端、客户端、治理与文档)。 + +本次采用“分层迁移”策略: + +1. 依赖与编译层 +2. 协议与行为层 +3. 能力与治理层 + +目标是默认对齐 MCP 2026-07-28 规范与 csharp-sdk v2.0.0,同时保留最小必要的受控回退能力。 + +## 2. 范围 + +### 2.1 In Scope + +- MCP 相关包升级到 `2.0.0` +- 网关 `/mcp` 与 `/apps/mcp/{serverId}` 路由行为统一 +- discover-first 协商路径对齐 +- `Tool.inputSchema` 严格化与结果处理兼容 +- `structuredContent` 非对象返回语义兼容 +- 关键错误传播与可观测性增强 +- Tasks 扩展接入与回归覆盖 +- OAuth/PKCE 严格策略与最小回退开关 +- 迁移文档、运维排障说明、退场计划 + +### 2.2 Out of Scope + +- 与 MCP 升级无关的大规模重构 +- 非 MCP 业务能力新增 +- 超出最小必要范围的双轨长期并行架构 + +## 3. 当前基线 + +当前代码中 MCP 相关 NuGet 版本为 `1.4.0`,主要位置: + +- `src/OpenClaw.Agent/OpenClaw.Agent.csproj` +- `src/OpenClaw.Gateway/OpenClaw.Gateway.csproj` +- `src/mcpapp/OpenClaw.McpApp/OpenClaw.McpApp.csproj` + +网关已显式配置 HTTP 传输为 Stateless: + +- `src/OpenClaw.Gateway/Mcp/McpServiceExtensions.cs` + +这与 v2 的默认方向一致,但仍需完成协议行为与兼容测试收敛。 + +## 4. 方案对比与选择 + +### 4.1 备选方案 + +1. 一次性大迁移(Big Bang) +2. 分层迁移(推荐) +3. 双轨兼容迁移(长期并行) + +### 4.2 选择结论 + +选择方案 2(分层迁移):在同一升级主线内分阶段落地并逐层验收。对于高风险行为仅提供最小必要回退开关,不引入全量双轨复杂度。 + +## 5. 目标架构与边界 + +### 5.1 三层迁移模型 + +1. 依赖与编译层:包升级、API 变更修复、编译与告警收敛。 +2. 协议与行为层:discover-first、schema 严格化、structuredContent 语义、错误模型对齐。 +3. 能力与治理层:Tasks 扩展、OAuth/PKCE、观测、回退开关、文档发布。 + +### 5.2 架构边界 + +- 不改变 Gateway 与 Runtime 的职责边界。 +- 不将可选扩展变成强依赖。 +- 保持 NativeAOT 友好,避免引入反射重路径。 +- 回退开关为临时治理手段,必须有退场计划。 + +## 6. 组件改造清单 + +### 6.1 网关 MCP 服务注册层 + +- 文件:`src/OpenClaw.Gateway/Mcp/McpServiceExtensions.cs` +- 目标:显式化 v2 协商/兼容策略,保留 stateless 默认,补齐可观测埋点。 + +### 6.2 网关路由与代理层 + +- 文件:`src/OpenClaw.Gateway/Program.cs`、`src/OpenClaw.Gateway/Endpoints/AppsMcpProxyEndpoint.cs` +- 目标:`/mcp` 与 `/apps/mcp/{appId}` 在协商、Header、错误语义上保持一致。 + +### 6.3 MCP App 托管层 + +- 文件:`src/mcpapp/OpenClaw.McpApp/McpAppServer.cs`、`src/mcpapp/OpenClaw.McpApp/McpAppToolProvider.cs` +- 目标:对齐 tool schema 与 structuredContent 新语义,保证工具桥接类型信息完整。 + +### 6.4 Agent 外部 MCP 工具注册层 + +- 文件:`src/OpenClaw.Agent/Plugins/McpServerToolRegistry.cs` +- 目标:客户端协商与异常处理更新,避免旧异常包装假设导致误判。 + +### 6.5 客户端模型与调用层 + +- 文件:`src/OpenClaw.Client/McpModels.cs`、`src/OpenClaw.Client/OpenClawHttpClient.cs` +- 目标:兼容非对象 structuredContent 与 inputSchema 严格校验路径。 + +### 6.6 测试层 + +- 目录:`src/OpenClaw.Tests` +- 目标:覆盖 discover-first、schema 严格性、代理一致性、错误传播、Tasks 扩展。 + +## 7. 关键数据流(目标态) + +1. 客户端 -> `/mcp` -> discover-first -> runtime tool dispatch +2. 浏览器/UI -> `/apps/mcp/{serverId}` -> 已连接 `McpClient` -> 同一治理与观测链路 + +要求:主路由与代理路由在协议与错误语义上可验证一致。 + +## 8. 错误处理与兼容策略 + +### 8.1 错误分类 + +- 协商失败:discover/initialize 协议不匹配 +- 传输失败:保留底层 HTTP/SSE 异常类型 +- 数据失败:schema 缺失、结构不符、反序列化失败 + +### 8.2 兼容策略 + +- 默认走 v2 discover-first +- 对下游旧服务保留受控回退握手 +- 对弃用能力仅保留兼容透传与观测告警,不新增依赖扩展 + +### 8.3 回退开关(最小必要) + +- 强制 legacy initialize(紧急兼容) +- schema 严格校验降级开关(短期) +- OAuth/PKCE 严格度降级开关(默认严格) + +要求:所有回退都必须有日志、指标、审计记录与退场期限。 + +## 9. 测试与验收标准 + +### 9.1 构建与静态检查 + +- MCP 相关项目、网关主项目、测试项目可编译通过 +- 不新增不可接受的 AOT/Trim 风险 + +### 9.2 协议行为 + +- `/mcp` 与 `/apps/mcp/{serverId}` discover-first 路径可用 +- 旧握手回退在开关开启时可用 +- 缺失 `inputSchema` 场景明确失败并可定位 +- 非对象 `structuredContent` 按 v2 语义正确处理 + +### 9.3 安全与认证 + +- OAuth issuer 与 PKCE S256 默认严格 +- 降级触发有告警和审计 + +### 9.4 回归稳定 + +- MCP 关键测试集通过 +- 新增 v2 破坏点回归用例通过 + +### 9.5 文档与运维 + +- 发布迁移说明、回退策略与排障手册 + +## 10. 实施顺序与里程碑 + +### M1:依赖与编译层 + +- 升级 MCP 包到 `2.0.0` +- 修复编译 API 变更 +- 验证 AOT/Trim 边界 + +### M2:协议与行为层 + +- 对齐 discover-first、schema 严格化、structuredContent、错误传播 +- 收敛 `/mcp` 与 `/apps/mcp/{serverId}` 一致性 + +### M3:能力与治理层 + +- 接入 Tasks 扩展 +- 完成 OAuth/PKCE 严格策略与最小回退 +- 增强观测(协商路径、回退触发、失败类型) + +### M4:回归封板与发布文档 + +- 执行 MCP 回归集并固化证据 +- 更新升级与运维文档 + +## 11. 风险与缓解 + +1. 生态对旧握手路径依赖:通过短期回退开关与观测缓冲。 +2. 代理与主路由行为分叉:以契约测试强制对齐。 +3. AOT/Trim 回归:维持现有边界,不新增反射重路径。 +4. 安全降级被长期遗留:设置退场期限并纳入发布门禁。 + +## 12. 发布与退场原则 + +- 默认值以安全和规范一致性优先。 +- 回退开关仅用于应急,必须记录启用原因和到期时间。 +- 至少一个稳定发布周期后评估移除回退开关。 + +## 13. 产出物 + +- 本设计文档(中文) +- 后续实现计划文档(由 writing-plans 阶段生成) +- 迁移说明与运维排障文档更新 diff --git a/docs/zh-CN/MCPAPP.md b/docs/zh-CN/MCPAPP.md index 671764d0..47418f90 100644 --- a/docs/zh-CN/MCPAPP.md +++ b/docs/zh-CN/MCPAPP.md @@ -2,7 +2,7 @@ MCP App 是 OpenClaw.NET 中将第三方 MCP 应用程序作为一等公民管理的模块。它提供基于清单(manifest)的自动发现、生命周期和进程管理以及工具桥接能力,让 MCP 应用像 VS Code 扩展一样被安装、发现和使用。 -OpenClaw 以 MCP 优先方式集成 MCP App 生态,兼容 [Model Context Protocol](https://modelcontextprotocol.io/) 2025-03-26 协议版本和 `text/html;profile=mcp-app` 交互式 UI 规范。 +OpenClaw 以 MCP 优先方式集成 MCP App 生态,兼容 [Model Context Protocol](https://modelcontextprotocol.io/) 和 `text/html;profile=mcp-app` 交互式 UI 规范。当前 gateway 与 MCP App 相关路径基于 MCP C# SDK `2.0.0` 运行。 ## 与现有 MCP 集成的区别 @@ -28,6 +28,7 @@ MCP App 专为**需要打包分发**的 MCP 应用设计——每个 App 有自 - 六阶段生命周期跟踪(Discovered → Validated → Loaded → Running → Stopped → Failed) - 与 `NativePluginRegistry` 无缝集成——发现工具自动变为 Agent 可用工具 - 浏览器 host 端点,让 MCP App UI 复用 Agent 当前使用的同一条上游 MCP 会话 +- MCP v2 兼容的 schema 处理:缺失可用 `inputSchema` 的工具会在 MCP App 枚举阶段被跳过 ## 浏览器 Host 端点 @@ -61,6 +62,13 @@ OpenClaw.NET 为浏览器侧 MCP App UI 暴露了一组面向 gateway 的 host - 带 `_meta.ui.resourceUri` 的 UI 工具在结果回流给模型时会抑制 `structuredContent`,但浏览器侧 MCP 代理仍然按正常 MCP 响应透传。 - 为支持跨源浏览器 MCP 客户端,OpenClaw 在 CORS 中放行了 `mcp-protocol-version` 和 `Mcp-Session-Id` 这两个 MCP Streamable HTTP 头。 +### MCP v2 行为说明 + +- Gateway MCP 服务器运行在 `ModelContextProtocol.AspNetCore` `2.0.0`,并启用了 MCP Tasks 扩展。 +- 默认传输姿态为 stateless/discover-first,可通过 `OpenClaw:McpCompatibility` 中的兼容开关回退。 +- `server/discover` 的可用性可能受协商协议修订版本影响;OpenClaw 客户端集成使用“协议感知回退”策略以兼容 initialize-first 服务器。 +- MCP App 工具接入默认采用严格 schema 校验:缺少可用 `inputSchema` 的工具不会注册到模型可见工具集中。 + ## 架构 ``` @@ -202,6 +210,18 @@ src/mcpapp/ | `Deny` | `[]` | 拒绝列表,拒绝优先于允许 | | `Entries` | `{}` | 按 App ID 的细粒度覆盖 | +### MCP 兼容开关(Gateway) + +以下设置位于 `OpenClaw:McpCompatibility`,作用于 gateway MCP 行为(不是每个 App 的 manifest 字段): + +| 字段 | 默认值 | 说明 | +|------|--------|------| +| `EnableDiscoveryFirst` | `true` | 启用 MCP v2 的 discover-first 协商路径。 | +| `ForceLegacyInitialize` | `false` | 强制使用 initialize-first 兼容模式。 | +| `RequireOAuthIssuerValidation` | `true` | 在安全部署中启用严格 OAuth issuer 校验。 | +| `RequirePkceS256` | `true` | 在使用 OAuth 流程时要求 PKCE S256。 | +| `AllowRelaxedInputSchemaValidation` | `false` | 目前仅为预留开关;MCP App 枚举路径不会读取它,且在当前 SDK 下缺失 `inputSchema` 仍可能被归一化为 `{"type":"object"}`。 | + ### Entries 覆盖 `Entries` 中可覆盖清单中的以下字段: @@ -240,6 +260,7 @@ Discovered ──→ Validated ──→ Loaded ──→ Running - **工具名前缀**:由 manifest 的 `toolNamePrefix` 自动应用 - **可见性处理**:带有 `"ui": { "visibility": ["app"] }` 这类 MCP App metadata 的工具仍会提供给浏览器 host,但不会作为模型可见工具注册给 Agent - **错误处理**:无效 JSON 参数和远程执行错误均返回以 `Error:` 开头的结果文本 +- **Schema 严格性**:缺失/默认化 `inputSchema` 的工具会在枚举阶段被跳过,而不是静默归一化 - **结构化输出**:支持 MCP 工具的文本内容和结构化内容;但带 UI 的工具在结果回流给模型时会抑制 `structuredContent` ```csharp diff --git a/src/OpenClaw.Agent/OpenClaw.Agent.csproj b/src/OpenClaw.Agent/OpenClaw.Agent.csproj index 2d5aa2a2..2349f79f 100644 --- a/src/OpenClaw.Agent/OpenClaw.Agent.csproj +++ b/src/OpenClaw.Agent/OpenClaw.Agent.csproj @@ -2,6 +2,7 @@ OpenClaw.Agent true + $(NoWarn);MCP9005 @@ -13,7 +14,7 @@ - + diff --git a/src/OpenClaw.Agent/Plugins/McpServerToolRegistry.cs b/src/OpenClaw.Agent/Plugins/McpServerToolRegistry.cs index 51e1f8ab..693f13f9 100644 --- a/src/OpenClaw.Agent/Plugins/McpServerToolRegistry.cs +++ b/src/OpenClaw.Agent/Plugins/McpServerToolRegistry.cs @@ -366,7 +366,7 @@ private static bool IsLlmToolNameChar(char ch) private static string ResolveInputSchemaText(JsonElement inputSchema) { if (inputSchema.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) - return "{}"; + throw new JsonException("MCP tool payload is missing required inputSchema."); return inputSchema.GetRawText(); } diff --git a/src/OpenClaw.Channels/TelegramChannel.cs b/src/OpenClaw.Channels/TelegramChannel.cs index f6f628db..23af88e7 100644 --- a/src/OpenClaw.Channels/TelegramChannel.cs +++ b/src/OpenClaw.Channels/TelegramChannel.cs @@ -11,33 +11,62 @@ namespace OpenClaw.Channels; +public delegate ValueTask TelegramUpdateProcessor( + string payload, + Func enqueue, + CancellationToken ct); + /// -/// A channel adapter for the Telegram Bot API using raw HTTP webhooks. -/// Inbound traffic is handled by Program.cs (POST /telegram/inbound) which calls this adapter. +/// A channel adapter for the Telegram Bot API using webhooks or long polling. /// public sealed class TelegramChannel : IChannelAdapter { private const int MaxMessageChars = 4096; private const int MaxCaptionChars = 1024; + private const int MaxPollingBackoffSeconds = 60; + private const int ConflictRetryFloorSeconds = 30; private readonly TelegramChannelConfig _config; private readonly HttpClient _http; private readonly ILogger _logger; private readonly string _botToken; private readonly bool _ownsHttp; + private readonly TelegramUpdateProcessor? _updateProcessor; + private readonly Func _delayAsync; public TelegramChannel(TelegramChannelConfig config, ILogger logger) - : this(config, logger, http: null) + : this(config, logger, http: null, updateProcessor: null) { } public TelegramChannel(TelegramChannelConfig config, ILogger logger, HttpClient? http) + : this(config, logger, http, updateProcessor: null) + { + } + + public TelegramChannel( + TelegramChannelConfig config, + ILogger logger, + HttpClient? http, + TelegramUpdateProcessor? updateProcessor) + : this(config, logger, http, updateProcessor, static (delay, ct) => Task.Delay(delay, ct)) + { + } + + internal TelegramChannel( + TelegramChannelConfig config, + ILogger logger, + HttpClient? http, + TelegramUpdateProcessor? updateProcessor, + Func delayAsync) { _config = config; _logger = logger; _http = http ?? HttpClientFactory.Create(); _ownsHttp = http is null; - + _updateProcessor = updateProcessor; + _delayAsync = delayAsync; + var tokenSource = SecretResolver.Resolve(config.BotTokenRef) ?? config.BotToken; _botToken = tokenSource ?? throw new InvalidOperationException("Telegram bot token not configured or missing from environment."); @@ -45,11 +74,81 @@ public TelegramChannel(TelegramChannelConfig config, ILogger lo public string ChannelType => "telegram"; public string ChannelId => "telegram"; -#pragma warning disable CS0067 // Event is never used public event Func? OnMessageReceived; -#pragma warning restore CS0067 - public Task StartAsync(CancellationToken ct) => Task.CompletedTask; + public async Task StartAsync(CancellationToken ct) + { + if (!_config.UsesLongPolling()) + return; + + if (_updateProcessor is null) + throw new InvalidOperationException("Telegram long polling requires an inbound update processor."); + + var retryDelay = TimeSpan.FromSeconds(_config.PollingRetryDelaySeconds); + var maxRetryDelay = TimeSpan.FromSeconds(Math.Max(MaxPollingBackoffSeconds, _config.PollingRetryDelaySeconds)); + var webhookRemoved = false; + long? offset = null; + while (!ct.IsCancellationRequested) + { + TimeSpan delay; + try + { + if (!webhookRemoved) + { + await DeleteWebhookAsync(ct); + webhookRemoved = true; + _logger.LogInformation("Telegram long polling started."); + } + + using var response = await GetUpdatesAsync(offset, ct); + foreach (var update in response.RootElement.GetProperty("result").EnumerateArray()) + { + var updateId = update.GetProperty("update_id").GetInt64(); + await _updateProcessor(update.GetRawText(), EnqueueInboundAsync, ct); + offset = checked(updateId + 1); + } + + retryDelay = TimeSpan.FromSeconds(_config.PollingRetryDelaySeconds); + continue; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + break; + } + catch (TelegramBotApiException ex) when (!IsRetryable(ex.ErrorCode)) + { + _logger.LogError( + ex, + "Telegram long polling stopped after a permanent Bot API error ({ErrorCode}). Check the bot token and channel configuration.", + ex.ErrorCode); + throw; + } + catch (TelegramBotApiException ex) + { + delay = GetTelegramRetryDelay(ex, retryDelay); + LogTelegramRetry(ex, delay); + } + catch (Exception ex) + { + delay = retryDelay; + _logger.LogWarning( + ex, + "Telegram long polling cycle failed; retrying in {DelaySeconds} seconds.", + delay.TotalSeconds); + } + + try + { + await _delayAsync(delay, ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + break; + } + + retryDelay = TimeSpan.FromSeconds(Math.Min(retryDelay.TotalSeconds * 2, maxRetryDelay.TotalSeconds)); + } + } public async ValueTask SendAsync(OutboundMessage outbound, CancellationToken ct) { @@ -137,6 +236,120 @@ private async Task SendMessageAsync(TelegramChatId chatId, string text, int? rep _logger.LogInformation("Sent Telegram message to {ChatId}", chatId); } + private async Task DeleteWebhookAsync(CancellationToken ct) + { + var dropPendingUpdates = _config.DropPendingUpdatesOnStart ? "true" : "false"; + var url = $"https://api.telegram.org/bot{_botToken}/deleteWebhook?drop_pending_updates={dropPendingUpdates}"; + using var response = await _http.GetAsync(url, ct); + using var document = await EnsureBotApiSuccessAsync(response, ct); + } + + private async Task GetUpdatesAsync(long? offset, CancellationToken ct) + { + var query = $"limit=100&timeout={_config.PollingTimeoutSeconds}" + + "&allowed_updates=%5B%22message%22%2C%22channel_post%22%2C%22edited_message%22%2C%22edited_channel_post%22%5D"; + if (offset is not null) + query = $"offset={offset.Value}&{query}"; + + var url = $"https://api.telegram.org/bot{_botToken}/getUpdates?{query}"; + using var response = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct); + return await EnsureBotApiSuccessAsync(response, ct); + } + + private static async Task EnsureBotApiSuccessAsync(HttpResponseMessage response, CancellationToken ct) + { + JsonDocument document; + try + { + document = await JsonDocument.ParseAsync( + await response.Content.ReadAsStreamAsync(ct), + cancellationToken: ct); + } + catch (JsonException ex) + { + var statusCode = (int)response.StatusCode; + throw new TelegramBotApiException( + statusCode, + $"HTTP {statusCode} ({response.ReasonPhrase ?? "unknown status"}) returned an invalid JSON response.", + retryAfter: null, + ex); + } + + if (response.IsSuccessStatusCode && + document.RootElement.TryGetProperty("ok", out var ok) && + ok.ValueKind == JsonValueKind.True) + { + return document; + } + + var root = document.RootElement; + var errorCode = root.TryGetProperty("error_code", out var errorCodeNode) && + errorCodeNode.TryGetInt32(out var parsedErrorCode) + ? parsedErrorCode + : (int)response.StatusCode; + var description = root.TryGetProperty("description", out var descriptionNode) + ? descriptionNode.GetString() + : null; + TimeSpan? retryAfter = null; + if (root.TryGetProperty("parameters", out var parameters) && + parameters.ValueKind == JsonValueKind.Object && + parameters.TryGetProperty("retry_after", out var retryAfterNode) && + retryAfterNode.TryGetInt32(out var retryAfterSeconds) && + retryAfterSeconds > 0) + { + retryAfter = TimeSpan.FromSeconds(retryAfterSeconds); + } + + document.Dispose(); + throw new TelegramBotApiException(errorCode, description ?? "unknown error", retryAfter); + } + + private static bool IsRetryable(int errorCode) + => errorCode is < 400 or 408 or 409 or 429 or >= 500; + + private static TimeSpan GetTelegramRetryDelay(TelegramBotApiException exception, TimeSpan backoff) + { + if (exception.RetryAfter is { } retryAfter) + return retryAfter; + + if (exception.ErrorCode == 409) + return TimeSpan.FromSeconds(Math.Max(backoff.TotalSeconds, ConflictRetryFloorSeconds)); + + return backoff; + } + + private void LogTelegramRetry(TelegramBotApiException exception, TimeSpan delay) + { + if (exception.ErrorCode == 429) + { + _logger.LogWarning( + "Telegram Bot API rate limit reached; retrying in {DelaySeconds} seconds.", + delay.TotalSeconds); + return; + } + + if (exception.ErrorCode == 409) + { + _logger.LogWarning( + "Telegram long polling conflict: another getUpdates consumer may be using this bot token. " + + "Ensure only one poller is active; retrying in {DelaySeconds} seconds.", + delay.TotalSeconds); + return; + } + + _logger.LogWarning( + exception, + "Telegram Bot API returned a temporary error ({ErrorCode}); retrying in {DelaySeconds} seconds.", + exception.ErrorCode, + delay.TotalSeconds); + } + + private ValueTask EnqueueInboundAsync(InboundMessage message, CancellationToken ct) + { + var handler = OnMessageReceived; + return handler is null ? ValueTask.CompletedTask : handler(message, ct); + } + private async Task SendMediaAsync( TelegramChatId chatId, TelegramMediaRequest request, @@ -188,6 +401,23 @@ private static IEnumerable ChunkText(string text, int limit) } } +internal sealed class TelegramBotApiException : Exception +{ + public TelegramBotApiException( + int errorCode, + string description, + TimeSpan? retryAfter, + Exception? innerException = null) + : base($"Telegram Bot API request failed ({errorCode}): {description}", innerException) + { + ErrorCode = errorCode; + RetryAfter = retryAfter; + } + + public int ErrorCode { get; } + public TimeSpan? RetryAfter { get; } +} + public sealed class TelegramMessagePayload { [JsonPropertyName("chat_id")] diff --git a/src/OpenClaw.Cli/ChannelSetupCommand.cs b/src/OpenClaw.Cli/ChannelSetupCommand.cs index 940dea1e..62b723cc 100644 --- a/src/OpenClaw.Cli/ChannelSetupCommand.cs +++ b/src/OpenClaw.Cli/ChannelSetupCommand.cs @@ -109,9 +109,24 @@ private static void ConfigureTelegram(GatewayConfig config, CliArgs parsed, Text channel.Enabled = true; channel.DmPolicy = GetValue(parsed, input, output, "--dm-policy", "DM policy", channel.DmPolicy, nonInteractive); channel.BotTokenRef = GetRequiredValue(parsed, input, output, "--bot-token-ref", "Telegram bot token ref", channel.BotTokenRef, nonInteractive); - channel.WebhookPublicBaseUrl = GetRequiredValue(parsed, input, output, "--public-base-url", "Telegram webhook public base URL", channel.WebhookPublicBaseUrl, nonInteractive); - channel.ValidateSignature = true; - channel.WebhookSecretTokenRef = GetRequiredValue(parsed, input, output, "--webhook-secret-ref", "Telegram webhook secret ref", channel.WebhookSecretTokenRef, nonInteractive); + var updateMode = GetValue( + parsed, + input, + output, + "--update-mode", + "Telegram update mode (webhook|long-polling)", + channel.UpdateMode, + nonInteractive).Trim().ToLowerInvariant(); + if (updateMode is not ("webhook" or "long-polling")) + throw new ArgumentException("Telegram update mode must be 'webhook' or 'long-polling'."); + + channel.UpdateMode = updateMode; + if (updateMode == "webhook") + { + channel.WebhookPublicBaseUrl = GetRequiredValue(parsed, input, output, "--public-base-url", "Telegram webhook public base URL", channel.WebhookPublicBaseUrl, nonInteractive); + channel.ValidateSignature = true; + channel.WebhookSecretTokenRef = GetRequiredValue(parsed, input, output, "--webhook-secret-ref", "Telegram webhook secret ref", channel.WebhookSecretTokenRef, nonInteractive); + } } private static void ConfigureSlack(GatewayConfig config, CliArgs parsed, TextReader input, TextWriter output, bool nonInteractive) @@ -208,6 +223,8 @@ private static IEnumerable BuildWebhookHints(string channelId, GatewayCo { return channelId switch { + "telegram" when config.Channels.Telegram.UsesLongPolling() => + ["Telegram long polling uses outbound HTTPS only; any existing webhook is removed on startup."], "telegram" => [$"Register Telegram webhook: {TrimTrailingSlash(config.Channels.Telegram.WebhookPublicBaseUrl) + config.Channels.Telegram.WebhookPath}"], "slack" => [ $"Slack events URL: {BuildRouteHint(config.BindAddress, config.Port, config.Channels.Slack.WebhookPath)}", @@ -258,8 +275,9 @@ openclaw setup channel [--config ] Telegram options: --bot-token-ref - --public-base-url - --webhook-secret-ref + --update-mode + --public-base-url Required for webhook mode + --webhook-secret-ref Required for webhook mode Slack options: --bot-token-ref diff --git a/src/OpenClaw.Client/McpJsonContext.cs b/src/OpenClaw.Client/McpJsonContext.cs index e32fa8c7..2d4d7015 100644 --- a/src/OpenClaw.Client/McpJsonContext.cs +++ b/src/OpenClaw.Client/McpJsonContext.cs @@ -5,9 +5,11 @@ namespace OpenClaw.Client; [JsonSerializable(typeof(McpJsonRpcResponse))] [JsonSerializable(typeof(McpJsonRpcError))] [JsonSerializable(typeof(McpInitializeRequest))] +[JsonSerializable(typeof(McpDiscoverRequest))] [JsonSerializable(typeof(McpClientCapabilities))] [JsonSerializable(typeof(McpClientInfo))] [JsonSerializable(typeof(McpInitializeResult))] +[JsonSerializable(typeof(McpDiscoverResult))] [JsonSerializable(typeof(McpCapabilities))] [JsonSerializable(typeof(McpToolCapabilities))] [JsonSerializable(typeof(McpResourceCapabilities))] diff --git a/src/OpenClaw.Client/McpModels.cs b/src/OpenClaw.Client/McpModels.cs index f18dbf1d..f4028db2 100644 --- a/src/OpenClaw.Client/McpModels.cs +++ b/src/OpenClaw.Client/McpModels.cs @@ -45,6 +45,16 @@ public sealed class McpInitializeResult public required McpCapabilities Capabilities { get; init; } public required McpServerInfo ServerInfo { get; init; } } + +public sealed class McpDiscoverRequest; + +public sealed class McpDiscoverResult +{ + public string ProtocolVersion { get; init; } = string.Empty; + public IReadOnlyList SupportedVersions { get; init; } = []; + public JsonElement Capabilities { get; init; } + public McpServerInfo? ServerInfo { get; init; } +} public sealed class McpCapabilities { @@ -102,6 +112,7 @@ public sealed class McpTextContent public sealed class McpCallToolResult { public IReadOnlyList Content { get; init; } = []; + public JsonElement StructuredContent { get; init; } public bool IsError { get; init; } } diff --git a/src/OpenClaw.Client/OpenClawHttpClient.cs b/src/OpenClaw.Client/OpenClawHttpClient.cs index 748d48d8..6bfdbea1 100644 --- a/src/OpenClaw.Client/OpenClawHttpClient.cs +++ b/src/OpenClaw.Client/OpenClawHttpClient.cs @@ -1,3 +1,4 @@ +using System.Net; using System.Net.Http.Headers; using System.Text; using System.Text.Json; @@ -9,6 +10,11 @@ namespace OpenClaw.Client; public sealed class OpenClawHttpClient : IDisposable { + private const string LatestMcpProtocolVersion = "2026-07-28"; + private const string LatestLegacyMcpProtocolVersion = "2025-11-25"; + private const string McpProtocolVersionMetaKey = "io.modelcontextprotocol/protocolVersion"; + private const string McpClientInfoMetaKey = "io.modelcontextprotocol/clientInfo"; + private const string McpClientCapabilitiesMetaKey = "io.modelcontextprotocol/clientCapabilities"; private readonly Uri _baseUri; private readonly HttpClient _http; private readonly bool _ownsHttpClient; @@ -87,6 +93,7 @@ public sealed class OpenClawHttpClient : IDisposable private readonly Uri _adminWhatsAppSetupUri; private readonly Uri _adminWhatsAppRestartUri; private long _mcpRequestId; + private string? _negotiatedMcpProtocolVersion; public OpenClawHttpClient(string baseUrl, string? authToken, HttpClient? httpClient = null) { @@ -259,8 +266,50 @@ public async Task StreamChatCompletionAsync( return fullText.ToString(); } - public Task InitializeMcpAsync(McpInitializeRequest request, CancellationToken cancellationToken) - => SendMcpAsync("initialize", request, McpJsonContext.Default.McpInitializeRequest, McpJsonContext.Default.McpInitializeResult, cancellationToken); + public async Task InitializeMcpAsync(McpInitializeRequest request, CancellationToken cancellationToken) + { + var result = await SendMcpAsync("initialize", request, McpJsonContext.Default.McpInitializeRequest, McpJsonContext.Default.McpInitializeResult, cancellationToken); + RememberNegotiatedProtocolVersion(result.ProtocolVersion); + return result; + } + + public async Task DiscoverMcpAsync(CancellationToken cancellationToken) + { + var request = new McpDiscoverRequest(); + try + { + var result = await SendMcpAsync( + "server/discover", + request, + McpJsonContext.Default.McpDiscoverRequest, + McpJsonContext.Default.McpDiscoverResult, + cancellationToken); + RememberNegotiatedProtocolVersion(ResolveNegotiatedProtocolVersion(result)); + return result; + } + catch (McpProtocolException ex) when (ex.StatusCode == HttpStatusCode.NotFound || ex.RpcCode == -32601) + { + return await DiscoverLegacyMcpAsync(cancellationToken).ConfigureAwait(false); + } + catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + return await DiscoverLegacyMcpAsync(cancellationToken).ConfigureAwait(false); + } + } + + private async Task DiscoverLegacyMcpAsync(CancellationToken cancellationToken) + { + var initialize = await InitializeMcpAsync( + new McpInitializeRequest { ProtocolVersion = LatestLegacyMcpProtocolVersion }, + cancellationToken).ConfigureAwait(false); + return new McpDiscoverResult + { + ProtocolVersion = initialize.ProtocolVersion, + SupportedVersions = [initialize.ProtocolVersion], + Capabilities = JsonSerializer.SerializeToElement(initialize.Capabilities, McpJsonContext.Default.McpCapabilities), + ServerInfo = initialize.ServerInfo + }; + } public Task ListMcpToolsAsync(CancellationToken cancellationToken) => SendMcpWithoutParamsAsync("tools/list", McpJsonContext.Default.McpToolListResult, cancellationToken); @@ -1257,6 +1306,7 @@ private async Task SendMcpAsync( JsonTypeInfo resultTypeInfo, CancellationToken cancellationToken) { + var protocolVersion = ResolveMcpProtocolVersion(method, parameters); using var stream = new MemoryStream(); using (var writer = new Utf8JsonWriter(stream)) { @@ -1265,15 +1315,7 @@ private async Task SendMcpAsync( writer.WriteString("id", Interlocked.Increment(ref _mcpRequestId).ToString()); writer.WriteString("method", method); writer.WritePropertyName("params"); - if (parameters is null || jsonTypeInfo is null) - { - writer.WriteStartObject(); - writer.WriteEndObject(); - } - else - { - JsonSerializer.Serialize(writer, parameters, jsonTypeInfo); - } + WriteMcpParams(writer, parameters, jsonTypeInfo, protocolVersion); writer.WriteEndObject(); } @@ -1285,6 +1327,10 @@ private async Task SendMcpAsync( req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); + req.Headers.TryAddWithoutValidation("mcp-protocol-version", protocolVersion); + req.Headers.TryAddWithoutValidation("Mcp-Method", method); + if (TryResolveMcpName(method, parameters, out var mcpName)) + req.Headers.TryAddWithoutValidation("Mcp-Name", mcpName); using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, cancellationToken); if (!resp.IsSuccessStatusCode) @@ -1296,7 +1342,7 @@ private async Task SendMcpAsync( if (envelope is null) throw new InvalidOperationException("Empty MCP response body."); if (envelope.Error is not null) - throw new InvalidOperationException($"MCP {envelope.Error.Code}: {envelope.Error.Message}"); + throw new McpProtocolException(resp.StatusCode, envelope.Error.Code, envelope.Error.Message); var result = envelope.Result.Deserialize(resultTypeInfo); if (result is null) @@ -1305,6 +1351,125 @@ private async Task SendMcpAsync( return result; } + private string ResolveMcpProtocolVersion(string method, TParams? parameters) + { + return parameters switch + { + McpInitializeRequest { ProtocolVersion: { Length: > 0 } protocolVersion } => protocolVersion, + _ when !string.IsNullOrWhiteSpace(_negotiatedMcpProtocolVersion) => _negotiatedMcpProtocolVersion!, + _ when string.Equals(method, "server/discover", StringComparison.Ordinal) => LatestMcpProtocolVersion, + _ => LatestLegacyMcpProtocolVersion + }; + } + + private static void WriteMcpParams( + Utf8JsonWriter writer, + TParams? parameters, + JsonTypeInfo? jsonTypeInfo, + string protocolVersion) + { + if (StringComparer.Ordinal.Compare(protocolVersion, LatestMcpProtocolVersion) < 0) + { + if (parameters is null || jsonTypeInfo is null) + { + writer.WriteStartObject(); + writer.WriteEndObject(); + } + else + { + JsonSerializer.Serialize(writer, parameters, jsonTypeInfo); + } + + return; + } + + var serializedParams = parameters is null || jsonTypeInfo is null + ? default + : JsonSerializer.SerializeToElement(parameters, jsonTypeInfo); + if (serializedParams.ValueKind is not (JsonValueKind.Undefined or JsonValueKind.Object)) + throw new InvalidOperationException("MCP request parameters must serialize as a JSON object."); + + JsonElement existingMeta = default; + writer.WriteStartObject(); + if (serializedParams.ValueKind == JsonValueKind.Object) + { + foreach (var property in serializedParams.EnumerateObject()) + { + if (property.NameEquals("_meta")) + { + existingMeta = property.Value; + continue; + } + + property.WriteTo(writer); + } + } + + writer.WritePropertyName("_meta"); + writer.WriteStartObject(); + if (existingMeta.ValueKind == JsonValueKind.Object) + { + foreach (var property in existingMeta.EnumerateObject()) + { + if (property.NameEquals(McpProtocolVersionMetaKey) || + property.NameEquals(McpClientInfoMetaKey) || + property.NameEquals(McpClientCapabilitiesMetaKey)) + { + continue; + } + + property.WriteTo(writer); + } + } + + writer.WriteString(McpProtocolVersionMetaKey, protocolVersion); + writer.WritePropertyName(McpClientInfoMetaKey); + JsonSerializer.Serialize( + writer, + new McpClientInfo { Name = "openclaw-client", Version = "1.0.0" }, + McpJsonContext.Default.McpClientInfo); + writer.WritePropertyName(McpClientCapabilitiesMetaKey); + JsonSerializer.Serialize(writer, new McpClientCapabilities(), McpJsonContext.Default.McpClientCapabilities); + writer.WriteEndObject(); + writer.WriteEndObject(); + } + + private static bool TryResolveMcpName(string method, TParams? parameters, out string name) + { + switch (method) + { + case "tools/call" when parameters is McpCallToolRequest toolRequest && !string.IsNullOrWhiteSpace(toolRequest.Name): + name = toolRequest.Name; + return true; + case "resources/read" when parameters is McpReadResourceRequest resourceRequest && !string.IsNullOrWhiteSpace(resourceRequest.Uri): + name = resourceRequest.Uri; + return true; + case "prompts/get" when parameters is McpGetPromptRequest promptRequest && !string.IsNullOrWhiteSpace(promptRequest.Name): + name = promptRequest.Name; + return true; + default: + name = string.Empty; + return false; + } + } + + private void RememberNegotiatedProtocolVersion(string? protocolVersion) + { + if (!string.IsNullOrWhiteSpace(protocolVersion)) + _negotiatedMcpProtocolVersion = protocolVersion; + } + + private static string ResolveNegotiatedProtocolVersion(McpDiscoverResult result) + => !string.IsNullOrWhiteSpace(result.ProtocolVersion) + ? result.ProtocolVersion + : result.SupportedVersions.Contains(LatestMcpProtocolVersion, StringComparer.Ordinal) + ? LatestMcpProtocolVersion + : result.SupportedVersions + .Where(static version => !string.IsNullOrWhiteSpace(version)) + .OrderDescending(StringComparer.Ordinal) + .FirstOrDefault() + ?? LatestLegacyMcpProtocolVersion; + private static async Task ExtractMcpResponseJsonAsync(HttpResponseMessage resp, CancellationToken cancellationToken) { var contentType = resp.Content.Headers.ContentType?.MediaType; @@ -1940,13 +2105,51 @@ private static async Task CreateHttpErrorAsync(HttpResponseMessage re var status = $"{(int)resp.StatusCode} {resp.ReasonPhrase}".Trim(); if (string.IsNullOrWhiteSpace(body)) - return new HttpRequestException($"HTTP {status}"); + return new HttpRequestException($"HTTP {status}", inner: null, resp.StatusCode); body = body.Trim(); + if (TryParseMcpError(body, out var rpcCode, out var rpcMessage)) + return new McpProtocolException(resp.StatusCode, rpcCode, rpcMessage); + if (body.Length > 8000) body = body[..8000] + "…"; - return new HttpRequestException($"HTTP {status}\n{body}"); + return new HttpRequestException($"HTTP {status}\n{body}", inner: null, resp.StatusCode); + } + + private static bool TryParseMcpError(string body, out int rpcCode, out string rpcMessage) + { + rpcCode = 0; + rpcMessage = string.Empty; + + try + { + var envelope = JsonSerializer.Deserialize(body, McpJsonContext.Default.McpJsonRpcResponse); + if (envelope?.Error is null) + return false; + + rpcCode = envelope.Error.Code; + rpcMessage = envelope.Error.Message; + return true; + } + catch (JsonException) + { + return false; + } + } + + private sealed class McpProtocolException : HttpRequestException + { + public McpProtocolException(HttpStatusCode statusCode, int? rpcCode, string message) + : base($"HTTP {(int)statusCode} ({(rpcCode.HasValue ? $"MCP {rpcCode.Value}" : "MCP")}): {message}", null, statusCode) + { + StatusCode = statusCode; + RpcCode = rpcCode; + } + + public new HttpStatusCode StatusCode { get; } + + public int? RpcCode { get; } } public void Dispose() diff --git a/src/OpenClaw.Core/Models/GatewayConfig.cs b/src/OpenClaw.Core/Models/GatewayConfig.cs index 96135c9b..7d8e57b1 100644 --- a/src/OpenClaw.Core/Models/GatewayConfig.cs +++ b/src/OpenClaw.Core/Models/GatewayConfig.cs @@ -42,6 +42,7 @@ public sealed class GatewayConfig public DynamicTurnRoutingConfig DynamicTurnRouting { get; set; } = new(); public RoutingConfig Routing { get; set; } = new(); public McpAppsConfig McpApps { get; set; } = new(); + public McpCompatibilityConfig McpCompatibility { get; set; } = new(); public DeploymentConfig Deployment { get; set; } = new(); public TailscaleConfig Tailscale { get; set; } = new(); public GmailPubSubConfig GmailPubSub { get; set; } = new(); @@ -81,6 +82,12 @@ public sealed class GatewayConfig public Dictionary TokenCostRateDetails { get; set; } = new(StringComparer.OrdinalIgnoreCase); } +public sealed class McpCompatibilityConfig +{ + public bool EnableDiscoveryFirst { get; set; } = true; + public bool ForceLegacyInitialize { get; set; } = false; +} + public sealed class TokenCostRateConfig { public decimal InputUsdPer1K { get; set; } @@ -804,6 +811,16 @@ public sealed class TelegramChannelConfig public string DmPolicy { get; set; } = "pairing"; // open, pairing, closed public string? BotToken { get; set; } public string BotTokenRef { get; set; } = "env:TELEGRAM_BOT_TOKEN"; + /// Inbound delivery mode: "webhook" or "long-polling". + public string UpdateMode { get; set; } = "webhook"; + public bool UsesWebhook() => MatchesUpdateMode("webhook"); + public bool UsesLongPolling() => MatchesUpdateMode("long-polling"); + /// Telegram getUpdates long-poll timeout in seconds. + public int PollingTimeoutSeconds { get; set; } = 30; + /// Delay before retrying a failed long-poll request. + public int PollingRetryDelaySeconds { get; set; } = 5; + /// Discard updates queued by Telegram when long polling starts. + public bool DropPendingUpdatesOnStart { get; set; } = false; public string WebhookPath { get; set; } = "/telegram/inbound"; public string? WebhookPublicBaseUrl { get; set; } public string[] AllowedFromUserIds { get; set; } = []; @@ -818,6 +835,9 @@ public sealed class TelegramChannelConfig /// Secret token reference (env: or raw:). Used when WebhookSecretToken is null. public string WebhookSecretTokenRef { get; set; } = "env:TELEGRAM_WEBHOOK_SECRET"; + + private bool MatchesUpdateMode(string expected) + => string.Equals(UpdateMode?.Trim(), expected, StringComparison.OrdinalIgnoreCase); } public sealed class SlackChannelConfig diff --git a/src/OpenClaw.Core/Validation/ConfigValidator.cs b/src/OpenClaw.Core/Validation/ConfigValidator.cs index dda3f602..94e5ed21 100644 --- a/src/OpenClaw.Core/Validation/ConfigValidator.cs +++ b/src/OpenClaw.Core/Validation/ConfigValidator.cs @@ -291,6 +291,12 @@ public static IReadOnlyList Validate(Models.GatewayConfig config) errors.Add($"Channels.Telegram.MaxInboundChars must be >= 1 (got {config.Channels.Telegram.MaxInboundChars})."); if (config.Channels.Telegram.MaxRequestBytes < 1024) errors.Add($"Channels.Telegram.MaxRequestBytes must be >= 1024 (got {config.Channels.Telegram.MaxRequestBytes})."); + if (!config.Channels.Telegram.UsesWebhook() && !config.Channels.Telegram.UsesLongPolling()) + errors.Add("Channels.Telegram.UpdateMode must be 'webhook' or 'long-polling'."); + if (config.Channels.Telegram.PollingTimeoutSeconds is < 1 or > 50) + errors.Add($"Channels.Telegram.PollingTimeoutSeconds must be between 1 and 50 (got {config.Channels.Telegram.PollingTimeoutSeconds})."); + if (config.Channels.Telegram.PollingRetryDelaySeconds is < 1 or > 300) + errors.Add($"Channels.Telegram.PollingRetryDelaySeconds must be between 1 and 300 (got {config.Channels.Telegram.PollingRetryDelaySeconds})."); if (config.Channels.WhatsApp.MaxInboundChars < 1) errors.Add($"Channels.WhatsApp.MaxInboundChars must be >= 1 (got {config.Channels.WhatsApp.MaxInboundChars})."); diff --git a/src/OpenClaw.Gateway/ChannelReadinessEvaluator.cs b/src/OpenClaw.Gateway/ChannelReadinessEvaluator.cs index b7221da7..edd42a69 100644 --- a/src/OpenClaw.Gateway/ChannelReadinessEvaluator.cs +++ b/src/OpenClaw.Gateway/ChannelReadinessEvaluator.cs @@ -108,6 +108,7 @@ private static ChannelReadinessState EvaluateTelegram(GatewayConfig config, bool var missing = new List(); var warnings = new List(); var guidance = new List(); + var usesWebhook = telegram.UsesWebhook(); if (string.IsNullOrWhiteSpace(ResolveTelegramToken(telegram))) { @@ -119,7 +120,7 @@ private static ChannelReadinessState EvaluateTelegram(GatewayConfig config, bool Reference = "OpenClaw:Channels:Telegram:BotTokenRef = env:TELEGRAM_BOT_TOKEN" }); } - if (telegram.ValidateSignature && string.IsNullOrWhiteSpace(ResolveTelegramSecret(telegram))) + if (usesWebhook && telegram.ValidateSignature && string.IsNullOrWhiteSpace(ResolveTelegramSecret(telegram))) { missing.Add("Telegram WebhookSecretToken or WebhookSecretTokenRef"); guidance.Add(new ChannelFixGuidance @@ -129,7 +130,7 @@ private static ChannelReadinessState EvaluateTelegram(GatewayConfig config, bool Reference = "OpenClaw:Channels:Telegram:WebhookSecretTokenRef = env:TELEGRAM_WEBHOOK_SECRET" }); } - if (!telegram.ValidateSignature) + if (usesWebhook && !telegram.ValidateSignature) { warnings.Add(isNonLoopbackBind ? "Telegram webhook secret validation is disabled on a public bind." diff --git a/src/OpenClaw.Gateway/Composition/ChannelServicesExtensions.cs b/src/OpenClaw.Gateway/Composition/ChannelServicesExtensions.cs index 1d9b0be0..a91164d3 100644 --- a/src/OpenClaw.Gateway/Composition/ChannelServicesExtensions.cs +++ b/src/OpenClaw.Gateway/Composition/ChannelServicesExtensions.cs @@ -55,7 +55,6 @@ public static IServiceCollection AddOpenClawChannelServices(this IServiceCollect if (config.Channels.Telegram.Enabled) { services.AddSingleton(config.Channels.Telegram); - services.AddSingleton(); services.AddSingleton(sp => new TelegramWebhookHandler( config.Channels.Telegram, @@ -63,6 +62,21 @@ public static IServiceCollection AddOpenClawChannelServices(this IServiceCollect sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService>())); + services.AddSingleton(sp => + { + var handler = sp.GetRequiredService(); + var logger = sp.GetRequiredService>(); + return new TelegramChannel( + config.Channels.Telegram, + logger, + http: null, + updateProcessor: async (payload, enqueue, ct) => + { + var result = await handler.HandleAsync(payload, enqueue, ct); + if (result.StatusCode >= StatusCodes.Status400BadRequest) + logger.LogWarning("Telegram update was rejected with status {StatusCode}.", result.StatusCode); + }); + }); } if (config.Channels.Teams.Enabled) diff --git a/src/OpenClaw.Gateway/Endpoints/AppsEndpoints.cs b/src/OpenClaw.Gateway/Endpoints/AppsEndpoints.cs index b7018b39..ce3df42a 100644 --- a/src/OpenClaw.Gateway/Endpoints/AppsEndpoints.cs +++ b/src/OpenClaw.Gateway/Endpoints/AppsEndpoints.cs @@ -134,7 +134,7 @@ private static string BuildPrompt(string? message, JsonArray? appEvents) var builder = new StringBuilder(); if (appEvents is { Count: > 0 }) { - builder.Append("[界面事件] User just completed operations in the interface (these tool calls bypassed you, syncing results — they are final):\n"); + builder.Append("[UI event] User just completed operations in the interface (these tool calls bypassed you, syncing results — they are final):\n"); foreach (var entry in appEvents) { var tool = AsString(entry?["tool"]) ?? "?"; @@ -176,4 +176,4 @@ private static async Task SendAsync(HttpContext ctx, JsonObject payload, Cancell private static string BuildAppMcpUrl(HttpContext ctx, string serverId) => $"{ctx.Request.Scheme}://{ctx.Request.Host}{ctx.Request.PathBase}/apps/mcp/{Uri.EscapeDataString(serverId)}"; -} \ No newline at end of file +} diff --git a/src/OpenClaw.Gateway/Endpoints/AppsMcpProxyEndpoint.cs b/src/OpenClaw.Gateway/Endpoints/AppsMcpProxyEndpoint.cs index 3b02a9b7..7a77ce16 100644 --- a/src/OpenClaw.Gateway/Endpoints/AppsMcpProxyEndpoint.cs +++ b/src/OpenClaw.Gateway/Endpoints/AppsMcpProxyEndpoint.cs @@ -24,7 +24,34 @@ public static async Task ConfigureSessionOptionsAsync( var registry = httpContext.RequestServices.GetRequiredService(); var upstream = registry.GetApp(serverId)?.Client; if (upstream is null) + { + sessionOptions.Handlers.ListToolsHandler = (_, _) => + ValueTask.FromResult(new ListToolsResult + { + Tools = [] + }); + + sessionOptions.Handlers.ListResourcesHandler = (_, _) => + ValueTask.FromResult(new ListResourcesResult + { + Resources = [] + }); + + sessionOptions.Handlers.ReadResourceHandler = (_, _) => + ValueTask.FromResult(new ReadResourceResult + { + Contents = [] + }); + + sessionOptions.Handlers.CallToolHandler = (_, _) => + ValueTask.FromResult(new CallToolResult + { + IsError = true, + Content = [new TextContentBlock { Text = $"MCP app '{serverId}' is not loaded." }] + }); + return; + } sessionOptions.Handlers.ListToolsHandler = async (ctx, ct2) => await upstream.ListToolsAsync(ctx.Params ?? new ListToolsRequestParams(), ct2); diff --git a/src/OpenClaw.Gateway/Endpoints/WebhookEndpoints.cs b/src/OpenClaw.Gateway/Endpoints/WebhookEndpoints.cs index b2121900..1664307d 100644 --- a/src/OpenClaw.Gateway/Endpoints/WebhookEndpoints.cs +++ b/src/OpenClaw.Gateway/Endpoints/WebhookEndpoints.cs @@ -101,7 +101,7 @@ public static void MapOpenClawWebhookEndpoints( }); } - if (startup.Config.Channels.Telegram.Enabled) + if (startup.Config.Channels.Telegram.Enabled && startup.Config.Channels.Telegram.UsesWebhook()) { var telegramWebhookHandler = app.Services.GetRequiredService(); byte[]? telegramSecretBytes = null; diff --git a/src/OpenClaw.Gateway/Extensions/GatewaySecurityExtensions.cs b/src/OpenClaw.Gateway/Extensions/GatewaySecurityExtensions.cs index a47ac82d..1b46b3f0 100644 --- a/src/OpenClaw.Gateway/Extensions/GatewaySecurityExtensions.cs +++ b/src/OpenClaw.Gateway/Extensions/GatewaySecurityExtensions.cs @@ -125,7 +125,7 @@ public static void EnforcePublicBindHardening(GatewayConfig config, bool isNonLo } } - if (config.Channels.Telegram.Enabled) + if (config.Channels.Telegram.Enabled && config.Channels.Telegram.UsesWebhook()) { if (!config.Channels.Telegram.ValidateSignature) { diff --git a/src/OpenClaw.Gateway/Mcp/McpServiceExtensions.cs b/src/OpenClaw.Gateway/Mcp/McpServiceExtensions.cs index 99a6c075..5efe4df7 100644 --- a/src/OpenClaw.Gateway/Mcp/McpServiceExtensions.cs +++ b/src/OpenClaw.Gateway/Mcp/McpServiceExtensions.cs @@ -1,4 +1,5 @@ using ModelContextProtocol; +using ModelContextProtocol.Extensions.Tasks; using ModelContextProtocol.Protocol; using Microsoft.Extensions.DependencyInjection.Extensions; using OpenClaw.Core.Abstractions; @@ -39,9 +40,12 @@ public static IServiceCollection AddOpenClawMcpServices( }) .WithHttpTransport(options => { - options.Stateless = true; + // Stateless (discovery-first) when EnableDiscoveryFirst is set and ForceLegacyInitialize is not. + options.Stateless = startup.Config.McpCompatibility.EnableDiscoveryFirst + && !startup.Config.McpCompatibility.ForceLegacyInitialize; options.ConfigureSessionOptions = AppsMcpProxyEndpoint.ConfigureSessionOptionsAsync; }) + .WithTasks(new InMemoryMcpTaskStore()) .WithTools() .WithResources() .WithPrompts(); diff --git a/src/OpenClaw.Gateway/OpenClaw.Gateway.csproj b/src/OpenClaw.Gateway/OpenClaw.Gateway.csproj index dd662135..f47c2359 100644 --- a/src/OpenClaw.Gateway/OpenClaw.Gateway.csproj +++ b/src/OpenClaw.Gateway/OpenClaw.Gateway.csproj @@ -63,7 +63,8 @@ - + + diff --git a/src/OpenClaw.Gateway/SecurityPostureBuilder.cs b/src/OpenClaw.Gateway/SecurityPostureBuilder.cs index 3ed64506..7d0e0bff 100644 --- a/src/OpenClaw.Gateway/SecurityPostureBuilder.cs +++ b/src/OpenClaw.Gateway/SecurityPostureBuilder.cs @@ -72,7 +72,10 @@ public static SecurityPostureResponse Build(GatewayStartupContext startup, Gatew recommendations.Add("Enable Twilio SMS signature validation before exposing a public bind."); } - if (publicBind && config.Channels.Telegram.Enabled && !config.Channels.Telegram.ValidateSignature) + if (publicBind && + config.Channels.Telegram.Enabled && + config.Channels.Telegram.UsesWebhook() && + !config.Channels.Telegram.ValidateSignature) { riskFlags.Add("public_bind_unsigned_telegram_webhooks"); recommendations.Add("Enable Telegram webhook secret validation before exposing a public bind."); @@ -141,7 +144,9 @@ public static SecurityPostureResponse Build(GatewayStartupContext startup, Gatew private static bool HasSignedWebhookValidationReadiness(GatewayConfig config) { return (!config.Channels.Sms.Twilio.Enabled || config.Channels.Sms.Twilio.ValidateSignature) - && (!config.Channels.Telegram.Enabled || config.Channels.Telegram.ValidateSignature) + && (!config.Channels.Telegram.Enabled || + !config.Channels.Telegram.UsesWebhook() || + config.Channels.Telegram.ValidateSignature) && (!config.Channels.WhatsApp.Enabled || !string.Equals(config.Channels.WhatsApp.Type, "official", StringComparison.OrdinalIgnoreCase) || config.Channels.WhatsApp.ValidateSignature) && (!config.Channels.Teams.Enabled || config.Channels.Teams.ValidateToken) && (!config.Channels.Slack.Enabled || config.Channels.Slack.ValidateSignature) diff --git a/src/OpenClaw.Gateway/appsettings.json b/src/OpenClaw.Gateway/appsettings.json index 3a674de0..6fecf37a 100644 --- a/src/OpenClaw.Gateway/appsettings.json +++ b/src/OpenClaw.Gateway/appsettings.json @@ -176,7 +176,7 @@ "Id": "minimax-m2-7", "Provider": "openai-compatible", "Model": "MiniMax-M2.7", - "BaseUrl": "", + "BaseUrl": "https://api.minimax.io/v1", "ApiKey": "env:LLM_API_KEY", "Tags": [ "cheap", @@ -197,7 +197,7 @@ "SupportsSystemMessages": true, "SupportsImageInput": false, "SupportsAudioInput": false, - "MaxContextTokens": 128000, + "MaxContextTokens": 204800, "MaxOutputTokens": 8192 } }, @@ -205,7 +205,7 @@ "Id": "minimax-m3", "Provider": "openai-compatible", "Model": "MiniMax-M3", - "BaseUrl": "", + "BaseUrl": "https://api.minimax.io/v1", "ApiKey": "env:LLM_API_KEY", "Tags": [ "balanced", @@ -225,8 +225,9 @@ "SupportsReasoningEffort": false, "SupportsSystemMessages": true, "SupportsImageInput": true, + "SupportsVideoInput": true, "SupportsAudioInput": false, - "MaxContextTokens": 256000, + "MaxContextTokens": 1000000, "MaxOutputTokens": 16384 } } @@ -456,6 +457,28 @@ } } }, + "McpApps": { + "Enabled": false, + "DiscoveryPaths": [ + ], + "AllowlistSemantics": "legacy", + "Allow": [ + "*" + ], + "Deny": [], + "Entries": { + "grocery-inventory": { + "Enabled": true + } + } + }, + "McpCompatibility": { + "EnableDiscoveryFirst": true, + "ForceLegacyInitialize": false, + "RequireOAuthIssuerValidation": true, + "RequirePkceS256": true, + "AllowRelaxedInputSchemaValidation": false + }, "Channels": { "AllowlistSemantics": "legacy", "Sms": { @@ -481,6 +504,10 @@ "DmPolicy": "pairing", "BotToken": null, "BotTokenRef": "env:TELEGRAM_BOT_TOKEN", + "UpdateMode": "webhook", + "PollingTimeoutSeconds": 30, + "PollingRetryDelaySeconds": 5, + "DropPendingUpdatesOnStart": false, "WebhookPath": "/telegram/inbound", "WebhookPublicBaseUrl": null, "AllowedFromUserIds": [], diff --git a/src/OpenClaw.Gateway/wwwroot/webchat.html b/src/OpenClaw.Gateway/wwwroot/webchat.html index 2db304f3..fa2c1b1b 100644 --- a/src/OpenClaw.Gateway/wwwroot/webchat.html +++ b/src/OpenClaw.Gateway/wwwroot/webchat.html @@ -48,7 +48,7 @@ - - - - - - +
-
暂无历史会话
+
No session history
- Authority 和 Client ID 保存至 localStorage,使用 PKCE 登录,无需客户端密钥。 + The authority and client ID are stored in localStorage. PKCE sign-in does not require a client secret.
Server authunknown
@@ -499,7 +499,7 @@

Doctor diagnostics

MCP Servers

-
@@ -575,23 +575,23 @@

Add Server