From 82ffd0b80027abb3ccca7cf184c68566070f3955 Mon Sep 17 00:00:00 2001 From: jhuang-tw <77732008+jhuang-tw@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:09:45 +0000 Subject: [PATCH] feat(ui): optimize MCP App widgets to reduce chat UI overhead Optimize MCP App widget behavior because tool-result cards can keep accumulating in long conversations. This can make the chat page increasingly slow, crowd the conversation with repeated UI surfaces, and make the information the user actually needs harder to find. Behavior: - default MCP App widgets to `off` - add a persisted `widgets` setting with `off`, `changes`, and `full` - add `devspace config set widgets ` - keep `DEVSPACE_WIDGETS` as the highest-priority temporary override - report the active mode during init, serve, and doctor commands - in `off`, register native MCP tools without MCP App metadata and do not register the App resource - in `changes` and `full`, preserve the existing MCP App registration and presentation behavior Rationale: - prevent embedded tool cards from continuously piling up in chat - reduce rendering and interaction overhead in long conversations - keep tool results readable without hiding important conversation content behind repeated UI containers - allow Owners to explicitly enable partial or full UI when needed Compatibility and security: - Owner-password OAuth is unchanged - the `/mcp` endpoint and tool names are unchanged - existing `DEVSPACE_WIDGETS` deployments remain supported - persisted configuration is overridden by the environment variable - no generated UI assets, helper scripts, workflows, or documentation files are included in the final change Validation: - npm run typecheck - npm test - npm run build Scope: - src/user-config.ts - src/config.ts - src/config.test.ts - src/cli.ts - src/server.ts --- src/cli.ts | 38 ++++++++++++++++++++++++++++---------- src/config.test.ts | 8 +++++++- src/config.ts | 6 +++--- src/server.ts | 33 +++++++++++++++++++++++++++++++-- src/user-config.ts | 1 + 5 files changed, 70 insertions(+), 16 deletions(-) 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;