Merge branch 'dev' into mcp-resource-content

This commit is contained in:
Aiden Cline 2026-06-11 21:33:53 -05:00
commit 4d6add0985
19 changed files with 267 additions and 45 deletions

View file

@ -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)
}

View file

@ -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: {

View file

@ -6,6 +6,9 @@ import { PositiveInt } from "../schema"
export class Local extends Schema.Class<Local>("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),

View file

@ -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",
}),

View file

@ -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,

View file

@ -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,

View file

@ -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)

View file

@ -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<string, any>()
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<void>(() => {}) // never resolves
if (connectShouldFail) throw new Error(connectError)
@ -246,6 +251,20 @@ function statusName(status: Record<string, MCPNS.Status> | 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
// ========================================================================

View file

@ -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",

View file

@ -1875,6 +1875,7 @@ export type McpLocalConfig = {
* Command and arguments to run the MCP server
*/
command: Array<string>
cwd?: string
environment?: {
[key: string]: string
}

View file

@ -19296,6 +19296,9 @@
},
"description": "Command and arguments to run the MCP server"
},
"cwd": {
"type": "string"
},
"environment": {
"type": "object",
"additionalProperties": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

View file

@ -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() {
<Meta property="og:title" content={modelTitle()} />
<Meta property="og:description" content={modelDescription()} />
<Meta property="og:url" content={modelUrl()} />
<Meta name="twitter:card" content="summary" />
<Meta property="og:image" content={statsUnfurlUrl} />
<Meta property="og:image:type" content="image/png" />
<Meta property="og:image:width" content="1200" />
<Meta property="og:image:height" content="630" />
<Meta property="og:image:alt" content={statsUnfurlAlt} />
<Meta name="twitter:card" content="summary_large_image" />
<Meta name="twitter:title" content={modelTitle()} />
<Meta name="twitter:description" content={modelDescription()} />
<Meta name="twitter:image" content={statsUnfurlUrl} />
<Meta name="twitter:image:alt" content={statsUnfurlAlt} />
<Header githubStars={githubStars() ?? "150K"} links={modelHeaderLinks} brandHref={import.meta.env.BASE_URL} />
<div data-component="container">
<div data-component="content">

View file

@ -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() {
<Meta property="og:title" content={labTitle()} />
<Meta property="og:description" content={labDescription()} />
<Meta property="og:url" content={labUrl()} />
<Meta name="twitter:card" content="summary" />
<Meta property="og:image" content={statsUnfurlUrl} />
<Meta property="og:image:type" content="image/png" />
<Meta property="og:image:width" content="1200" />
<Meta property="og:image:height" content="630" />
<Meta property="og:image:alt" content={statsUnfurlAlt} />
<Meta name="twitter:card" content="summary_large_image" />
<Meta name="twitter:title" content={labTitle()} />
<Meta name="twitter:description" content={labDescription()} />
<Meta name="twitter:image" content={statsUnfurlUrl} />
<Meta name="twitter:image:alt" content={statsUnfurlAlt} />
<Header githubStars={githubStars() ?? "150K"} links={labHeaderLinks} brandHref={import.meta.env.BASE_URL} />
<div data-component="container">
<div data-component="content">

View file

@ -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({

View file

@ -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}
</text>
}
when={props.complete}
when={props.complete || props.failed}
>
<box flexDirection="row">
<text
@ -1973,7 +1976,7 @@ export function InlineToolRow(props: {
fg={props.failed ? props.errorColor : props.color}
attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
>
{props.children}
{props.failed && !props.complete ? (props.failure ?? props.children) : props.children}
</text>
</box>
</Show>
@ -2445,7 +2448,7 @@ function ApplyPatch(props: ToolProps) {
</For>
</Match>
<Match when={true}>
<InlineTool icon="%" pending="Preparing patch..." complete={false} part={props.part}>
<InlineTool icon="%" pending="Preparing patch..." failure="Patch failed" complete={false} part={props.part}>
Patch
</InlineTool>
</Match>
@ -2465,7 +2468,13 @@ function TodoWrite(props: ToolProps) {
</BlockTool>
</Match>
<Match when={true}>
<InlineTool icon="⚙" pending="Updating todos..." complete={false} part={props.part}>
<InlineTool
icon="⚙"
pending="Updating todos..."
failure="Todo update failed"
complete={false}
part={props.part}
>
Updating todos...
</InlineTool>
</Match>

10
packages/tui/sst-env.d.ts vendored Normal file
View file

@ -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 */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}

View file

@ -155,6 +155,22 @@ function StickyScrollFixture(props: { separated: boolean; scroll: (scroll: Scrol
)
}
function FailedPendingToolFixture() {
return (
<InlineToolRow icon="%" complete={false} pending="Preparing patch..." failed={true} failure="Patch failed">
Patch
</InlineToolRow>
)
}
function FailedCompleteToolFixture() {
return (
<InlineToolRow icon="→" complete={true} pending="Reading file..." failed={true} failure="Read failed">
Read src/index.ts
</InlineToolRow>
)
}
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(() => <FailedPendingToolFixture />, { 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(() => <FailedCompleteToolFixture />, { 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([

View file

@ -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). |
---