Просмотр исходного кода

feat(mcp): add resource read tools (#33483)

Shoubhit Dash 2 месяцев назад
Родитель
Сommit
3f3f120825

+ 5 - 1
packages/opencode/src/mcp/catalog.ts

@@ -86,6 +86,7 @@ export function fetch<T extends { name: string }>(
   client: Client,
   list: (client: Client) => Promise<T[]>,
   label: string,
+  key?: (item: T) => string,
 ) {
   return Effect.tryPromise({
     try: () => list(client),
@@ -100,7 +101,10 @@ export function fetch<T extends { name: string }>(
     Effect.map((items) => {
       const sanitizedClient = sanitize(clientName)
       return Object.fromEntries(
-        items.map((item) => [sanitizedClient + ":" + sanitize(item.name), { ...item, client: clientName }]),
+        items.map((item) => [
+          key ? clientName + ":" + key(item) : sanitizedClient + ":" + sanitize(item.name),
+          { ...item, client: clientName },
+        ]),
       )
     }),
     Effect.orElseSucceed(() => undefined),

+ 15 - 4
packages/opencode/src/mcp/index.ts

@@ -161,7 +161,7 @@ export interface Interface {
   readonly clients: () => Effect.Effect<Record<string, MCPClient>>
   readonly tools: () => Effect.Effect<Record<string, Tool>>
   readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
-  readonly resources: () => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
+  readonly resources: (clientName?: string) => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
   readonly add: (name: string, mcp: ConfigMCPV1.Info) => Effect.Effect<{ status: Record<string, Status> | Status }>
   readonly connect: (name: string) => Effect.Effect<void, NotFoundError>
   readonly disconnect: (name: string) => Effect.Effect<void, NotFoundError>
@@ -654,17 +654,22 @@ export const layer = Layer.effect(
       s: State,
       listFn: (c: Client, timeout?: number) => Promise<T[]>,
       label: string,
+      key?: (item: T) => string,
+      targetClientName?: string,
     ) {
       return Effect.gen(function* () {
         const cfg = yield* cfgSvc.get()
         return yield* Effect.forEach(
-          Object.entries(s.clients).filter(([name]) => s.status[name]?.status === "connected"),
+          Object.entries(s.clients).filter(
+            ([name]) => s.status[name]?.status === "connected" && (!targetClientName || name === targetClientName),
+          ),
           ([clientName, client]) =>
             McpCatalog.fetch(
               clientName,
               client,
               (c) => listFn(c, requestTimeout(s, clientName, cfg.mcp?.[clientName], cfg.experimental?.mcp_timeout)),
               label,
+              key,
             ).pipe(Effect.map((items) => Object.entries(items ?? {}))),
           { concurrency: "unbounded" },
         ).pipe(Effect.map((results) => Object.fromEntries<T & { client: string }>(results.flat())))
@@ -675,8 +680,14 @@ export const layer = Layer.effect(
       return yield* collectFromConnected(yield* InstanceState.get(state), McpCatalog.prompts, "prompts")
     })
 
-    const resources = Effect.fn("MCP.resources")(function* () {
-      return yield* collectFromConnected(yield* InstanceState.get(state), McpCatalog.resources, "resources")
+    const resources = Effect.fn("MCP.resources")(function* (clientName?: string) {
+      return yield* collectFromConnected(
+        yield* InstanceState.get(state),
+        McpCatalog.resources,
+        "resources",
+        (resource) => resource.uri,
+        clientName,
+      )
     })
 
     const withClient = Effect.fnUntraced(function* <A>(

+ 2 - 1
packages/opencode/src/permission/index.ts

@@ -214,9 +214,10 @@ export function merge(...rulesets: PermissionV1.Ruleset[]): PermissionV1.Rule[]
 
 export function disabled(tools: string[], ruleset: PermissionV1.Ruleset): Set<string> {
   const edits = ["edit", "write", "apply_patch"]
+  const reads = ["list_mcp_resources", "read_mcp_resource"]
   return new Set(
     tools.filter((tool) => {
-      const permission = edits.includes(tool) ? "edit" : tool
+      const permission = edits.includes(tool) ? "edit" : reads.includes(tool) ? "read" : tool
       const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
       return rule?.pattern === "*" && rule.action === "deny"
     }),

+ 55 - 5
packages/opencode/src/session/prompt.ts

@@ -66,6 +66,14 @@ globalThis.AI_SDK_LOG_WARNINGS = false
 
 const decodeMessageInfo = Schema.decodeUnknownExit(SessionV1.Info)
 const decodeMessagePart = Schema.decodeUnknownExit(SessionV1.Part)
+const MAX_MCP_RESOURCE_BLOB_BYTES = 10 * 1024 * 1024
+const SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES = new Set([
+  "application/pdf",
+  "image/gif",
+  "image/jpeg",
+  "image/png",
+  "image/webp",
+])
 
 const STRUCTURED_OUTPUT_DESCRIPTION = `Use this tool to return your final response in the requested structured format.
 
@@ -77,6 +85,18 @@ IMPORTANT:
 
 const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested structured output. You MUST use the StructuredOutput tool to provide your final response. Do NOT respond with plain text - you MUST call the StructuredOutput tool with your answer formatted according to the schema.`
 
+function mcpResourceBase64Size(value: string) {
+  const trimmed = value.replace(/\s/g, "")
+  const padding = trimmed.endsWith("==") ? 2 : trimmed.endsWith("=") ? 1 : 0
+  return Math.max(0, Math.floor((trimmed.length * 3) / 4) - padding)
+}
+
+function formatMcpResourceBytes(value: number) {
+  if (value < 1024) return `${value} B`
+  if (value < 1024 * 1024) return `${Math.ceil(value / 1024)} KB`
+  return `${Math.ceil(value / (1024 * 1024))} MB`
+}
+
 function isOrphanedInterruptedTool(part: SessionV1.ToolPart) {
   // cleanup() marks abandoned tool_use blocks this way after retries/aborts.
   // They are not pending work and must not trigger an assistant-prefill request.
@@ -731,7 +751,8 @@ export const layer = Layer.effect(
               if (!content) throw new Error(`Resource not found: ${clientName}/${uri}`)
               const items = Array.isArray(content.contents) ? content.contents : [content.contents]
               for (const c of items) {
-                if ("text" in c && c.text) {
+                if (!c || typeof c !== "object") continue
+                if ("text" in c && typeof c.text === "string" && c.text) {
                   pieces.push({
                     messageID: info.id,
                     sessionID: input.sessionID,
@@ -739,18 +760,47 @@ export const layer = Layer.effect(
                     synthetic: true,
                     text: c.text,
                   })
-                } else if ("blob" in c && c.blob) {
-                  const mime = "mimeType" in c ? c.mimeType : part.mime
+                } else if ("blob" in c && typeof c.blob === "string" && c.blob) {
+                  const mime = "mimeType" in c && typeof c.mimeType === "string" ? c.mimeType : part.mime
+                  const filename = "uri" in c && typeof c.uri === "string" ? c.uri : part.filename
+                  const size = mcpResourceBase64Size(c.blob)
+                  if (!SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES.has(mime)) {
+                    pieces.push({
+                      messageID: info.id,
+                      sessionID: input.sessionID,
+                      type: "text",
+                      synthetic: true,
+                      text: `[Binary MCP resource omitted: ${filename ?? uri} (${mime}, ${formatMcpResourceBytes(size)}) is not a supported attachment type]`,
+                    })
+                    continue
+                  }
+                  if (size > MAX_MCP_RESOURCE_BLOB_BYTES) {
+                    pieces.push({
+                      messageID: info.id,
+                      sessionID: input.sessionID,
+                      type: "text",
+                      synthetic: true,
+                      text: `[Binary MCP resource omitted: ${filename ?? uri} (${mime}, ${formatMcpResourceBytes(size)}) exceeds ${formatMcpResourceBytes(MAX_MCP_RESOURCE_BLOB_BYTES)}]`,
+                    })
+                    continue
+                  }
                   pieces.push({
                     messageID: info.id,
                     sessionID: input.sessionID,
                     type: "text",
                     synthetic: true,
-                    text: `[Binary content: ${mime}]`,
+                    text: `[Binary MCP resource attached: ${filename ?? uri} (${mime})]`,
+                  })
+                  pieces.push({
+                    messageID: info.id,
+                    sessionID: input.sessionID,
+                    type: "file",
+                    mime,
+                    filename,
+                    url: `data:${mime};base64,${c.blob}`,
                   })
                 }
               }
-              pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID })
             } else {
               const error = Cause.squash(exit.cause)
               yield* Effect.logError("failed to read MCP resource", { error, clientName, uri })

+ 287 - 2
packages/opencode/src/session/tools.ts

@@ -20,6 +20,18 @@ import { PartID } from "./schema"
 import { EffectBridge } from "@/effect/bridge"
 import { ProviderV2 } from "@opencode-ai/core/provider"
 import { ModelV2 } from "@opencode-ai/core/model"
+import { isRecord } from "@/util/record"
+
+const LIST_MCP_RESOURCES_TOOL = "list_mcp_resources"
+const READ_MCP_RESOURCE_TOOL = "read_mcp_resource"
+const MAX_MCP_RESOURCE_BLOB_BYTES = 10 * 1024 * 1024
+const SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES = new Set([
+  "application/pdf",
+  "image/gif",
+  "image/jpeg",
+  "image/png",
+  "image/webp",
+])
 
 export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
   agent: Agent.Info
@@ -114,6 +126,175 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
     })
   }
 
+  const hasMcpResourceServer = Object.values(yield* mcp.clients()).some(
+    (client) => !!client.getServerCapabilities()?.resources,
+  )
+  if (hasMcpResourceServer) {
+    tools[LIST_MCP_RESOURCES_TOOL] = tool({
+      description:
+        "Lists resources provided by connected MCP servers. Resources provide context such as files, database schemas, or application-specific information.",
+      inputSchema: jsonSchema(
+        ProviderTransform.schema(input.model, {
+          type: "object",
+          properties: {
+            server: {
+              type: "string",
+              description: "Optional MCP server name. When omitted, lists resources from every connected server.",
+            },
+          },
+          additionalProperties: false,
+        }),
+      ),
+      execute(args, opts) {
+        return run.promise(
+          Effect.gen(function* () {
+            const parsed = parseListMcpResourcesArgs(args)
+            const ctx = context(toRecord(args), opts)
+            const clients = yield* mcp.clients()
+            const resourceServers = Object.entries(clients)
+              .filter((entry) => !!entry[1].getServerCapabilities()?.resources)
+              .map((entry) => entry[0])
+              .sort((a, b) => a.localeCompare(b))
+            if (parsed.server && !resourceServers.includes(parsed.server)) {
+              throw new Error(
+                resourceServers.length === 0
+                  ? `MCP server "${parsed.server}" does not support resources`
+                  : `MCP server "${parsed.server}" does not support resources. Available resource servers: ${resourceServers.join(", ")}`,
+              )
+            }
+            const permissionPatterns = parsed.server
+              ? [`mcp:${parsed.server}:*`]
+              : resourceServers.map((server) => `mcp:${server}:*`)
+            yield* plugin.trigger(
+              "tool.execute.before",
+              { tool: LIST_MCP_RESOURCES_TOOL, sessionID: ctx.sessionID, callID: opts.toolCallId },
+              { args },
+            )
+            yield* ctx.ask({
+              permission: "read",
+              metadata: parsed.server ? { server: parsed.server } : {},
+              patterns: permissionPatterns,
+              always: permissionPatterns,
+            })
+
+            const resources = Object.values(yield* mcp.resources(parsed.server))
+            const filtered = resources
+              .filter((resource) => !parsed.server || resource.client === parsed.server)
+              .toSorted((a, b) =>
+                (a.client + "\u0000" + a.name + "\u0000" + a.uri).localeCompare(
+                  b.client + "\u0000" + b.name + "\u0000" + b.uri,
+                ),
+              )
+            const content = JSON.stringify({ resources: filtered.map(formatMcpResource) }, null, 2)
+            const truncated = yield* truncate.output(content, {}, input.agent)
+            const output = {
+              title: parsed.server ? `MCP resources: ${parsed.server}` : "MCP resources",
+              metadata: {
+                count: filtered.length,
+                servers: resourceServers,
+                ...(parsed.server ? { server: parsed.server } : {}),
+                truncated: truncated.truncated,
+                ...(truncated.truncated && { outputPath: truncated.outputPath }),
+              },
+              output: truncated.content,
+            }
+            yield* plugin.trigger(
+              "tool.execute.after",
+              { tool: LIST_MCP_RESOURCES_TOOL, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
+              output,
+            )
+            if (opts.abortSignal?.aborted) {
+              yield* input.processor.completeToolCall(opts.toolCallId, output)
+            }
+            return output
+          }),
+        )
+      },
+    })
+
+    tools[READ_MCP_RESOURCE_TOOL] = tool({
+      description:
+        "Read a specific resource from an MCP server using the server name and resource URI. The URI is an MCP identifier and does not need to be a file URL.",
+      inputSchema: jsonSchema(
+        ProviderTransform.schema(input.model, {
+          type: "object",
+          properties: {
+            server: {
+              type: "string",
+              description: "MCP server name exactly as returned by list_mcp_resources.",
+            },
+            uri: {
+              type: "string",
+              description: "Resource URI to read. Use the exact URI string returned by list_mcp_resources.",
+            },
+          },
+          required: ["server", "uri"],
+          additionalProperties: false,
+        }),
+      ),
+      execute(args, opts) {
+        return run.promise(
+          Effect.gen(function* () {
+            const parsed = parseReadMcpResourceArgs(args)
+            const ctx = context(toRecord(args), opts)
+            const clients = yield* mcp.clients()
+            const client = clients[parsed.server]
+            if (!client) {
+              throw new Error(`MCP server "${parsed.server}" is not connected`)
+            }
+            if (!client.getServerCapabilities()?.resources) {
+              throw new Error(`MCP server "${parsed.server}" does not support resources`)
+            }
+            yield* plugin.trigger(
+              "tool.execute.before",
+              { tool: READ_MCP_RESOURCE_TOOL, sessionID: ctx.sessionID, callID: opts.toolCallId },
+              { args },
+            )
+            yield* ctx.ask({
+              permission: "read",
+              metadata: { server: parsed.server, uri: parsed.uri },
+              patterns: [`mcp:${parsed.server}:${parsed.uri}`],
+              always: [`mcp:${parsed.server}:*`],
+            })
+
+            const content = yield* mcp.readResource(parsed.server, parsed.uri)
+            if (!content) throw new Error(`Failed to read MCP resource: ${parsed.server}/${parsed.uri}`)
+
+            const formatted = formatMcpResourceContent(parsed.server, parsed.uri, content)
+            const truncated = yield* truncate.output(formatted.text, {}, input.agent)
+            const output = {
+              title: `MCP resource: ${parsed.uri}`,
+              metadata: {
+                server: parsed.server,
+                uri: parsed.uri,
+                contents: formatted.contents,
+                attachments: formatted.attachments.length,
+                truncated: truncated.truncated,
+                ...(truncated.truncated && { outputPath: truncated.outputPath }),
+              },
+              output: truncated.content,
+              attachments: formatted.attachments.map((attachment) => ({
+                ...attachment,
+                id: PartID.ascending(),
+                sessionID: ctx.sessionID,
+                messageID: input.processor.message.id,
+              })),
+            }
+            yield* plugin.trigger(
+              "tool.execute.after",
+              { tool: READ_MCP_RESOURCE_TOOL, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
+              output,
+            )
+            if (opts.abortSignal?.aborted) {
+              yield* input.processor.completeToolCall(opts.toolCallId, output)
+            }
+            return output
+          }),
+        )
+      },
+    })
+  }
+
   for (const [key, item] of Object.entries(yield* mcp.tools())) {
     const execute = item.execute
     if (!execute) continue
@@ -163,10 +344,24 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
               const { resource } = contentItem
               if (resource.text) textParts.push(resource.text)
               if (resource.blob) {
+                const mime = resource.mimeType ?? "application/octet-stream"
+                const size = base64Size(resource.blob)
+                if (!SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES.has(mime)) {
+                  textParts.push(
+                    `[Binary MCP resource omitted: ${resource.uri} (${mime}, ${formatBytes(size)}) is not a supported attachment type]`,
+                  )
+                  continue
+                }
+                if (size > MAX_MCP_RESOURCE_BLOB_BYTES) {
+                  textParts.push(
+                    `[Binary MCP resource omitted: ${resource.uri} (${mime}, ${formatBytes(size)}) exceeds ${formatBytes(MAX_MCP_RESOURCE_BLOB_BYTES)}]`,
+                  )
+                  continue
+                }
                 attachments.push({
                   type: "file",
-                  mime: resource.mimeType ?? "application/octet-stream",
-                  url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`,
+                  mime,
+                  url: `data:${mime};base64,${resource.blob}`,
                   filename: resource.uri,
                 })
               }
@@ -204,4 +399,94 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
   return tools
 })
 
+function toRecord(value: unknown) {
+  if (isRecord(value)) return value
+  return {}
+}
+
+function parseListMcpResourcesArgs(value: unknown) {
+  const args = toRecord(value)
+  return { server: optionalString(args, "server") }
+}
+
+function parseReadMcpResourceArgs(value: unknown) {
+  const args = toRecord(value)
+  return { server: requiredString(args, "server"), uri: requiredString(args, "uri") }
+}
+
+function optionalString(args: Record<string, unknown>, key: string) {
+  const value = args[key]
+  if (value === undefined || value === null || value === "") return undefined
+  if (typeof value !== "string") throw new Error(`${key} must be a string`)
+  return value
+}
+
+function requiredString(args: Record<string, unknown>, key: string) {
+  const value = optionalString(args, key)
+  if (value) return value
+  throw new Error(`${key} is required`)
+}
+
+function formatMcpResource(resource: MCP.Resource) {
+  const result = Object.fromEntries(Object.entries(resource).filter((entry) => entry[0] !== "client"))
+  return { ...result, server: resource.client }
+}
+
+function formatMcpResourceContent(server: string, uri: string, content: { contents: unknown }) {
+  const items = (Array.isArray(content.contents) ? content.contents : [content.contents]).filter(isRecord)
+  const text: string[] = []
+  const attachments: Omit<SessionV1.FilePart, "id" | "sessionID" | "messageID">[] = []
+
+  for (const item of items) {
+    const itemUri = typeof item.uri === "string" ? item.uri : uri
+    const mime = typeof item.mimeType === "string" ? item.mimeType : "application/octet-stream"
+    if (typeof item.text === "string") {
+      text.push(`Resource: ${itemUri}\nMIME: ${mime}\n${item.text}`)
+      continue
+    }
+    if (typeof item.blob === "string") {
+      const size = base64Size(item.blob)
+      if (!SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES.has(mime)) {
+        text.push(
+          `[Binary MCP resource omitted: ${itemUri} (${mime}, ${formatBytes(size)}) is not a supported attachment type]`,
+        )
+        continue
+      }
+      if (size > MAX_MCP_RESOURCE_BLOB_BYTES) {
+        text.push(
+          `[Binary MCP resource omitted: ${itemUri} (${mime}, ${formatBytes(size)}) exceeds ${formatBytes(MAX_MCP_RESOURCE_BLOB_BYTES)}]`,
+        )
+        continue
+      }
+      text.push(`[Binary MCP resource attached: ${itemUri} (${mime})]`)
+      attachments.push({
+        type: "file",
+        mime,
+        url: `data:${mime};base64,${item.blob}`,
+        filename: itemUri,
+      })
+      continue
+    }
+    text.push(`[MCP resource content without text or blob: ${itemUri}]`)
+  }
+
+  return {
+    contents: items.length,
+    attachments,
+    text: text.join("\n\n") || `MCP resource ${uri} from ${server} returned no contents.`,
+  }
+}
+
+function base64Size(value: string) {
+  const trimmed = value.replace(/\s/g, "")
+  const padding = trimmed.endsWith("==") ? 2 : trimmed.endsWith("=") ? 1 : 0
+  return Math.max(0, Math.floor((trimmed.length * 3) / 4) - padding)
+}
+
+function formatBytes(value: number) {
+  if (value < 1024) return `${value} B`
+  if (value < 1024 * 1024) return `${Math.ceil(value / 1024)} KB`
+  return `${Math.ceil(value / (1024 * 1024))} MB`
+}
+
 export * as SessionTools from "./tools"

+ 10 - 7
packages/opencode/test/mcp/lifecycle.test.ts

@@ -361,7 +361,7 @@ it.instance(
 
         expect(Object.keys(yield* mcp.tools())).toEqual(["paged-server_tool-one", "paged-server_tool-two"])
         expect(Object.keys(yield* mcp.prompts())).toEqual(["paged-server:prompt-one", "paged-server:prompt-two"])
-        expect(Object.keys(yield* mcp.resources())).toEqual(["paged-server:resource-one", "paged-server:resource-two"])
+        expect(Object.keys(yield* mcp.resources())).toEqual(["paged-server:test://one", "paged-server:test://two"])
         expect(serverState.listToolsCalls).toBe(2)
         expect(serverState.listPromptsCalls).toBe(2)
         expect(serverState.listResourcesCalls).toBe(2)
@@ -796,7 +796,10 @@ it.instance(
       Effect.gen(function* () {
         lastCreatedClientName = "resource-server"
         const serverState = getOrCreateClientState("resource-server")
-        serverState.resources = [{ name: "my-resource", uri: "file:///test.txt", description: "A test resource" }]
+        serverState.resources = [
+          { name: "my-resource", uri: "file:///test.txt", description: "A test resource" },
+          { name: "my-resource", uri: "ui://component-state", description: "A second resource with same name" },
+        ]
 
         yield* mcp.add("resource-server", {
           type: "local",
@@ -804,10 +807,10 @@ it.instance(
         })
 
         const resources = yield* mcp.resources()
-        expect(Object.keys(resources).length).toBe(1)
-        const key = Object.keys(resources)[0]
-        expect(key).toContain("resource-server")
-        expect(key).toContain("my-resource")
+        expect(Object.keys(resources)).toEqual([
+          "resource-server:file:///test.txt",
+          "resource-server:ui://component-state",
+        ])
       }),
     ),
   {
@@ -863,7 +866,7 @@ it.instance(
         expect(statusName(result.status, "resource-only-server")).toBe("connected")
         expect(serverState.listToolsCalls).toBe(0)
         expect(Object.keys(yield* mcp.tools())).toHaveLength(0)
-        expect(Object.keys(yield* mcp.resources())).toEqual(["resource-only-server:docs"])
+        expect(Object.keys(yield* mcp.resources())).toEqual(["resource-only-server:docs://readme"])
         expect(serverState.listResourcesCalls).toBe(1)
         expect(serverState.listPromptsCalls).toBe(0)
       }),

+ 1 - 1
packages/opencode/test/mcp/oauth-auto-connect.test.ts

@@ -269,7 +269,7 @@ mcpTest.instance(
         const result = yield* mcp.authenticate("test-oauth-resources")
         expect(result.status).toBe("connected")
         expect(listToolsCalls).toBe(0)
-        expect(Object.keys(yield* mcp.resources())).toEqual(["test-oauth-resources:docs"])
+        expect(Object.keys(yield* mcp.resources())).toEqual(["test-oauth-resources:docs://readme"])
       }),
     ),
   { config: config("test-oauth-resources") },

+ 1 - 1
packages/tui/src/component/prompt/autocomplete.tsx

@@ -773,7 +773,7 @@ export function Autocomplete(props: {
               </text>
               <Show when={option().description}>
                 <text fg={index === store.selected ? selectedForeground(theme) : theme.textMuted} wrapMode="none">
-                  {option().description}
+                  {" " + option().description?.trimStart()}
                 </text>
               </Show>
             </box>