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
12 changes: 4 additions & 8 deletions .specgit.yaml
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
version: 1
delivery: open-issues-batch
delivery: expose-negotiated-protocol
context:
kind: branch
branch: feat/434-open-issues-batch
branch: feat/450-expose-negotiated-protocol
issues:
- 434
- 442
- 443
- 440
- 436
pr: 444
- 450
pr: 451
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
31 changes: 22 additions & 9 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 @@ -137,7 +139,7 @@ export const McpListCommand = effectCmd({
statusText = "not initialized"
} else if (status.status === "connected") {
statusIcon = "✓"
statusText = "connected"
statusText = `connected${status.era ? ` · ${status.era}` : ""}${status.protocolVersion ? ` (${status.protocolVersion})` : ""}`
if (hasOAuth && hasStoredTokens) {
hint = " (OAuth)"
}
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 @@ -18,9 +18,8 @@
* ─▶ fire ElicitationResult hook (result | cancelled)
* ─▶ respond to server
*/
import { Effect, Option, Schema } from "effect"

Check warning on line 21 in packages/opencode/src/mcp/elicitation.ts

View workflow job for this annotation

GitHub Actions / Typecheck

eslint(no-unused-vars)

Identifier 'Schema' is imported but never used.
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 @@
* 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 @@
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 @@
// 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 @@
// 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 @@
* 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 @@
)
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