diff --git a/packages/console/app/src/lib/stats-proxy.ts b/packages/console/app/src/lib/stats-proxy.ts index 399bf0efd88..e75ab33da51 100644 --- a/packages/console/app/src/lib/stats-proxy.ts +++ b/packages/console/app/src/lib/stats-proxy.ts @@ -10,7 +10,11 @@ export async function statsProxy(evt: APIEvent) { targetUrl.hostname = Resource.App.stage === "production" ? "stats.opencode.ai" : "stats.dev.opencode.ai" targetUrl.port = "" - if (targetUrl.pathname.startsWith(`${dataPath}/_build/`) || targetUrl.pathname === `${dataPath}/banner.jpg`) { + if ( + targetUrl.pathname.startsWith(`${dataPath}/_build/`) || + targetUrl.pathname === `${dataPath}/banner.jpg` || + targetUrl.pathname === `${dataPath}/banner.png` + ) { targetUrl.pathname = targetUrl.pathname.slice(dataPath.length) } diff --git a/packages/console/core/src/billing.ts b/packages/console/core/src/billing.ts index 82307658d77..879cd8c6775 100644 --- a/packages/console/core/src/billing.ts +++ b/packages/console/core/src/billing.ts @@ -272,7 +272,10 @@ export namespace Billing { }, payment_method_options: { card: { - setup_future_usage: "on_session", + setup_future_usage: "off_session", + }, + link: { + setup_future_usage: "off_session", }, }, //payment_method_data: { diff --git a/packages/core/src/config/mcp.ts b/packages/core/src/config/mcp.ts index fce853815b4..54998e18506 100644 --- a/packages/core/src/config/mcp.ts +++ b/packages/core/src/config/mcp.ts @@ -6,6 +6,9 @@ import { PositiveInt } from "../schema" export class Local extends Schema.Class("ConfigV2.MCP.Local")({ type: Schema.Literal("local"), command: Schema.String.pipe(Schema.Array), + cwd: Schema.String.pipe(Schema.optional).annotate({ + description: "Working directory for the MCP server process. Relative paths resolve from the workspace directory.", + }), environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), disabled: Schema.Boolean.pipe(Schema.optional), timeout: PositiveInt.pipe(Schema.optional), diff --git a/packages/core/src/v1/config/mcp.ts b/packages/core/src/v1/config/mcp.ts index 2e224780ba6..0a2aeff12fb 100644 --- a/packages/core/src/v1/config/mcp.ts +++ b/packages/core/src/v1/config/mcp.ts @@ -8,6 +8,9 @@ export const Local = Schema.Struct({ command: Schema.mutable(Schema.Array(Schema.String)).annotate({ description: "Command and arguments to run the MCP server", }), + cwd: Schema.optional(Schema.String).annotate({ + description: "Working directory for the MCP server process. Relative paths resolve from the workspace directory.", + }), environment: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({ description: "Environment variables to set when running the MCP server", }), diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index 3b4f13868ed..c474cac51a7 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -139,7 +139,14 @@ function mcp(info: typeof ConfigV1.Info.Type) { function migrateMcp(info: ConfigMCPV1.Info) { const disabled = info.enabled === undefined ? undefined : !info.enabled if (info.type === "local") - return { type: info.type, command: info.command, environment: info.environment, disabled, timeout: info.timeout } + return { + type: info.type, + command: info.command, + cwd: info.cwd, + environment: info.environment, + disabled, + timeout: info.timeout, + } return { type: info.type, url: info.url, diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 75da812348a..9a4beab1586 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -1,3 +1,4 @@ +import path from "node:path" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { type Tool } from "ai" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" @@ -304,7 +305,8 @@ export const layer = Layer.effect( mcp: ConfigMCPV1.Info & { type: "local" }, ) { const [cmd, ...args] = mcp.command - const cwd = yield* InstanceState.directory + const baseDir = yield* InstanceState.directory + const cwd = mcp.cwd ? path.resolve(baseDir, mcp.cwd) : baseDir const transport = new StdioClientTransport({ stderr: "pipe", command: cmd, diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index cce3c7014bc..027efc0974b 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1375,6 +1375,24 @@ export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 } } + // Gemini requires a single `type`, not a JSON Schema type array such as + // `["number","string"]` (emitted by some MCP servers). Plain `@ai-sdk/google` + // rewrites these into an `anyOf` of single-type schemas, but OpenAI-compatible + // transports (e.g. GitHub Copilot proxying to Gemini) forward them verbatim + // and the backend rejects the array form. Mirror the SDK: split non-null + // types into `anyOf`, and lift `null` into `nullable`. + if (Array.isArray(result.type)) { + const hasNull = result.type.includes("null") + const nonNull = result.type.filter((entry: unknown) => entry !== "null") + if (nonNull.length === 0) { + result.type = "null" + } else { + delete result.type + result.anyOf = nonNull.map((entry: unknown) => ({ type: entry })) + if (hasNull) result.nullable = true + } + } + // Filter required array to only include fields that exist in properties if (result.type === "object" && result.properties && Array.isArray(result.required)) { result.required = result.required.filter((field: any) => field in result.properties) diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 0aef477d146..763f6a3197e 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -1,8 +1,10 @@ +import path from "node:path" import { expect, mock, beforeEach } from "bun:test" import { ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js" import { Cause, Effect, Exit } from "effect" import type { MCP as MCPNS } from "../../src/mcp/index" import { testEffect } from "../lib/effect" +import { TestInstance } from "../fixture/fixture" // --- Mock infrastructure --- @@ -48,6 +50,8 @@ let connectError = "Mock transport cannot connect" let clientCreateCount = 0 // Tracks how many times transport.close() is called across all mock transports let transportCloseCount = 0 +// Captures the opts passed to each MockStdioTransport, keyed by lastCreatedClientName +const stdioOptsByName = new Map() function getOrCreateClientState(name?: string): MockClientState { const key = name ?? "default" @@ -82,8 +86,9 @@ function getOrCreateClientState(name?: string): MockClientState { class MockStdioTransport { stderr: null = null pid = 12345 - // oxlint-disable-next-line no-useless-constructor - constructor(_opts: any) {} + constructor(opts: any) { + if (lastCreatedClientName) stdioOptsByName.set(lastCreatedClientName, opts) + } async start() { if (connectShouldHang) return new Promise(() => {}) // never resolves if (connectShouldFail) throw new Error(connectError) @@ -246,6 +251,20 @@ function statusName(status: Record | MCPNS.Status, server: return status[server]?.status } +it.instance( + "local mcp cwd resolves relative paths against instance directory", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + const { directory } = yield* TestInstance + lastCreatedClientName = "rel-cwd" + yield* mcp.add("rel-cwd", { type: "local", command: ["echo", "test"], cwd: "plugins/sub" }) + expect(stdioOptsByName.get("rel-cwd")?.cwd).toBe(path.resolve(directory, "plugins/sub")) + }), + ), + { config: { mcp: {} } }, +) + // ======================================================================== // Test: tools() are cached after connect // ======================================================================== diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index c3c8cbf8171..c23a2aa9995 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -857,6 +857,93 @@ describe("ProviderTransform.schema - gemini nested array items", () => { }) }) +describe("ProviderTransform.schema - gemini type arrays", () => { + // Mirrors @ai-sdk/google's convertJSONSchemaToOpenAPISchema: JSON Schema type + // arrays (e.g. `["number","string"]`, common in MCP tool schemas) become an + // `anyOf` of single-type schemas, with `null` lifted into `nullable`. Plain + // @ai-sdk/google rewrites these, but OpenAI-compatible transports such as + // GitHub Copilot (proxying to Gemini) forward them verbatim and the backend + // rejects the array form. + const geminiModel = { + providerID: "google", + api: { + id: "gemini-3-pro", + }, + } as any + + test("splits a multi-type array into anyOf and drops the type array", () => { + const schema = { + type: "object", + properties: { + status: { type: ["number", "string"], description: "status filter" }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.properties.status.type).toBeUndefined() + expect(result.properties.status.anyOf).toEqual([{ type: "number" }, { type: "string" }]) + expect(result.properties.status.nullable).toBeUndefined() + // Sibling keywords stay alongside the generated anyOf. + expect(result.properties.status.description).toBe("status filter") + }) + + test("lifts null into nullable for a nullable type array", () => { + const schema = { + type: "object", + properties: { + maybe: { type: ["string", "null"], description: "nullable string" }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.properties.maybe.type).toBeUndefined() + expect(result.properties.maybe.anyOf).toEqual([{ type: "string" }]) + expect(result.properties.maybe.nullable).toBe(true) + }) + + test("collapses an all-null type array to type null", () => { + const schema = { + type: "object", + properties: { + nothing: { type: ["null"] }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.properties.nothing.type).toBe("null") + expect(result.properties.nothing.anyOf).toBeUndefined() + }) + + test("rewrites type arrays for gemini served through github-copilot", () => { + const copilotGeminiModel = { + providerID: "github-copilot", + api: { + id: "gemini-3.5-flash", + npm: "@ai-sdk/github-copilot", + }, + } as any + + const schema = { + type: "object", + properties: { + hook_id: { type: "number", description: "ID of the webhook" }, + status: { type: ["number", "string"], description: "Filter by response status code" }, + }, + required: ["hook_id"], + additionalProperties: false, + } as any + + const result = ProviderTransform.schema(copilotGeminiModel, schema) as any + + expect(result.properties.status.anyOf).toEqual([{ type: "number" }, { type: "string" }]) + expect(result.properties.status.type).toBeUndefined() + expect(result.properties.hook_id.type).toBe("number") + }) +}) + describe("ProviderTransform.schema - gemini combiner nodes", () => { const geminiModel = { providerID: "google", diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 4ac8dbe3702..100034d29fd 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1875,6 +1875,7 @@ export type McpLocalConfig = { * Command and arguments to run the MCP server */ command: Array + cwd?: string environment?: { [key: string]: string } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index ae20937283a..d1755d4de15 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -19296,6 +19296,9 @@ }, "description": "Command and arguments to run the MCP server" }, + "cwd": { + "type": "string" + }, "environment": { "type": "object", "additionalProperties": { diff --git a/packages/stats/app/public/banner.png b/packages/stats/app/public/banner.png new file mode 100644 index 00000000000..4ff2ca5e78f Binary files /dev/null and b/packages/stats/app/public/banner.png differ diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx index fe86ecde912..825cfc1b06b 100644 --- a/packages/stats/app/src/routes/[lab]/[model].tsx +++ b/packages/stats/app/src/routes/[lab]/[model].tsx @@ -32,7 +32,10 @@ import { type ThemePreference, } from "../stats-shell" -const statsModelFallbackUrl = "https://stats.opencode.ai" +const statsCanonicalBaseUrl = "https://opencode.ai/data/" +const statsUnfurlPath = "banner.png" +const statsUnfurlAlt = "OpenCode Data wordmark on a dark patterned background" +const statsUnfurlUrl = new URL(statsUnfurlPath, statsCanonicalBaseUrl).toString() const modelHeaderLinks: readonly HeaderLink[] = [ { href: "#overview", label: "Overview" }, { href: "#usage", label: "Usage" }, @@ -118,8 +121,8 @@ export default function StatsModel() { ) const modelUrl = createMemo(() => new URL( - `${import.meta.env.BASE_URL}${catalogEntry()?.id ?? `${labParam()}/${stats()?.slug ?? modelParam()}`}`, - event?.request.url ?? (typeof window === "undefined" ? statsModelFallbackUrl : window.location.href), + catalogEntry()?.id ?? [labParam(), stats()?.slug ?? modelParam()].filter((part) => part.length > 0).join("/"), + statsCanonicalBaseUrl, ).toString(), ) const updateThemePreference = (preference: ThemePreference) => { @@ -147,9 +150,16 @@ export default function StatsModel() { - + + + + + + + +
diff --git a/packages/stats/app/src/routes/[lab]/index.tsx b/packages/stats/app/src/routes/[lab]/index.tsx index 2e39e1ffa2b..18f9545d08a 100644 --- a/packages/stats/app/src/routes/[lab]/index.tsx +++ b/packages/stats/app/src/routes/[lab]/index.tsx @@ -28,7 +28,10 @@ import { type ThemePreference, } from "../stats-shell" -const statsLabFallbackUrl = "https://stats.opencode.ai" +const statsCanonicalBaseUrl = "https://opencode.ai/data/" +const statsUnfurlPath = "banner.png" +const statsUnfurlAlt = "OpenCode Data wordmark on a dark patterned background" +const statsUnfurlUrl = new URL(statsUnfurlPath, statsCanonicalBaseUrl).toString() const labHeaderLinks: readonly HeaderLink[] = [ { href: "#overview", label: "Overview" }, { href: "#usage", label: "Usage" }, @@ -71,12 +74,7 @@ export default function StatsLab() { () => `Explore ${labName()} models used in OpenCode, with recent token usage, context windows, release dates, and model-specific data.`, ) - const labUrl = createMemo(() => - new URL( - `${import.meta.env.BASE_URL}${lab()?.id ?? labParam()}`, - event?.request.url ?? (typeof window === "undefined" ? statsLabFallbackUrl : window.location.href), - ).toString(), - ) + const labUrl = createMemo(() => new URL(lab()?.id ?? labParam(), statsCanonicalBaseUrl).toString()) const updateThemePreference = (preference: ThemePreference) => { applyThemePreference(preference) setThemePreference(preference) @@ -102,9 +100,16 @@ export default function StatsLab() { - + + + + + + + +
diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 0b831196326..aa002080b1d 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -540,7 +540,7 @@ export function Prompt(props: PromptProps) { }, { title: "Move session", - desc: "Move the session to another project directory", + desc: "Move to another project dir", name: "session.move", category: "Session", slashName: "move", @@ -1086,22 +1086,31 @@ export function Prompt(props: PromptProps) { } else { move.startSubmit() sdk.client.session - .prompt({ - sessionID, - ...selectedModel, - agent: agent.name, - model: selectedModel, - variant, - parts: [ - ...editorParts, - { - type: "text", - text: inputText, - }, - ...nonTextParts, - ], + .prompt( + { + sessionID, + ...selectedModel, + agent: agent.name, + model: selectedModel, + variant, + parts: [ + ...editorParts, + { + type: "text", + text: inputText, + }, + ...nonTextParts, + ], + }, + { throwOnError: true }, + ) + .catch((error) => { + toast.show({ + title: "Failed to send prompt", + message: errorMessage(error), + variant: "error", + }) }) - .catch(() => {}) if (editorParts.length > 0) editor.markSelectionSent() } history.append({ diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 7736eb75b0c..922272f0e75 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1831,6 +1831,7 @@ function InlineTool(props: { color?: RGBA complete: unknown pending: string + failure?: string spinner?: boolean subagent?: boolean children: JSX.Element @@ -1884,6 +1885,7 @@ function InlineTool(props: { errorExpanded={errorExpanded()} complete={props.complete} pending={props.pending} + failure={props.failure} spinner={props.spinner} subagent={props.subagent} separateAfter={(id) => id !== undefined && ctx.userMessageIDs().has(id)} @@ -1915,6 +1917,7 @@ export function InlineToolRow(props: { errorExpanded?: boolean complete: unknown pending: string + failure?: string spinner?: boolean subagent?: boolean children: JSX.Element @@ -1958,7 +1961,7 @@ export function InlineToolRow(props: { ~ {props.pending} } - when={props.complete} + when={props.complete || props.failed} > - {props.children} + {props.failed && !props.complete ? (props.failure ?? props.children) : props.children} @@ -2445,7 +2448,7 @@ function ApplyPatch(props: ToolProps) { - + Patch @@ -2465,7 +2468,13 @@ function TodoWrite(props: ToolProps) { - + Updating todos... diff --git a/packages/tui/sst-env.d.ts b/packages/tui/sst-env.d.ts new file mode 100644 index 00000000000..64441936d7a --- /dev/null +++ b/packages/tui/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx b/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx index 19ccc21e5b2..880dc027114 100644 --- a/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx +++ b/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx @@ -155,6 +155,22 @@ function StickyScrollFixture(props: { separated: boolean; scroll: (scroll: Scrol ) } +function FailedPendingToolFixture() { + return ( + + Patch + + ) +} + +function FailedCompleteToolFixture() { + return ( + + Read src/index.ts + + ) +} + async function renderFrame(component: () => JSX.Element, options: { width: number; height: number }) { testSetup = await testRender(component, options) await testSetup.renderOnce() @@ -173,6 +189,18 @@ describe("TUI inline tool wrapping", () => { expect(toolDisplay("plugin_tool")).toBe("generic") }) + test("replaces pending copy when a tool fails before completion", async () => { + const frame = await renderFrame(() => , { width: 72, height: 3 }) + expect(frame).toContain("Patch failed") + expect(frame).not.toContain("Preparing patch") + }) + + test("preserves useful completed copy when a tool fails", async () => { + const frame = await renderFrame(() => , { width: 72, height: 3 }) + expect(frame).toContain("Read src/index.ts") + expect(frame).not.toContain("Read failed") + }) + test("filters malformed nested tool wire data", () => { expect( parseApplyPatchFiles([ diff --git a/packages/web/src/content/docs/mcp-servers.mdx b/packages/web/src/content/docs/mcp-servers.mdx index 1b3006b1cbf..215938ec3b1 100644 --- a/packages/web/src/content/docs/mcp-servers.mdx +++ b/packages/web/src/content/docs/mcp-servers.mdx @@ -116,13 +116,14 @@ use the mcp_everything tool to add the number 3 and 4 Here are all the options for configuring a local MCP server. -| Option | Type | Required | Description | -| ------------- | ------- | -------- | ----------------------------------------------------------------------------------- | -| `type` | String | Y | Type of MCP server connection, must be `"local"`. | -| `command` | Array | Y | Command and arguments to run the MCP server. | -| `environment` | Object | | Environment variables to set when running the server. | -| `enabled` | Boolean | | Enable or disable the MCP server on startup. | -| `timeout` | Number | | Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds). | +| Option | Type | Required | Description | +| ------------- | ------- | -------- | ---------------------------------------------------------------------------------------- | +| `type` | String | Y | Type of MCP server connection, must be `"local"`. | +| `command` | Array | Y | Command and arguments to run the MCP server. | +| `cwd` | String | | Working directory for the MCP server process. Relative paths resolve from the workspace. | +| `environment` | Object | | Environment variables to set when running the server. | +| `enabled` | Boolean | | Enable or disable the MCP server on startup. | +| `timeout` | Number | | Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds). | ---