Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .github/releases/v1.0.33.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
## opencode {VERSION}

Stable release from `main` branch. Batch closure of every remaining open security and CI issue: SSRF-hardened attachment downloads, library-modeled HTML escaping across all OAuth error pages, a submit_result reliability nudge for review-aggregate workflow nodes, and the dev lineage re-synced with main so SpecGit Acceptance runs green end to end.

---

### 🐛 Bug Fixes

- **submit_result reliability for review-aggregate nodes, #436 (PR #444)**: children that produced their whole review in prose but never called submit_result killed the node and its work; the output contract now states up front that message text does not count as submitting and that a missing submission fails the node, and spawn grants exactly one same-session nudge turn to hand back the already-completed result before settling - captured-but-invalid payloads stay non-retried since that is a deterministic contract violation.
- **Attachment download fetched the raw markdown URL, #442 (PR #444)**: the token-bearing request now targets only the validated URL object, with the https/github.com/user-attachments guard extracted into a unit-tested `isAllowedAttachmentUrl` predicate; closes CodeQL js/request-forgery alert #65 at the next default-branch scan.
- **OAuth error pages escaped through an unmodeled sanitizer, #443 (PR #444)**: the hand-rolled replaceAll chain in `util/html` was correct but invisible to CodeQL, keeping js/reflected-xss alerts #60/#61/#62 open after the real fix landed; escapeHtml now delegates to the escape-html package (identical output, modeled sanitizer), covering xai, codex, snowflake cortex, and mcp/oauth-callback pages.

---

### ⚙️ CI / Engineering

- **dev-lineage SpecGit Acceptance unblocked, #434 (PRs #444/#445)**: dev carried the old local `npm install --no-save` acceptance step that dies on bun catalog protocol deps; syncing main's global-install template through the promotion PR let the first binding-free dev-to-main verdict pass (11m23s), re-opening the standard promotion path.
- **Secret-scanning fixture assembled, not literal, #440 (PR #444)**: the http-recorder redaction test now builds its Google-key-shaped sample from concatenated literals so the repo never contains a plausible live-format key blob.

---

### 🧪 Test Summary

```
CI gates on main at promotion merge (#445):
Typecheck: pass
Unit Tests (linux): pass
E2E Tests (linux): pass
E2E Tests (windows): pass
SpecGit Acceptance: pass
dag-core gate: 52 pass
dag suite: 622 pass / 53 files
lint ratchet: 4811 <= 4850
```

---

### 🔍 Verification

- PR #444 carried its own accepted SpecGit verdict into dev (8/8 checks including two anchor-race reruns of the required jobs), then PR #445 re-ran the full main gate on the merged tree.
- CodeQL alerts #60/#61/#62/#65 are expected to auto-close on the post-promotion default-branch analysis; secret-scanning alert #1 resolves as used_in_tests pending owner confirmation that no such key exists in Google Cloud.
- Remaining open issues are the two deliberate deferrals: #433 engine-level shell guardrail exploration and #435 /dag project-wide discovery fallback.

---

**Full changelog:** [`{previous_tag}`...`{current_tag}`](https://github.com/LeXwDeX/OpenCode-GraphAgent/compare/{previous_tag}...{current_tag})
13 changes: 5 additions & 8 deletions .specgit.yaml
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
version: 1
delivery: open-issues-batch
delivery: upgrade-mcp-client
context:
kind: branch
branch: feat/434-open-issues-batch
branch: refactor/447-upgrade-mcp-client
issues:
- 434
- 442
- 443
- 440
- 436
pr: 444
- 447
- 448
pr: 449
137 changes: 21 additions & 116 deletions bun.lock

Large diffs are not rendered by default.

3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
"semver": "7.7.4",
"typescript": "5.8.2",
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"zod": "4.1.8",
"zod": "^4.2.0",
"remeda": "2.26.0",
"sst": "4.13.1",
"shiki": "4.2.0",
Expand Down Expand Up @@ -153,7 +153,6 @@
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch"
}
}
8 changes: 8 additions & 0 deletions packages/core/src/v1/config/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ export const Local = Schema.Struct({
timeout: Schema.optional(PositiveInt).annotate({
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
}),
protocol: Schema.optional(Schema.Literals(["auto", "legacy", "modern"])).annotate({
description:
"Protocol era negotiation: 'auto' probes server/discover and falls back to the 2025 initialize handshake, 'legacy' skips the probe and runs the 2025 initialize handshake, 'modern' speaks only 2026-07-28 with no fallback. Defaults to 'auto'.",
}),
}).annotate({ identifier: "McpLocalConfig" })
export type Local = Schema.Schema.Type<typeof Local>

Expand Down Expand Up @@ -56,6 +60,10 @@ export const Remote = Schema.Struct({
timeout: Schema.optional(PositiveInt).annotate({
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
}),
protocol: Schema.optional(Schema.Literals(["auto", "legacy", "modern"])).annotate({
description:
"Protocol era negotiation: 'auto' probes server/discover and falls back to the 2025 initialize handshake, 'legacy' skips the probe and runs the 2025 initialize handshake, 'modern' speaks only 2026-07-28 with no fallback. Defaults to 'auto'.",
}),
}).annotate({ identifier: "McpRemoteConfig" })
export type Remote = Schema.Schema.Type<typeof Remote>

Expand Down
4 changes: 3 additions & 1 deletion packages/opencode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"@tsconfig/bun": "catalog:",
"@types/babel__core": "7.20.5",
"@types/bun": "catalog:",
"@modelcontextprotocol/server": "2.0.0",
"@types/cross-spawn": "catalog:",
"@types/mime-types": "3.0.1",
"@types/escape-html": "1.0.3",
Expand Down Expand Up @@ -84,7 +85,8 @@
"@effect/platform-node": "catalog:",
"@ff-labs/fff-bun": "0.9.4",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@modelcontextprotocol/sdk": "1.29.0",
"@modelcontextprotocol/client": "2.0.0",
"@modelcontextprotocol/core": "2.0.0",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
Expand Down
29 changes: 21 additions & 8 deletions packages/opencode/src/cli/cmd/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import { cmd } from "./cmd"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { effectCmd } from "../effect-cmd"
import { Cause } from "effect"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/sdk/types.js"
import {
Client,
StreamableHTTPClientTransport,
UnauthorizedError,
LATEST_PROTOCOL_VERSION,
} from "@modelcontextprotocol/client"
import * as prompts from "@clack/prompts"
import { UI } from "../ui"
import { MCP } from "../../mcp"
Expand Down Expand Up @@ -787,10 +789,21 @@ export const McpDebugCommand = effectCmd({
})

try {
const client = new Client({
name: "opencode-debug",
version: InstallationVersion,
})
const client = new Client(
{ name: "opencode-debug", version: InstallationVersion },
{
// Mirror createClient's per-server era mapping so diagnostics
// match the runtime connection behavior (#448).
versionNegotiation: {
mode:
serverConfig.protocol === "legacy"
? "legacy"
: serverConfig.protocol === "modern"
? { pin: "2026-07-28" }
: "auto",
},
},
)
await client.connect(transport)
prompts.log.success("Connection successful (already authenticated)")
await client.close()
Expand Down
11 changes: 3 additions & 8 deletions packages/opencode/src/mcp/catalog.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import {
CallToolResultSchema,
ListToolsResultSchema,
ToolSchema,
type Tool as MCPToolDef,
} from "@modelcontextprotocol/sdk/types.js"
import { ListToolsResultSchema, ToolSchema } from "@modelcontextprotocol/core"
import { Client } from "@modelcontextprotocol/client"
import type { Tool as MCPToolDef } from "@modelcontextprotocol/client"
import { dynamicTool, jsonSchema, type JSONSchema7, type Tool } from "ai"
import { Effect } from "effect"

Expand Down Expand Up @@ -56,7 +52,6 @@ export function convertTool(mcpTool: MCPToolDef, client: Client, timeout?: numbe
name: mcpTool.name,
arguments: (args || {}) as Record<string, unknown>,
},
CallToolResultSchema,
{
resetTimeoutOnProgress: true,
signal: options.abortSignal,
Expand Down
77 changes: 38 additions & 39 deletions packages/opencode/src/mcp/elicitation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,7 @@
* ─▶ respond to server
*/
import { Effect, Option, Schema } from "effect"
import type { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"
import type { Client } from "@modelcontextprotocol/client"
import { Question } from "@/question"
import { SettingsHook, type TriggerResult } from "@/hook/settings"
import { Notification } from "@/notification"
Expand Down Expand Up @@ -85,10 +84,7 @@ interface FieldSpec {
* reason. MCP form-mode allows flat objects whose properties are primitive
* string/number/integer/boolean, optionally with an enum.
*/
export function classifyProperty(
name: string,
prop: unknown,
): { field: FieldSpec } | { reject: string } {
export function classifyProperty(name: string, prop: unknown): { field: FieldSpec } | { reject: string } {
if (typeof prop !== "object" || prop === null || Array.isArray(prop))
return { reject: `property "${name}" must be an object` }
const p = prop as Record<string, unknown>
Expand All @@ -97,19 +93,34 @@ export function classifyProperty(
if (Array.isArray(p.enum)) {
const enumValues = p.enum.filter((v): v is string => typeof v === "string")
if (enumValues.length !== p.enum.length) return { reject: `property "${name}" enum must be all strings` }
return { field: { name, description: typeof p.description === "string" ? p.description : undefined, kind: "enum", enumValues } }
return {
field: {
name,
description: typeof p.description === "string" ? p.description : undefined,
kind: "enum",
enumValues,
},
}
}
if (type === "boolean") {
return { field: { name, description: typeof p.description === "string" ? p.description : undefined, kind: "boolean" } }
return {
field: { name, description: typeof p.description === "string" ? p.description : undefined, kind: "boolean" },
}
}
if (type === "string") {
return { field: { name, description: typeof p.description === "string" ? p.description : undefined, kind: "string" } }
return {
field: { name, description: typeof p.description === "string" ? p.description : undefined, kind: "string" },
}
}
if (type === "number") {
return { field: { name, description: typeof p.description === "string" ? p.description : undefined, kind: "number" } }
return {
field: { name, description: typeof p.description === "string" ? p.description : undefined, kind: "number" },
}
}
if (type === "integer") {
return { field: { name, description: typeof p.description === "string" ? p.description : undefined, kind: "integer" } }
return {
field: { name, description: typeof p.description === "string" ? p.description : undefined, kind: "integer" },
}
}
return { reject: `property "${name}" type "${String(type)}" not supported (flat primitives only)` }
}
Expand Down Expand Up @@ -276,13 +287,11 @@ export const handleElicitation = Effect.fn("MCP.elicitation.handle")(function* (
// decline without surfacing the Question.
if (settingsHook) {
const hookResult = yield* settingsHook
.trigger(
{ event: "Elicitation", prompt: input.message, schema: input.requestedSchema } as never,
{ sessionID, transcriptPath: "" },
)
.pipe(
Effect.catch(() => Effect.succeed<TriggerResult>({ additionalContexts: [], systemMessages: [] })),
)
.trigger({ event: "Elicitation", prompt: input.message, schema: input.requestedSchema } as never, {
sessionID,
transcriptPath: "",
})
.pipe(Effect.catch(() => Effect.succeed<TriggerResult>({ additionalContexts: [], systemMessages: [] })))
yield* SettingsHook.landSystemMessages(hookResult as TriggerResult, { sessionID })
if ((hookResult as TriggerResult).blocked) {
yield* Effect.logWarning("elicitation declined: hook blocked")
Expand All @@ -301,14 +310,12 @@ export const handleElicitation = Effect.fn("MCP.elicitation.handle")(function* (
// rejection (user dismissed) both resolve to decline; only a validated reply
// resolves to accept.
const fields = fieldSpecsFromSchema(input.requestedSchema)
const validated = yield* question
.ask({ sessionID: SessionID.make(sessionID), questions: mapped.questions })
.pipe(
// timeoutOption returns None on timeout; timeoutOrElse would also work.
Effect.timeoutOption(ELICITATION_TIMEOUT_MS),
Effect.map((opt) => (Option.isNone(opt) ? undefined : validateAndCoerce(fields, opt.value))),
Effect.catch(() => Effect.succeed<undefined>(undefined)), // user reject (RejectedError) → decline
)
const validated = yield* question.ask({ sessionID: SessionID.make(sessionID), questions: mapped.questions }).pipe(
// timeoutOption returns None on timeout; timeoutOrElse would also work.
Effect.timeoutOption(ELICITATION_TIMEOUT_MS),
Effect.map((opt) => (Option.isNone(opt) ? undefined : validateAndCoerce(fields, opt.value))),
Effect.catch(() => Effect.succeed<undefined>(undefined)), // user reject (RejectedError) → decline
)

// ElicitationResult hook fires on resolution (result on accept, cancelled otherwise).
if (settingsHook) {
Expand All @@ -330,17 +337,11 @@ export const handleElicitation = Effect.fn("MCP.elicitation.handle")(function* (
* Register the elicitation handler on a connected MCP client. The handler is a
* plain async function (Promise-returning) that bridges into the Effect world.
*/
export function registerElicitationHandler(
client: Client,
bridge: import("@/effect/bridge").EffectBridge.Shape,
) {
// Dynamic import keeps the protocol schema lazy — the MCP SDK is only pulled
// in when elicitation is actually wired, not at module-eval time of callers.
export function registerElicitationHandler(client: Client, bridge: import("@/effect/bridge").EffectBridge.Shape) {
// The handler receives the full JSON-RPC request `{ method, params: {...} }`;
// the elicitation fields live under `params`.
const handler = async (request: {
params?: { message?: string; requestedSchema?: unknown; mode?: string }
}) => {
// the elicitation fields live under `params`. On 2026-era connections the
// client drives input_required retries through this same handler.
const handler = async (request: { params?: { message?: string; requestedSchema?: unknown; mode?: string } }) => {
const params = request.params ?? {}
const sessionID = SessionContext.sessionID ?? activeSession.at(-1)?.id
const response = await bridge.promise(
Expand All @@ -353,7 +354,5 @@ export function registerElicitationHandler(
)
return response
}
// The schema is imported eagerly at module load so registration is synchronous
// (a lazy dynamic import would race with the first incoming request).
client.setRequestHandler(ElicitRequestSchema, handler as never)
client.setRequestHandler("elicitation/create", handler as never)
}
Loading
Loading