From 7df9a16ffdd9008ac5616757170746ebef2291eb Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 7 Jul 2026 13:58:52 -0500 Subject: [PATCH] feat(mcp): attach resources from TUI --- bun.lock | 1 + packages/core/src/session.ts | 193 ++++++++++++++---- packages/core/test/session-prompt.test.ts | 181 +++++++++++++++- packages/schema/src/mcp.ts | 27 +++ packages/schema/test/mcp.test.ts | 10 + packages/sdk/js/src/v2/gen/sdk.gen.ts | 72 +++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 150 ++++++++++++++ packages/tui/package.json | 1 + .../tui/src/component/prompt/autocomplete.tsx | 13 +- packages/tui/src/context/data.tsx | 45 ++++ packages/tui/test/cli/tui/data.test.tsx | 68 ++++++ packages/tui/test/fixture/tui-sdk.ts | 5 + 12 files changed, 722 insertions(+), 44 deletions(-) diff --git a/bun.lock b/bun.lock index 491dbd1679..118d0a9dc3 100644 --- a/bun.lock +++ b/bun.lock @@ -998,6 +998,7 @@ "@opencode-ai/client": "workspace:*", "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", + "@opencode-ai/schema": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/simulation": "workspace:*", "@opencode-ai/ui": "workspace:*", diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index ea18ed3909..d6668f5026 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -11,6 +11,7 @@ import { Location } from "./location" import { SessionMessage } from "./session/message" import { Base64, FileAttachment, Prompt } from "@opencode-ai/schema/prompt" import { PromptInput } from "@opencode-ai/schema/prompt-input" +import { Mcp } from "@opencode-ai/schema/mcp" import { EventV2 } from "./event" import { Database } from "./database/database" import { SessionProjector } from "./session/projector" @@ -45,6 +46,7 @@ import { Shell } from "./shell" import { Shell as ShellSchema } from "@opencode-ai/schema/shell" import { KeyedMutex } from "./effect/keyed-mutex" import { fileURLToPath } from "url" +import { MCP } from "./mcp/index" export const RevertState = Revert.State export type RevertState = Revert.State @@ -273,6 +275,7 @@ const layer = Layer.effect( const scope = yield* Scope.Scope const activeShells = new Set() const shellLocks = KeyedMutex.makeUnsafe() + const promptLocks = KeyedMutex.makeUnsafe() const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message) const isDurableSessionEvent = Schema.is(SessionEvent.Durable) const decode = (row: typeof SessionMessageTable.$inferSelect) => @@ -475,40 +478,48 @@ const layer = Layer.effect( EventV2.isSynced(item) || isDurableSessionEvent(item), ), ), - prompt: Effect.fn("V2Session.prompt")((input) => - Effect.uninterruptible( - Effect.gen(function* () { - const session = yield* result.get(input.sessionID) - // A staged revert must be committed before admitting new input so the prompt - // continues from the reverted boundary rather than stale post-boundary history. - if (session.revert) - yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events)) - const prompt = yield* resolvePrompt(input.prompt).pipe(Effect.provideService(FSUtil.Service, fs)) - const messageID = input.id ?? SessionMessage.ID.create() - const delivery = input.delivery ?? "steer" - const expected = { sessionID: input.sessionID, messageID, prompt, delivery } - const admitted = yield* SessionInput.admit(db, events, { - id: messageID, - sessionID: input.sessionID, - prompt, - delivery, - }).pipe( - Effect.catchDefect((defect) => - defect instanceof SessionInput.LifecycleConflict - ? new PromptConflictError({ sessionID: input.sessionID, messageID }) - : Effect.die(defect), - ), - ) - if (!SessionInput.equivalent(admitted, expected)) - return yield* new PromptConflictError({ sessionID: input.sessionID, messageID }) - if (input.resume !== false) { - if (activeShells.has(admitted.sessionID)) return admitted - yield* execution.wake(admitted.sessionID) - } - return admitted - }), - ), - ), + prompt: Effect.fn("V2Session.prompt")((input) => { + const admit = Effect.gen(function* () { + const session = yield* result.get(input.sessionID) + // A staged revert must be committed before admitting new input so the prompt + // continues from the reverted boundary rather than stale post-boundary history. + if (session.revert) + yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events)) + const messageID = input.id ?? SessionMessage.ID.create() + const delivery = input.delivery ?? "steer" + const recorded = input.id === undefined ? undefined : yield* SessionInput.find(db, input.id) + const readMcpResource = (resource: Mcp.ResourceReference) => + Effect.gen(function* () { + const mcp = yield* MCP.Service + return yield* mcp.readResource(resource) + }).pipe(Effect.provide(locations.get(session.location))) + const prompt = + recorded?.type === "prompt" && matchesResolvedMcpPrompt(recorded.prompt, input.prompt) + ? recorded.prompt + : yield* resolvePrompt(input.prompt, fs, readMcpResource) + const expected = { sessionID: input.sessionID, messageID, prompt, delivery } + const admitted = yield* SessionInput.admit(db, events, { + id: messageID, + sessionID: input.sessionID, + prompt, + delivery, + }).pipe( + Effect.catchDefect((defect) => + defect instanceof SessionInput.LifecycleConflict + ? new PromptConflictError({ sessionID: input.sessionID, messageID }) + : Effect.die(defect), + ), + ) + if (!SessionInput.equivalent(admitted, expected)) + return yield* new PromptConflictError({ sessionID: input.sessionID, messageID }) + if (input.resume !== false) { + if (activeShells.has(admitted.sessionID)) return admitted + yield* execution.wake(admitted.sessionID) + } + return admitted + }) + return Effect.uninterruptible(input.id === undefined ? admit : promptLocks.withLock(input.id)(admit)) + }), command: Effect.fn("V2Session.command")(function* (input) { const session = yield* result.get(input.sessionID) const commands = yield* CommandV2.Service.pipe(Effect.provide(locations.get(session.location))) @@ -742,20 +753,75 @@ function synthesizeTerminalShellInfo(started: ShellSchema.Info): ShellSchema.Inf } } -const resolvePrompt = Effect.fn("V2Session.resolvePrompt")(function* (input: PromptInput.Prompt) { - const fs = yield* FSUtil.Service +const resolvePrompt = Effect.fn("V2Session.resolvePrompt")(function* ( + input: PromptInput.Prompt, + fs: FSUtil.Interface, + readMcpResource: ( + input: Mcp.ResourceReference, + ) => Effect.Effect, +) { const files = input.files - ? yield* Effect.forEach(input.files, (file) => materializeAttachment(fs, file), { concurrency: 8 }) + ? (yield* Effect.forEach(input.files, (file) => materializeAttachment(fs, readMcpResource, file), { + concurrency: 8, + })).flat() : undefined return Prompt.make({ text: input.text, agents: input.agents, files }) }) const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 +const MAX_MCP_ATTACHMENT_PARTS = 100 const materializeAttachment = Effect.fn("V2Session.materializeAttachment")(function* ( fs: FSUtil.Interface, + readMcpResource: ( + input: Mcp.ResourceReference, + ) => Effect.Effect, input: PromptInput.FileAttachment, ) { + const reference = Mcp.parseResourceUri(input.uri) + if (reference) { + const resource = yield* readMcpResource(reference).pipe( + Effect.mapError( + (error) => new AttachmentError({ uri: input.uri, message: `Unable to read MCP resource: ${error.message}` }), + ), + ) + if (!resource || resource.contents.length === 0) + return yield* new AttachmentError({ uri: input.uri, message: `Unable to read MCP resource: ${reference.uri}` }) + if ( + resource.contents.length > MAX_MCP_ATTACHMENT_PARTS || + resource.contents.reduce( + (total, part) => total + (part.type === "text" ? Buffer.byteLength(part.text) : base64ByteLength(part.blob)), + 0, + ) > MAX_ATTACHMENT_BYTES + ) + return yield* new AttachmentError({ + uri: input.uri, + message: `MCP resource exceeds attachment limits: ${reference.uri}`, + }) + return yield* Effect.forEach(resource.contents, (part, index) => + Effect.gen(function* () { + const bytes = part.type === "text" ? Buffer.from(part.text) : yield* decodeMcpBlob(input.uri, part.blob) + return yield* createAttachment( + index === 0 + ? input + : { + ...input, + name: `${input.name ?? part.uri}-${index + 1}`, + mention: undefined, + }, + { + bytes, + source: { type: "uri", uri: input.uri }, + start: undefined, + end: undefined, + name: undefined, + mime: part.type === "text" ? "text/plain" : undefined, + }, + ) + }), + ) + } + const resolved = input.uri.startsWith("data:") ? { bytes: yield* decodeDataURL(input.uri), @@ -766,6 +832,20 @@ const materializeAttachment = Effect.fn("V2Session.materializeAttachment")(funct mime: undefined, } : yield* readFileAttachment(fs, input.uri) + return [yield* createAttachment(input, resolved)] +}) + +const createAttachment = Effect.fnUntraced(function* ( + input: PromptInput.FileAttachment, + resolved: { + readonly bytes: Uint8Array + readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string } + readonly start: number | undefined + readonly end: number | undefined + readonly name: string | undefined + readonly mime: string | undefined + }, +) { if (resolved.bytes.byteLength > MAX_ATTACHMENT_BYTES) return yield* new AttachmentError({ uri: input.uri, @@ -793,6 +873,45 @@ const materializeAttachment = Effect.fn("V2Session.materializeAttachment")(funct }) }) +function decodeMcpBlob(uri: string, blob: string) { + return Effect.try({ + try: () => { + const bytes = Buffer.from(blob, "base64") + if (bytes.toString("base64") !== blob) throw new Error("Non-canonical base64") + return bytes + }, + catch: () => new AttachmentError({ uri, message: `MCP resource returned invalid base64 content: ${uri}` }), + }) +} + +function base64ByteLength(value: string) { + const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0 + return Math.floor(value.length * 0.75) - padding +} + +function matchesResolvedMcpPrompt(resolved: Prompt, input: PromptInput.Prompt) { + if (resolved.text !== input.text || JSON.stringify(resolved.agents ?? []) !== JSON.stringify(input.agents ?? [])) + return false + const files = input.files ?? [] + if (files.length === 0 || files.some((file) => Mcp.parseResourceUri(file.uri) === undefined)) return false + const uris = files.map((file) => file.uri) + if (new Set(uris).size !== uris.length) return false + const resolvedFiles = resolved.files ?? [] + const sources = resolvedFiles.flatMap((file) => (file.source.type === "uri" ? [file.source.uri] : [])) + if (sources.length !== resolvedFiles.length) return false + if (JSON.stringify(sources.filter((uri, index) => index === 0 || uri !== sources[index - 1])) !== JSON.stringify(uris)) + return false + return files.every((file) => { + const first = resolvedFiles.find((resolved) => resolved.source.type === "uri" && resolved.source.uri === file.uri) + if (!first) return false + return ( + first.name === file.name && + first.description === file.description && + JSON.stringify(first.mention) === JSON.stringify(file.mention) + ) + }) +} + const readFileAttachment = Effect.fn("V2Session.readFileAttachment")(function* (fs: FSUtil.Interface, uri: string) { const url = yield* Effect.try({ try: () => new URL(uri), diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 3e76ddbb42..f252f06b3d 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -24,6 +24,8 @@ import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionInput } from "@opencode-ai/core/session/input" import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" +import { MCP } from "@opencode-ai/core/mcp/index" +import { Mcp } from "@opencode-ai/schema/mcp" import { testEffect } from "./lib/effect" const executionCalls: SessionV2.ID[] = [] @@ -49,20 +51,62 @@ const execution = Layer.succeed( awaitIdle: () => Effect.void, }), ) +let mcpResourcesAvailable = true +let mcpResourceReads = 0 +const mcp = Layer.succeed( + MCP.Service, + MCP.Service.of({ + servers: () => Effect.succeed([]), + tools: () => Effect.succeed([]), + callTool: () => Effect.die("unused mcp.callTool"), + instructions: () => Effect.succeed([]), + prompts: () => Effect.succeed([]), + prompt: () => Effect.succeed(undefined), + resourceCatalog: () => Effect.succeed(MCP.ResourceCatalog.make({ resources: [], templates: [] })), + readResource: (input) => + Effect.sync(() => { + mcpResourceReads++ + if (!mcpResourcesAvailable || input.server !== "docs") return undefined + if (input.uri === "docs://many") + return MCP.ResourceContent.make({ + server: "docs", + uri: input.uri, + contents: Array.from({ length: 101 }, (_, index) => ({ + type: "text" as const, + uri: `docs://many/${index}`, + text: "", + })), + }) + if (input.uri !== "docs://readme") return undefined + return MCP.ResourceContent.make({ + server: "docs", + uri: input.uri, + contents: [ + { type: "text", uri: input.uri, text: '{"title":"Readme"}', mimeType: "application/json" }, + { type: "blob", uri: "docs://logo", blob: "iVBORw0KGgo=", mimeType: "application/octet-stream" }, + ], + }) + }), + }), +) const it = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]), - [[SessionExecution.node, execution]], + [ + [SessionExecution.node, execution], + [MCP.node, mcp], + ], ), ) const sessionID = SessionV2.ID.make("ses_prompt_test") const messageID = SessionMessage.ID.create() +const directory = AbsolutePath.make(process.cwd()) const setup = Effect.gen(function* () { const { db } = yield* Database.Service yield* db .insert(ProjectTable) - .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .values({ id: Project.ID.global, worktree: directory, sandboxes: [] }) .onConflictDoNothing() .run() .pipe(Effect.orDie) @@ -72,7 +116,7 @@ const setup = Effect.gen(function* () { id: sessionID, project_id: Project.ID.global, slug: "test", - directory: "/project", + directory, title: "test", version: "test", }) @@ -343,6 +387,135 @@ describe("SessionV2.prompt", () => { }), ) + it.effect("materializes MCP resource content before admission", () => + Effect.gen(function* () { + yield* setup + mcpResourcesAvailable = true + const session = yield* SessionV2.Service + const uri = Mcp.resourceUri({ server: "docs", uri: "docs://readme" }) + + const message = yield* session.prompt({ + sessionID, + prompt: { + text: "Inspect @Readme", + files: [ + { + uri, + name: "Readme", + description: "Project documentation", + mention: { start: 8, end: 15, text: "@Readme" }, + }, + ], + }, + resume: false, + }) + + expect(message.prompt.files).toEqual([ + { + data: Buffer.from('{"title":"Readme"}').toString("base64"), + mime: "text/plain", + source: { type: "uri", uri }, + name: "Readme", + description: "Project documentation", + mention: { start: 8, end: 15, text: "@Readme" }, + }, + { + data: "iVBORw0KGgo=", + mime: "image/png", + source: { type: "uri", uri }, + name: "Readme-2", + description: "Project documentation", + }, + ]) + const stored = yield* admitted(message.id) + expect(stored?.type === "prompt" ? stored.prompt.files : undefined).toEqual(message.prompt.files) + }), + ) + + it.effect("rejects unavailable MCP resource attachments", () => + Effect.gen(function* () { + yield* setup + mcpResourcesAvailable = true + const session = yield* SessionV2.Service + const uri = Mcp.resourceUri({ server: "docs", uri: "docs://missing" }) + + const error = yield* session + .prompt({ sessionID, prompt: { text: "Inspect this", files: [{ uri }] }, resume: false }) + .pipe(Effect.flip) + + expect(error).toMatchObject({ + _tag: "Session.AttachmentError", + uri, + message: "Unable to read MCP resource: docs://missing", + }) + }), + ) + + it.effect("reuses durable MCP content for exact prompt retries", () => + Effect.gen(function* () { + yield* setup + mcpResourcesAvailable = true + mcpResourceReads = 0 + const session = yield* SessionV2.Service + const uri = Mcp.resourceUri({ server: "docs", uri: "docs://readme" }) + const input = { + id: messageID, + sessionID, + prompt: PromptInput.Prompt.make({ text: "Inspect this", files: [{ uri }] }), + resume: false as const, + } + + const first = yield* session.prompt(input) + mcpResourcesAvailable = false + const retried = yield* session.prompt(input) + + expect(retried).toEqual(first) + expect(mcpResourceReads).toBe(1) + }), + ) + + it.effect("coalesces concurrent MCP prompt retries before reading content", () => + Effect.gen(function* () { + yield* setup + mcpResourcesAvailable = true + mcpResourceReads = 0 + const session = yield* SessionV2.Service + const input = { + id: messageID, + sessionID, + prompt: PromptInput.Prompt.make({ + text: "Inspect this", + files: [{ uri: Mcp.resourceUri({ server: "docs", uri: "docs://readme" }), name: "Readme" }], + }), + resume: false as const, + } + + const messages = yield* Effect.all([session.prompt(input), session.prompt(input)], { concurrency: "unbounded" }) + + expect(messages[1]).toEqual(messages[0]) + expect(mcpResourceReads).toBe(1) + }), + ) + + it.effect("rejects MCP resources with too many content parts", () => + Effect.gen(function* () { + yield* setup + mcpResourcesAvailable = true + const session = yield* SessionV2.Service + const uri = Mcp.resourceUri({ server: "docs", uri: "docs://many" }) + + const error = yield* session + .prompt({ sessionID, prompt: { text: "Inspect this", files: [{ uri }] }, resume: false }) + .pipe(Effect.flip) + + expect(error).toMatchObject({ + _tag: "Session.AttachmentError", + uri, + message: "MCP resource exceeds attachment limits: docs://many", + }) + }), + ) + it.effect("sniffs data URL content instead of trusting its declared MIME", () => Effect.gen(function* () { yield* setup @@ -648,7 +821,7 @@ describe("SessionV2.prompt", () => { id: other, project_id: Project.ID.global, slug: "other", - directory: "/project", + directory, title: "other", version: "test", }) diff --git a/packages/schema/src/mcp.ts b/packages/schema/src/mcp.ts index a86ffe6d67..51f4bd964f 100644 --- a/packages/schema/src/mcp.ts +++ b/packages/schema/src/mcp.ts @@ -38,6 +38,33 @@ export const Server = Schema.Struct({ integrationID: optional(IntegrationID), }).annotate({ identifier: "Mcp.Server" }) +export interface ResourceReference extends Schema.Schema.Type {} +export const ResourceReference = Schema.Struct({ + server: Schema.String, + uri: Schema.String, +}).annotate({ identifier: "Mcp.ResourceReference" }) + +export function resourceUri(input: ResourceReference) { + const url = new URL("mcp://resource") + url.searchParams.set("server", input.server) + url.searchParams.set("uri", input.uri) + return url.href +} + +export function parseResourceUri(input: string) { + try { + const url = new URL(input) + if (url.protocol !== "mcp:" || url.hostname !== "resource" || url.pathname || url.hash) return + const server = url.searchParams.get("server") + const uri = url.searchParams.get("uri") + if (!server || !uri) return + const reference = ResourceReference.make({ server, uri }) + return resourceUri(reference) === input ? reference : undefined + } catch { + return + } +} + export interface Resource extends Schema.Schema.Type {} export const Resource = Schema.Struct({ server: Schema.String, diff --git a/packages/schema/test/mcp.test.ts b/packages/schema/test/mcp.test.ts index 75daa7fe1c..a417d6bafc 100644 --- a/packages/schema/test/mcp.test.ts +++ b/packages/schema/test/mcp.test.ts @@ -3,6 +3,16 @@ import { Schema } from "effect" import { Mcp } from "../src/mcp.js" describe("Mcp resources", () => { + test("round trips canonical resource attachment URIs", () => { + const reference = { server: "Docs & Search", uri: "docs://guide/chapter?q=one two" } + const uri = Mcp.resourceUri(reference) + + expect(uri).toBe("mcp://resource?server=Docs+%26+Search&uri=docs%3A%2F%2Fguide%2Fchapter%3Fq%3Done+two") + expect(Mcp.parseResourceUri(uri)).toEqual(reference) + expect(Mcp.parseResourceUri(`${uri}&extra=true`)).toBeUndefined() + expect(Mcp.parseResourceUri("docs://guide")).toBeUndefined() + }) + test("decodes resource catalogs and omits absent metadata", () => { const value = Schema.decodeUnknownSync(Mcp.ResourceCatalog)({ resources: [{ server: "docs", name: "Readme", uri: "docs://readme" }], diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 458f9c2d2e..5efe1bac45 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -277,6 +277,8 @@ import type { V2CredentialUpdateErrors, V2CredentialUpdateResponses, V2DebugLocationErrors, + V2DebugLocationEvictErrors, + V2DebugLocationEvictResponses, V2DebugLocationResponses, V2EventSubscribeErrors, V2EventSubscribeResponses, @@ -310,6 +312,8 @@ import type { V2LocationGetResponses, V2McpListErrors, V2McpListResponses, + V2McpResourceCatalogErrors, + V2McpResourceCatalogResponses, V2ModelDefaultErrors, V2ModelDefaultResponses, V2ModelListErrors, @@ -6239,6 +6243,7 @@ export class Session3 extends HeyApiClient { metadata?: { [key: string]: unknown } + resume?: boolean | null }, options?: Options, ) { @@ -6251,6 +6256,7 @@ export class Session3 extends HeyApiClient { { in: "body", key: "text" }, { in: "body", key: "description" }, { in: "body", key: "metadata" }, + { in: "body", key: "resume" }, ], }, ], @@ -6969,6 +6975,34 @@ export class Integration extends HeyApiClient { } } +export class Resource2 extends HeyApiClient { + /** + * List MCP resources + * + * Retrieve resources and resource templates from connected MCP servers. + */ + public catalog( + parameters?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get< + V2McpResourceCatalogResponses, + V2McpResourceCatalogErrors, + ThrowOnError + >({ + url: "/api/mcp/resource", + ...options, + ...params, + }) + } +} + export class Mcp2 extends HeyApiClient { /** * List MCP servers @@ -6991,6 +7025,11 @@ export class Mcp2 extends HeyApiClient { ...params, }) } + + private _resource?: Resource2 + get resource(): Resource2 { + return (this._resource ??= new Resource2({ client: this.client })) + } } export class Credential extends HeyApiClient { @@ -8117,6 +8156,34 @@ export class Vcs2 extends HeyApiClient { } } +export class Location2 extends HeyApiClient { + /** + * Evict a loaded location + * + * Dispose the requested location's cached services so its next use boots them fresh. + */ + public evict( + parameters?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).delete< + V2DebugLocationEvictResponses, + V2DebugLocationEvictErrors, + ThrowOnError + >({ + url: "/api/debug/location", + ...options, + ...params, + }) + } +} + export class Debug extends HeyApiClient { /** * List loaded locations @@ -8129,6 +8196,11 @@ export class Debug extends HeyApiClient { ...options, }) } + + private _location?: Location2 + get location2(): Location2 { + return (this._location ??= new Location2({ client: this.client })) + } } export class V2 extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 87aa5ddbe1..36e6e41328 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -95,6 +95,7 @@ export type Event = | EventTuiToastShow2 | EventTuiSessionSelect2 | EventMcpToolsChanged + | EventMcpResourcesChanged | EventMcpStatusChanged | EventCommandExecuted | EventFileEdited @@ -1603,6 +1604,13 @@ export type GlobalEvent = { server: string } } + | { + id: string + type: "mcp.resources.changed" + properties: { + server: string + } + } | { id: string type: "mcp.status.changed" @@ -2991,6 +2999,14 @@ export type ProviderNotFoundError = { message: string } +export type McpResource2 = { + server: string + name: string + uri: string + description?: string + mimeType?: string +} + export type FormNotFoundError = { _tag: "FormNotFoundError" id: string @@ -3153,6 +3169,7 @@ export type V2Event = | TuiToastShow | TuiSessionSelect | McpToolsChanged + | McpResourcesChanged | McpStatusChanged | CommandExecuted | FileEdited @@ -5702,6 +5719,19 @@ export type McpServer = { integrationID?: string } +export type McpResourceTemplate = { + server: string + name: string + uriTemplate: string + description?: string + mimeType?: string +} + +export type McpResourceCatalog = { + resources: Array + templates: Array +} + export type ProjectCurrent = { id: string directory: string @@ -6608,6 +6638,19 @@ export type McpToolsChanged = { } } +export type McpResourcesChanged = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "mcp.resources.changed" + location?: LocationRef + data: { + server: string + } +} + export type McpStatusChanged = { id: string created: number @@ -7760,6 +7803,14 @@ export type EventMcpToolsChanged = { } } +export type EventMcpResourcesChanged = { + id: string + type: "mcp.resources.changed" + properties: { + server: string + } +} + export type EventMcpStatusChanged = { id: string type: "mcp.status.changed" @@ -8597,6 +8648,7 @@ export type V2EventV2 = | InstallationUpdateAvailableV2 | VcsBranchUpdatedV2 | McpStatusChangedV2 + | McpResourcesChangedV2 | PermissionAskedV2 | PermissionRepliedV2 | QuestionAskedV2 @@ -9686,6 +9738,19 @@ export type EventLogSyncedV2 = { seq?: number } +export type McpResourceV2 = { + server: string + name: string + uri: string + description?: string + mimeType?: string +} + +export type McpResourceCatalogV2 = { + resources: Array + templates: Array +} + export type ProjectTimeV2 = { created: number updated: number @@ -10615,6 +10680,19 @@ export type McpStatusChangedV2 = { } } +export type McpResourcesChangedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "mcp.resources.changed" + location?: LocationRefV2 + data: { + server: string + } +} + export type PermissionAskedV2 = { id: string created: number @@ -15518,6 +15596,7 @@ export type V2SessionSyntheticData = { metadata?: { [key: string]: unknown } + resume?: boolean | null } path: { sessionID: string @@ -16685,6 +16764,43 @@ export type V2McpListResponses = { export type V2McpListResponse = V2McpListResponses[keyof V2McpListResponses] +export type V2McpResourceCatalogData = { + body?: never + path?: never + query?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + } + url: "/api/mcp/resource" +} + +export type V2McpResourceCatalogErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestErrorV2 + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2McpResourceCatalogError = V2McpResourceCatalogErrors[keyof V2McpResourceCatalogErrors] + +export type V2McpResourceCatalogResponses = { + /** + * Success + */ + 200: { + location: LocationInfoV2 + data: McpResourceCatalogV2 + } +} + +export type V2McpResourceCatalogResponse = V2McpResourceCatalogResponses[keyof V2McpResourceCatalogResponses] + export type V2CredentialRemoveData = { body?: never path: { @@ -18549,6 +18665,40 @@ export type V2VcsDiffResponses = { export type V2VcsDiffResponse = V2VcsDiffResponses[keyof V2VcsDiffResponses] +export type V2DebugLocationEvictData = { + body?: never + path?: never + query?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + } + url: "/api/debug/location" +} + +export type V2DebugLocationEvictErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestErrorV2 + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2DebugLocationEvictError = V2DebugLocationEvictErrors[keyof V2DebugLocationEvictErrors] + +export type V2DebugLocationEvictResponses = { + /** + * + */ + 204: void +} + +export type V2DebugLocationEvictResponse = V2DebugLocationEvictResponses[keyof V2DebugLocationEvictResponses] + export type V2DebugLocationData = { body?: never path?: never diff --git a/packages/tui/package.json b/packages/tui/package.json index 4264ac40e7..de765624fd 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -54,6 +54,7 @@ "@opencode-ai/client": "workspace:*", "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", + "@opencode-ai/schema": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/simulation": "workspace:*", "@opencode-ai/ui": "workspace:*", diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 65bff40838..ef94b3d4de 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -3,7 +3,7 @@ import { pathToFileURL } from "bun" import fuzzysort from "fuzzysort" import path from "path" import { firstBy } from "remeda" -import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js" +import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal, on } from "solid-js" import { createStore } from "solid-js/store" import { useEditorContext } from "../../context/editor" import { useProject } from "../../context/project" @@ -23,6 +23,7 @@ import { useFrecency } from "../../prompt/frecency" import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap" import { displayCharAt, mentionTriggerIndex } from "../../prompt/display" import type { FileSystemEntry } from "@opencode-ai/sdk/v2" +import { Mcp } from "@opencode-ai/schema/mcp" function removeLineRange(input: string) { const hashIndex = input.lastIndexOf("#") @@ -104,6 +105,12 @@ export function Autocomplete(props: { input: "keyboard" as "keyboard" | "mouse", }) + createEffect( + on(location, (location) => { + void data.location.mcp.resource.refresh(location).catch(() => undefined) + }), + ) + const [positionTick, setPositionTick] = createSignal(0) createEffect(() => { @@ -363,7 +370,7 @@ export function Autocomplete(props: { const options: AutocompleteOption[] = [] const width = props.anchor().width - 4 - for (const res of Object.values(sync.data.mcp_resource)) { + for (const res of data.location.mcp.resource.catalog(location())?.resources ?? []) { options.push({ display: Locale.truncateMiddle(res.name, width), // Match the name only; matching the URI caused unrelated fuzzy hits. @@ -373,7 +380,7 @@ export function Autocomplete(props: { insertPart(res.name, { type: "file", value: { - uri: res.uri, + uri: Mcp.resourceUri({ server: res.server, uri: res.uri }), name: res.name, description: res.description, mention: { start: 0, end: 0, text: "" }, diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 723f80633d..8c8f68a605 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -21,6 +21,7 @@ import type { SkillV2Info, V2Event, } from "@opencode-ai/sdk/v2" +import type { ServerMcpCatalogOutput } from "@opencode-ai/client/promise" import { createStore, produce, reconcile } from "solid-js/store" import { createSimpleContext } from "./helper" import { useSDK } from "./sdk" @@ -37,6 +38,7 @@ type LocationData = { command?: CommandV2Info[] integration?: IntegrationInfo[] mcp?: McpServer[] + mcpResource?: ServerMcpCatalogOutput["data"] model?: ModelV2Info[] provider?: ProviderV2Info[] reference?: ReferenceInfo[] @@ -113,6 +115,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ let connectionGeneration = 0 let statusChanges: Set | undefined let bootstrapping: Promise | undefined + const pendingMcpResourceRefresh = new Map() + const mcpResourceRefreshes = new Map>() function setSessionStatus(sessionID: string, status: DataSessionStatus) { statusChanges?.add(sessionID) @@ -757,6 +761,15 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ if (bootstrapping) break void result.location.mcp.refresh(event.location) break + case "mcp.resources.changed": { + const location = event.location ?? defaultLocation() + if (bootstrapping || mcpResourceRefreshes.has(locationKey(location))) { + pendingMcpResourceRefresh.set(locationKey(location), location) + break + } + void result.location.mcp.resource.refresh(location) + break + } } } @@ -942,6 +955,32 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ const key = locationKey(result.data.location) setStore("location", key, { ...store.location[key], mcp: result.data.data }) }, + resource: { + catalog(location?: LocationRef) { + return store.location[locationKey(location ?? defaultLocation())]?.mcpResource + }, + refresh(ref?: LocationRef) { + const location = ref ?? defaultLocation() + const key = locationKey(location) + const active = mcpResourceRefreshes.get(key) + if (active) return active + const refresh = sdk.api["server.mcp"] + .catalog({ location: locationQuery(location) }) + .then((result) => { + const key = locationKey(result.location) + setStore("location", key, { ...store.location[key], mcpResource: mutable(result.data) }) + }) + .finally(() => { + mcpResourceRefreshes.delete(key) + const pending = pendingMcpResourceRefresh.get(key) + if (!pending || bootstrapping) return + pendingMcpResourceRefresh.delete(key) + void result.location.mcp.resource.refresh(pending) + }) + mcpResourceRefreshes.set(key, refresh) + return refresh + }, + }, }, model: { list(location?: LocationRef) { @@ -1010,6 +1049,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ result.location.agent.refresh(), result.location.integration.refresh(), result.location.mcp.refresh(), + result.location.mcp.resource.refresh(), result.location.model.refresh(), result.location.provider.refresh(), result.location.reference.refresh(), @@ -1023,6 +1063,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) .finally(() => { bootstrapping = undefined + for (const [key, location] of pendingMcpResourceRefresh) { + if (mcpResourceRefreshes.has(key)) continue + pendingMcpResourceRefresh.delete(key) + void result.location.mcp.resource.refresh(location) + } }) return bootstrapping } diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index b7b349a196..d39f205195 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -108,6 +108,74 @@ test("refreshes resources into reactive getters", async () => { } }) +test("refreshes MCP resource catalogs after change events", async () => { + const events = createEventStream() + let resources = [{ server: "docs", name: "Readme", uri: "docs://readme" }] + let requests = 0 + let release: (() => void) | undefined + const calls = createFetch((url) => { + if (url.pathname !== "/api/mcp/resource") return + requests++ + const data = { resources, templates: [] } + if (requests === 3) + return new Promise((resolve) => { + release = () => + resolve(json({ location: { directory, project: { id: "proj_test", directory } }, data })) + }) + return json({ + location: { directory, project: { id: "proj_test", directory } }, + data, + }) + }, events) + let data!: ReturnType + + function Probe() { + data = useData() + return {data.location.mcp.resource.catalog()?.resources[0]?.name ?? "missing"} + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => requests === 1) + expect(data.location.mcp.resource.catalog()?.resources[0]?.uri).toBe("docs://readme") + + resources = [{ server: "docs", name: "Guide", uri: "docs://guide" }] + emitEvent(events, { + id: "evt_mcp_resources", + created: 1, + type: "mcp.resources.changed", + data: { server: "docs" }, + }) + await wait(() => requests === 2 && data.location.mcp.resource.catalog()?.resources[0]?.name === "Guide") + + const refresh = data.location.mcp.resource.refresh() + await wait(() => requests === 3) + resources = [{ server: "docs", name: "Reference", uri: "docs://reference" }] + emitEvent(events, { + id: "evt_mcp_resources_during_refresh", + created: 2, + type: "mcp.resources.changed", + data: { server: "docs" }, + }) + release?.() + await refresh + await wait(() => requests === 4 && data.location.mcp.resource.catalog()?.resources[0]?.name === "Reference") + } finally { + app.renderer.destroy() + } +}) + test("restores running manual compaction before applying live deltas", async () => { const events = createEventStream() const calls = createFetch((url) => { diff --git a/packages/tui/test/fixture/tui-sdk.ts b/packages/tui/test/fixture/tui-sdk.ts index 8c6e476db0..d515fec0ed 100644 --- a/packages/tui/test/fixture/tui-sdk.ts +++ b/packages/tui/test/fixture/tui-sdk.ts @@ -99,6 +99,11 @@ export function createFetch(override?: FetchHandler, events?: ReturnType