diff --git a/src/cli.ts b/src/cli.ts index 7a1ac63f..ba3959b0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -161,6 +161,7 @@ async function runInit({ force }: { force: boolean }): Promise { port, allowedRoots, publicBaseUrl, + widgets: files.config.widgets ?? "off", subagents: resolveSubagentsFlag(files.config), }; const auth = { @@ -177,6 +178,7 @@ async function runInit({ force }: { force: boolean }): Promise { ...seededSkillPaths.map((path) => `Default skill: ${path}`), `Local MCP URL: http://${config.host}:${config.port}/mcp`, ...(publicBaseUrl ? [`Public MCP URL: ${publicBaseUrl}/mcp`] : []), + `UI widgets: ${config.widgets} (owner setting)`, ]; prompts.note(lines.join("\n"), "DevSpace configured"); prompts.note( @@ -219,6 +221,7 @@ async function serve(): Promise { console.log(`public base url: ${config.publicBaseUrl}`); console.log(`allowed roots: ${config.allowedRoots.join(", ")}`); console.log(`allowed hosts: ${config.allowedHosts.join(", ")}`); + console.log(`widgets: ${config.widgets}`); if (config.allowedHosts.includes("*")) { console.warn("warning: Host header allowlist is disabled because DEVSPACE_ALLOWED_HOSTS=*"); } @@ -264,6 +267,7 @@ async function runDoctor(): Promise { console.log(`Public MCP URL: ${new URL("/mcp", config.publicBaseUrl).toString()}`); console.log(`Allowed roots: ${config.allowedRoots.join(", ")}`); console.log(`Allowed hosts: ${config.allowedHosts.join(", ")}`); + console.log(`Widgets: ${config.widgets}`); } catch (error) { console.log(`Config status: ${error instanceof Error ? error.message : String(error)}`); } @@ -281,20 +285,33 @@ function runConfigCommand(args: string[]): void { if (subcommand !== "set") { throw new Error(`Unknown config command: ${subcommand}`); } - if (key !== "publicBaseUrl") { - throw new Error("Only `devspace config set publicBaseUrl ` is supported right now."); + const value = rest.join(" ").trim(); + if (key === "publicBaseUrl") { + if (!value) throw new Error("Missing publicBaseUrl value."); + writeDevspaceConfig({ + ...files.config, + publicBaseUrl: normalizeOptionalPublicBaseUrl(value), + }); + console.log(`Updated ${files.configPath}`); + return; } - const value = rest.join(" ").trim(); - if (!value) { - throw new Error("Missing publicBaseUrl value."); + if (key === "widgets") { + if (value !== "off" && value !== "changes" && value !== "full") { + throw new Error("widgets must be one of: off, changes, full."); + } + writeDevspaceConfig({ + ...files.config, + widgets: value, + }); + console.log(`Updated ${files.configPath}`); + console.log("Restart DevSpace and refresh/reconnect the MCP App for the UI mode change to take effect."); + return; } - writeDevspaceConfig({ - ...files.config, - publicBaseUrl: normalizeOptionalPublicBaseUrl(value), - }); - console.log(`Updated ${files.configPath}`); + throw new Error( + "Supported settings: publicBaseUrl , widgets .", + ); } function printHelp(): void { @@ -309,6 +326,7 @@ function printHelp(): void { " devspace doctor Show config, runtime, and native dependency status", " devspace config get Print persisted config", " devspace config set publicBaseUrl ", + " devspace config set widgets ", " devspace agents ls List subagent sessions", " devspace agents run [--model ] ", " devspace agents show ", diff --git a/src/config.test.ts b/src/config.test.ts index 0b4f99a8..13f342cc 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -12,7 +12,7 @@ const baseEnv = { DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", }; -assert.equal(loadConfig(baseEnv).widgets, "full"); +assert.equal(loadConfig(baseEnv).widgets, "off"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "changes" }).widgets, "changes"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "full" }).widgets, "full"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "off" }).widgets, "off"); @@ -162,6 +162,7 @@ writeFileSync( port: 8787, allowedRoots: [process.cwd()], publicBaseUrl: "https://devspace.example.com", + widgets: "changes", subagents: true, }), ); @@ -176,6 +177,11 @@ const fileConfig = loadConfig({ DEVSPACE_CONFIG_DIR: configDir }); assert.equal(fileConfig.port, 8787); assert.equal(fileConfig.oauth.ownerToken, "persisted-owner-token-long-enough"); assert.equal(fileConfig.publicBaseUrl, "https://devspace.example.com"); +assert.equal(fileConfig.widgets, "changes"); +assert.equal( + loadConfig({ DEVSPACE_CONFIG_DIR: configDir, DEVSPACE_WIDGETS: "full" }).widgets, + "full", +); assert.equal(fileConfig.subagents, true); assert.deepEqual(fileConfig.allowedHosts, [ "localhost", diff --git a/src/config.ts b/src/config.ts index 4fc1bcbb..3724fbb8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -148,8 +148,8 @@ function parseLoggingConfig(env: NodeJS.ProcessEnv): LoggingConfig { } function parseWidgetMode(value: string | undefined): WidgetMode { - if (!value || value === "full") return "full"; - if (value === "off" || value === "changes") return value; + if (!value || value === "off") return "off"; + if (value === "changes" || value === "full") return value; throw new Error(`Invalid DEVSPACE_WIDGETS: ${value}`); } @@ -223,7 +223,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { allowedHosts: parseAllowedHosts(env.DEVSPACE_ALLOWED_HOSTS, derivedAllowedHosts), publicBaseUrl, toolMode: parseToolMode(env), - widgets: parseWidgetMode(env.DEVSPACE_WIDGETS), + widgets: parseWidgetMode(env.DEVSPACE_WIDGETS ?? files.config.widgets), stateDir: resolve(expandHomePath(env.DEVSPACE_STATE_DIR ?? files.config.stateDir ?? defaultStateDir())), worktreeRoot: resolve(expandHomePath(env.DEVSPACE_WORKTREE_ROOT ?? files.config.worktreeRoot ?? defaultWorktreeRoot())), skillsEnabled: env.DEVSPACE_SKILLS === undefined ? true : parseBoolean(env.DEVSPACE_SKILLS), diff --git a/src/server.ts b/src/server.ts index 7dd9629e..cd64959e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -11,7 +11,7 @@ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import { checkResourceAllowed, resourceUrlFromServerUrl } from "@modelcontextprotocol/sdk/shared/auth-utils.js"; import { registerAppResource, - registerAppTool, + registerAppTool as registerUiTool, RESOURCE_MIME_TYPE, } from "@modelcontextprotocol/ext-apps/server"; import express from "express"; @@ -155,6 +155,32 @@ function toolWidgetDescriptorMeta( }; } +const widgetModeByServer = new WeakMap(); + +function stripAppMeta(value: T): T { + if (!value || typeof value !== "object") return value; + const { _meta: _discardedMeta, ...nativeValue } = value as Record; + return nativeValue as T; +} + +// Preserve the upstream MCP App path for changes/full, but use native +// MCP tools in off mode so hosts do not allocate embedded UI cards. +const registerAppTool = (( + server: McpServer, + name: string, + definition: any, + handler: (...args: any[]) => any, +) => { + if (widgetModeByServer.get(server) !== "off") { + return registerUiTool(server, name, definition, handler); + } + + return server.registerTool( + name, + stripAppMeta(definition), + async (...args: any[]) => stripAppMeta(await handler(...args)), + ); +}) as typeof registerUiTool; const toolNames = { openWorkspace: "open_workspace", read: "read", @@ -701,8 +727,10 @@ function createMcpServer( instructions: serverInstructions(config), }, ); + widgetModeByServer.set(server, config.widgets); - registerAppResource( + if (config.widgets !== "off") { + registerAppResource( server, "DevSpace Diff Card", WORKSPACE_APP_URI, @@ -732,6 +760,7 @@ function createMcpServer( }; }, ); + } registerAppTool( server, diff --git a/src/user-config.ts b/src/user-config.ts index c8da90b5..38adf48b 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -14,6 +14,7 @@ export interface DevspaceUserConfig { port?: number; allowedRoots?: string[]; publicBaseUrl?: string | null; + widgets?: "off" | "changes" | "full"; allowedHosts?: string[]; stateDir?: string; worktreeRoot?: string;