diff --git a/CHANGELOG.md b/CHANGELOG.md index cf6af3717c5..d16e2d53512 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai - Agents/fallback: classify internal live-session model switch conflicts as unknown fallback failures instead of provider overloads, preventing local vLLM endpoints from receiving misleading overloaded cooldowns. Refs #63229. Thanks @clawdia-lobster. - Control UI: keep session-specific assistant identity loads authoritative after WebSocket connect, so non-main agent chat sessions do not show the main agent name in the header after bootstrap refreshes. Fixes #72776. Thanks @rockytian-top. - Agents/Qwen: preserve exact custom `modelstudio` provider configs with foreign `api` owners so explicit OpenAI-compatible Model Studio endpoints no longer get normalized into the bundled Qwen plugin path. Fixes #64483. Thanks @FiredMosquito831. +- MCP/bundle-mcp: normalize CLI-native `type: "http"` MCP server entries to OpenClaw `transport: "streamable-http"` on save, repair existing configs with doctor, and keep embedded Pi from falling back to legacy SSE GET-first startup for those servers. Fixes #72757. Thanks @Studioscale. - Media-understanding/audio: migrate deprecated `{input}` placeholders in legacy `audio.transcription.command` configs to `{{MediaPath}}`, so custom audio transcribers no longer receive the literal placeholder after doctor repair. Fixes #72760. Thanks @krisfanue3-hash. - Ollama/onboarding: de-dupe suggested bare local models against installed `:latest` tags and skip redundant pulls, so setup shows the installed model once and no longer says it is downloading an already available model. Fixes #68952. Thanks @tleyden. - Control UI/Gateway: preserve WebChat client version labels across localhost, 127.0.0.1, and IPv6 loopback aliases on the same port, avoiding misleading `vcontrol-ui` connection logs while investigating duplicate-message reports. Refs #72753 and #72742. Thanks @LumenFromTheFuture and @allesgutefy. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index f87b0eb3fc6..aaa19906bbe 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -8a37b104c6b3a25618cbf4ecd0dd511703997fb1a10a1167226ab9918eb85455 config-baseline.json -a1839a03fc557a5439fc7b4ce2d45c7212b61e15588e15886bb22b65ff7dc32d config-baseline.core.json +3546f416ff22ead14952cd105c7b88e3b7b76d5ddc10269e73f69ed1950f0603 config-baseline.json +b29ade2d1d2415b030b4d5ec36097a93ab4ea943b7d2a52da95829be1c28fc2a config-baseline.core.json 07963db49502132f26db396c56b36e018b110e6c55a68b3cb012d3ec96f43901 config-baseline.channel.json ed65cefbef96f034ce2b73069d9d5bacc341a43489ff9b20a34d40956b877f79 config-baseline.plugin.json diff --git a/docs/cli/mcp.md b/docs/cli/mcp.md index 5baec8598cd..e670951c94f 100644 --- a/docs/cli/mcp.md +++ b/docs/cli/mcp.md @@ -381,6 +381,7 @@ Notes: - `list` sorts server names. - `show` without a name prints the full configured MCP server object. - `set` expects one JSON object value on the command line. +- Use `transport: "streamable-http"` for Streamable HTTP MCP servers. `openclaw mcp set` also normalizes CLI-native `type: "http"` to the same canonical config shape for compatibility. - `unset` fails if the named server does not exist. Examples: @@ -389,7 +390,7 @@ Examples: openclaw mcp list openclaw mcp show context7 --json openclaw mcp set context7 '{"command":"uvx","args":["context7-mcp"]}' -openclaw mcp set docs '{"url":"https://mcp.example.com"}' +openclaw mcp set docs '{"url":"https://mcp.example.com","transport":"streamable-http"}' openclaw mcp unset context7 ``` @@ -404,7 +405,8 @@ Example config shape: "args": ["context7-mcp"] }, "docs": { - "url": "https://mcp.example.com" + "url": "https://mcp.example.com", + "transport": "streamable-http" } } } @@ -470,6 +472,8 @@ Sensitive values in `url` (userinfo) and `headers` are redacted in logs and stat | `headers` | Optional key-value map of HTTP headers (for example auth tokens) | | `connectionTimeoutMs` | Per-server connection timeout in ms (optional) | +OpenClaw config uses `transport: "streamable-http"` as the canonical spelling. CLI-native MCP `type: "http"` values are accepted when saved through `openclaw mcp set` and repaired by `openclaw doctor --fix` in existing config, but `transport` is what embedded Pi consumes directly. + Example: ```json diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 0fcb4fea6b2..ff3756dbc04 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -88,6 +88,9 @@ target server during config edits. - `mcp.servers`: named stdio or remote MCP server definitions for runtimes that expose configured MCP tools. + Remote entries use `transport: "streamable-http"` or `transport: "sse"`; + `type: "http"` is a CLI-native alias that `openclaw mcp set` and + `openclaw doctor --fix` normalize into the canonical `transport` field. - `mcp.sessionIdleTtlMs`: idle TTL for session-scoped bundled MCP runtimes. One-shot embedded runs request run-end cleanup; this TTL is the backstop for long-lived sessions and future callers. diff --git a/docs/plugins/bundles.md b/docs/plugins/bundles.md index f7074d46b7d..9e9214453e2 100644 --- a/docs/plugins/bundles.md +++ b/docs/plugins/bundles.md @@ -149,6 +149,7 @@ MCP servers can use stdio or HTTP transport: ``` - `transport` may be set to `"streamable-http"` or `"sse"`; when omitted, OpenClaw uses `sse` +- `type: "http"` is a CLI-native downstream shape; use `transport: "streamable-http"` in OpenClaw config. `openclaw mcp set` and `openclaw doctor --fix` normalize the common alias. - only `http:` and `https:` URL schemes are allowed - `headers` values support `${ENV_VAR}` interpolation - a server entry with both `command` and `url` is rejected diff --git a/src/agents/mcp-transport-config.test.ts b/src/agents/mcp-transport-config.test.ts index f062355e0a0..cc95ebf209e 100644 --- a/src/agents/mcp-transport-config.test.ts +++ b/src/agents/mcp-transport-config.test.ts @@ -150,4 +150,17 @@ describe("resolveMcpTransportConfig", () => { url: "https://mcp.example.com/http", }); }); + + it("treats CLI-native http type as streamable HTTP for compatibility", () => { + const resolved = resolveMcpTransportConfig("probe", { + url: "https://mcp.example.com/http", + type: "http", + }); + + expect(resolved).toMatchObject({ + kind: "http", + transportType: "streamable-http", + url: "https://mcp.example.com/http", + }); + }); }); diff --git a/src/agents/mcp-transport-config.ts b/src/agents/mcp-transport-config.ts index edd21d5eb30..7083174f251 100644 --- a/src/agents/mcp-transport-config.ts +++ b/src/agents/mcp-transport-config.ts @@ -1,3 +1,4 @@ +import { resolveOpenClawMcpTransportAlias } from "../config/mcp-config-normalize.js"; import { logWarn } from "../logger.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { sanitizeForLog } from "../terminal/ansi.js"; @@ -61,6 +62,17 @@ function getRequestedTransport(rawServer: unknown): string { return normalizeLowercaseStringOrEmpty((rawServer as { transport?: string }).transport); } +function getRequestedTransportAlias(rawServer: unknown): HttpMcpTransportType | "" { + if ( + !rawServer || + typeof rawServer !== "object" || + typeof (rawServer as { type?: unknown }).type !== "string" + ) { + return ""; + } + return resolveOpenClawMcpTransportAlias((rawServer as { type?: string }).type) ?? ""; +} + function resolveHttpTransportConfig( serverName: string, rawServer: unknown, @@ -98,6 +110,8 @@ export function resolveMcpTransportConfig( ): ResolvedMcpTransportConfig | null { const logServerName = sanitizeForLog(serverName); const requestedTransport = getRequestedTransport(rawServer); + const requestedTransportAlias = requestedTransport ? "" : getRequestedTransportAlias(rawServer); + const effectiveTransport = requestedTransport || requestedTransportAlias; const stdioLaunch = resolveStdioMcpServerLaunchConfig(rawServer, { onDroppedEnv: (key) => { logWarn( @@ -119,17 +133,17 @@ export function resolveMcpTransportConfig( } if ( - requestedTransport && - requestedTransport !== "sse" && - requestedTransport !== "streamable-http" + effectiveTransport && + effectiveTransport !== "sse" && + effectiveTransport !== "streamable-http" ) { logWarn( - `bundle-mcp: skipped server "${logServerName}" because transport "${sanitizeForLog(requestedTransport)}" is not supported.`, + `bundle-mcp: skipped server "${logServerName}" because transport "${sanitizeForLog(effectiveTransport)}" is not supported.`, ); return null; } - if (requestedTransport === "streamable-http") { + if (effectiveTransport === "streamable-http") { const httpTransport = resolveHttpTransportConfig(serverName, rawServer, "streamable-http"); if (httpTransport) { return httpTransport; diff --git a/src/commands/doctor/shared/deprecation-compat.ts b/src/commands/doctor/shared/deprecation-compat.ts index 81593803394..9067eeaf724 100644 --- a/src/commands/doctor/shared/deprecation-compat.ts +++ b/src/commands/doctor/shared/deprecation-compat.ts @@ -111,6 +111,18 @@ export const DOCTOR_DEPRECATION_COMPAT_RECORDS = [ docsPath: "/automation", tests: ["src/commands/doctor/shared/legacy-config-migrate.test.ts"], }), + deprecatedCompatRecord({ + code: "doctor-mcp-server-type-alias", + owner: "config", + introduced: "2026-04-27", + source: "mcp.servers.*.type", + migration: "src/commands/doctor/shared/legacy-config-migrations.runtime.mcp.ts", + replacement: "mcp.servers.*.transport", + docsPath: "/cli/mcp", + tests: ["src/commands/doctor/shared/legacy-config-migrate.test.ts"], + notes: + "OpenClaw stores transport names; CLI backends receive their own type fields through runtime adapters.", + }), deprecatedCompatRecord({ code: "doctor-gateway-bind-host-aliases", owner: "gateway", diff --git a/src/commands/doctor/shared/legacy-config-migrate.test.ts b/src/commands/doctor/shared/legacy-config-migrate.test.ts index 350bc816c2e..7ac75cedb6e 100644 --- a/src/commands/doctor/shared/legacy-config-migrate.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrate.test.ts @@ -262,6 +262,58 @@ describe("legacy migrate sandbox scope aliases", () => { }); }); +describe("legacy migrate MCP server type aliases", () => { + it("moves CLI-native http type to OpenClaw streamable HTTP transport", () => { + const res = migrateLegacyConfigForTest({ + mcp: { + servers: { + silo: { + type: "http", + url: "https://example.com/mcp", + }, + legacySse: { + type: "sse", + url: "https://example.com/sse", + }, + }, + }, + }); + + expect(res.changes).toContain( + 'Moved mcp.servers.silo.type "http" → transport "streamable-http".', + ); + expect(res.changes).toContain('Moved mcp.servers.legacySse.type "sse" → transport "sse".'); + expect(res.config?.mcp?.servers?.silo).toEqual({ + url: "https://example.com/mcp", + transport: "streamable-http", + }); + expect(res.config?.mcp?.servers?.legacySse).toEqual({ + url: "https://example.com/sse", + transport: "sse", + }); + }); + + it("removes CLI-native type when canonical transport is already set", () => { + const res = migrateLegacyConfigForTest({ + mcp: { + servers: { + mixed: { + type: "http", + transport: "sse", + url: "https://example.com/mcp", + }, + }, + }, + }); + + expect(res.changes).toContain('Removed mcp.servers.mixed.type (transport "sse" already set).'); + expect(res.config?.mcp?.servers?.mixed).toEqual({ + url: "https://example.com/mcp", + transport: "sse", + }); + }); +}); + describe("legacy migrate x_search auth", () => { it("moves only legacy x_search auth into plugin-owned xai config", () => { const res = migrateLegacyConfigForTest({ diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.mcp.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.mcp.ts new file mode 100644 index 00000000000..e3351c24150 --- /dev/null +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.mcp.ts @@ -0,0 +1,53 @@ +import { + defineLegacyConfigMigration, + type LegacyConfigMigrationSpec, + type LegacyConfigRule, +} from "../../../config/legacy.shared.js"; +import { + isKnownCliMcpTypeAlias, + resolveOpenClawMcpTransportAlias, +} from "../../../config/mcp-config-normalize.js"; +import { isRecord } from "./legacy-config-record-shared.js"; + +const MCP_SERVER_TYPE_RULE: LegacyConfigRule = { + path: ["mcp", "servers"], + message: + 'mcp.servers entries use OpenClaw transport names; CLI-native type aliases are legacy here. Run "openclaw doctor --fix".', + match: (value) => + isRecord(value) && + Object.values(value).some((server) => isRecord(server) && isKnownCliMcpTypeAlias(server.type)), +}; + +export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_MCP: LegacyConfigMigrationSpec[] = [ + defineLegacyConfigMigration({ + id: "mcp.servers.type->transport", + describe: "Move CLI-native MCP server type aliases to OpenClaw transport", + legacyRules: [MCP_SERVER_TYPE_RULE], + apply: (raw, changes) => { + const mcp = isRecord(raw.mcp) ? raw.mcp : undefined; + const servers = isRecord(mcp?.servers) ? mcp?.servers : undefined; + if (!servers) { + return; + } + + for (const [serverName, rawServer] of Object.entries(servers)) { + if (!isRecord(rawServer) || !isKnownCliMcpTypeAlias(rawServer.type)) { + continue; + } + const rawType = typeof rawServer.type === "string" ? rawServer.type : ""; + const alias = resolveOpenClawMcpTransportAlias(rawServer.type); + if (typeof rawServer.transport !== "string" && alias) { + rawServer.transport = alias; + changes.push(`Moved mcp.servers.${serverName}.type "${rawType}" → transport "${alias}".`); + } else if (typeof rawServer.transport === "string") { + changes.push( + `Removed mcp.servers.${serverName}.type (transport "${rawServer.transport}" already set).`, + ); + } else { + changes.push(`Removed mcp.servers.${serverName}.type "${rawType}".`); + } + delete rawServer.type; + } + }, + }), +]; diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.ts index 4240ce981bd..f2e7588e6c2 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.ts @@ -1,12 +1,14 @@ import type { LegacyConfigMigrationSpec } from "../../../config/legacy.shared.js"; import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_AGENTS } from "./legacy-config-migrations.runtime.agents.js"; import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_GATEWAY } from "./legacy-config-migrations.runtime.gateway.js"; +import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_MCP } from "./legacy-config-migrations.runtime.mcp.js"; import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_PROVIDERS } from "./legacy-config-migrations.runtime.providers.js"; import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_TTS } from "./legacy-config-migrations.runtime.tts.js"; export const LEGACY_CONFIG_MIGRATIONS_RUNTIME: LegacyConfigMigrationSpec[] = [ ...LEGACY_CONFIG_MIGRATIONS_RUNTIME_AGENTS, ...LEGACY_CONFIG_MIGRATIONS_RUNTIME_GATEWAY, + ...LEGACY_CONFIG_MIGRATIONS_RUNTIME_MCP, ...LEGACY_CONFIG_MIGRATIONS_RUNTIME_PROVIDERS, ...LEGACY_CONFIG_MIGRATIONS_RUNTIME_TTS, ]; diff --git a/src/config/mcp-config-normalize.ts b/src/config/mcp-config-normalize.ts index 290b006dc4a..3195f2bf177 100644 --- a/src/config/mcp-config-normalize.ts +++ b/src/config/mcp-config-normalize.ts @@ -1,6 +1,43 @@ import { isRecord } from "../utils.js"; export type ConfigMcpServers = Record>; +export type OpenClawMcpHttpTransport = "sse" | "streamable-http"; + +const CLI_MCP_TYPE_TO_OPENCLAW_TRANSPORT: Record = { + http: "streamable-http", + "streamable-http": "streamable-http", + sse: "sse", + stdio: "stdio", +}; + +function normalizeMcpString(value: unknown): string { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + +export function resolveOpenClawMcpTransportAlias( + value: unknown, +): OpenClawMcpHttpTransport | undefined { + const mapped = CLI_MCP_TYPE_TO_OPENCLAW_TRANSPORT[normalizeMcpString(value)]; + return mapped === "sse" || mapped === "streamable-http" ? mapped : undefined; +} + +export function isKnownCliMcpTypeAlias(value: unknown): boolean { + return Object.hasOwn(CLI_MCP_TYPE_TO_OPENCLAW_TRANSPORT, normalizeMcpString(value)); +} + +export function canonicalizeConfiguredMcpServer( + server: Record, +): Record { + const next = { ...server }; + const transportAlias = resolveOpenClawMcpTransportAlias(next.type); + if (typeof next.transport !== "string" && transportAlias) { + next.transport = transportAlias; + } + if (isKnownCliMcpTypeAlias(next.type)) { + delete next.type; + } + return next; +} export function normalizeConfiguredMcpServers(value: unknown): ConfigMcpServers { if (!isRecord(value)) { diff --git a/src/config/mcp-config.test.ts b/src/config/mcp-config.test.ts index c0fb5bf0778..a242e414d24 100644 --- a/src/config/mcp-config.test.ts +++ b/src/config/mcp-config.test.ts @@ -154,4 +154,27 @@ describe("config mcp config", () => { }); }); }); + + it("canonicalizes CLI-native HTTP type aliases when saving MCP config", async () => { + await withMcpConfigHome({}, async () => { + const setResult = await setConfiguredMcpServer({ + name: "remote", + server: { + type: "http", + url: "https://example.com/mcp", + }, + }); + + expect(setResult.ok).toBe(true); + const loaded = await listConfiguredMcpServers(); + expect(loaded.ok).toBe(true); + if (!loaded.ok) { + throw new Error("expected MCP config to load"); + } + expect(loaded.mcpServers.remote).toEqual({ + url: "https://example.com/mcp", + transport: "streamable-http", + }); + }); + }); }); diff --git a/src/config/mcp-config.ts b/src/config/mcp-config.ts index 8c45757e919..e3597494dbb 100644 --- a/src/config/mcp-config.ts +++ b/src/config/mcp-config.ts @@ -1,6 +1,10 @@ import { isRecord } from "../utils.js"; import { readSourceConfigSnapshot } from "./io.js"; -import { normalizeConfiguredMcpServers, type ConfigMcpServers } from "./mcp-config-normalize.js"; +import { + canonicalizeConfiguredMcpServer, + normalizeConfiguredMcpServers, + type ConfigMcpServers, +} from "./mcp-config-normalize.js"; import { replaceConfigFile } from "./mutate.js"; import type { OpenClawConfig } from "./types.openclaw.js"; import { validateConfigObjectWithPlugins } from "./validation.js"; @@ -65,7 +69,7 @@ export async function setConfiguredMcpServer(params: { const next = structuredClone(loaded.config); const servers = normalizeConfiguredMcpServers(next.mcp?.servers); - servers[name] = { ...params.server }; + servers[name] = canonicalizeConfiguredMcpServer(params.server); next.mcp = { ...next.mcp, servers, diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index 38cb47a8c96..21bf9fe7aee 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -23316,6 +23316,18 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { type: "string", format: "uri", }, + transport: { + anyOf: [ + { + type: "string", + const: "sse", + }, + { + type: "string", + const: "streamable-http", + }, + ], + }, headers: { type: "object", propertyNames: { diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 46fa58ee9c0..beaf4d20d45 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -133,6 +133,7 @@ describe("config schema", () => { } | undefined; expect(serversNode?.additionalProperties?.properties?.headers).toBeTruthy(); + expect(serversNode?.additionalProperties?.properties?.transport).toBeTruthy(); }); it("merges plugin ui hints", () => { diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 185bcaafb8c..1394c47aa10 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -219,6 +219,7 @@ const McpServerSchema = z cwd: z.string().optional(), workingDirectory: z.string().optional(), url: HttpUrlSchema.optional(), + transport: z.union([z.literal("sse"), z.literal("streamable-http")]).optional(), headers: z .record( z.string(),