feat(mcp): attach resources from TUI
This commit is contained in:
parent
947bbf9490
commit
7df9a16ffd
12 changed files with 722 additions and 44 deletions
|
|
@ -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",
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue