mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-28 22:21:25 -04:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d3ff36869 | |||
| cc3ddb5bb0 | |||
| 7df9a16ffd |
@@ -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
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: "" },
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: [] })
|
||||
|
||||
Reference in New Issue
Block a user