fix(mcp): normalize streamable http server aliases

This commit is contained in:
Peter Steinberger
2026-04-27 12:29:10 +01:00
parent 3da4b28d1b
commit 053aff6d35
17 changed files with 244 additions and 11 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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",
});
});
});

View File

@@ -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;

View File

@@ -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",

View File

@@ -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({

View File

@@ -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;
}
},
}),
];

View File

@@ -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,
];

View File

@@ -1,6 +1,43 @@
import { isRecord } from "../utils.js";
export type ConfigMcpServers = Record<string, Record<string, unknown>>;
export type OpenClawMcpHttpTransport = "sse" | "streamable-http";
const CLI_MCP_TYPE_TO_OPENCLAW_TRANSPORT: Record<string, OpenClawMcpHttpTransport | "stdio"> = {
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<string, unknown>,
): Record<string, unknown> {
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)) {

View File

@@ -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",
});
});
});
});

View File

@@ -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,

View File

@@ -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: {

View File

@@ -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", () => {

View File

@@ -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(),