mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:50:43 +00:00
fix(mcp): normalize streamable http server aliases
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
}),
|
||||
];
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user