Compare commits

...

3 Commits

Author SHA1 Message Date
Aiden Cline 4d3ff36869 refactor(tui): simplify MCP catalog refresh 2026-07-07 14:24:23 -05:00
Aiden Cline cc3ddb5bb0 Merge remote-tracking branch 'origin/v2' into mcp-attachments
# Conflicts:
#	packages/tui/test/cli/tui/data.test.tsx
2026-07-07 14:00:51 -05:00
Aiden Cline 7df9a16ffd feat(mcp): attach resources from TUI 2026-07-07 13:58:52 -05:00
12 changed files with 676 additions and 44 deletions
+1
View File
@@ -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:*",
+156 -37
View File
@@ -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<SessionSchema.ID>()
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
const promptLocks = KeyedMutex.makeUnsafe<SessionMessage.ID>()
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<MCP.ResourceContent | undefined, MCP.NotFoundError>,
) {
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<MCP.ResourceContent | undefined, MCP.NotFoundError>,
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),
+177 -4
View File
@@ -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",
})
+27
View File
@@ -38,6 +38,33 @@ export const Server = Schema.Struct({
integrationID: optional(IntegrationID),
}).annotate({ identifier: "Mcp.Server" })
export interface ResourceReference extends Schema.Schema.Type<typeof ResourceReference> {}
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<typeof Resource> {}
export const Resource = Schema.Struct({
server: Schema.String,
+10
View File
@@ -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" }],
+72
View File
@@ -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<never, ThrowOnError>,
) {
@@ -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<ThrowOnError extends boolean = false>(
parameters?: {
location?: {
directory?: string | null
workspace?: string | null
} | null
},
options?: Options<never, ThrowOnError>,
) {
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<ThrowOnError extends boolean = false>(
parameters?: {
location?: {
directory?: string | null
workspace?: string | null
} | null
},
options?: Options<never, ThrowOnError>,
) {
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 {
+150
View File
@@ -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<McpResource2>
templates: Array<McpResourceTemplate>
}
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<McpResourceV2>
templates: Array<McpResourceTemplate>
}
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 = {
/**
* <No Content>
*/
204: void
}
export type V2DebugLocationEvictResponse = V2DebugLocationEvictResponses[keyof V2DebugLocationEvictResponses]
export type V2DebugLocationData = {
body?: never
path?: never
+1
View File
@@ -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:*",
@@ -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: "" },
+19
View File
@@ -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[]
@@ -763,6 +765,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
if (bootstrapping) break
void result.location.mcp.refresh(event.location)
break
case "mcp.resources.changed":
if (bootstrapping) break
void result.location.mcp.resource.refresh(event.location)
break
}
}
@@ -948,6 +954,18 @@ 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
},
async refresh(ref?: LocationRef) {
const result = await sdk.api["server.mcp"].catalog({
location: locationQuery(ref ?? defaultLocation()),
})
const key = locationKey(result.location)
setStore("location", key, { ...store.location[key], mcpResource: mutable(result.data) })
},
},
},
model: {
list(location?: LocationRef) {
@@ -1016,6 +1034,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(),
+48
View File
@@ -108,6 +108,54 @@ 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
const calls = createFetch((url) => {
if (url.pathname !== "/api/mcp/resource") return
requests++
return json({
location: { directory, project: { id: "proj_test", directory } },
data: { resources, templates: [] },
})
}, events)
let data!: ReturnType<typeof useData>
function Probe() {
data = useData()
return <text>{data.location.mcp.resource.catalog()?.resources[0]?.name ?? "missing"}</text>
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
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")
} finally {
app.renderer.destroy()
}
})
test("updates session location when moved", async () => {
const events = createEventStream()
const destination = "/tmp/opencode-moved"
+5
View File
@@ -99,6 +99,11 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
if (url.pathname === "/api/mcp")
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
if (url.pathname === "/api/mcp/resource")
return json({
location: { directory, project: { id: "proj_test", directory: worktree } },
data: { resources: [], templates: [] },
})
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/active") return json({ data: {} })
if (/^\/api\/session\/[^/]+\/form$/.test(url.pathname)) return json({ data: [] })