mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 10:59:49 -04:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d3ff36869 | |||
| cc3ddb5bb0 | |||
| 7df9a16ffd |
@@ -998,6 +998,7 @@
|
|||||||
"@opencode-ai/client": "workspace:*",
|
"@opencode-ai/client": "workspace:*",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/simulation": "workspace:*",
|
"@opencode-ai/simulation": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
|
|||||||
+156
-37
@@ -11,6 +11,7 @@ import { Location } from "./location"
|
|||||||
import { SessionMessage } from "./session/message"
|
import { SessionMessage } from "./session/message"
|
||||||
import { Base64, FileAttachment, Prompt } from "@opencode-ai/schema/prompt"
|
import { Base64, FileAttachment, Prompt } from "@opencode-ai/schema/prompt"
|
||||||
import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||||
|
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||||
import { EventV2 } from "./event"
|
import { EventV2 } from "./event"
|
||||||
import { Database } from "./database/database"
|
import { Database } from "./database/database"
|
||||||
import { SessionProjector } from "./session/projector"
|
import { SessionProjector } from "./session/projector"
|
||||||
@@ -45,6 +46,7 @@ import { Shell } from "./shell"
|
|||||||
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
|
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
|
||||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
|
import { MCP } from "./mcp/index"
|
||||||
|
|
||||||
export const RevertState = Revert.State
|
export const RevertState = Revert.State
|
||||||
export type RevertState = Revert.State
|
export type RevertState = Revert.State
|
||||||
@@ -273,6 +275,7 @@ const layer = Layer.effect(
|
|||||||
const scope = yield* Scope.Scope
|
const scope = yield* Scope.Scope
|
||||||
const activeShells = new Set<SessionSchema.ID>()
|
const activeShells = new Set<SessionSchema.ID>()
|
||||||
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
||||||
|
const promptLocks = KeyedMutex.makeUnsafe<SessionMessage.ID>()
|
||||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
|
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
|
||||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||||
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||||
@@ -475,40 +478,48 @@ const layer = Layer.effect(
|
|||||||
EventV2.isSynced(item) || isDurableSessionEvent(item),
|
EventV2.isSynced(item) || isDurableSessionEvent(item),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
prompt: Effect.fn("V2Session.prompt")((input) =>
|
prompt: Effect.fn("V2Session.prompt")((input) => {
|
||||||
Effect.uninterruptible(
|
const admit = Effect.gen(function* () {
|
||||||
Effect.gen(function* () {
|
const session = yield* result.get(input.sessionID)
|
||||||
const session = yield* result.get(input.sessionID)
|
// A staged revert must be committed before admitting new input so the prompt
|
||||||
// A staged revert must be committed before admitting new input so the prompt
|
// continues from the reverted boundary rather than stale post-boundary history.
|
||||||
// continues from the reverted boundary rather than stale post-boundary history.
|
if (session.revert)
|
||||||
if (session.revert)
|
yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
|
||||||
yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
|
const messageID = input.id ?? SessionMessage.ID.create()
|
||||||
const prompt = yield* resolvePrompt(input.prompt).pipe(Effect.provideService(FSUtil.Service, fs))
|
const delivery = input.delivery ?? "steer"
|
||||||
const messageID = input.id ?? SessionMessage.ID.create()
|
const recorded = input.id === undefined ? undefined : yield* SessionInput.find(db, input.id)
|
||||||
const delivery = input.delivery ?? "steer"
|
const readMcpResource = (resource: Mcp.ResourceReference) =>
|
||||||
const expected = { sessionID: input.sessionID, messageID, prompt, delivery }
|
Effect.gen(function* () {
|
||||||
const admitted = yield* SessionInput.admit(db, events, {
|
const mcp = yield* MCP.Service
|
||||||
id: messageID,
|
return yield* mcp.readResource(resource)
|
||||||
sessionID: input.sessionID,
|
}).pipe(Effect.provide(locations.get(session.location)))
|
||||||
prompt,
|
const prompt =
|
||||||
delivery,
|
recorded?.type === "prompt" && matchesResolvedMcpPrompt(recorded.prompt, input.prompt)
|
||||||
}).pipe(
|
? recorded.prompt
|
||||||
Effect.catchDefect((defect) =>
|
: yield* resolvePrompt(input.prompt, fs, readMcpResource)
|
||||||
defect instanceof SessionInput.LifecycleConflict
|
const expected = { sessionID: input.sessionID, messageID, prompt, delivery }
|
||||||
? new PromptConflictError({ sessionID: input.sessionID, messageID })
|
const admitted = yield* SessionInput.admit(db, events, {
|
||||||
: Effect.die(defect),
|
id: messageID,
|
||||||
),
|
sessionID: input.sessionID,
|
||||||
)
|
prompt,
|
||||||
if (!SessionInput.equivalent(admitted, expected))
|
delivery,
|
||||||
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
|
}).pipe(
|
||||||
if (input.resume !== false) {
|
Effect.catchDefect((defect) =>
|
||||||
if (activeShells.has(admitted.sessionID)) return admitted
|
defect instanceof SessionInput.LifecycleConflict
|
||||||
yield* execution.wake(admitted.sessionID)
|
? new PromptConflictError({ sessionID: input.sessionID, messageID })
|
||||||
}
|
: Effect.die(defect),
|
||||||
return admitted
|
),
|
||||||
}),
|
)
|
||||||
),
|
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) {
|
command: Effect.fn("V2Session.command")(function* (input) {
|
||||||
const session = yield* result.get(input.sessionID)
|
const session = yield* result.get(input.sessionID)
|
||||||
const commands = yield* CommandV2.Service.pipe(Effect.provide(locations.get(session.location)))
|
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 resolvePrompt = Effect.fn("V2Session.resolvePrompt")(function* (
|
||||||
const fs = yield* FSUtil.Service
|
input: PromptInput.Prompt,
|
||||||
|
fs: FSUtil.Interface,
|
||||||
|
readMcpResource: (
|
||||||
|
input: Mcp.ResourceReference,
|
||||||
|
) => Effect.Effect<MCP.ResourceContent | undefined, MCP.NotFoundError>,
|
||||||
|
) {
|
||||||
const files = input.files
|
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
|
: undefined
|
||||||
return Prompt.make({ text: input.text, agents: input.agents, files })
|
return Prompt.make({ text: input.text, agents: input.agents, files })
|
||||||
})
|
})
|
||||||
|
|
||||||
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
|
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
|
||||||
|
const MAX_MCP_ATTACHMENT_PARTS = 100
|
||||||
|
|
||||||
const materializeAttachment = Effect.fn("V2Session.materializeAttachment")(function* (
|
const materializeAttachment = Effect.fn("V2Session.materializeAttachment")(function* (
|
||||||
fs: FSUtil.Interface,
|
fs: FSUtil.Interface,
|
||||||
|
readMcpResource: (
|
||||||
|
input: Mcp.ResourceReference,
|
||||||
|
) => Effect.Effect<MCP.ResourceContent | undefined, MCP.NotFoundError>,
|
||||||
input: PromptInput.FileAttachment,
|
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:")
|
const resolved = input.uri.startsWith("data:")
|
||||||
? {
|
? {
|
||||||
bytes: yield* decodeDataURL(input.uri),
|
bytes: yield* decodeDataURL(input.uri),
|
||||||
@@ -766,6 +832,20 @@ const materializeAttachment = Effect.fn("V2Session.materializeAttachment")(funct
|
|||||||
mime: undefined,
|
mime: undefined,
|
||||||
}
|
}
|
||||||
: yield* readFileAttachment(fs, input.uri)
|
: 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)
|
if (resolved.bytes.byteLength > MAX_ATTACHMENT_BYTES)
|
||||||
return yield* new AttachmentError({
|
return yield* new AttachmentError({
|
||||||
uri: input.uri,
|
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 readFileAttachment = Effect.fn("V2Session.readFileAttachment")(function* (fs: FSUtil.Interface, uri: string) {
|
||||||
const url = yield* Effect.try({
|
const url = yield* Effect.try({
|
||||||
try: () => new URL(uri),
|
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 { SessionInput } from "@opencode-ai/core/session/input"
|
||||||
import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
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"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
const executionCalls: SessionV2.ID[] = []
|
const executionCalls: SessionV2.ID[] = []
|
||||||
@@ -49,20 +51,62 @@ const execution = Layer.succeed(
|
|||||||
awaitIdle: () => Effect.void,
|
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(
|
const it = testEffect(
|
||||||
AppNodeBuilder.build(
|
AppNodeBuilder.build(
|
||||||
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]),
|
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 sessionID = SessionV2.ID.make("ses_prompt_test")
|
||||||
const messageID = SessionMessage.ID.create()
|
const messageID = SessionMessage.ID.create()
|
||||||
|
const directory = AbsolutePath.make(process.cwd())
|
||||||
|
|
||||||
const setup = Effect.gen(function* () {
|
const setup = Effect.gen(function* () {
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
yield* db
|
yield* db
|
||||||
.insert(ProjectTable)
|
.insert(ProjectTable)
|
||||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
.values({ id: Project.ID.global, worktree: directory, sandboxes: [] })
|
||||||
.onConflictDoNothing()
|
.onConflictDoNothing()
|
||||||
.run()
|
.run()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
@@ -72,7 +116,7 @@ const setup = Effect.gen(function* () {
|
|||||||
id: sessionID,
|
id: sessionID,
|
||||||
project_id: Project.ID.global,
|
project_id: Project.ID.global,
|
||||||
slug: "test",
|
slug: "test",
|
||||||
directory: "/project",
|
directory,
|
||||||
title: "test",
|
title: "test",
|
||||||
version: "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", () =>
|
it.effect("sniffs data URL content instead of trusting its declared MIME", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* setup
|
yield* setup
|
||||||
@@ -648,7 +821,7 @@ describe("SessionV2.prompt", () => {
|
|||||||
id: other,
|
id: other,
|
||||||
project_id: Project.ID.global,
|
project_id: Project.ID.global,
|
||||||
slug: "other",
|
slug: "other",
|
||||||
directory: "/project",
|
directory,
|
||||||
title: "other",
|
title: "other",
|
||||||
version: "test",
|
version: "test",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -38,6 +38,33 @@ export const Server = Schema.Struct({
|
|||||||
integrationID: optional(IntegrationID),
|
integrationID: optional(IntegrationID),
|
||||||
}).annotate({ identifier: "Mcp.Server" })
|
}).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 interface Resource extends Schema.Schema.Type<typeof Resource> {}
|
||||||
export const Resource = Schema.Struct({
|
export const Resource = Schema.Struct({
|
||||||
server: Schema.String,
|
server: Schema.String,
|
||||||
|
|||||||
@@ -3,6 +3,16 @@ import { Schema } from "effect"
|
|||||||
import { Mcp } from "../src/mcp.js"
|
import { Mcp } from "../src/mcp.js"
|
||||||
|
|
||||||
describe("Mcp resources", () => {
|
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", () => {
|
test("decodes resource catalogs and omits absent metadata", () => {
|
||||||
const value = Schema.decodeUnknownSync(Mcp.ResourceCatalog)({
|
const value = Schema.decodeUnknownSync(Mcp.ResourceCatalog)({
|
||||||
resources: [{ server: "docs", name: "Readme", uri: "docs://readme" }],
|
resources: [{ server: "docs", name: "Readme", uri: "docs://readme" }],
|
||||||
|
|||||||
@@ -277,6 +277,8 @@ import type {
|
|||||||
V2CredentialUpdateErrors,
|
V2CredentialUpdateErrors,
|
||||||
V2CredentialUpdateResponses,
|
V2CredentialUpdateResponses,
|
||||||
V2DebugLocationErrors,
|
V2DebugLocationErrors,
|
||||||
|
V2DebugLocationEvictErrors,
|
||||||
|
V2DebugLocationEvictResponses,
|
||||||
V2DebugLocationResponses,
|
V2DebugLocationResponses,
|
||||||
V2EventSubscribeErrors,
|
V2EventSubscribeErrors,
|
||||||
V2EventSubscribeResponses,
|
V2EventSubscribeResponses,
|
||||||
@@ -310,6 +312,8 @@ import type {
|
|||||||
V2LocationGetResponses,
|
V2LocationGetResponses,
|
||||||
V2McpListErrors,
|
V2McpListErrors,
|
||||||
V2McpListResponses,
|
V2McpListResponses,
|
||||||
|
V2McpResourceCatalogErrors,
|
||||||
|
V2McpResourceCatalogResponses,
|
||||||
V2ModelDefaultErrors,
|
V2ModelDefaultErrors,
|
||||||
V2ModelDefaultResponses,
|
V2ModelDefaultResponses,
|
||||||
V2ModelListErrors,
|
V2ModelListErrors,
|
||||||
@@ -6239,6 +6243,7 @@ export class Session3 extends HeyApiClient {
|
|||||||
metadata?: {
|
metadata?: {
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
resume?: boolean | null
|
||||||
},
|
},
|
||||||
options?: Options<never, ThrowOnError>,
|
options?: Options<never, ThrowOnError>,
|
||||||
) {
|
) {
|
||||||
@@ -6251,6 +6256,7 @@ export class Session3 extends HeyApiClient {
|
|||||||
{ in: "body", key: "text" },
|
{ in: "body", key: "text" },
|
||||||
{ in: "body", key: "description" },
|
{ in: "body", key: "description" },
|
||||||
{ in: "body", key: "metadata" },
|
{ 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 {
|
export class Mcp2 extends HeyApiClient {
|
||||||
/**
|
/**
|
||||||
* List MCP servers
|
* List MCP servers
|
||||||
@@ -6991,6 +7025,11 @@ export class Mcp2 extends HeyApiClient {
|
|||||||
...params,
|
...params,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _resource?: Resource2
|
||||||
|
get resource(): Resource2 {
|
||||||
|
return (this._resource ??= new Resource2({ client: this.client }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Credential extends HeyApiClient {
|
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 {
|
export class Debug extends HeyApiClient {
|
||||||
/**
|
/**
|
||||||
* List loaded locations
|
* List loaded locations
|
||||||
@@ -8129,6 +8196,11 @@ export class Debug extends HeyApiClient {
|
|||||||
...options,
|
...options,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _location?: Location2
|
||||||
|
get location2(): Location2 {
|
||||||
|
return (this._location ??= new Location2({ client: this.client }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class V2 extends HeyApiClient {
|
export class V2 extends HeyApiClient {
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ export type Event =
|
|||||||
| EventTuiToastShow2
|
| EventTuiToastShow2
|
||||||
| EventTuiSessionSelect2
|
| EventTuiSessionSelect2
|
||||||
| EventMcpToolsChanged
|
| EventMcpToolsChanged
|
||||||
|
| EventMcpResourcesChanged
|
||||||
| EventMcpStatusChanged
|
| EventMcpStatusChanged
|
||||||
| EventCommandExecuted
|
| EventCommandExecuted
|
||||||
| EventFileEdited
|
| EventFileEdited
|
||||||
@@ -1603,6 +1604,13 @@ export type GlobalEvent = {
|
|||||||
server: string
|
server: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
id: string
|
||||||
|
type: "mcp.resources.changed"
|
||||||
|
properties: {
|
||||||
|
server: string
|
||||||
|
}
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
id: string
|
id: string
|
||||||
type: "mcp.status.changed"
|
type: "mcp.status.changed"
|
||||||
@@ -2991,6 +2999,14 @@ export type ProviderNotFoundError = {
|
|||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type McpResource2 = {
|
||||||
|
server: string
|
||||||
|
name: string
|
||||||
|
uri: string
|
||||||
|
description?: string
|
||||||
|
mimeType?: string
|
||||||
|
}
|
||||||
|
|
||||||
export type FormNotFoundError = {
|
export type FormNotFoundError = {
|
||||||
_tag: "FormNotFoundError"
|
_tag: "FormNotFoundError"
|
||||||
id: string
|
id: string
|
||||||
@@ -3153,6 +3169,7 @@ export type V2Event =
|
|||||||
| TuiToastShow
|
| TuiToastShow
|
||||||
| TuiSessionSelect
|
| TuiSessionSelect
|
||||||
| McpToolsChanged
|
| McpToolsChanged
|
||||||
|
| McpResourcesChanged
|
||||||
| McpStatusChanged
|
| McpStatusChanged
|
||||||
| CommandExecuted
|
| CommandExecuted
|
||||||
| FileEdited
|
| FileEdited
|
||||||
@@ -5702,6 +5719,19 @@ export type McpServer = {
|
|||||||
integrationID?: string
|
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 = {
|
export type ProjectCurrent = {
|
||||||
id: string
|
id: string
|
||||||
directory: 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 = {
|
export type McpStatusChanged = {
|
||||||
id: string
|
id: string
|
||||||
created: number
|
created: number
|
||||||
@@ -7760,6 +7803,14 @@ export type EventMcpToolsChanged = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EventMcpResourcesChanged = {
|
||||||
|
id: string
|
||||||
|
type: "mcp.resources.changed"
|
||||||
|
properties: {
|
||||||
|
server: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type EventMcpStatusChanged = {
|
export type EventMcpStatusChanged = {
|
||||||
id: string
|
id: string
|
||||||
type: "mcp.status.changed"
|
type: "mcp.status.changed"
|
||||||
@@ -8597,6 +8648,7 @@ export type V2EventV2 =
|
|||||||
| InstallationUpdateAvailableV2
|
| InstallationUpdateAvailableV2
|
||||||
| VcsBranchUpdatedV2
|
| VcsBranchUpdatedV2
|
||||||
| McpStatusChangedV2
|
| McpStatusChangedV2
|
||||||
|
| McpResourcesChangedV2
|
||||||
| PermissionAskedV2
|
| PermissionAskedV2
|
||||||
| PermissionRepliedV2
|
| PermissionRepliedV2
|
||||||
| QuestionAskedV2
|
| QuestionAskedV2
|
||||||
@@ -9686,6 +9738,19 @@ export type EventLogSyncedV2 = {
|
|||||||
seq?: number
|
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 = {
|
export type ProjectTimeV2 = {
|
||||||
created: number
|
created: number
|
||||||
updated: 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 = {
|
export type PermissionAskedV2 = {
|
||||||
id: string
|
id: string
|
||||||
created: number
|
created: number
|
||||||
@@ -15518,6 +15596,7 @@ export type V2SessionSyntheticData = {
|
|||||||
metadata?: {
|
metadata?: {
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
resume?: boolean | null
|
||||||
}
|
}
|
||||||
path: {
|
path: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
@@ -16685,6 +16764,43 @@ export type V2McpListResponses = {
|
|||||||
|
|
||||||
export type V2McpListResponse = V2McpListResponses[keyof 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 = {
|
export type V2CredentialRemoveData = {
|
||||||
body?: never
|
body?: never
|
||||||
path: {
|
path: {
|
||||||
@@ -18549,6 +18665,40 @@ export type V2VcsDiffResponses = {
|
|||||||
|
|
||||||
export type V2VcsDiffResponse = V2VcsDiffResponses[keyof 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 = {
|
export type V2DebugLocationData = {
|
||||||
body?: never
|
body?: never
|
||||||
path?: never
|
path?: never
|
||||||
|
|||||||
@@ -54,6 +54,7 @@
|
|||||||
"@opencode-ai/client": "workspace:*",
|
"@opencode-ai/client": "workspace:*",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/simulation": "workspace:*",
|
"@opencode-ai/simulation": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { pathToFileURL } from "bun"
|
|||||||
import fuzzysort from "fuzzysort"
|
import fuzzysort from "fuzzysort"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { firstBy } from "remeda"
|
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 { createStore } from "solid-js/store"
|
||||||
import { useEditorContext } from "../../context/editor"
|
import { useEditorContext } from "../../context/editor"
|
||||||
import { useProject } from "../../context/project"
|
import { useProject } from "../../context/project"
|
||||||
@@ -23,6 +23,7 @@ import { useFrecency } from "../../prompt/frecency"
|
|||||||
import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap"
|
import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap"
|
||||||
import { displayCharAt, mentionTriggerIndex } from "../../prompt/display"
|
import { displayCharAt, mentionTriggerIndex } from "../../prompt/display"
|
||||||
import type { FileSystemEntry } from "@opencode-ai/sdk/v2"
|
import type { FileSystemEntry } from "@opencode-ai/sdk/v2"
|
||||||
|
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||||
|
|
||||||
function removeLineRange(input: string) {
|
function removeLineRange(input: string) {
|
||||||
const hashIndex = input.lastIndexOf("#")
|
const hashIndex = input.lastIndexOf("#")
|
||||||
@@ -104,6 +105,12 @@ export function Autocomplete(props: {
|
|||||||
input: "keyboard" as "keyboard" | "mouse",
|
input: "keyboard" as "keyboard" | "mouse",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(location, (location) => {
|
||||||
|
void data.location.mcp.resource.refresh(location).catch(() => undefined)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const [positionTick, setPositionTick] = createSignal(0)
|
const [positionTick, setPositionTick] = createSignal(0)
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -363,7 +370,7 @@ export function Autocomplete(props: {
|
|||||||
const options: AutocompleteOption[] = []
|
const options: AutocompleteOption[] = []
|
||||||
const width = props.anchor().width - 4
|
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({
|
options.push({
|
||||||
display: Locale.truncateMiddle(res.name, width),
|
display: Locale.truncateMiddle(res.name, width),
|
||||||
// Match the name only; matching the URI caused unrelated fuzzy hits.
|
// Match the name only; matching the URI caused unrelated fuzzy hits.
|
||||||
@@ -373,7 +380,7 @@ export function Autocomplete(props: {
|
|||||||
insertPart(res.name, {
|
insertPart(res.name, {
|
||||||
type: "file",
|
type: "file",
|
||||||
value: {
|
value: {
|
||||||
uri: res.uri,
|
uri: Mcp.resourceUri({ server: res.server, uri: res.uri }),
|
||||||
name: res.name,
|
name: res.name,
|
||||||
description: res.description,
|
description: res.description,
|
||||||
mention: { start: 0, end: 0, text: "" },
|
mention: { start: 0, end: 0, text: "" },
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type {
|
|||||||
SkillV2Info,
|
SkillV2Info,
|
||||||
V2Event,
|
V2Event,
|
||||||
} from "@opencode-ai/sdk/v2"
|
} from "@opencode-ai/sdk/v2"
|
||||||
|
import type { ServerMcpCatalogOutput } from "@opencode-ai/client/promise"
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
@@ -37,6 +38,7 @@ type LocationData = {
|
|||||||
command?: CommandV2Info[]
|
command?: CommandV2Info[]
|
||||||
integration?: IntegrationInfo[]
|
integration?: IntegrationInfo[]
|
||||||
mcp?: McpServer[]
|
mcp?: McpServer[]
|
||||||
|
mcpResource?: ServerMcpCatalogOutput["data"]
|
||||||
model?: ModelV2Info[]
|
model?: ModelV2Info[]
|
||||||
provider?: ProviderV2Info[]
|
provider?: ProviderV2Info[]
|
||||||
reference?: ReferenceInfo[]
|
reference?: ReferenceInfo[]
|
||||||
@@ -763,6 +765,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
if (bootstrapping) break
|
if (bootstrapping) break
|
||||||
void result.location.mcp.refresh(event.location)
|
void result.location.mcp.refresh(event.location)
|
||||||
break
|
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)
|
const key = locationKey(result.data.location)
|
||||||
setStore("location", key, { ...store.location[key], mcp: result.data.data })
|
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: {
|
model: {
|
||||||
list(location?: LocationRef) {
|
list(location?: LocationRef) {
|
||||||
@@ -1016,6 +1034,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
result.location.agent.refresh(),
|
result.location.agent.refresh(),
|
||||||
result.location.integration.refresh(),
|
result.location.integration.refresh(),
|
||||||
result.location.mcp.refresh(),
|
result.location.mcp.refresh(),
|
||||||
|
result.location.mcp.resource.refresh(),
|
||||||
result.location.model.refresh(),
|
result.location.model.refresh(),
|
||||||
result.location.provider.refresh(),
|
result.location.provider.refresh(),
|
||||||
result.location.reference.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 () => {
|
test("updates session location when moved", async () => {
|
||||||
const events = createEventStream()
|
const events = createEventStream()
|
||||||
const destination = "/tmp/opencode-moved"
|
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: [] })
|
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
|
||||||
if (url.pathname === "/api/mcp")
|
if (url.pathname === "/api/mcp")
|
||||||
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
|
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") return json({ data: [], cursor: {} })
|
||||||
if (url.pathname === "/api/session/active") return json({ data: {} })
|
if (url.pathname === "/api/session/active") return json({ data: {} })
|
||||||
if (/^\/api\/session\/[^/]+\/form$/.test(url.pathname)) return json({ data: [] })
|
if (/^\/api\/session\/[^/]+\/form$/.test(url.pathname)) return json({ data: [] })
|
||||||
|
|||||||
Reference in New Issue
Block a user