mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-16 01:19:19 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 48e6118640 | |||
| 988efb6217 | |||
| e1d1352d8f | |||
| ab5bd520e2 |
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { materializeMcpResources } from "./mcp-resource"
|
||||||
|
|
||||||
|
describe("MCP resource prompt parts", () => {
|
||||||
|
test("materializes text and blob content without retaining the resource source", async () => {
|
||||||
|
const prompt = await materializeMcpResources(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: "file",
|
||||||
|
path: "docs://readme",
|
||||||
|
content: "@Readme",
|
||||||
|
start: 0,
|
||||||
|
end: 7,
|
||||||
|
filename: "Readme",
|
||||||
|
source: {
|
||||||
|
type: "resource",
|
||||||
|
clientName: "docs",
|
||||||
|
uri: "docs://readme",
|
||||||
|
text: { value: "@Readme", start: 0, end: 7 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
async () => ({
|
||||||
|
server: "docs",
|
||||||
|
uri: "docs://readme",
|
||||||
|
contents: [
|
||||||
|
{ type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" },
|
||||||
|
{ type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prompt).toEqual([
|
||||||
|
{
|
||||||
|
type: "file",
|
||||||
|
path: "docs://readme",
|
||||||
|
content: "@Readme",
|
||||||
|
start: 0,
|
||||||
|
end: 7,
|
||||||
|
mime: "text/plain",
|
||||||
|
filename: "Readme",
|
||||||
|
url: "data:text/plain;base64,aGVsbG8=",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "file",
|
||||||
|
path: "docs://logo",
|
||||||
|
content: "",
|
||||||
|
start: 0,
|
||||||
|
end: 0,
|
||||||
|
mime: "image/png",
|
||||||
|
filename: "Readme-2",
|
||||||
|
url: "data:image/png;base64,aGVsbG8=",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("fails when a resource is unavailable", async () => {
|
||||||
|
await expect(
|
||||||
|
materializeMcpResources(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: "file",
|
||||||
|
path: "docs://missing",
|
||||||
|
content: "@Missing",
|
||||||
|
start: 0,
|
||||||
|
end: 8,
|
||||||
|
source: {
|
||||||
|
type: "resource",
|
||||||
|
clientName: "docs",
|
||||||
|
uri: "docs://missing",
|
||||||
|
text: { value: "@Missing", start: 0, end: 8 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
async () => null,
|
||||||
|
),
|
||||||
|
).rejects.toThrow("Unable to read MCP resource: docs:docs://missing")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { McpResourceContent } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import type { FileAttachmentPart, Prompt } from "@/context/prompt"
|
||||||
|
|
||||||
|
type ResourceSource = Extract<NonNullable<FileAttachmentPart["source"]>, { type: "resource" }>
|
||||||
|
|
||||||
|
export const hasMcpResources = (prompt: Prompt) =>
|
||||||
|
prompt.some((part) => part.type === "file" && part.source?.type === "resource")
|
||||||
|
|
||||||
|
export async function materializeMcpResources(
|
||||||
|
prompt: Prompt,
|
||||||
|
read: (source: ResourceSource) => Promise<McpResourceContent | null>,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
await Promise.all(
|
||||||
|
prompt.map(async (part): Promise<Prompt> => {
|
||||||
|
if (part.type !== "file" || part.source?.type !== "resource") return [part]
|
||||||
|
const resource = await read(part.source)
|
||||||
|
if (!resource) throw new Error(`Unable to read MCP resource: ${part.source.clientName}:${part.source.uri}`)
|
||||||
|
if (resource.contents.length === 0)
|
||||||
|
throw new Error(`MCP resource returned no content: ${part.source.clientName}:${part.source.uri}`)
|
||||||
|
return resource.contents.map((content, index) => {
|
||||||
|
const mime =
|
||||||
|
content.mimeType ?? part.mime ?? (content.type === "text" ? "text/plain" : "application/octet-stream")
|
||||||
|
return {
|
||||||
|
type: "file",
|
||||||
|
path: content.uri,
|
||||||
|
content: index === 0 ? part.content : "",
|
||||||
|
start: index === 0 ? part.start : 0,
|
||||||
|
end: index === 0 ? part.end : 0,
|
||||||
|
mime,
|
||||||
|
filename: index === 0 ? part.filename : `${part.filename ?? "resource"}-${index + 1}`,
|
||||||
|
url: `data:${mime};base64,${content.type === "text" ? encodeText(content.text) : content.blob}`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).flat()
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeText(value: string) {
|
||||||
|
const bytes = new TextEncoder().encode(value)
|
||||||
|
const chunks: string[] = []
|
||||||
|
for (let index = 0; index < bytes.length; index += 0x8000) {
|
||||||
|
chunks.push(String.fromCharCode(...bytes.subarray(index, index + 0x8000)))
|
||||||
|
}
|
||||||
|
return btoa(chunks.join(""))
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import { setCursorPosition } from "./editor-dom"
|
|||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
import { ScopedKey } from "@/utils/server-scope"
|
import { ScopedKey } from "@/utils/server-scope"
|
||||||
import { createPromptSubmissionState } from "./submission-state"
|
import { createPromptSubmissionState } from "./submission-state"
|
||||||
|
import { hasMcpResources, materializeMcpResources } from "./mcp-resource"
|
||||||
|
|
||||||
type PendingPrompt = {
|
type PendingPrompt = {
|
||||||
abort: AbortController
|
abort: AbortController
|
||||||
@@ -54,7 +55,6 @@ const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttac
|
|||||||
|
|
||||||
export async function sendFollowupDraft(input: FollowupSendInput) {
|
export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||||
const text = draftText(input.draft.prompt)
|
const text = draftText(input.draft.prompt)
|
||||||
const images = draftImages(input.draft.prompt)
|
|
||||||
const setBusy = () => {
|
const setBusy = () => {
|
||||||
if (!input.optimisticBusy) return
|
if (!input.optimisticBusy) return
|
||||||
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "busy" })
|
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "busy" })
|
||||||
@@ -71,6 +71,19 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const materialize = () =>
|
||||||
|
materializeMcpResources(input.draft.prompt, async (source) => {
|
||||||
|
const response = await input.client.v2.mcp.resource.read(
|
||||||
|
{
|
||||||
|
location: { directory: input.draft.sessionDirectory },
|
||||||
|
server: source.clientName,
|
||||||
|
uri: source.uri,
|
||||||
|
},
|
||||||
|
{ throwOnError: true },
|
||||||
|
)
|
||||||
|
return response.data.data
|
||||||
|
})
|
||||||
|
|
||||||
const [head, ...tail] = text.split(" ")
|
const [head, ...tail] = text.split(" ")
|
||||||
const cmd = head?.startsWith("/") ? head.slice(1) : undefined
|
const cmd = head?.startsWith("/") ? head.slice(1) : undefined
|
||||||
if (cmd && input.sync.data.command.find((item) => item.name === cmd)) {
|
if (cmd && input.sync.data.command.find((item) => item.name === cmd)) {
|
||||||
@@ -81,6 +94,9 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const prompt = hasMcpResources(input.draft.prompt) ? await materialize() : input.draft.prompt
|
||||||
|
const images = draftImages(prompt)
|
||||||
|
|
||||||
await input.client.session.command({
|
await input.client.session.command({
|
||||||
sessionID: input.draft.sessionID,
|
sessionID: input.draft.sessionID,
|
||||||
command: cmd,
|
command: cmd,
|
||||||
@@ -88,13 +104,28 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
|||||||
agent: input.draft.agent,
|
agent: input.draft.agent,
|
||||||
model: `${input.draft.model.providerID}/${input.draft.model.modelID}`,
|
model: `${input.draft.model.providerID}/${input.draft.model.modelID}`,
|
||||||
variant: input.draft.variant,
|
variant: input.draft.variant,
|
||||||
parts: images.map((attachment) => ({
|
parts: [
|
||||||
|
...images.map((attachment) => ({
|
||||||
id: Identifier.ascending("part"),
|
id: Identifier.ascending("part"),
|
||||||
type: "file" as const,
|
type: "file" as const,
|
||||||
mime: attachment.mime,
|
mime: attachment.mime,
|
||||||
url: attachment.dataUrl,
|
url: attachment.dataUrl,
|
||||||
filename: attachment.filename,
|
filename: attachment.filename,
|
||||||
})),
|
})),
|
||||||
|
...prompt.flatMap((part) =>
|
||||||
|
part.type === "file" && part.url?.startsWith("data:")
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: Identifier.ascending("part"),
|
||||||
|
type: "file" as const,
|
||||||
|
mime: part.mime ?? "text/plain",
|
||||||
|
url: part.url ?? part.path,
|
||||||
|
filename: part.filename,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
],
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -103,9 +134,26 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let ready = false
|
||||||
|
let prompt = input.draft.prompt
|
||||||
|
if (hasMcpResources(prompt)) {
|
||||||
|
setBusy()
|
||||||
|
if (!(await wait())) {
|
||||||
|
setIdle()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
prompt = await materialize()
|
||||||
|
} catch (error) {
|
||||||
|
setIdle()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
ready = true
|
||||||
|
}
|
||||||
|
const images = draftImages(prompt)
|
||||||
const messageID = input.messageID ?? Identifier.ascending("message")
|
const messageID = input.messageID ?? Identifier.ascending("message")
|
||||||
const { requestParts, optimisticParts } = buildRequestParts({
|
const { requestParts, optimisticParts } = buildRequestParts({
|
||||||
prompt: input.draft.prompt,
|
prompt,
|
||||||
context: input.draft.context,
|
context: input.draft.context,
|
||||||
images,
|
images,
|
||||||
text,
|
text,
|
||||||
@@ -144,7 +192,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!(await wait())) {
|
if (!ready && !(await wait())) {
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setIdle()
|
setIdle()
|
||||||
remove()
|
remove()
|
||||||
@@ -457,39 +505,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (text.startsWith("/")) {
|
|
||||||
const [cmdName, ...args] = text.split(" ")
|
|
||||||
const commandName = cmdName.slice(1)
|
|
||||||
const customCommand = sync().data.command.find((c) => c.name === commandName)
|
|
||||||
if (customCommand) {
|
|
||||||
clearInput()
|
|
||||||
client.session
|
|
||||||
.command({
|
|
||||||
sessionID: session.id,
|
|
||||||
command: commandName,
|
|
||||||
arguments: args.join(" "),
|
|
||||||
agent,
|
|
||||||
model: `${model.providerID}/${model.modelID}`,
|
|
||||||
variant,
|
|
||||||
parts: images.map((attachment) => ({
|
|
||||||
id: Identifier.ascending("part"),
|
|
||||||
type: "file" as const,
|
|
||||||
mime: attachment.mime,
|
|
||||||
url: attachment.dataUrl,
|
|
||||||
filename: attachment.filename,
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
showToast({
|
|
||||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
|
||||||
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
|
||||||
})
|
|
||||||
restoreInput()
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
||||||
const messageID = Identifier.ascending("message")
|
const messageID = Identifier.ascending("message")
|
||||||
|
|
||||||
|
|||||||
@@ -134,6 +134,28 @@ describe("applyGlobalEvent", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("applyDirectoryEvent", () => {
|
describe("applyDirectoryEvent", () => {
|
||||||
|
test("refreshes MCP state after MCP events", () => {
|
||||||
|
const [store, setStore] = createStore(baseState())
|
||||||
|
const calls: string[] = []
|
||||||
|
const apply = (type: "mcp.status.changed" | "mcp.resources.changed") =>
|
||||||
|
applyDirectoryEvent({
|
||||||
|
event: { type, properties: { server: "docs" } },
|
||||||
|
store,
|
||||||
|
setStore,
|
||||||
|
push() {},
|
||||||
|
directory: "/tmp",
|
||||||
|
loadLsp() {},
|
||||||
|
loadMcp: () => calls.push("status"),
|
||||||
|
loadMcpResources: () => calls.push("resources"),
|
||||||
|
})
|
||||||
|
|
||||||
|
apply("mcp.resources.changed")
|
||||||
|
expect(calls).toEqual(["resources"])
|
||||||
|
calls.length = 0
|
||||||
|
apply("mcp.status.changed")
|
||||||
|
expect(calls).toEqual(["status", "resources"])
|
||||||
|
})
|
||||||
|
|
||||||
test("initializes text delta accumulation from the current part text", () => {
|
test("initializes text delta accumulation from the current part text", () => {
|
||||||
const part = { ...textPart("part", "session", "message"), text: "existing" }
|
const part = { ...textPart("part", "session", "message"), text: "existing" }
|
||||||
const [store, setStore] = createStore(baseState({ part: { message: [part] } }))
|
const [store, setStore] = createStore(baseState({ part: { message: [part] } }))
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ export function applyDirectoryEvent(input: {
|
|||||||
directory: string
|
directory: string
|
||||||
loadLsp: () => void
|
loadLsp: () => void
|
||||||
loadReferences?: () => void
|
loadReferences?: () => void
|
||||||
|
loadMcp?: () => void
|
||||||
|
loadMcpResources?: () => void
|
||||||
vcsCache?: VcsCache
|
vcsCache?: VcsCache
|
||||||
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
|
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
|
||||||
retainedLimit?: number
|
retainedLimit?: number
|
||||||
@@ -409,5 +411,14 @@ export function applyDirectoryEvent(input: {
|
|||||||
input.loadReferences?.()
|
input.loadReferences?.()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case "mcp.status.changed": {
|
||||||
|
input.loadMcp?.()
|
||||||
|
input.loadMcpResources?.()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "mcp.resources.changed": {
|
||||||
|
input.loadMcpResources?.()
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,17 @@ export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: Opencod
|
|||||||
export const loadMcpResourcesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
|
export const loadMcpResourcesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
|
||||||
queryOptions<Record<string, McpResource>>({
|
queryOptions<Record<string, McpResource>>({
|
||||||
queryKey: [scope, directory, "mcpResources"] as const,
|
queryKey: [scope, directory, "mcpResources"] as const,
|
||||||
queryFn: () => sdk.experimental.resource.list().then((r) => r.data ?? {}),
|
queryFn: () =>
|
||||||
|
sdk.v2.mcp.resource
|
||||||
|
.catalog({ location: { directory } }, { throwOnError: true })
|
||||||
|
.then((response) =>
|
||||||
|
Object.fromEntries(
|
||||||
|
response.data.data.resources.map((resource) => [
|
||||||
|
`${encodeURIComponent(resource.server)}:${resource.uri}`,
|
||||||
|
{ ...resource, client: resource.server },
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
placeholderData: {},
|
placeholderData: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -416,6 +426,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
loadReferences: () => {
|
loadReferences: () => {
|
||||||
void queryClient.fetchQuery(queryOptionsApi.references(key))
|
void queryClient.fetchQuery(queryOptionsApi.references(key))
|
||||||
},
|
},
|
||||||
|
loadMcp: () => {
|
||||||
|
void queryClient.refetchQueries(queryOptionsApi.mcp(key))
|
||||||
|
},
|
||||||
|
loadMcpResources: () => {
|
||||||
|
void queryClient.refetchQueries(queryOptionsApi.mcpResources(key))
|
||||||
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -439,8 +439,28 @@ export type Endpoint10_0Input = { readonly location?: Endpoint10_0Request["query
|
|||||||
export type Endpoint10_0Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.list"]>>
|
export type Endpoint10_0Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.list"]>>
|
||||||
export type ServerMcpListOperation<E = never> = (input?: Endpoint10_0Input) => Effect.Effect<Endpoint10_0Output, E>
|
export type ServerMcpListOperation<E = never> = (input?: Endpoint10_0Input) => Effect.Effect<Endpoint10_0Output, E>
|
||||||
|
|
||||||
|
type Endpoint10_1Request = Parameters<RawClient["server.mcp"]["mcp.resource.catalog"]>[0]
|
||||||
|
export type Endpoint10_1Input = { readonly location?: Endpoint10_1Request["query"]["location"] }
|
||||||
|
export type Endpoint10_1Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.resource.catalog"]>>
|
||||||
|
export type ServerMcpResourceCatalogOperation<E = never> = (
|
||||||
|
input?: Endpoint10_1Input,
|
||||||
|
) => Effect.Effect<Endpoint10_1Output, E>
|
||||||
|
|
||||||
|
type Endpoint10_2Request = Parameters<RawClient["server.mcp"]["mcp.resource.read"]>[0]
|
||||||
|
export type Endpoint10_2Input = {
|
||||||
|
readonly location?: Endpoint10_2Request["query"]["location"]
|
||||||
|
readonly server: Endpoint10_2Request["payload"]["server"]
|
||||||
|
readonly uri: Endpoint10_2Request["payload"]["uri"]
|
||||||
|
}
|
||||||
|
export type Endpoint10_2Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.resource.read"]>>
|
||||||
|
export type ServerMcpReadResourceOperation<E = never> = (
|
||||||
|
input: Endpoint10_2Input,
|
||||||
|
) => Effect.Effect<Endpoint10_2Output, E>
|
||||||
|
|
||||||
export interface ServerMcpApi<E = never> {
|
export interface ServerMcpApi<E = never> {
|
||||||
readonly list: ServerMcpListOperation<E>
|
readonly list: ServerMcpListOperation<E>
|
||||||
|
readonly resourceCatalog: ServerMcpResourceCatalogOperation<E>
|
||||||
|
readonly readResource: ServerMcpReadResourceOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
type Endpoint11_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
|
type Endpoint11_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
|
||||||
|
|||||||
@@ -529,7 +529,28 @@ type Endpoint10_0Input = { readonly location?: Endpoint10_0Request["query"]["loc
|
|||||||
const Endpoint10_0 = (raw: RawClient["server.mcp"]) => (input?: Endpoint10_0Input) =>
|
const Endpoint10_0 = (raw: RawClient["server.mcp"]) => (input?: Endpoint10_0Input) =>
|
||||||
raw["mcp.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
raw["mcp.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
const adaptGroup10 = (raw: RawClient["server.mcp"]) => ({ list: Endpoint10_0(raw) })
|
type Endpoint10_1Request = Parameters<RawClient["server.mcp"]["mcp.resource.catalog"]>[0]
|
||||||
|
type Endpoint10_1Input = { readonly location?: Endpoint10_1Request["query"]["location"] }
|
||||||
|
const Endpoint10_1 = (raw: RawClient["server.mcp"]) => (input?: Endpoint10_1Input) =>
|
||||||
|
raw["mcp.resource.catalog"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
type Endpoint10_2Request = Parameters<RawClient["server.mcp"]["mcp.resource.read"]>[0]
|
||||||
|
type Endpoint10_2Input = {
|
||||||
|
readonly location?: Endpoint10_2Request["query"]["location"]
|
||||||
|
readonly server: Endpoint10_2Request["payload"]["server"]
|
||||||
|
readonly uri: Endpoint10_2Request["payload"]["uri"]
|
||||||
|
}
|
||||||
|
const Endpoint10_2 = (raw: RawClient["server.mcp"]) => (input: Endpoint10_2Input) =>
|
||||||
|
raw["mcp.resource.read"]({
|
||||||
|
query: { location: input["location"] },
|
||||||
|
payload: { server: input["server"], uri: input["uri"] },
|
||||||
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
const adaptGroup10 = (raw: RawClient["server.mcp"]) => ({
|
||||||
|
list: Endpoint10_0(raw),
|
||||||
|
resourceCatalog: Endpoint10_1(raw),
|
||||||
|
readResource: Endpoint10_2(raw),
|
||||||
|
})
|
||||||
|
|
||||||
type Endpoint11_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
|
type Endpoint11_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
|
||||||
type Endpoint11_0Input = {
|
type Endpoint11_0Input = {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type {
|
|||||||
ProviderApi,
|
ProviderApi,
|
||||||
ReferenceApi,
|
ReferenceApi,
|
||||||
SessionApi,
|
SessionApi,
|
||||||
|
ServerMcpApi,
|
||||||
SkillApi,
|
SkillApi,
|
||||||
} from "./api.js"
|
} from "./api.js"
|
||||||
export { Service } from "./service.js"
|
export { Service } from "./service.js"
|
||||||
@@ -27,6 +28,7 @@ export { FileSystem } from "@opencode-ai/schema/filesystem"
|
|||||||
export { Form } from "@opencode-ai/schema/form"
|
export { Form } from "@opencode-ai/schema/form"
|
||||||
export { Integration } from "@opencode-ai/schema/integration"
|
export { Integration } from "@opencode-ai/schema/integration"
|
||||||
export { Location } from "@opencode-ai/schema/location"
|
export { Location } from "@opencode-ai/schema/location"
|
||||||
|
export { Mcp } from "@opencode-ai/schema/mcp"
|
||||||
export { Model } from "@opencode-ai/schema/model"
|
export { Model } from "@opencode-ai/schema/model"
|
||||||
export { Permission } from "@opencode-ai/schema/permission"
|
export { Permission } from "@opencode-ai/schema/permission"
|
||||||
export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
|
export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
ProviderApi as EffectProviderApi,
|
ProviderApi as EffectProviderApi,
|
||||||
ReferenceApi as EffectReferenceApi,
|
ReferenceApi as EffectReferenceApi,
|
||||||
SessionApi as EffectSessionApi,
|
SessionApi as EffectSessionApi,
|
||||||
|
ServerMcpApi as EffectServerMcpApi,
|
||||||
SkillApi as EffectSkillApi,
|
SkillApi as EffectSkillApi,
|
||||||
} from "../effect/api/api.js"
|
} from "../effect/api/api.js"
|
||||||
import type { Effect, Stream } from "effect"
|
import type { Effect, Stream } from "effect"
|
||||||
@@ -37,6 +38,7 @@ export type PluginApi = PromisifyApi<EffectPluginApi<unknown>>
|
|||||||
export type ProviderApi = PromisifyApi<EffectProviderApi<unknown>>
|
export type ProviderApi = PromisifyApi<EffectProviderApi<unknown>>
|
||||||
export type ReferenceApi = PromisifyApi<EffectReferenceApi<unknown>>
|
export type ReferenceApi = PromisifyApi<EffectReferenceApi<unknown>>
|
||||||
export type SessionApi = PromisifyApi<EffectSessionApi<unknown>>
|
export type SessionApi = PromisifyApi<EffectSessionApi<unknown>>
|
||||||
|
export type ServerMcpApi = PromisifyApi<EffectServerMcpApi<unknown>>
|
||||||
export type SkillApi = PromisifyApi<EffectSkillApi<unknown>>
|
export type SkillApi = PromisifyApi<EffectSkillApi<unknown>>
|
||||||
|
|
||||||
export interface CatalogApi {
|
export interface CatalogApi {
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ import type {
|
|||||||
IntegrationAttemptCancelOutput,
|
IntegrationAttemptCancelOutput,
|
||||||
ServerMcpListInput,
|
ServerMcpListInput,
|
||||||
ServerMcpListOutput,
|
ServerMcpListOutput,
|
||||||
|
ServerMcpResourceCatalogInput,
|
||||||
|
ServerMcpResourceCatalogOutput,
|
||||||
|
ServerMcpReadResourceInput,
|
||||||
|
ServerMcpReadResourceOutput,
|
||||||
CredentialUpdateInput,
|
CredentialUpdateInput,
|
||||||
CredentialUpdateOutput,
|
CredentialUpdateOutput,
|
||||||
CredentialRemoveInput,
|
CredentialRemoveInput,
|
||||||
@@ -890,6 +894,31 @@ export function make(options: ClientOptions) {
|
|||||||
},
|
},
|
||||||
requestOptions,
|
requestOptions,
|
||||||
),
|
),
|
||||||
|
resourceCatalog: (input?: ServerMcpResourceCatalogInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<ServerMcpResourceCatalogOutput>(
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/mcp/resource`,
|
||||||
|
query: { location: input?.["location"] },
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [401, 400],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
readResource: (input: ServerMcpReadResourceInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<ServerMcpReadResourceOutput>(
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: `/api/mcp/resource/read`,
|
||||||
|
query: { location: input["location"] },
|
||||||
|
body: { server: input["server"], uri: input["uri"] },
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [404, 401, 400],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
credential: {
|
credential: {
|
||||||
update: (input: CredentialUpdateInput, requestOptions?: RequestOptions) =>
|
update: (input: CredentialUpdateInput, requestOptions?: RequestOptions) =>
|
||||||
|
|||||||
@@ -106,6 +106,14 @@ export type ProviderNotFoundError = {
|
|||||||
export const isProviderNotFoundError = (value: unknown): value is ProviderNotFoundError =>
|
export const isProviderNotFoundError = (value: unknown): value is ProviderNotFoundError =>
|
||||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ProviderNotFoundError"
|
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ProviderNotFoundError"
|
||||||
|
|
||||||
|
export type McpServerNotFoundError = {
|
||||||
|
readonly _tag: "McpServerNotFoundError"
|
||||||
|
readonly server: string
|
||||||
|
readonly message: string
|
||||||
|
}
|
||||||
|
export const isMcpServerNotFoundError = (value: unknown): value is McpServerNotFoundError =>
|
||||||
|
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "McpServerNotFoundError"
|
||||||
|
|
||||||
export type FormNotFoundError = { readonly _tag: "FormNotFoundError"; readonly id: string; readonly message: string }
|
export type FormNotFoundError = { readonly _tag: "FormNotFoundError"; readonly id: string; readonly message: string }
|
||||||
export const isFormNotFoundError = (value: unknown): value is FormNotFoundError =>
|
export const isFormNotFoundError = (value: unknown): value is FormNotFoundError =>
|
||||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "FormNotFoundError"
|
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "FormNotFoundError"
|
||||||
@@ -2478,6 +2486,60 @@ export type ServerMcpListOutput = {
|
|||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ServerMcpResourceCatalogInput = {
|
||||||
|
readonly location?: {
|
||||||
|
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||||
|
}["location"]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerMcpResourceCatalogOutput = {
|
||||||
|
readonly location: {
|
||||||
|
readonly directory: string
|
||||||
|
readonly workspaceID?: string
|
||||||
|
readonly project: { readonly id: string; readonly directory: string }
|
||||||
|
}
|
||||||
|
readonly data: {
|
||||||
|
readonly resources: ReadonlyArray<{
|
||||||
|
readonly server: string
|
||||||
|
readonly name: string
|
||||||
|
readonly uri: string
|
||||||
|
readonly description?: string
|
||||||
|
readonly mimeType?: string
|
||||||
|
}>
|
||||||
|
readonly templates: ReadonlyArray<{
|
||||||
|
readonly server: string
|
||||||
|
readonly name: string
|
||||||
|
readonly uriTemplate: string
|
||||||
|
readonly description?: string
|
||||||
|
readonly mimeType?: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerMcpReadResourceInput = {
|
||||||
|
readonly location?: {
|
||||||
|
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||||
|
}["location"]
|
||||||
|
readonly server: { readonly server: string; readonly uri: string }["server"]
|
||||||
|
readonly uri: { readonly server: string; readonly uri: string }["uri"]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerMcpReadResourceOutput = {
|
||||||
|
readonly location: {
|
||||||
|
readonly directory: string
|
||||||
|
readonly workspaceID?: string
|
||||||
|
readonly project: { readonly id: string; readonly directory: string }
|
||||||
|
}
|
||||||
|
readonly data: {
|
||||||
|
readonly server: string
|
||||||
|
readonly uri: string
|
||||||
|
readonly contents: ReadonlyArray<
|
||||||
|
| { readonly type: "text"; readonly uri: string; readonly text: string; readonly mimeType?: string }
|
||||||
|
| { readonly type: "blob"; readonly uri: string; readonly blob: string; readonly mimeType?: string }
|
||||||
|
>
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
export type CredentialUpdateInput = {
|
export type CredentialUpdateInput = {
|
||||||
readonly credentialID: { readonly credentialID: string }["credentialID"]
|
readonly credentialID: { readonly credentialID: string }["credentialID"]
|
||||||
readonly location?: {
|
readonly location?: {
|
||||||
@@ -5508,6 +5570,14 @@ export type EventSubscribeOutput =
|
|||||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
readonly data: { readonly server: string }
|
readonly data: { readonly server: string }
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly created: number
|
||||||
|
readonly metadata?: { readonly [x: string]: unknown }
|
||||||
|
readonly type: "mcp.resources.changed"
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: { readonly server: string }
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly created: number
|
readonly created: number
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type {
|
|||||||
ProviderApi,
|
ProviderApi,
|
||||||
ReferenceApi,
|
ReferenceApi,
|
||||||
SessionApi,
|
SessionApi,
|
||||||
|
ServerMcpApi,
|
||||||
SkillApi,
|
SkillApi,
|
||||||
} from "./api.js"
|
} from "./api.js"
|
||||||
export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types"
|
export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { SessionInput as CoreSessionInput } from "@opencode-ai/core/session/inpu
|
|||||||
import { SessionMessage as CoreSessionMessage } from "@opencode-ai/core/session/message"
|
import { SessionMessage as CoreSessionMessage } from "@opencode-ai/core/session/message"
|
||||||
import { Agent } from "@opencode-ai/schema/agent"
|
import { Agent } from "@opencode-ai/schema/agent"
|
||||||
import { Location } from "@opencode-ai/schema/location"
|
import { Location } from "@opencode-ai/schema/location"
|
||||||
|
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||||
import { Model } from "@opencode-ai/schema/model"
|
import { Model } from "@opencode-ai/schema/model"
|
||||||
import { Project } from "@opencode-ai/schema/project"
|
import { Project } from "@opencode-ai/schema/project"
|
||||||
import { Provider } from "@opencode-ai/schema/provider"
|
import { Provider } from "@opencode-ai/schema/provider"
|
||||||
@@ -26,6 +27,7 @@ const Client = await import("../src/effect")
|
|||||||
test("effect entrypoint exposes canonical Schema contracts", () => {
|
test("effect entrypoint exposes canonical Schema contracts", () => {
|
||||||
expect(Client.Agent).toBe(Agent)
|
expect(Client.Agent).toBe(Agent)
|
||||||
expect(Client.Model).toBe(Model)
|
expect(Client.Model).toBe(Model)
|
||||||
|
expect(Client.Mcp).toBe(Mcp)
|
||||||
expect(Client.Session).toBe(Session)
|
expect(Client.Session).toBe(Session)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src/promise/index"
|
import { isMcpServerNotFoundError, isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src/promise/index"
|
||||||
|
|
||||||
test("exposes every standard HTTP API group", () => {
|
test("exposes every standard HTTP API group", () => {
|
||||||
const client = OpenCode.make({ baseUrl: "http://localhost:3000" })
|
const client = OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||||
@@ -48,6 +48,74 @@ test("exposes every standard HTTP API group", () => {
|
|||||||
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
|
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
|
||||||
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
|
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
|
||||||
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
|
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
|
||||||
|
expect(Object.keys(client["server.mcp"])).toEqual(["list", "resourceCatalog", "readResource"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("MCP resource methods use the public HTTP contract", async () => {
|
||||||
|
const requests: Array<{ method: string; url: string; body?: unknown }> = []
|
||||||
|
const client = OpenCode.make({
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
fetch: async (input, init) => {
|
||||||
|
const request = input instanceof Request ? input : new Request(input, init)
|
||||||
|
requests.push({
|
||||||
|
method: request.method,
|
||||||
|
url: request.url,
|
||||||
|
body: request.method === "POST" ? await request.json() : undefined,
|
||||||
|
})
|
||||||
|
if (request.method === "POST")
|
||||||
|
return Response.json({
|
||||||
|
location: { directory: "/tmp/project", project: { id: "proj_test", directory: "/tmp/project" } },
|
||||||
|
data: {
|
||||||
|
server: "docs",
|
||||||
|
uri: "docs://readme",
|
||||||
|
contents: [{ type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return Response.json({
|
||||||
|
location: { directory: "/tmp/project", project: { id: "proj_test", directory: "/tmp/project" } },
|
||||||
|
data: {
|
||||||
|
resources: [{ server: "docs", name: "Readme", uri: "docs://readme" }],
|
||||||
|
templates: [{ server: "docs", name: "File", uriTemplate: "docs://{path}" }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const location = { directory: "/tmp/project" }
|
||||||
|
expect((await client["server.mcp"].resourceCatalog({ location })).data.resources[0]?.uri).toBe("docs://readme")
|
||||||
|
expect(
|
||||||
|
(await client["server.mcp"].readResource({ server: "docs", uri: "docs://readme", location })).data?.contents,
|
||||||
|
).toEqual([{ type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" }])
|
||||||
|
expect(requests).toEqual([
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
url: "http://localhost:3000/api/mcp/resource?location%5Bdirectory%5D=%2Ftmp%2Fproject",
|
||||||
|
body: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
url: "http://localhost:3000/api/mcp/resource/read?location%5Bdirectory%5D=%2Ftmp%2Fproject",
|
||||||
|
body: { server: "docs", uri: "docs://readme" },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("MCP resource reads preserve unknown-server errors", async () => {
|
||||||
|
const client = OpenCode.make({
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
fetch: async () =>
|
||||||
|
Response.json(
|
||||||
|
{ _tag: "McpServerNotFoundError", server: "missing", message: "MCP server not found: missing" },
|
||||||
|
{ status: 404 },
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client["server.mcp"].readResource({ server: "missing", uri: "docs://readme" })
|
||||||
|
throw new Error("Expected request to fail")
|
||||||
|
} catch (error) {
|
||||||
|
expect(isMcpServerNotFoundError(error)).toBe(true)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("file.read returns binary content from the public HTTP contract", async () => {
|
test("file.read returns binary content from the public HTTP contract", async () => {
|
||||||
|
|||||||
+231
-14
@@ -21,15 +21,24 @@ import {
|
|||||||
ListToolsResultSchema,
|
ListToolsResultSchema,
|
||||||
PromptListChangedNotificationSchema,
|
PromptListChangedNotificationSchema,
|
||||||
PromptSchema,
|
PromptSchema,
|
||||||
|
ResourceListChangedNotificationSchema,
|
||||||
|
ResourceUpdatedNotificationSchema,
|
||||||
type LoggingMessageNotification,
|
type LoggingMessageNotification,
|
||||||
LoggingMessageNotificationSchema,
|
LoggingMessageNotificationSchema,
|
||||||
ToolListChangedNotificationSchema,
|
ToolListChangedNotificationSchema,
|
||||||
ToolSchema,
|
ToolSchema,
|
||||||
} from "@modelcontextprotocol/sdk/types.js"
|
} from "@modelcontextprotocol/sdk/types.js"
|
||||||
import { Cause, Effect, Exit, Schema } from "effect"
|
import { Cause, Effect, Exit, Schema, Scope, Semaphore } from "effect"
|
||||||
import { ConfigMCP } from "../config/mcp"
|
import { ConfigMCP } from "../config/mcp"
|
||||||
import { InstallationVersion } from "../installation/version"
|
import { InstallationVersion } from "../installation/version"
|
||||||
|
|
||||||
|
declare module "@modelcontextprotocol/sdk/client/streamableHttp.js" {
|
||||||
|
interface StreamableHTTPClientTransport {
|
||||||
|
/** Added by the repository's MCP SDK session-recovery patch. */
|
||||||
|
onsessionexpired?: () => Promise<void>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_STARTUP_TIMEOUT = 30_000
|
const DEFAULT_STARTUP_TIMEOUT = 30_000
|
||||||
const DEFAULT_CATALOG_TIMEOUT = 30_000
|
const DEFAULT_CATALOG_TIMEOUT = 30_000
|
||||||
const DEFAULT_EXECUTION_TIMEOUT = 12 * 60 * 60 * 1_000 // 12 hours
|
const DEFAULT_EXECUTION_TIMEOUT = 12 * 60 * 60 * 1_000 // 12 hours
|
||||||
@@ -68,11 +77,13 @@ export interface ToolDefinition {
|
|||||||
export interface PromptDefinition {
|
export interface PromptDefinition {
|
||||||
readonly name: string
|
readonly name: string
|
||||||
readonly description: string | undefined
|
readonly description: string | undefined
|
||||||
readonly arguments: ReadonlyArray<{
|
readonly arguments:
|
||||||
|
| ReadonlyArray<{
|
||||||
readonly name: string
|
readonly name: string
|
||||||
readonly description: string | undefined
|
readonly description: string | undefined
|
||||||
readonly required: boolean | undefined
|
readonly required: boolean | undefined
|
||||||
}> | undefined
|
}>
|
||||||
|
| undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PromptMessage {
|
export interface PromptMessage {
|
||||||
@@ -84,6 +95,28 @@ export interface PromptResult {
|
|||||||
readonly messages: ReadonlyArray<PromptMessage>
|
readonly messages: ReadonlyArray<PromptMessage>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ResourceDefinition {
|
||||||
|
readonly name: string
|
||||||
|
readonly uri: string
|
||||||
|
readonly description: string | undefined
|
||||||
|
readonly mimeType: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceTemplateDefinition {
|
||||||
|
readonly name: string
|
||||||
|
readonly uriTemplate: string
|
||||||
|
readonly description: string | undefined
|
||||||
|
readonly mimeType: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ResourceContentPart =
|
||||||
|
| { readonly type: "text"; readonly uri: string; readonly text: string; readonly mimeType: string | undefined }
|
||||||
|
| { readonly type: "blob"; readonly uri: string; readonly blob: string; readonly mimeType: string | undefined }
|
||||||
|
|
||||||
|
export interface ReadResourceResult {
|
||||||
|
readonly contents: ReadonlyArray<ResourceContentPart>
|
||||||
|
}
|
||||||
|
|
||||||
export type CallToolContent =
|
export type CallToolContent =
|
||||||
| { readonly type: "text"; readonly text: string }
|
| { readonly type: "text"; readonly text: string }
|
||||||
| { readonly type: "media"; readonly data: string; readonly mimeType: string }
|
| { readonly type: "media"; readonly data: string; readonly mimeType: string }
|
||||||
@@ -124,6 +157,17 @@ export interface Connection {
|
|||||||
readonly tools: () => Effect.Effect<ToolDefinition[], Error>
|
readonly tools: () => Effect.Effect<ToolDefinition[], Error>
|
||||||
/** Lists the server's prompts; returns [] when the server doesn't advertise prompt support, fails on a transport error. */
|
/** Lists the server's prompts; returns [] when the server doesn't advertise prompt support, fails on a transport error. */
|
||||||
readonly prompts: () => Effect.Effect<PromptDefinition[], Error>
|
readonly prompts: () => Effect.Effect<PromptDefinition[], Error>
|
||||||
|
/** Lists the server's resources; returns [] when the server doesn't advertise resource support. */
|
||||||
|
readonly resources: () => Effect.Effect<ResourceDefinition[], Error>
|
||||||
|
/** Lists the server's resource templates; returns [] when the server doesn't advertise resource support. */
|
||||||
|
readonly resourceTemplates: () => Effect.Effect<ResourceTemplateDefinition[], Error>
|
||||||
|
/** Reads one resource; returns undefined when the server doesn't advertise resource support. */
|
||||||
|
readonly readResource: (input: { readonly uri: string }) => Effect.Effect<ReadResourceResult | undefined, Error>
|
||||||
|
/** Subscribes for the lifetime of the current scope; duplicate local listeners share one server subscription. */
|
||||||
|
readonly subscribeResource: (
|
||||||
|
input: { readonly uri: string },
|
||||||
|
callback: () => void,
|
||||||
|
) => Effect.Effect<boolean, Error, Scope.Scope>
|
||||||
/** Invokes a prompt on the server. Interruption aborts the in-flight request. */
|
/** Invokes a prompt on the server. Interruption aborts the in-flight request. */
|
||||||
readonly prompt: (input: {
|
readonly prompt: (input: {
|
||||||
readonly name: string
|
readonly name: string
|
||||||
@@ -141,6 +185,8 @@ export interface Connection {
|
|||||||
readonly onToolsChanged: (callback: () => void) => void
|
readonly onToolsChanged: (callback: () => void) => void
|
||||||
/** Registers a callback fired when the server announces its prompt list changed; no-op if unsupported. */
|
/** Registers a callback fired when the server announces its prompt list changed; no-op if unsupported. */
|
||||||
readonly onPromptsChanged: (callback: () => void) => void
|
readonly onPromptsChanged: (callback: () => void) => void
|
||||||
|
/** Registers a callback fired when the server announces its resource catalog changed or its HTTP session recovers. */
|
||||||
|
readonly onResourcesChanged: (callback: () => void) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Connects an MCP server; closing the calling scope tears down the transport and any spawned process. */
|
/** Connects an MCP server; closing the calling scope tears down the transport and any spawned process. */
|
||||||
@@ -168,7 +214,8 @@ export const connect = Effect.fnUntraced(function* (
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (!URL.canParse(config.url)) return yield* new ConnectError({ server, message: `Invalid MCP URL for "${server}"` })
|
if (!URL.canParse(config.url))
|
||||||
|
return yield* new ConnectError({ server, message: `Invalid MCP URL for "${server}"` })
|
||||||
return new StreamableHTTPClientTransport(new URL(config.url), {
|
return new StreamableHTTPClientTransport(new URL(config.url), {
|
||||||
requestInit: config.headers ? { headers: config.headers } : undefined,
|
requestInit: config.headers ? { headers: config.headers } : undefined,
|
||||||
authProvider,
|
authProvider,
|
||||||
@@ -202,13 +249,57 @@ export const connect = Effect.fnUntraced(function* (
|
|||||||
}).pipe(Effect.exit)
|
}).pipe(Effect.exit)
|
||||||
if (Exit.isSuccess(exit)) {
|
if (Exit.isSuccess(exit)) {
|
||||||
yield* Effect.addFinalizer(() =>
|
yield* Effect.addFinalizer(() =>
|
||||||
cleanupStdioDescendants(transport).pipe(
|
cleanupStdioDescendants(transport).pipe(Effect.andThen(Effect.promise(() => client.close())), Effect.ignore),
|
||||||
Effect.andThen(Effect.promise(() => client.close())),
|
|
||||||
Effect.ignore,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
const catalogTimeout = config.timeout?.catalog ?? DEFAULT_CATALOG_TIMEOUT
|
const catalogTimeout = config.timeout?.catalog ?? DEFAULT_CATALOG_TIMEOUT
|
||||||
const executionTimeout = config.timeout?.execution ?? DEFAULT_EXECUTION_TIMEOUT
|
const executionTimeout = config.timeout?.execution ?? DEFAULT_EXECUTION_TIMEOUT
|
||||||
|
const resourceSubscriptions = new Map<string, Set<() => void>>()
|
||||||
|
const resourceListChangedCallbacks = new Set<() => void>()
|
||||||
|
const resourceSubscriptionLock = Semaphore.makeUnsafe(1)
|
||||||
|
const notify = async (callbacks: Iterable<() => void>) => {
|
||||||
|
for (const callback of callbacks) {
|
||||||
|
try {
|
||||||
|
await callback()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client.setNotificationHandler(ResourceUpdatedNotificationSchema, (notification) =>
|
||||||
|
notify(resourceSubscriptions.get(notification.params.uri) ?? []),
|
||||||
|
)
|
||||||
|
client.setNotificationHandler(ResourceListChangedNotificationSchema, () => notify(resourceListChangedCallbacks))
|
||||||
|
if (transport instanceof StreamableHTTPClientTransport) {
|
||||||
|
const recover = transport.onsessionexpired
|
||||||
|
transport.onsessionexpired = async () => {
|
||||||
|
await recover?.()
|
||||||
|
await notify(resourceListChangedCallbacks)
|
||||||
|
if (client.getServerCapabilities()?.resources?.subscribe)
|
||||||
|
// The transport cannot send ordinary requests until its recovery callback returns.
|
||||||
|
setTimeout(() => {
|
||||||
|
Effect.runFork(
|
||||||
|
resourceSubscriptionLock.withPermit(
|
||||||
|
Effect.forEach(
|
||||||
|
resourceSubscriptions.keys(),
|
||||||
|
(uri) =>
|
||||||
|
Effect.tryPromise({
|
||||||
|
try: (signal) => client.subscribeResource({ uri }, { signal, timeout: catalogTimeout }),
|
||||||
|
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||||
|
}).pipe(
|
||||||
|
Effect.tapError((error) =>
|
||||||
|
Effect.logWarning("failed to restore MCP resource subscription", {
|
||||||
|
server,
|
||||||
|
uri,
|
||||||
|
error: error.message,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Effect.ignore,
|
||||||
|
),
|
||||||
|
{ discard: true },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
instructions: client.getInstructions()?.trim() || undefined,
|
instructions: client.getInstructions()?.trim() || undefined,
|
||||||
tools: () =>
|
tools: () =>
|
||||||
@@ -257,7 +348,9 @@ export const connect = Effect.fnUntraced(function* (
|
|||||||
),
|
),
|
||||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.tapError((error) => Effect.logWarning("failed to list MCP prompts", { server, error: error.message })),
|
Effect.tapError((error) =>
|
||||||
|
Effect.logWarning("failed to list MCP prompts", { server, error: error.message }),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return prompts.map((prompt) => ({
|
return prompts.map((prompt) => ({
|
||||||
name: prompt.name,
|
name: prompt.name,
|
||||||
@@ -269,6 +362,127 @@ export const connect = Effect.fnUntraced(function* (
|
|||||||
})),
|
})),
|
||||||
}))
|
}))
|
||||||
}),
|
}),
|
||||||
|
resources: () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (!client.getServerCapabilities()?.resources) return []
|
||||||
|
const resources = yield* Effect.tryPromise({
|
||||||
|
try: () =>
|
||||||
|
paginate(
|
||||||
|
(cursor) =>
|
||||||
|
client.listResources(cursor === undefined ? undefined : { cursor }, { timeout: catalogTimeout }),
|
||||||
|
(result) => result.resources,
|
||||||
|
),
|
||||||
|
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||||
|
}).pipe(
|
||||||
|
Effect.tapError((error) =>
|
||||||
|
Effect.logWarning("failed to list MCP resources", { server, error: error.message }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return resources.map((resource) => ({
|
||||||
|
name: resource.name,
|
||||||
|
uri: resource.uri,
|
||||||
|
description: resource.description,
|
||||||
|
mimeType: resource.mimeType,
|
||||||
|
}))
|
||||||
|
}),
|
||||||
|
resourceTemplates: () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (!client.getServerCapabilities()?.resources) return []
|
||||||
|
const templates = yield* Effect.tryPromise({
|
||||||
|
try: () =>
|
||||||
|
paginate(
|
||||||
|
(cursor) =>
|
||||||
|
client.listResourceTemplates(cursor === undefined ? undefined : { cursor }, {
|
||||||
|
timeout: catalogTimeout,
|
||||||
|
}),
|
||||||
|
(result) => result.resourceTemplates,
|
||||||
|
),
|
||||||
|
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||||
|
}).pipe(
|
||||||
|
Effect.tapError((error) =>
|
||||||
|
Effect.logWarning("failed to list MCP resource templates", { server, error: error.message }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return templates.map((template) => ({
|
||||||
|
name: template.name,
|
||||||
|
uriTemplate: template.uriTemplate,
|
||||||
|
description: template.description,
|
||||||
|
mimeType: template.mimeType,
|
||||||
|
}))
|
||||||
|
}),
|
||||||
|
readResource: (input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (!client.getServerCapabilities()?.resources) return undefined
|
||||||
|
const result = yield* Effect.tryPromise({
|
||||||
|
try: (signal) => client.readResource({ uri: input.uri }, { signal, timeout: executionTimeout }),
|
||||||
|
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||||
|
}).pipe(
|
||||||
|
Effect.tapError((error) =>
|
||||||
|
Effect.logWarning("failed to read MCP resource", { server, uri: input.uri, error: error.message }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
contents: result.contents.map(
|
||||||
|
(part): ResourceContentPart =>
|
||||||
|
"text" in part
|
||||||
|
? { type: "text", uri: part.uri, text: part.text, mimeType: part.mimeType }
|
||||||
|
: { type: "blob", uri: part.uri, blob: part.blob, mimeType: part.mimeType },
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
subscribeResource: (input, callback) =>
|
||||||
|
Effect.acquireRelease(
|
||||||
|
resourceSubscriptionLock.withPermit(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (!client.getServerCapabilities()?.resources?.subscribe) return undefined
|
||||||
|
const listener = () => callback()
|
||||||
|
const current = resourceSubscriptions.get(input.uri)
|
||||||
|
if (current) current.add(listener)
|
||||||
|
if (!current) {
|
||||||
|
yield* Effect.tryPromise({
|
||||||
|
try: (signal) => client.subscribeResource({ uri: input.uri }, { signal, timeout: catalogTimeout }),
|
||||||
|
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||||
|
}).pipe(
|
||||||
|
Effect.tapError((error) =>
|
||||||
|
Effect.logWarning("failed to subscribe to MCP resource", {
|
||||||
|
server,
|
||||||
|
uri: input.uri,
|
||||||
|
error: error.message,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
resourceSubscriptions.set(input.uri, new Set([listener]))
|
||||||
|
}
|
||||||
|
return listener
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
(listener) => {
|
||||||
|
if (!listener) return Effect.void
|
||||||
|
return resourceSubscriptionLock.withPermit(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const listeners = resourceSubscriptions.get(input.uri)
|
||||||
|
if (!listeners) return
|
||||||
|
listeners.delete(listener)
|
||||||
|
if (listeners.size > 0) return
|
||||||
|
resourceSubscriptions.delete(input.uri)
|
||||||
|
yield* Effect.tryPromise({
|
||||||
|
try: (signal) => client.unsubscribeResource({ uri: input.uri }, { signal, timeout: catalogTimeout }),
|
||||||
|
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||||
|
}).pipe(
|
||||||
|
Effect.tapError((error) =>
|
||||||
|
Effect.logWarning("failed to unsubscribe from MCP resource", {
|
||||||
|
server,
|
||||||
|
uri: input.uri,
|
||||||
|
error: error.message,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Effect.ignore,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ interruptible: true },
|
||||||
|
).pipe(Effect.map((listener) => listener !== undefined)),
|
||||||
prompt: (input) =>
|
prompt: (input) =>
|
||||||
Effect.tryPromise({
|
Effect.tryPromise({
|
||||||
try: (signal) =>
|
try: (signal) =>
|
||||||
@@ -315,7 +529,10 @@ export const connect = Effect.fnUntraced(function* (
|
|||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
onClose: (callback) => {
|
onClose: (callback) => {
|
||||||
client.onclose = callback
|
client.onclose = () => {
|
||||||
|
resourceSubscriptions.clear()
|
||||||
|
callback()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onLog: (callback) => {
|
onLog: (callback) => {
|
||||||
client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => callback(notification.params))
|
client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => callback(notification.params))
|
||||||
@@ -328,13 +545,13 @@ export const connect = Effect.fnUntraced(function* (
|
|||||||
if (!client.getServerCapabilities()?.prompts?.listChanged) return
|
if (!client.getServerCapabilities()?.prompts?.listChanged) return
|
||||||
client.setNotificationHandler(PromptListChangedNotificationSchema, async () => callback())
|
client.setNotificationHandler(PromptListChangedNotificationSchema, async () => callback())
|
||||||
},
|
},
|
||||||
|
onResourcesChanged: (callback) => {
|
||||||
|
resourceListChangedCallbacks.add(callback)
|
||||||
|
},
|
||||||
} satisfies Connection
|
} satisfies Connection
|
||||||
}
|
}
|
||||||
|
|
||||||
yield* cleanupStdioDescendants(transport).pipe(
|
yield* cleanupStdioDescendants(transport).pipe(Effect.andThen(Effect.promise(() => transport.close())), Effect.ignore)
|
||||||
Effect.andThen(Effect.promise(() => transport.close())),
|
|
||||||
Effect.ignore,
|
|
||||||
)
|
|
||||||
const error = Cause.squash(exit.cause)
|
const error = Cause.squash(exit.cause)
|
||||||
if (error instanceof UnauthorizedError) return yield* new NeedsAuthError({ server })
|
if (error instanceof UnauthorizedError) return yield* new NeedsAuthError({ server })
|
||||||
return yield* new ConnectError({ server, message: error instanceof Error ? error.message : String(error) })
|
return yield* new ConnectError({ server, message: error instanceof Error ? error.message : String(error) })
|
||||||
|
|||||||
+105
-50
@@ -83,48 +83,16 @@ export class PromptResult extends Schema.Class<PromptResult>("MCP.PromptResult")
|
|||||||
messages: Schema.Array(PromptMessage),
|
messages: Schema.Array(PromptMessage),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class Resource extends Schema.Class<Resource>("MCP.Resource")({
|
export const Resource = Mcp.Resource
|
||||||
server: ServerName,
|
export type Resource = Mcp.Resource
|
||||||
name: Schema.String,
|
export const ResourceTemplate = Mcp.ResourceTemplate
|
||||||
uri: Schema.String,
|
export type ResourceTemplate = Mcp.ResourceTemplate
|
||||||
description: Schema.String.pipe(Schema.optional),
|
export const ResourceCatalog = Mcp.ResourceCatalog
|
||||||
mimeType: Schema.String.pipe(Schema.optional),
|
export type ResourceCatalog = Mcp.ResourceCatalog
|
||||||
}) {}
|
export const ResourceContentPart = Mcp.ResourceContentPart
|
||||||
|
export type ResourceContentPart = Mcp.ResourceContentPart
|
||||||
export class ResourceTemplate extends Schema.Class<ResourceTemplate>("MCP.ResourceTemplate")({
|
export const ResourceContent = Mcp.ResourceContent
|
||||||
server: ServerName,
|
export type ResourceContent = Mcp.ResourceContent
|
||||||
name: Schema.String,
|
|
||||||
uriTemplate: Schema.String,
|
|
||||||
description: Schema.String.pipe(Schema.optional),
|
|
||||||
mimeType: Schema.String.pipe(Schema.optional),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class ResourceCatalog extends Schema.Class<ResourceCatalog>("MCP.ResourceCatalog")({
|
|
||||||
resources: Schema.Array(Resource),
|
|
||||||
templates: Schema.Array(ResourceTemplate),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export const ResourceContentPart = Schema.Union([
|
|
||||||
Schema.Struct({
|
|
||||||
type: Schema.Literal("text"),
|
|
||||||
uri: Schema.String,
|
|
||||||
text: Schema.String,
|
|
||||||
mimeType: Schema.String.pipe(Schema.optional),
|
|
||||||
}),
|
|
||||||
Schema.Struct({
|
|
||||||
type: Schema.Literal("blob"),
|
|
||||||
uri: Schema.String,
|
|
||||||
blob: Schema.String,
|
|
||||||
mimeType: Schema.String.pipe(Schema.optional),
|
|
||||||
}),
|
|
||||||
]).pipe(Schema.toTaggedUnion("type"))
|
|
||||||
export type ResourceContentPart = typeof ResourceContentPart.Type
|
|
||||||
|
|
||||||
export class ResourceContent extends Schema.Class<ResourceContent>("MCP.ResourceContent")({
|
|
||||||
server: ServerName,
|
|
||||||
uri: Schema.String,
|
|
||||||
contents: Schema.Array(ResourceContentPart),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("MCP.NotFoundError", {
|
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("MCP.NotFoundError", {
|
||||||
server: ServerName,
|
server: ServerName,
|
||||||
@@ -148,6 +116,9 @@ type ServerEntry = {
|
|||||||
client?: MCPClient.Connection
|
client?: MCPClient.Connection
|
||||||
tools?: ReadonlyArray<Tool>
|
tools?: ReadonlyArray<Tool>
|
||||||
prompts?: ReadonlyArray<Prompt>
|
prompts?: ReadonlyArray<Prompt>
|
||||||
|
resources?: ReadonlyArray<Resource>
|
||||||
|
resourceTemplates?: ReadonlyArray<ResourceTemplate>
|
||||||
|
resourceRevision: number
|
||||||
// Set when a remote server is registered as an OAuth integration; the credential lives in the global store.
|
// Set when a remote server is registered as an OAuth integration; the credential lives in the global store.
|
||||||
integrationID?: Integration.ID
|
integrationID?: Integration.ID
|
||||||
}
|
}
|
||||||
@@ -208,6 +179,7 @@ export const layer = Layer.effect(
|
|||||||
config: { ...server, timeout: { ...timeout, ...server.timeout } },
|
config: { ...server, timeout: { ...timeout, ...server.timeout } },
|
||||||
status: { status: "pending" },
|
status: { status: "pending" },
|
||||||
startup: Deferred.makeUnsafe<void>(),
|
startup: Deferred.makeUnsafe<void>(),
|
||||||
|
resourceRevision: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -415,6 +387,55 @@ export const layer = Layer.effect(
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const toResource = (server: ServerName, def: MCPClient.ResourceDefinition) =>
|
||||||
|
Resource.make({
|
||||||
|
server,
|
||||||
|
name: def.name,
|
||||||
|
uri: def.uri,
|
||||||
|
description: def.description,
|
||||||
|
mimeType: def.mimeType,
|
||||||
|
})
|
||||||
|
|
||||||
|
const toResourceTemplate = (server: ServerName, def: MCPClient.ResourceTemplateDefinition) =>
|
||||||
|
ResourceTemplate.make({
|
||||||
|
server,
|
||||||
|
name: def.name,
|
||||||
|
uriTemplate: def.uriTemplate,
|
||||||
|
description: def.description,
|
||||||
|
mimeType: def.mimeType,
|
||||||
|
})
|
||||||
|
|
||||||
|
const invalidateResources = (entry: ServerEntry) => {
|
||||||
|
entry.resourceRevision += 1
|
||||||
|
entry.resources = undefined
|
||||||
|
entry.resourceTemplates = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadResources = Effect.fnUntraced(function* (
|
||||||
|
name: ServerName,
|
||||||
|
entry: ServerEntry,
|
||||||
|
connection: MCPClient.Connection,
|
||||||
|
) {
|
||||||
|
const revision = entry.resourceRevision
|
||||||
|
const result = yield* Effect.all(
|
||||||
|
{
|
||||||
|
resources:
|
||||||
|
entry.resources === undefined
|
||||||
|
? connection.resources().pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
|
: Effect.succeed(undefined),
|
||||||
|
templates:
|
||||||
|
entry.resourceTemplates === undefined
|
||||||
|
? connection.resourceTemplates().pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
|
: Effect.succeed(undefined),
|
||||||
|
},
|
||||||
|
{ concurrency: "unbounded" },
|
||||||
|
)
|
||||||
|
if (entry.client !== connection || entry.resourceRevision !== revision) return
|
||||||
|
if (result.resources !== undefined) entry.resources = result.resources.map((def) => toResource(name, def))
|
||||||
|
if (result.templates !== undefined)
|
||||||
|
entry.resourceTemplates = result.templates.map((def) => toResourceTemplate(name, def))
|
||||||
|
})
|
||||||
|
|
||||||
const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
|
const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
|
||||||
connection.tools().pipe(
|
connection.tools().pipe(
|
||||||
Effect.map((defs) => {
|
Effect.map((defs) => {
|
||||||
@@ -441,8 +462,10 @@ export const layer = Layer.effect(
|
|||||||
entry.client = undefined
|
entry.client = undefined
|
||||||
entry.tools = undefined
|
entry.tools = undefined
|
||||||
entry.prompts = undefined
|
entry.prompts = undefined
|
||||||
|
invalidateResources(entry)
|
||||||
entry.status = { status: "failed", error: "Connection closed" }
|
entry.status = { status: "failed", error: "Connection closed" }
|
||||||
fork(events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore))
|
fork(events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore))
|
||||||
|
fork(events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore))
|
||||||
fork(events.publish(Command.Event.Updated, {}).pipe(Effect.ignore))
|
fork(events.publish(Command.Event.Updated, {}).pipe(Effect.ignore))
|
||||||
fork(events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore))
|
fork(events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore))
|
||||||
})
|
})
|
||||||
@@ -458,6 +481,11 @@ export const layer = Layer.effect(
|
|||||||
connection.onPromptsChanged(() => {
|
connection.onPromptsChanged(() => {
|
||||||
fork(refreshPrompts(name, entry, connection).pipe(Effect.ignore))
|
fork(refreshPrompts(name, entry, connection).pipe(Effect.ignore))
|
||||||
})
|
})
|
||||||
|
connection.onResourcesChanged(() => {
|
||||||
|
if (entry.client !== connection) return
|
||||||
|
invalidateResources(entry)
|
||||||
|
fork(events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverLog = (server: ServerName, message: MCPClient.LogMessage) => {
|
const serverLog = (server: ServerName, message: MCPClient.LogMessage) => {
|
||||||
@@ -491,6 +519,7 @@ export const layer = Layer.effect(
|
|||||||
Effect.exit,
|
Effect.exit,
|
||||||
)
|
)
|
||||||
if (Exit.isSuccess(result)) {
|
if (Exit.isSuccess(result)) {
|
||||||
|
invalidateResources(entry)
|
||||||
entry.client = result.value.connection
|
entry.client = result.value.connection
|
||||||
entry.tools = result.value.tools.map((def) => toTool(name, def))
|
entry.tools = result.value.tools.map((def) => toTool(name, def))
|
||||||
entry.prompts = []
|
entry.prompts = []
|
||||||
@@ -501,6 +530,7 @@ export const layer = Layer.effect(
|
|||||||
// after the initial registration sweep and emits no list-changed notification would otherwise
|
// after the initial registration sweep and emits no list-changed notification would otherwise
|
||||||
// stay invisible to the model.
|
// stay invisible to the model.
|
||||||
yield* events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
|
yield* events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
|
||||||
|
yield* events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)
|
||||||
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||||
fork(refreshPrompts(name, entry, result.value.connection).pipe(Effect.ignore))
|
fork(refreshPrompts(name, entry, result.value.connection).pipe(Effect.ignore))
|
||||||
return
|
return
|
||||||
@@ -541,6 +571,7 @@ export const layer = Layer.effect(
|
|||||||
entry.client = undefined
|
entry.client = undefined
|
||||||
entry.tools = undefined
|
entry.tools = undefined
|
||||||
entry.prompts = undefined
|
entry.prompts = undefined
|
||||||
|
invalidateResources(entry)
|
||||||
yield* events.publish(Command.Event.Updated, {}).pipe(Effect.ignore)
|
yield* events.publish(Command.Event.Updated, {}).pipe(Effect.ignore)
|
||||||
}
|
}
|
||||||
yield* startServer(name, entry)
|
yield* startServer(name, entry)
|
||||||
@@ -557,11 +588,6 @@ export const layer = Layer.effect(
|
|||||||
concurrency: "unbounded",
|
concurrency: "unbounded",
|
||||||
discard: true,
|
discard: true,
|
||||||
})
|
})
|
||||||
const gate = Effect.fnUntraced(function* (server: ServerName | string) {
|
|
||||||
const target = yield* requireServer(server)
|
|
||||||
yield* Deferred.await(target.entry.startup)
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
servers: Effect.fn("MCP.servers")(function* () {
|
servers: Effect.fn("MCP.servers")(function* () {
|
||||||
const entries = Array.from(runtime).toSorted(([a], [b]) => a.localeCompare(b))
|
const entries = Array.from(runtime).toSorted(([a], [b]) => a.localeCompare(b))
|
||||||
@@ -637,11 +663,40 @@ export const layer = Layer.effect(
|
|||||||
}),
|
}),
|
||||||
resourceCatalog: Effect.fn("MCP.resourceCatalog")(function* () {
|
resourceCatalog: Effect.fn("MCP.resourceCatalog")(function* () {
|
||||||
yield* whenAllReady
|
yield* whenAllReady
|
||||||
return new ResourceCatalog({ resources: [], templates: [] })
|
yield* Effect.forEach(
|
||||||
|
runtime,
|
||||||
|
([name, entry]) => (entry.client ? loadResources(name, entry, entry.client) : Effect.void),
|
||||||
|
{ concurrency: "unbounded", discard: true },
|
||||||
|
)
|
||||||
|
return ResourceCatalog.make({
|
||||||
|
resources: Array.from(runtime.values())
|
||||||
|
.flatMap((entry) => entry.resources ?? [])
|
||||||
|
.toSorted(
|
||||||
|
(a, b) => a.server.localeCompare(b.server) || a.name.localeCompare(b.name) || a.uri.localeCompare(b.uri),
|
||||||
|
),
|
||||||
|
templates: Array.from(runtime.values())
|
||||||
|
.flatMap((entry) => entry.resourceTemplates ?? [])
|
||||||
|
.toSorted(
|
||||||
|
(a, b) =>
|
||||||
|
a.server.localeCompare(b.server) ||
|
||||||
|
a.name.localeCompare(b.name) ||
|
||||||
|
a.uriTemplate.localeCompare(b.uriTemplate),
|
||||||
|
),
|
||||||
|
})
|
||||||
}),
|
}),
|
||||||
readResource: Effect.fn("MCP.readResource")(function* (input) {
|
readResource: Effect.fn("MCP.readResource")(function* (input) {
|
||||||
yield* gate(input.server)
|
const target = yield* requireServer(input.server)
|
||||||
return undefined
|
yield* Deferred.await(target.entry.startup)
|
||||||
|
if (!target.entry.client) return undefined
|
||||||
|
const result = yield* target.entry.client
|
||||||
|
.readResource({ uri: input.uri })
|
||||||
|
.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
|
if (!result) return undefined
|
||||||
|
return ResourceContent.make({
|
||||||
|
server: target.name,
|
||||||
|
uri: input.uri,
|
||||||
|
contents: result.contents,
|
||||||
|
})
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -4,16 +4,27 @@ import {
|
|||||||
CallToolRequestSchema,
|
CallToolRequestSchema,
|
||||||
GetPromptRequestSchema,
|
GetPromptRequestSchema,
|
||||||
ListPromptsRequestSchema,
|
ListPromptsRequestSchema,
|
||||||
|
ListResourcesRequestSchema,
|
||||||
|
ListResourceTemplatesRequestSchema,
|
||||||
ListToolsRequestSchema,
|
ListToolsRequestSchema,
|
||||||
|
ReadResourceRequestSchema,
|
||||||
} from "@modelcontextprotocol/sdk/types.js"
|
} from "@modelcontextprotocol/sdk/types.js"
|
||||||
|
|
||||||
const server = new Server({ name: "timeout", version: "1.0.0" }, { capabilities: { prompts: {}, tools: {} } })
|
const server = new Server(
|
||||||
|
{ name: "timeout", version: "1.0.0" },
|
||||||
|
{ capabilities: { prompts: {}, resources: {}, tools: {} } },
|
||||||
|
)
|
||||||
|
|
||||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||||
if (process.env.MCP_TIMEOUT_TARGET === "catalog") await Bun.sleep(100)
|
if (process.env.MCP_TIMEOUT_TARGET === "catalog") await Bun.sleep(100)
|
||||||
return { tools: [{ name: "slow", inputSchema: { type: "object" } }] }
|
return { tools: [{ name: "slow", inputSchema: { type: "object" } }] }
|
||||||
})
|
})
|
||||||
server.setRequestHandler(ListPromptsRequestSchema, () => Promise.resolve({ prompts: [{ name: "slow" }] }))
|
server.setRequestHandler(ListPromptsRequestSchema, () => Promise.resolve({ prompts: [{ name: "slow" }] }))
|
||||||
|
server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
||||||
|
if (process.env.MCP_TIMEOUT_TARGET === "resource-catalog") await Bun.sleep(100)
|
||||||
|
return { resources: [{ name: "slow", uri: "test://slow" }] }
|
||||||
|
})
|
||||||
|
server.setRequestHandler(ListResourceTemplatesRequestSchema, () => Promise.resolve({ resourceTemplates: [] }))
|
||||||
server.setRequestHandler(CallToolRequestSchema, async () => {
|
server.setRequestHandler(CallToolRequestSchema, async () => {
|
||||||
await Bun.sleep(100)
|
await Bun.sleep(100)
|
||||||
return { content: [] }
|
return { content: [] }
|
||||||
@@ -22,5 +33,9 @@ server.setRequestHandler(GetPromptRequestSchema, async () => {
|
|||||||
await Bun.sleep(100)
|
await Bun.sleep(100)
|
||||||
return { messages: [] }
|
return { messages: [] }
|
||||||
})
|
})
|
||||||
|
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
||||||
|
await Bun.sleep(100)
|
||||||
|
return { contents: [{ uri: request.params.uri, text: "slow" }] }
|
||||||
|
})
|
||||||
|
|
||||||
await server.connect(new StdioServerTransport())
|
await server.connect(new StdioServerTransport())
|
||||||
|
|||||||
@@ -14,15 +14,12 @@ export const emptyMcpLayer = Layer.succeed(
|
|||||||
instructions: () => Effect.succeed([]),
|
instructions: () => Effect.succeed([]),
|
||||||
prompts: () => Effect.succeed([]),
|
prompts: () => Effect.succeed([]),
|
||||||
prompt: () => Effect.succeed(undefined),
|
prompt: () => Effect.succeed(undefined),
|
||||||
resourceCatalog: () => Effect.succeed(new MCP.ResourceCatalog({ resources: [], templates: [] })),
|
resourceCatalog: () => Effect.succeed(MCP.ResourceCatalog.make({ resources: [], templates: [] })),
|
||||||
readResource: () => Effect.succeed(undefined),
|
readResource: () => Effect.succeed(undefined),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const emptyConfigLayer = Layer.succeed(
|
export const emptyConfigLayer = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))
|
||||||
Config.Service,
|
|
||||||
Config.Service.of({ entries: () => Effect.succeed([]) }),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const testLocationLayer = Layer.succeed(
|
export const testLocationLayer = Layer.succeed(
|
||||||
Location.Service,
|
Location.Service,
|
||||||
|
|||||||
@@ -3,26 +3,203 @@ import { describe, expect, test } from "bun:test"
|
|||||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
|
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
|
||||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
||||||
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
|
||||||
|
import {
|
||||||
|
CallToolRequestSchema,
|
||||||
|
ListResourcesRequestSchema,
|
||||||
|
ListResourceTemplatesRequestSchema,
|
||||||
|
ListToolsRequestSchema,
|
||||||
|
ReadResourceRequestSchema,
|
||||||
|
SubscribeRequestSchema,
|
||||||
|
UnsubscribeRequestSchema,
|
||||||
|
} from "@modelcontextprotocol/sdk/types.js"
|
||||||
import { ConfigMCP } from "@opencode-ai/core/config/mcp"
|
import { ConfigMCP } from "@opencode-ai/core/config/mcp"
|
||||||
|
import { Config } from "@opencode-ai/core/config"
|
||||||
|
import { Credential } from "@opencode-ai/core/credential"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
|
import { Form } from "@opencode-ai/core/form"
|
||||||
|
import { Integration } from "@opencode-ai/core/integration"
|
||||||
|
import { Location } from "@opencode-ai/core/location"
|
||||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||||
import { MCPClient } from "@opencode-ai/core/mcp/client"
|
import { MCPClient } from "@opencode-ai/core/mcp/client"
|
||||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||||
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { SessionV2 } from "@opencode-ai/core/session"
|
import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
import { McpTool } from "@opencode-ai/core/tool/mcp"
|
import { McpTool } from "@opencode-ai/core/tool/mcp"
|
||||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||||
import { Deferred, Effect, Fiber, Layer, Stream } from "effect"
|
import { Deferred, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
import { location } from "./fixture/location"
|
||||||
import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
|
import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
|
||||||
|
|
||||||
let assertion: Deferred.Deferred<PermissionV2.AssertInput> | undefined
|
let assertion: Deferred.Deferred<PermissionV2.AssertInput> | undefined
|
||||||
let decision: Effect.Effect<void, PermissionV2.Error> = Effect.void
|
let decision: Effect.Effect<void, PermissionV2.Error> = Effect.void
|
||||||
let calls = 0
|
let calls = 0
|
||||||
|
|
||||||
|
type ResourcePage = {
|
||||||
|
items: Array<{ name: string; uri: string; description?: string; mimeType?: string }>
|
||||||
|
nextCursor?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResourceTemplatePage = {
|
||||||
|
items: Array<{ name: string; uriTemplate: string; description?: string; mimeType?: string }>
|
||||||
|
nextCursor?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function resourceServer(input: { resources?: boolean; subscribe?: boolean; listChanged?: boolean } = {}) {
|
||||||
|
return Effect.acquireRelease(
|
||||||
|
Effect.promise(async () => {
|
||||||
|
const state = {
|
||||||
|
resources: [] as ResourcePage["items"],
|
||||||
|
templates: [] as ResourceTemplatePage["items"],
|
||||||
|
resourcePages: undefined as Record<string, ResourcePage> | undefined,
|
||||||
|
templatePages: undefined as Record<string, ResourceTemplatePage> | undefined,
|
||||||
|
contents: [
|
||||||
|
{ uri: "docs://readme", text: "hello", mimeType: "text/plain" },
|
||||||
|
{ uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
|
||||||
|
] as Array<{ uri: string; text: string; mimeType?: string } | { uri: string; blob: string; mimeType?: string }>,
|
||||||
|
resourceLists: 0,
|
||||||
|
templateLists: 0,
|
||||||
|
resourceListFailures: 0,
|
||||||
|
subscriptions: [] as string[],
|
||||||
|
unsubscriptions: [] as string[],
|
||||||
|
onSubscription: undefined as (() => void) | undefined,
|
||||||
|
}
|
||||||
|
const makeProtocol = async () => {
|
||||||
|
const protocol = new Server(
|
||||||
|
{ name: "mcp-resources", version: "1.0.0" },
|
||||||
|
{
|
||||||
|
capabilities: {
|
||||||
|
tools: {},
|
||||||
|
...(input.resources === false
|
||||||
|
? {}
|
||||||
|
: { resources: { subscribe: input.subscribe, listChanged: input.listChanged } }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] }))
|
||||||
|
if (input.resources !== false) {
|
||||||
|
protocol.setRequestHandler(ListResourcesRequestSchema, (request) => {
|
||||||
|
state.resourceLists += 1
|
||||||
|
if (state.resourceListFailures > 0) {
|
||||||
|
state.resourceListFailures -= 1
|
||||||
|
throw new Error("resource list failed")
|
||||||
|
}
|
||||||
|
const page = state.resourcePages?.[request.params?.cursor ?? "initial"]
|
||||||
|
return Promise.resolve({ resources: page?.items ?? state.resources, nextCursor: page?.nextCursor })
|
||||||
|
})
|
||||||
|
protocol.setRequestHandler(ListResourceTemplatesRequestSchema, (request) => {
|
||||||
|
state.templateLists += 1
|
||||||
|
const page = state.templatePages?.[request.params?.cursor ?? "initial"]
|
||||||
|
return Promise.resolve({ resourceTemplates: page?.items ?? state.templates, nextCursor: page?.nextCursor })
|
||||||
|
})
|
||||||
|
protocol.setRequestHandler(ReadResourceRequestSchema, () => Promise.resolve({ contents: state.contents }))
|
||||||
|
protocol.setRequestHandler(SubscribeRequestSchema, (request) => {
|
||||||
|
state.subscriptions.push(request.params.uri)
|
||||||
|
state.onSubscription?.()
|
||||||
|
return Promise.resolve({})
|
||||||
|
})
|
||||||
|
protocol.setRequestHandler(UnsubscribeRequestSchema, (request) => {
|
||||||
|
state.unsubscriptions.push(request.params.uri)
|
||||||
|
return Promise.resolve({})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||||
|
sessionIdGenerator: () => crypto.randomUUID(),
|
||||||
|
enableJsonResponse: true,
|
||||||
|
})
|
||||||
|
await protocol.connect(transport)
|
||||||
|
return { protocol, transport }
|
||||||
|
}
|
||||||
|
let current = await makeProtocol()
|
||||||
|
let expireSession = false
|
||||||
|
const http = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch: (request) => {
|
||||||
|
if (expireSession && request.headers.has("mcp-session-id")) {
|
||||||
|
expireSession = false
|
||||||
|
return new Response("session expired", { status: 404 })
|
||||||
|
}
|
||||||
|
return current.transport.handleRequest(request)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
url: http.url.toString(),
|
||||||
|
sendResourceListChanged: () => current.protocol.sendResourceListChanged(),
|
||||||
|
sendResourceUpdated: (uri: string) => current.protocol.sendResourceUpdated({ uri }),
|
||||||
|
restart: async () => {
|
||||||
|
current = await makeProtocol()
|
||||||
|
expireSession = true
|
||||||
|
},
|
||||||
|
close: async () => {
|
||||||
|
await current.protocol.close().catch(() => {})
|
||||||
|
http.stop(true)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
(server) => Effect.promise(server.close),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resourceMcpLayer(url: string, changed: Deferred.Deferred<void>) {
|
||||||
|
const directory = AbsolutePath.make(import.meta.dir)
|
||||||
|
const unusedIntegration = () => Effect.die("unused integration service")
|
||||||
|
let resourceChanges = 0
|
||||||
|
return MCP.layer.pipe(
|
||||||
|
Layer.provide(
|
||||||
|
Layer.mergeAll(
|
||||||
|
Layer.succeed(
|
||||||
|
Config.Service,
|
||||||
|
Config.Service.of({
|
||||||
|
entries: () =>
|
||||||
|
Effect.succeed([
|
||||||
|
new Config.Document({
|
||||||
|
type: "document",
|
||||||
|
info: new Config.Info({
|
||||||
|
mcp: new ConfigMCP.Info({
|
||||||
|
servers: { resources: new ConfigMCP.Remote({ type: "remote", url, oauth: false }) },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Layer.succeed(Location.Service, Location.Service.of(location({ directory }))),
|
||||||
|
Layer.mock(EventV2.Service, {
|
||||||
|
subscribe: () => Stream.never,
|
||||||
|
publish: (definition) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
if (definition.type === "mcp.resources.changed" && ++resourceChanges === 2)
|
||||||
|
Deferred.doneUnsafe(changed, Exit.void)
|
||||||
|
return undefined as never
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
Layer.mock(Form.Service, {}),
|
||||||
|
Layer.mock(Integration.Service, {
|
||||||
|
connection: {
|
||||||
|
active: unusedIntegration,
|
||||||
|
resolve: unusedIntegration,
|
||||||
|
key: unusedIntegration,
|
||||||
|
oauth: unusedIntegration,
|
||||||
|
update: unusedIntegration,
|
||||||
|
remove: unusedIntegration,
|
||||||
|
},
|
||||||
|
attempt: {
|
||||||
|
status: unusedIntegration,
|
||||||
|
complete: unusedIntegration,
|
||||||
|
cancel: unusedIntegration,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Layer.mock(Credential.Service, {}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const mcp = Layer.mock(MCP.Service, {
|
const mcp = Layer.mock(MCP.Service, {
|
||||||
tools: () =>
|
tools: () =>
|
||||||
Effect.succeed([
|
Effect.succeed([
|
||||||
@@ -241,6 +418,297 @@ test("applies the configured MCP execution timeout to prompts", async () => {
|
|||||||
await expect(result).rejects.toThrow("Request timed out")
|
await expect(result).rejects.toThrow("Request timed out")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("applies configured MCP timeouts to resource operations", async () => {
|
||||||
|
const catalog = Effect.runPromise(
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const connection = yield* MCPClient.connect(
|
||||||
|
"resource-catalog-timeout",
|
||||||
|
new ConfigMCP.Local({
|
||||||
|
type: "local",
|
||||||
|
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
|
||||||
|
environment: { MCP_TIMEOUT_TARGET: "resource-catalog" },
|
||||||
|
timeout: new ConfigMCP.Timeout({ catalog: 10 }),
|
||||||
|
}),
|
||||||
|
import.meta.dir,
|
||||||
|
)
|
||||||
|
return yield* connection.resources()
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await expect(catalog).rejects.toThrow("Request timed out")
|
||||||
|
|
||||||
|
const read = Effect.runPromise(
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const connection = yield* MCPClient.connect(
|
||||||
|
"resource-read-timeout",
|
||||||
|
new ConfigMCP.Local({
|
||||||
|
type: "local",
|
||||||
|
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
|
||||||
|
timeout: new ConfigMCP.Timeout({ execution: 10 }),
|
||||||
|
}),
|
||||||
|
import.meta.dir,
|
||||||
|
)
|
||||||
|
return yield* connection.readResource({ uri: "test://slow" })
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await expect(read).rejects.toThrow("Request timed out")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("lists, reads, and invalidates MCP resources", async () => {
|
||||||
|
await Effect.runPromise(
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const server = yield* resourceServer({ listChanged: true })
|
||||||
|
server.state.resourcePages = {
|
||||||
|
initial: {
|
||||||
|
items: [{ name: "Readme", uri: "docs://readme", description: "Project docs" }],
|
||||||
|
nextCursor: "resources-2",
|
||||||
|
},
|
||||||
|
"resources-2": { items: [{ name: "Logo", uri: "docs://logo", mimeType: "image/png" }] },
|
||||||
|
}
|
||||||
|
server.state.templatePages = {
|
||||||
|
initial: {
|
||||||
|
items: [{ name: "File", uriTemplate: "docs://{path}" }],
|
||||||
|
nextCursor: "templates-2",
|
||||||
|
},
|
||||||
|
"templates-2": { items: [{ name: "Issue", uriTemplate: "issue://{id}", description: "Issue" }] },
|
||||||
|
}
|
||||||
|
const connection = yield* MCPClient.connect(
|
||||||
|
"resources",
|
||||||
|
new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
|
||||||
|
import.meta.dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(yield* connection.resources()).toEqual([
|
||||||
|
{ name: "Readme", uri: "docs://readme", description: "Project docs", mimeType: undefined },
|
||||||
|
{ name: "Logo", uri: "docs://logo", description: undefined, mimeType: "image/png" },
|
||||||
|
])
|
||||||
|
expect(yield* connection.resourceTemplates()).toEqual([
|
||||||
|
{ name: "File", uriTemplate: "docs://{path}", description: undefined, mimeType: undefined },
|
||||||
|
{ name: "Issue", uriTemplate: "issue://{id}", description: "Issue", mimeType: undefined },
|
||||||
|
])
|
||||||
|
expect(yield* connection.readResource({ uri: "docs://readme" })).toEqual({
|
||||||
|
contents: [
|
||||||
|
{ type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" },
|
||||||
|
{ type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const changed = yield* Deferred.make<void>()
|
||||||
|
connection.onResourcesChanged(() => Deferred.doneUnsafe(changed, Exit.void))
|
||||||
|
yield* Effect.promise(server.sendResourceListChanged)
|
||||||
|
yield* Deferred.await(changed)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("shares scoped MCP resource subscriptions", async () => {
|
||||||
|
await Effect.runPromise(
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const server = yield* resourceServer({ subscribe: true })
|
||||||
|
const connection = yield* MCPClient.connect(
|
||||||
|
"resources",
|
||||||
|
new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
|
||||||
|
import.meta.dir,
|
||||||
|
)
|
||||||
|
const firstScope = yield* Scope.make()
|
||||||
|
const secondScope = yield* Scope.make()
|
||||||
|
const secondUpdate = yield* Deferred.make<void>()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
yield* connection
|
||||||
|
.subscribeResource({ uri: "docs://readme" }, () => {
|
||||||
|
throw new Error("listener failed")
|
||||||
|
})
|
||||||
|
.pipe(Scope.provide(firstScope)),
|
||||||
|
).toBe(true)
|
||||||
|
expect(
|
||||||
|
yield* connection
|
||||||
|
.subscribeResource({ uri: "docs://readme" }, () => Deferred.doneUnsafe(secondUpdate, Exit.void))
|
||||||
|
.pipe(Scope.provide(secondScope)),
|
||||||
|
).toBe(true)
|
||||||
|
expect(server.state.subscriptions).toEqual(["docs://readme"])
|
||||||
|
|
||||||
|
yield* Effect.promise(() => server.sendResourceUpdated("docs://readme"))
|
||||||
|
yield* Deferred.await(secondUpdate)
|
||||||
|
yield* Scope.close(firstScope, Exit.void)
|
||||||
|
expect(server.state.unsubscriptions).toEqual([])
|
||||||
|
yield* Scope.close(secondScope, Exit.void)
|
||||||
|
expect(server.state.unsubscriptions).toEqual(["docs://readme"])
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("releases MCP resource subscriptions provided a closed scope", async () => {
|
||||||
|
await Effect.runPromise(
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const server = yield* resourceServer({ subscribe: true })
|
||||||
|
const connection = yield* MCPClient.connect(
|
||||||
|
"resources",
|
||||||
|
new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
|
||||||
|
import.meta.dir,
|
||||||
|
)
|
||||||
|
const closed = yield* Scope.make()
|
||||||
|
yield* Scope.close(closed, Exit.void)
|
||||||
|
expect(
|
||||||
|
yield* connection
|
||||||
|
.subscribeResource({ uri: "docs://readme" }, () => {})
|
||||||
|
.pipe(
|
||||||
|
Scope.provide(closed),
|
||||||
|
Effect.timeoutOrElse({
|
||||||
|
duration: "1 second",
|
||||||
|
orElse: () => Effect.fail(new Error("closed-scope resource subscription deadlocked")),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
expect(server.state.subscriptions).toEqual(["docs://readme"])
|
||||||
|
expect(server.state.unsubscriptions).toEqual(["docs://readme"])
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("restores MCP resource state after HTTP session recovery", async () => {
|
||||||
|
await Effect.runPromise(
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const server = yield* resourceServer({ subscribe: true, listChanged: true })
|
||||||
|
server.state.resources = [{ name: "Readme", uri: "docs://readme" }]
|
||||||
|
const connection = yield* MCPClient.connect(
|
||||||
|
"resources",
|
||||||
|
new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
|
||||||
|
import.meta.dir,
|
||||||
|
)
|
||||||
|
const changed = yield* Deferred.make<void>()
|
||||||
|
const subscriptionScope = yield* Scope.make()
|
||||||
|
connection.onResourcesChanged(() => Deferred.doneUnsafe(changed, Exit.void))
|
||||||
|
expect(
|
||||||
|
yield* connection
|
||||||
|
.subscribeResource({ uri: "docs://readme" }, () => {})
|
||||||
|
.pipe(Scope.provide(subscriptionScope)),
|
||||||
|
).toBe(true)
|
||||||
|
|
||||||
|
yield* Effect.promise(server.restart)
|
||||||
|
server.state.resources = [{ name: "Guide", uri: "docs://guide" }]
|
||||||
|
const resubscribed = yield* Deferred.make<void>()
|
||||||
|
server.state.onSubscription = () => Deferred.doneUnsafe(resubscribed, Exit.void)
|
||||||
|
expect((yield* connection.resources()).map((resource) => resource.uri)).toEqual(["docs://guide"])
|
||||||
|
yield* Deferred.await(changed)
|
||||||
|
yield* Deferred.await(resubscribed)
|
||||||
|
expect(server.state.subscriptions).toEqual(["docs://readme", "docs://readme"])
|
||||||
|
yield* Scope.close(subscriptionScope, Exit.void)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("skips unsupported MCP resource subscriptions", async () => {
|
||||||
|
await Effect.runPromise(
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const server = yield* resourceServer()
|
||||||
|
const connection = yield* MCPClient.connect(
|
||||||
|
"resources",
|
||||||
|
new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
|
||||||
|
import.meta.dir,
|
||||||
|
)
|
||||||
|
expect(yield* connection.subscribeResource({ uri: "docs://readme" }, () => {})).toBe(false)
|
||||||
|
expect(server.state.subscriptions).toEqual([])
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("skips MCP resource requests when the capability is absent", async () => {
|
||||||
|
await Effect.runPromise(
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const server = yield* resourceServer({ resources: false })
|
||||||
|
const connection = yield* MCPClient.connect(
|
||||||
|
"resources",
|
||||||
|
new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
|
||||||
|
import.meta.dir,
|
||||||
|
)
|
||||||
|
expect(yield* connection.resources()).toEqual([])
|
||||||
|
expect(yield* connection.resourceTemplates()).toEqual([])
|
||||||
|
expect(yield* connection.readResource({ uri: "docs://readme" })).toBeUndefined()
|
||||||
|
expect({ resources: server.state.resourceLists, templates: server.state.templateLists }).toEqual({
|
||||||
|
resources: 0,
|
||||||
|
templates: 0,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("caches and invalidates MCP resource catalogs", async () => {
|
||||||
|
await Effect.runPromise(
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const server = yield* resourceServer({ listChanged: true, subscribe: true })
|
||||||
|
server.state.resources = [{ name: "Readme", uri: "docs://readme" }]
|
||||||
|
server.state.templates = [{ name: "File", uriTemplate: "docs://{path}" }]
|
||||||
|
server.state.resourceListFailures = 1
|
||||||
|
const changed = yield* Deferred.make<void>()
|
||||||
|
|
||||||
|
yield* Effect.gen(function* () {
|
||||||
|
const service = yield* MCP.Service
|
||||||
|
expect(yield* service.resourceCatalog()).toEqual({
|
||||||
|
resources: [],
|
||||||
|
templates: [
|
||||||
|
{
|
||||||
|
server: "resources",
|
||||||
|
name: "File",
|
||||||
|
uriTemplate: "docs://{path}",
|
||||||
|
description: undefined,
|
||||||
|
mimeType: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect((yield* service.resourceCatalog()).resources).toEqual([
|
||||||
|
{
|
||||||
|
server: "resources",
|
||||||
|
name: "Readme",
|
||||||
|
uri: "docs://readme",
|
||||||
|
description: undefined,
|
||||||
|
mimeType: undefined,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
yield* service.resourceCatalog()
|
||||||
|
expect({ resources: server.state.resourceLists, templates: server.state.templateLists }).toEqual({
|
||||||
|
resources: 2,
|
||||||
|
templates: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
server.state.resources = [{ name: "Guide", uri: "docs://guide" }]
|
||||||
|
yield* Effect.promise(server.sendResourceListChanged)
|
||||||
|
yield* Deferred.await(changed)
|
||||||
|
expect((yield* service.resourceCatalog()).resources.map((resource) => resource.uri)).toEqual(["docs://guide"])
|
||||||
|
expect({ resources: server.state.resourceLists, templates: server.state.templateLists }).toEqual({
|
||||||
|
resources: 3,
|
||||||
|
templates: 2,
|
||||||
|
})
|
||||||
|
expect(yield* service.readResource({ server: "resources", uri: "docs://readme" })).toEqual({
|
||||||
|
server: "resources",
|
||||||
|
uri: "docs://readme",
|
||||||
|
contents: [
|
||||||
|
{ type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" },
|
||||||
|
{ type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}).pipe(Effect.provide(resourceMcpLayer(server.url, changed)))
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it.effect("advertises MCP output schemas to Code Mode", () =>
|
it.effect("advertises MCP output schemas to Code Mode", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const registry = yield* ToolRegistry.Service
|
const registry = yield* ToolRegistry.Service
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ export const groupNames = {
|
|||||||
} as const
|
} as const
|
||||||
|
|
||||||
export const endpointNames = {
|
export const endpointNames = {
|
||||||
|
"mcp.resource.catalog": "resourceCatalog",
|
||||||
|
"mcp.resource.read": "readResource",
|
||||||
"session.messages": "list",
|
"session.messages": "list",
|
||||||
"integration.connect.key": "connectKey",
|
"integration.connect.key": "connectKey",
|
||||||
"integration.connect.oauth": "connectOauth",
|
"integration.connect.oauth": "connectOauth",
|
||||||
|
|||||||
@@ -61,6 +61,15 @@ export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFo
|
|||||||
{ httpApiStatus: 404 },
|
{ httpApiStatus: 404 },
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
export class McpServerNotFoundError extends Schema.TaggedErrorClass<McpServerNotFoundError>()(
|
||||||
|
"McpServerNotFoundError",
|
||||||
|
{
|
||||||
|
server: Schema.String,
|
||||||
|
message: Schema.String,
|
||||||
|
},
|
||||||
|
{ httpApiStatus: 404 },
|
||||||
|
) {}
|
||||||
|
|
||||||
export class SessionNotFoundError extends Schema.TaggedErrorClass<SessionNotFoundError>()(
|
export class SessionNotFoundError extends Schema.TaggedErrorClass<SessionNotFoundError>()(
|
||||||
"SessionNotFoundError",
|
"SessionNotFoundError",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Mcp } from "@opencode-ai/schema/mcp"
|
|||||||
import { Location } from "@opencode-ai/schema/location"
|
import { Location } from "@opencode-ai/schema/location"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
|
import { McpServerNotFoundError } from "../errors.js"
|
||||||
import { LocationQuery, locationQueryOpenApi } from "./location.js"
|
import { LocationQuery, locationQueryOpenApi } from "./location.js"
|
||||||
|
|
||||||
export const McpGroup = HttpApiGroup.make("server.mcp")
|
export const McpGroup = HttpApiGroup.make("server.mcp")
|
||||||
@@ -19,4 +20,34 @@ export const McpGroup = HttpApiGroup.make("server.mcp")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.annotateMerge(OpenApi.annotations({ title: "mcp", description: "MCP server status routes." }))
|
.add(
|
||||||
|
HttpApiEndpoint.get("mcp.resource.catalog", "/api/mcp/resource", {
|
||||||
|
query: LocationQuery,
|
||||||
|
success: Location.response(Mcp.ResourceCatalog),
|
||||||
|
})
|
||||||
|
.annotateMerge(locationQueryOpenApi)
|
||||||
|
.annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
identifier: "v2.mcp.resource.catalog",
|
||||||
|
summary: "List MCP resources",
|
||||||
|
description: "Retrieve resources and resource templates from connected MCP servers.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.add(
|
||||||
|
HttpApiEndpoint.post("mcp.resource.read", "/api/mcp/resource/read", {
|
||||||
|
query: LocationQuery,
|
||||||
|
payload: Schema.Struct({ server: Schema.String, uri: Schema.String }),
|
||||||
|
success: Location.response(Schema.NullOr(Mcp.ResourceContent)),
|
||||||
|
error: McpServerNotFoundError,
|
||||||
|
})
|
||||||
|
.annotateMerge(locationQueryOpenApi)
|
||||||
|
.annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
identifier: "v2.mcp.resource.read",
|
||||||
|
summary: "Read MCP resource",
|
||||||
|
description: "Read the current content of one resource from a connected MCP server.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.annotateMerge(OpenApi.annotations({ title: "mcp", description: "MCP server and resource routes." }))
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ import { isOpenCodeEvent } from "../src/groups/event.js"
|
|||||||
test("classifies public events by type", () => {
|
test("classifies public events by type", () => {
|
||||||
expect(isOpenCodeEvent({ type: "server.connected" })).toBe(true)
|
expect(isOpenCodeEvent({ type: "server.connected" })).toBe(true)
|
||||||
expect(isOpenCodeEvent({ type: "mcp.status.changed" })).toBe(true)
|
expect(isOpenCodeEvent({ type: "mcp.status.changed" })).toBe(true)
|
||||||
|
expect(isOpenCodeEvent({ type: "mcp.resources.changed" })).toBe(true)
|
||||||
expect(isOpenCodeEvent({ type: "mcp.tools.changed" })).toBe(false)
|
expect(isOpenCodeEvent({ type: "mcp.tools.changed" })).toBe(false)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export const ServerDefinitions = Event.inventory(
|
|||||||
...InstallationEvent.Definitions,
|
...InstallationEvent.Definitions,
|
||||||
...VcsEvent.Definitions,
|
...VcsEvent.Definitions,
|
||||||
McpEvent.StatusChanged,
|
McpEvent.StatusChanged,
|
||||||
|
McpEvent.ResourcesChanged,
|
||||||
// Shared transitional: V1 contracts the current TUI still consumes during
|
// Shared transitional: V1 contracts the current TUI still consumes during
|
||||||
// the migration (permission.asked/replied, question.asked, session.error).
|
// the migration (permission.asked/replied, question.asked, session.error).
|
||||||
// Remove when the TUI moves to the current permission/question surfaces.
|
// Remove when the TUI moves to the current permission/question surfaces.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export { Form } from "./form.js"
|
|||||||
export { Integration } from "./integration.js"
|
export { Integration } from "./integration.js"
|
||||||
export { LLM } from "./llm.js"
|
export { LLM } from "./llm.js"
|
||||||
export { Location } from "./location.js"
|
export { Location } from "./location.js"
|
||||||
|
export { Mcp } from "./mcp.js"
|
||||||
export { Model } from "./model.js"
|
export { Model } from "./model.js"
|
||||||
export { Permission } from "./permission.js"
|
export { Permission } from "./permission.js"
|
||||||
export { PermissionSaved } from "./permission-saved.js"
|
export { PermissionSaved } from "./permission-saved.js"
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ export const ToolsChanged = Event.ephemeral({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const ResourcesChanged = Event.ephemeral({
|
||||||
|
type: "mcp.resources.changed",
|
||||||
|
schema: {
|
||||||
|
server: Schema.String,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
export const BrowserOpenFailed = Event.ephemeral({
|
export const BrowserOpenFailed = Event.ephemeral({
|
||||||
type: "mcp.browser.open.failed",
|
type: "mcp.browser.open.failed",
|
||||||
schema: {
|
schema: {
|
||||||
@@ -27,4 +34,4 @@ export const StatusChanged = Event.ephemeral({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const Definitions = Event.inventory(ToolsChanged, StatusChanged)
|
export const Definitions = Event.inventory(ToolsChanged, ResourcesChanged, StatusChanged)
|
||||||
|
|||||||
@@ -25,14 +25,9 @@ const NeedsClientRegistration = Schema.Struct({
|
|||||||
}).annotate({ identifier: "Mcp.Status.NeedsClientRegistration" })
|
}).annotate({ identifier: "Mcp.Status.NeedsClientRegistration" })
|
||||||
|
|
||||||
export type Status = typeof Status.Type
|
export type Status = typeof Status.Type
|
||||||
export const Status = Schema.Union([
|
export const Status = Schema.Union([Connected, Pending, Disabled, Failed, NeedsAuth, NeedsClientRegistration]).pipe(
|
||||||
Connected,
|
Schema.toTaggedUnion("status"),
|
||||||
Pending,
|
)
|
||||||
Disabled,
|
|
||||||
Failed,
|
|
||||||
NeedsAuth,
|
|
||||||
NeedsClientRegistration,
|
|
||||||
]).pipe(Schema.toTaggedUnion("status"))
|
|
||||||
|
|
||||||
export interface Server extends Schema.Schema.Type<typeof Server> {}
|
export interface Server extends Schema.Schema.Type<typeof Server> {}
|
||||||
export const Server = Schema.Struct({
|
export const Server = Schema.Struct({
|
||||||
@@ -42,3 +37,50 @@ export const Server = Schema.Struct({
|
|||||||
// without matching by name, which could collide with provider or plugin integrations.
|
// without matching by name, which could collide with provider or plugin integrations.
|
||||||
integrationID: optional(IntegrationID),
|
integrationID: optional(IntegrationID),
|
||||||
}).annotate({ identifier: "Mcp.Server" })
|
}).annotate({ identifier: "Mcp.Server" })
|
||||||
|
|
||||||
|
export interface Resource extends Schema.Schema.Type<typeof Resource> {}
|
||||||
|
export const Resource = Schema.Struct({
|
||||||
|
server: Schema.String,
|
||||||
|
name: Schema.String,
|
||||||
|
uri: Schema.String,
|
||||||
|
description: optional(Schema.String),
|
||||||
|
mimeType: optional(Schema.String),
|
||||||
|
}).annotate({ identifier: "Mcp.Resource" })
|
||||||
|
|
||||||
|
export interface ResourceTemplate extends Schema.Schema.Type<typeof ResourceTemplate> {}
|
||||||
|
export const ResourceTemplate = Schema.Struct({
|
||||||
|
server: Schema.String,
|
||||||
|
name: Schema.String,
|
||||||
|
uriTemplate: Schema.String,
|
||||||
|
description: optional(Schema.String),
|
||||||
|
mimeType: optional(Schema.String),
|
||||||
|
}).annotate({ identifier: "Mcp.ResourceTemplate" })
|
||||||
|
|
||||||
|
export interface ResourceCatalog extends Schema.Schema.Type<typeof ResourceCatalog> {}
|
||||||
|
export const ResourceCatalog = Schema.Struct({
|
||||||
|
resources: Schema.Array(Resource),
|
||||||
|
templates: Schema.Array(ResourceTemplate),
|
||||||
|
}).annotate({ identifier: "Mcp.ResourceCatalog" })
|
||||||
|
|
||||||
|
export const ResourceContentPart = Schema.Union([
|
||||||
|
Schema.Struct({
|
||||||
|
type: Schema.Literal("text"),
|
||||||
|
uri: Schema.String,
|
||||||
|
text: Schema.String,
|
||||||
|
mimeType: optional(Schema.String),
|
||||||
|
}),
|
||||||
|
Schema.Struct({
|
||||||
|
type: Schema.Literal("blob"),
|
||||||
|
uri: Schema.String,
|
||||||
|
blob: Schema.String,
|
||||||
|
mimeType: optional(Schema.String),
|
||||||
|
}),
|
||||||
|
]).pipe(Schema.toTaggedUnion("type"), Schema.annotate({ identifier: "Mcp.ResourceContentPart" }))
|
||||||
|
export type ResourceContentPart = typeof ResourceContentPart.Type
|
||||||
|
|
||||||
|
export interface ResourceContent extends Schema.Schema.Type<typeof ResourceContent> {}
|
||||||
|
export const ResourceContent = Schema.Struct({
|
||||||
|
server: Schema.String,
|
||||||
|
uri: Schema.String,
|
||||||
|
contents: Schema.Array(ResourceContentPart),
|
||||||
|
}).annotate({ identifier: "Mcp.ResourceContent" })
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
|
|||||||
import { DateTime, Schema } from "effect"
|
import { DateTime, Schema } from "effect"
|
||||||
import { Agent } from "../src/agent.js"
|
import { Agent } from "../src/agent.js"
|
||||||
import { FileSystem } from "../src/filesystem.js"
|
import { FileSystem } from "../src/filesystem.js"
|
||||||
|
import { Mcp } from "../src/mcp.js"
|
||||||
import { Model } from "../src/model.js"
|
import { Model } from "../src/model.js"
|
||||||
import { Project } from "../src/project.js"
|
import { Project } from "../src/project.js"
|
||||||
import { Provider } from "../src/provider.js"
|
import { Provider } from "../src/provider.js"
|
||||||
@@ -51,6 +52,11 @@ describe("contract hygiene", () => {
|
|||||||
const identifiers = [
|
const identifiers = [
|
||||||
Agent.Color,
|
Agent.Color,
|
||||||
FileSystem.Submatch,
|
FileSystem.Submatch,
|
||||||
|
Mcp.Resource,
|
||||||
|
Mcp.ResourceTemplate,
|
||||||
|
Mcp.ResourceCatalog,
|
||||||
|
Mcp.ResourceContentPart,
|
||||||
|
Mcp.ResourceContent,
|
||||||
Model.Ref,
|
Model.Ref,
|
||||||
Model.Capabilities,
|
Model.Capabilities,
|
||||||
Model.Cost,
|
Model.Cost,
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ describe("public event manifest", () => {
|
|||||||
expect(EventManifest.Server.get("mcp.status.changed")).toBe(McpEvent.StatusChanged)
|
expect(EventManifest.Server.get("mcp.status.changed")).toBe(McpEvent.StatusChanged)
|
||||||
expect(EventManifest.Server.get("session.deleted")).toBe(SessionEvent.Deleted)
|
expect(EventManifest.Server.get("session.deleted")).toBe(SessionEvent.Deleted)
|
||||||
expect(EventManifest.Server.has("mcp.tools.changed")).toBe(false)
|
expect(EventManifest.Server.has("mcp.tools.changed")).toBe(false)
|
||||||
|
expect(EventManifest.Server.get("mcp.resources.changed")).toBe(McpEvent.ResourcesChanged)
|
||||||
expect(Agent.Event.Updated.durable).toBeUndefined()
|
expect(Agent.Event.Updated.durable).toBeUndefined()
|
||||||
expect(EventManifest.Durable.has("agent.updated")).toBe(false)
|
expect(EventManifest.Durable.has("agent.updated")).toBe(false)
|
||||||
})
|
})
|
||||||
@@ -76,7 +77,7 @@ describe("public event manifest", () => {
|
|||||||
expect(Form.Event.Definitions).toEqual([Form.Event.Created, Form.Event.Replied, Form.Event.Cancelled])
|
expect(Form.Event.Definitions).toEqual([Form.Event.Created, Form.Event.Replied, Form.Event.Cancelled])
|
||||||
expect(Reference.Event.Definitions).toEqual([Reference.Event.Updated])
|
expect(Reference.Event.Definitions).toEqual([Reference.Event.Updated])
|
||||||
expect(Plugin.Event.Definitions).toEqual([Plugin.Event.Added, Plugin.Event.Updated])
|
expect(Plugin.Event.Definitions).toEqual([Plugin.Event.Added, Plugin.Event.Updated])
|
||||||
expect(McpEvent.Definitions).toEqual([McpEvent.ToolsChanged, McpEvent.StatusChanged])
|
expect(McpEvent.Definitions).toEqual([McpEvent.ToolsChanged, McpEvent.ResourcesChanged, McpEvent.StatusChanged])
|
||||||
expect(EventManifest.Latest.has("mcp.browser.open.failed")).toBe(false)
|
expect(EventManifest.Latest.has("mcp.browser.open.failed")).toBe(false)
|
||||||
expect(EventManifest.Latest.has("ide.installed")).toBe(false)
|
expect(EventManifest.Latest.has("ide.installed")).toBe(false)
|
||||||
expect(IdeEvent.Definitions).toEqual([IdeEvent.Installed])
|
expect(IdeEvent.Definitions).toEqual([IdeEvent.Installed])
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { Schema } from "effect"
|
||||||
|
import { Mcp } from "../src/mcp.js"
|
||||||
|
|
||||||
|
describe("Mcp resources", () => {
|
||||||
|
test("decodes resource catalogs and omits absent metadata", () => {
|
||||||
|
const value = Schema.decodeUnknownSync(Mcp.ResourceCatalog)({
|
||||||
|
resources: [{ server: "docs", name: "Readme", uri: "docs://readme" }],
|
||||||
|
templates: [{ server: "docs", name: "File", uriTemplate: "docs://{path}" }],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(Schema.encodeSync(Mcp.ResourceCatalog)(value)).toEqual({
|
||||||
|
resources: [{ server: "docs", name: "Readme", uri: "docs://readme" }],
|
||||||
|
templates: [{ server: "docs", name: "File", uriTemplate: "docs://{path}" }],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves text and base64 blob contents", () => {
|
||||||
|
expect(
|
||||||
|
Schema.decodeUnknownSync(Mcp.ResourceContent)({
|
||||||
|
server: "docs",
|
||||||
|
uri: "docs://readme",
|
||||||
|
contents: [
|
||||||
|
{ type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" },
|
||||||
|
{ type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
server: "docs",
|
||||||
|
uri: "docs://readme",
|
||||||
|
contents: [
|
||||||
|
{ type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" },
|
||||||
|
{ type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -9,6 +9,7 @@ export { Credential } from "@opencode-ai/schema/credential"
|
|||||||
export { FileSystem } from "@opencode-ai/schema/filesystem"
|
export { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||||
export { Integration } from "@opencode-ai/schema/integration"
|
export { Integration } from "@opencode-ai/schema/integration"
|
||||||
export { Location } from "@opencode-ai/schema/location"
|
export { Location } from "@opencode-ai/schema/location"
|
||||||
|
export { Mcp } from "@opencode-ai/schema/mcp"
|
||||||
export { Model } from "@opencode-ai/schema/model"
|
export { Model } from "@opencode-ai/schema/model"
|
||||||
export { Permission } from "@opencode-ai/schema/permission"
|
export { Permission } from "@opencode-ai/schema/permission"
|
||||||
export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
|
export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { Agent } from "@opencode-ai/schema/agent"
|
import { Agent } from "@opencode-ai/schema/agent"
|
||||||
import { Model } from "@opencode-ai/schema/model"
|
import { Model } from "@opencode-ai/schema/model"
|
||||||
|
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||||
import { Session } from "@opencode-ai/schema/session"
|
import { Session } from "@opencode-ai/schema/session"
|
||||||
|
|
||||||
const SDK = await import("../src/index")
|
const SDK = await import("../src/index")
|
||||||
@@ -8,6 +9,7 @@ const SDK = await import("../src/index")
|
|||||||
test("re-exports canonical contracts directly from Schema", () => {
|
test("re-exports canonical contracts directly from Schema", () => {
|
||||||
expect(SDK.Agent).toBe(Agent)
|
expect(SDK.Agent).toBe(Agent)
|
||||||
expect(SDK.Model).toBe(Model)
|
expect(SDK.Model).toBe(Model)
|
||||||
|
expect(SDK.Mcp).toBe(Mcp)
|
||||||
expect(SDK.Session).toBe(Session)
|
expect(SDK.Session).toBe(Session)
|
||||||
expect(Object.keys(SDK).sort()).toEqual([
|
expect(Object.keys(SDK).sort()).toEqual([
|
||||||
"AbsolutePath",
|
"AbsolutePath",
|
||||||
@@ -18,6 +20,7 @@ test("re-exports canonical contracts directly from Schema", () => {
|
|||||||
"FileSystem",
|
"FileSystem",
|
||||||
"Integration",
|
"Integration",
|
||||||
"Location",
|
"Location",
|
||||||
|
"Mcp",
|
||||||
"Model",
|
"Model",
|
||||||
"OpenCode",
|
"OpenCode",
|
||||||
"Permission",
|
"Permission",
|
||||||
|
|||||||
@@ -310,6 +310,10 @@ import type {
|
|||||||
V2LocationGetResponses,
|
V2LocationGetResponses,
|
||||||
V2McpListErrors,
|
V2McpListErrors,
|
||||||
V2McpListResponses,
|
V2McpListResponses,
|
||||||
|
V2McpResourceCatalogErrors,
|
||||||
|
V2McpResourceCatalogResponses,
|
||||||
|
V2McpResourceReadErrors,
|
||||||
|
V2McpResourceReadResponses,
|
||||||
V2ModelDefaultErrors,
|
V2ModelDefaultErrors,
|
||||||
V2ModelDefaultResponses,
|
V2ModelDefaultResponses,
|
||||||
V2ModelListErrors,
|
V2ModelListErrors,
|
||||||
@@ -6969,6 +6973,74 @@ 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read MCP resource
|
||||||
|
*
|
||||||
|
* Read the current content of one resource from a connected MCP server.
|
||||||
|
*/
|
||||||
|
public read<ThrowOnError extends boolean = false>(
|
||||||
|
parameters?: {
|
||||||
|
location?: {
|
||||||
|
directory?: string | null
|
||||||
|
workspace?: string | null
|
||||||
|
} | null
|
||||||
|
server?: string
|
||||||
|
uri?: string
|
||||||
|
},
|
||||||
|
options?: Options<never, ThrowOnError>,
|
||||||
|
) {
|
||||||
|
const params = buildClientParams(
|
||||||
|
[parameters],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
args: [
|
||||||
|
{ in: "query", key: "location" },
|
||||||
|
{ in: "body", key: "server" },
|
||||||
|
{ in: "body", key: "uri" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return (options?.client ?? this.client).post<V2McpResourceReadResponses, V2McpResourceReadErrors, ThrowOnError>({
|
||||||
|
url: "/api/mcp/resource/read",
|
||||||
|
...options,
|
||||||
|
...params,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...options?.headers,
|
||||||
|
...params.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class Mcp2 extends HeyApiClient {
|
export class Mcp2 extends HeyApiClient {
|
||||||
/**
|
/**
|
||||||
* List MCP servers
|
* List MCP servers
|
||||||
@@ -6991,6 +7063,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 {
|
||||||
|
|||||||
@@ -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,20 @@ export type ProviderNotFoundError = {
|
|||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type McpResource2 = {
|
||||||
|
server: string
|
||||||
|
name: string
|
||||||
|
uri: string
|
||||||
|
description?: string
|
||||||
|
mimeType?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type McpServerNotFoundError1 = {
|
||||||
|
_tag: "McpServerNotFoundError"
|
||||||
|
server: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
export type FormNotFoundError = {
|
export type FormNotFoundError = {
|
||||||
_tag: "FormNotFoundError"
|
_tag: "FormNotFoundError"
|
||||||
id: string
|
id: string
|
||||||
@@ -3153,6 +3175,7 @@ export type V2Event =
|
|||||||
| TuiToastShow
|
| TuiToastShow
|
||||||
| TuiSessionSelect
|
| TuiSessionSelect
|
||||||
| McpToolsChanged
|
| McpToolsChanged
|
||||||
|
| McpResourcesChanged
|
||||||
| McpStatusChanged
|
| McpStatusChanged
|
||||||
| CommandExecuted
|
| CommandExecuted
|
||||||
| FileEdited
|
| FileEdited
|
||||||
@@ -5702,6 +5725,39 @@ 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 McpResourceContentPart =
|
||||||
|
| {
|
||||||
|
type: "text"
|
||||||
|
uri: string
|
||||||
|
text: string
|
||||||
|
mimeType?: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "blob"
|
||||||
|
uri: string
|
||||||
|
blob: string
|
||||||
|
mimeType?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type McpResourceContent = {
|
||||||
|
server: string
|
||||||
|
uri: string
|
||||||
|
contents: Array<McpResourceContentPart>
|
||||||
|
}
|
||||||
|
|
||||||
export type ProjectCurrent = {
|
export type ProjectCurrent = {
|
||||||
id: string
|
id: string
|
||||||
directory: string
|
directory: string
|
||||||
@@ -6608,6 +6664,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 +7829,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"
|
||||||
@@ -8034,6 +8111,12 @@ export type SessionMessagesResponseV2 = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type McpServerNotFoundErrorV2 = {
|
||||||
|
_tag: "McpServerNotFoundError"
|
||||||
|
server: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
export type SessionV2 = {
|
export type SessionV2 = {
|
||||||
id: string
|
id: string
|
||||||
slug: string
|
slug: string
|
||||||
@@ -8597,6 +8680,7 @@ export type V2EventV2 =
|
|||||||
| InstallationUpdateAvailableV2
|
| InstallationUpdateAvailableV2
|
||||||
| VcsBranchUpdatedV2
|
| VcsBranchUpdatedV2
|
||||||
| McpStatusChangedV2
|
| McpStatusChangedV2
|
||||||
|
| McpResourcesChangedV2
|
||||||
| PermissionAskedV2
|
| PermissionAskedV2
|
||||||
| PermissionRepliedV2
|
| PermissionRepliedV2
|
||||||
| QuestionAskedV2
|
| QuestionAskedV2
|
||||||
@@ -9686,6 +9770,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 +10712,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
|
||||||
@@ -16685,6 +16795,87 @@ 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 V2McpResourceReadData = {
|
||||||
|
body: {
|
||||||
|
server: string
|
||||||
|
uri: string
|
||||||
|
}
|
||||||
|
path?: never
|
||||||
|
query?: {
|
||||||
|
location?: {
|
||||||
|
directory?: string | null
|
||||||
|
workspace?: string | null
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
url: "/api/mcp/resource/read"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type V2McpResourceReadErrors = {
|
||||||
|
/**
|
||||||
|
* InvalidRequestError
|
||||||
|
*/
|
||||||
|
400: InvalidRequestErrorV2
|
||||||
|
/**
|
||||||
|
* UnauthorizedError
|
||||||
|
*/
|
||||||
|
401: UnauthorizedError
|
||||||
|
/**
|
||||||
|
* McpServerNotFoundError
|
||||||
|
*/
|
||||||
|
404: McpServerNotFoundErrorV2
|
||||||
|
}
|
||||||
|
|
||||||
|
export type V2McpResourceReadError = V2McpResourceReadErrors[keyof V2McpResourceReadErrors]
|
||||||
|
|
||||||
|
export type V2McpResourceReadResponses = {
|
||||||
|
/**
|
||||||
|
* Success
|
||||||
|
*/
|
||||||
|
200: {
|
||||||
|
location: LocationInfoV2
|
||||||
|
data: McpResourceContent | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type V2McpResourceReadResponse = V2McpResourceReadResponses[keyof V2McpResourceReadResponses]
|
||||||
|
|
||||||
export type V2CredentialRemoveData = {
|
export type V2CredentialRemoveData = {
|
||||||
body?: never
|
body?: never
|
||||||
path: {
|
path: {
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { MCP } from "@opencode-ai/core/mcp/index"
|
|||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||||
import { Api } from "../api"
|
import { Api } from "../api"
|
||||||
|
import { McpServerNotFoundError } from "@opencode-ai/protocol/errors"
|
||||||
import { response } from "../location"
|
import { response } from "../location"
|
||||||
|
|
||||||
export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
|
export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
return handlers.handle(
|
return handlers
|
||||||
|
.handle(
|
||||||
"mcp.list",
|
"mcp.list",
|
||||||
Effect.fn(function* () {
|
Effect.fn(function* () {
|
||||||
const service = yield* MCP.Service
|
const service = yield* MCP.Service
|
||||||
@@ -21,5 +23,24 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
|
|||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.handle(
|
||||||
|
"mcp.resource.catalog",
|
||||||
|
Effect.fn(function* () {
|
||||||
|
const service = yield* MCP.Service
|
||||||
|
return yield* response(service.resourceCatalog())
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handle(
|
||||||
|
"mcp.resource.read",
|
||||||
|
Effect.fn(function* (ctx) {
|
||||||
|
const service = yield* MCP.Service
|
||||||
|
return yield* response(
|
||||||
|
service.readResource({ server: ctx.payload.server, uri: ctx.payload.uri }).pipe(
|
||||||
|
Effect.map((result) => result ?? null),
|
||||||
|
Effect.mapError((error) => new McpServerNotFoundError({ server: error.server, message: error.message })),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -207,7 +207,13 @@ export function Autocomplete(props: {
|
|||||||
props.setPrompt((draft) => {
|
props.setPrompt((draft) => {
|
||||||
if (part.type === "file") {
|
if (part.type === "file") {
|
||||||
const files = (draft.files ??= [])
|
const files = (draft.files ??= [])
|
||||||
const existingIndex = files.findIndex((file) => file.uri === part.value.uri)
|
const existingIndex = files.findIndex(
|
||||||
|
(file) =>
|
||||||
|
file.uri === part.value.uri &&
|
||||||
|
file.mcp?.server === part.value.mcp?.server &&
|
||||||
|
file.mcp?.location.directory === part.value.mcp?.location.directory &&
|
||||||
|
file.mcp?.location.workspaceID === part.value.mcp?.location.workspaceID,
|
||||||
|
)
|
||||||
if (existingIndex !== -1) {
|
if (existingIndex !== -1) {
|
||||||
const existing = files[existingIndex]
|
const existing = files[existingIndex]
|
||||||
if (existing?.mention) {
|
if (existing?.mention) {
|
||||||
@@ -215,6 +221,7 @@ export function Autocomplete(props: {
|
|||||||
existing.mention.end = extmarkEnd
|
existing.mention.end = extmarkEnd
|
||||||
existing.mention.text = virtualText
|
existing.mention.text = virtualText
|
||||||
}
|
}
|
||||||
|
props.setExtmark({ type: "file", index: existingIndex }, extmarkId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (part.value.mention) {
|
if (part.value.mention) {
|
||||||
@@ -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.
|
||||||
@@ -377,6 +384,7 @@ export function Autocomplete(props: {
|
|||||||
name: res.name,
|
name: res.name,
|
||||||
description: res.description,
|
description: res.description,
|
||||||
mention: { start: 0, end: 0, text: "" },
|
mention: { start: 0, end: 0, text: "" },
|
||||||
|
mcp: { server: res.server, uri: res.uri, location: location() ?? data.location.default() },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { useExit } from "../../context/exit"
|
|||||||
import { promptOffsetWidth } from "../../prompt/display"
|
import { promptOffsetWidth } from "../../prompt/display"
|
||||||
import { createStore, produce, unwrap } from "solid-js/store"
|
import { createStore, produce, unwrap } from "solid-js/store"
|
||||||
import { emptyPrompt, usePromptHistory, type PromptInfo, type PromptPartRef } from "../../prompt/history"
|
import { emptyPrompt, usePromptHistory, type PromptInfo, type PromptPartRef } from "../../prompt/history"
|
||||||
|
import { materializeMcpResources } from "./mcp-resource"
|
||||||
import { computePromptTraits } from "../../prompt/traits"
|
import { computePromptTraits } from "../../prompt/traits"
|
||||||
import { expandPastedTextPlaceholders, expandTrackedPastedText } from "../../prompt/part"
|
import { expandPastedTextPlaceholders, expandTrackedPastedText } from "../../prompt/part"
|
||||||
import { usePromptStash } from "../../prompt/stash"
|
import { usePromptStash } from "../../prompt/stash"
|
||||||
@@ -164,9 +165,9 @@ export function Prompt(props: PromptProps) {
|
|||||||
const status = createMemo(() => data.session.status(props.sessionID ?? ""))
|
const status = createMemo(() => data.session.status(props.sessionID ?? ""))
|
||||||
const activeSubagents = createMemo(() => {
|
const activeSubagents = createMemo(() => {
|
||||||
if (!props.sessionID) return 0
|
if (!props.sessionID) return 0
|
||||||
return data.session.family(props.sessionID).filter(
|
return data.session
|
||||||
(id) => id !== props.sessionID && data.session.status(id) === "running",
|
.family(props.sessionID)
|
||||||
).length
|
.filter((id) => id !== props.sessionID && data.session.status(id) === "running").length
|
||||||
})
|
})
|
||||||
const runningShells = createMemo(
|
const runningShells = createMemo(
|
||||||
() => data.shell.list(currentLocation()).filter((shell) => shell.metadata.sessionID === props.sessionID).length,
|
() => data.shell.list(currentLocation()).filter((shell) => shell.metadata.sessionID === props.sessionID).length,
|
||||||
@@ -984,6 +985,36 @@ export function Prompt(props: PromptProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const variant = local.model.variant.current()
|
const variant = local.model.variant.current()
|
||||||
|
const inputText = expandTrackedPastedText(
|
||||||
|
store.prompt.text,
|
||||||
|
input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
|
||||||
|
const ref = store.extmarkToPart.get(extmark.id)
|
||||||
|
if (ref?.type !== "pasted") return []
|
||||||
|
const part = store.prompt.pasted[ref.index]
|
||||||
|
if (!part) return []
|
||||||
|
return [{ start: extmark.start, end: extmark.end, text: part.text }]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const commandName = inputText.split("\n")[0].split(" ")[0].slice(1)
|
||||||
|
const isCommand =
|
||||||
|
inputText.startsWith("/") &&
|
||||||
|
(data.location.command.list(currentLocation()) ?? []).some((command) => command.name === commandName)
|
||||||
|
const isSkill =
|
||||||
|
inputText.startsWith("/") &&
|
||||||
|
(data.location.skill.list(currentLocation()) ?? []).some(
|
||||||
|
(skill) => skill.slash === true && skill.name === commandName,
|
||||||
|
)
|
||||||
|
const files =
|
||||||
|
store.mode === "normal" && !isSkill
|
||||||
|
? await materializeMcpResources(store.prompt.files, (resource) =>
|
||||||
|
data.location.mcp.resource.read(resource),
|
||||||
|
).catch((error) => {
|
||||||
|
toast.show({ title: "Failed to read MCP resource", message: errorMessage(error), variant: "error" })
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
if (!files) return false
|
||||||
|
|
||||||
let sessionID = props.sessionID
|
let sessionID = props.sessionID
|
||||||
let session = sessionID ? data.session.get(sessionID) : undefined
|
let session = sessionID ? data.session.get(sessionID) : undefined
|
||||||
let finishMoveProgress = false
|
let finishMoveProgress = false
|
||||||
@@ -1025,17 +1056,6 @@ export function Prompt(props: PromptProps) {
|
|||||||
session = structuredClone(created) as SessionV2Info
|
session = structuredClone(created) as SessionV2Info
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputText = expandTrackedPastedText(
|
|
||||||
store.prompt.text,
|
|
||||||
input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
|
|
||||||
const ref = store.extmarkToPart.get(extmark.id)
|
|
||||||
if (ref?.type !== "pasted") return []
|
|
||||||
const part = store.prompt.pasted[ref.index]
|
|
||||||
if (!part) return []
|
|
||||||
return [{ start: extmark.start, end: extmark.end, text: part.text }]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Capture mode before it gets reset
|
// Capture mode before it gets reset
|
||||||
const currentMode = store.mode
|
const currentMode = store.mode
|
||||||
const editorSelection = editorContext()
|
const editorSelection = editorContext()
|
||||||
@@ -1063,12 +1083,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
command: inputText,
|
command: inputText,
|
||||||
})
|
})
|
||||||
setStore("mode", "normal")
|
setStore("mode", "normal")
|
||||||
} else if (
|
} else if (isCommand) {
|
||||||
inputText.startsWith("/") &&
|
|
||||||
(data.location.command.list(currentLocation()) ?? []).some(
|
|
||||||
(command) => command.name === inputText.split("\n")[0].split(" ")[0].slice(1),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
move.startSubmit()
|
move.startSubmit()
|
||||||
// Parse command from first line, preserve multi-line content in arguments
|
// Parse command from first line, preserve multi-line content in arguments
|
||||||
const firstLineEnd = inputText.indexOf("\n")
|
const firstLineEnd = inputText.indexOf("\n")
|
||||||
@@ -1077,25 +1092,25 @@ export function Prompt(props: PromptProps) {
|
|||||||
const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1)
|
const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1)
|
||||||
const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "")
|
const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "")
|
||||||
|
|
||||||
void sdk.api.session
|
const error = await sdk.api.session
|
||||||
.command({
|
.command({
|
||||||
sessionID,
|
sessionID,
|
||||||
command: command.slice(1),
|
command: command.slice(1),
|
||||||
arguments: args,
|
arguments: args,
|
||||||
agent: agent.id,
|
agent: agent.id,
|
||||||
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
|
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
|
||||||
files: store.prompt.files,
|
files,
|
||||||
agents: store.prompt.agents,
|
agents: store.prompt.agents,
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.then(
|
||||||
toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" })
|
() => undefined,
|
||||||
})
|
(error) => error,
|
||||||
} else if (
|
|
||||||
inputText.startsWith("/") &&
|
|
||||||
(data.location.skill.list(currentLocation()) ?? []).some(
|
|
||||||
(skill) => skill.slash === true && skill.name === inputText.split("\n")[0].split(" ")[0].slice(1),
|
|
||||||
)
|
)
|
||||||
) {
|
if (error) {
|
||||||
|
toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else if (isSkill) {
|
||||||
move.startSubmit()
|
move.startSubmit()
|
||||||
void sdk.api.session.skill({
|
void sdk.api.session.skill({
|
||||||
sessionID,
|
sessionID,
|
||||||
@@ -1135,7 +1150,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
sessionID,
|
sessionID,
|
||||||
prompt: {
|
prompt: {
|
||||||
text: [...editorParts.map((part) => part.text), inputText].filter(Boolean).join("\n\n"),
|
text: [...editorParts.map((part) => part.text), inputText].filter(Boolean).join("\n\n"),
|
||||||
files: store.prompt.files,
|
files,
|
||||||
agents: store.prompt.agents,
|
agents: store.prompt.agents,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Buffer } from "node:buffer"
|
||||||
|
import type { ServerMcpReadResourceOutput, SessionPromptInput } from "@opencode-ai/client/promise"
|
||||||
|
import type { LocationRef } from "@opencode-ai/sdk/v2"
|
||||||
|
import type { PromptFile } from "../../prompt/history"
|
||||||
|
|
||||||
|
type Files = NonNullable<SessionPromptInput["prompt"]["files"]>
|
||||||
|
type ResourceContent = NonNullable<ServerMcpReadResourceOutput["data"]>
|
||||||
|
|
||||||
|
export async function materializeMcpResources(
|
||||||
|
files: PromptFile[] | undefined,
|
||||||
|
read: (input: { server: string; uri: string; location: LocationRef }) => Promise<ResourceContent | null>,
|
||||||
|
): Promise<Files> {
|
||||||
|
return (
|
||||||
|
await Promise.all(
|
||||||
|
(files ?? []).map(async (file): Promise<Files> => {
|
||||||
|
if (!file.mcp) return [{ uri: file.uri, name: file.name, description: file.description, mention: file.mention }]
|
||||||
|
const resource = await read(file.mcp)
|
||||||
|
if (!resource) throw new Error(`Unable to read MCP resource: ${file.mcp.server}:${file.mcp.uri}`)
|
||||||
|
if (resource.contents.length === 0)
|
||||||
|
throw new Error(`MCP resource returned no content: ${file.mcp.server}:${file.mcp.uri}`)
|
||||||
|
return resource.contents.map((content, index) => ({
|
||||||
|
uri: `data:${content.mimeType ?? (content.type === "text" ? "text/plain" : "application/octet-stream")};base64,${
|
||||||
|
content.type === "text" ? Buffer.from(content.text).toString("base64") : content.blob
|
||||||
|
}`,
|
||||||
|
name: index === 0 ? file.name : `${file.name ?? "resource"}-${index + 1}`,
|
||||||
|
description: file.description,
|
||||||
|
mention: index === 0 ? file.mention : undefined,
|
||||||
|
}))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).flat()
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import type {
|
|||||||
SkillV2Info,
|
SkillV2Info,
|
||||||
V2Event,
|
V2Event,
|
||||||
} from "@opencode-ai/sdk/v2"
|
} from "@opencode-ai/sdk/v2"
|
||||||
|
import type { ServerMcpResourceCatalogOutput, ServerMcpReadResourceOutput } 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?: ServerMcpResourceCatalogOutput["data"]
|
||||||
model?: ModelV2Info[]
|
model?: ModelV2Info[]
|
||||||
provider?: ProviderV2Info[]
|
provider?: ProviderV2Info[]
|
||||||
reference?: ReferenceInfo[]
|
reference?: ReferenceInfo[]
|
||||||
@@ -113,6 +115,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
let connectionGeneration = 0
|
let connectionGeneration = 0
|
||||||
let statusChanges: Set<string> | undefined
|
let statusChanges: Set<string> | undefined
|
||||||
let bootstrapping: Promise<void> | undefined
|
let bootstrapping: Promise<void> | undefined
|
||||||
|
const pendingMcpRefresh = new Map<string, { location: LocationRef; status: boolean }>()
|
||||||
|
|
||||||
function setSessionStatus(sessionID: string, status: DataSessionStatus) {
|
function setSessionStatus(sessionID: string, status: DataSessionStatus) {
|
||||||
statusChanges?.add(sessionID)
|
statusChanges?.add(sessionID)
|
||||||
@@ -754,8 +757,24 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
// Authenticating an MCP integration reconnects its server, which emits mcp.status.changed,
|
// Authenticating an MCP integration reconnects its server, which emits mcp.status.changed,
|
||||||
// so the mcp list refreshes here rather than off integration.updated.
|
// so the mcp list refreshes here rather than off integration.updated.
|
||||||
case "mcp.status.changed":
|
case "mcp.status.changed":
|
||||||
if (bootstrapping) break
|
if (bootstrapping) {
|
||||||
void result.location.mcp.refresh(event.location)
|
const location = event.location ?? defaultLocation()
|
||||||
|
pendingMcpRefresh.set(locationKey(location), { location, status: true })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
void Promise.all([
|
||||||
|
result.location.mcp.refresh(event.location),
|
||||||
|
result.location.mcp.resource.refresh(event.location),
|
||||||
|
])
|
||||||
|
break
|
||||||
|
case "mcp.resources.changed":
|
||||||
|
if (bootstrapping) {
|
||||||
|
const location = event.location ?? defaultLocation()
|
||||||
|
const pending = pendingMcpRefresh.get(locationKey(location))
|
||||||
|
pendingMcpRefresh.set(locationKey(location), { location, status: pending?.status ?? false })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
void result.location.mcp.resource.refresh(event.location)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -938,9 +957,27 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
return store.location[locationKey(location ?? defaultLocation())]?.mcp
|
return store.location[locationKey(location ?? defaultLocation())]?.mcp
|
||||||
},
|
},
|
||||||
async refresh(ref?: LocationRef) {
|
async refresh(ref?: LocationRef) {
|
||||||
const result = await sdk.client.v2.mcp.list({ location: locationQuery(ref) }, { throwOnError: true })
|
const result = await sdk.api["server.mcp"].list({ location: locationQuery(ref) })
|
||||||
const key = locationKey(result.data.location)
|
const key = locationKey(result.location)
|
||||||
setStore("location", key, { ...store.location[key], mcp: result.data.data })
|
setStore("location", key, { ...store.location[key], mcp: mutable(result.data) })
|
||||||
|
},
|
||||||
|
resource: {
|
||||||
|
catalog(location?: LocationRef) {
|
||||||
|
return store.location[locationKey(location ?? defaultLocation())]?.mcpResource
|
||||||
|
},
|
||||||
|
async refresh(ref?: LocationRef) {
|
||||||
|
const result = await sdk.api["server.mcp"].resourceCatalog({ location: locationQuery(ref) })
|
||||||
|
const key = locationKey(result.location)
|
||||||
|
setStore("location", key, { ...store.location[key], mcpResource: mutable(result.data) })
|
||||||
|
},
|
||||||
|
async read(input: { server: string; uri: string; location?: LocationRef }) {
|
||||||
|
const result = await sdk.api["server.mcp"].readResource({
|
||||||
|
server: input.server,
|
||||||
|
uri: input.uri,
|
||||||
|
location: locationQuery(input.location),
|
||||||
|
})
|
||||||
|
return result.data as ServerMcpReadResourceOutput["data"]
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
model: {
|
model: {
|
||||||
@@ -1010,6 +1047,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(),
|
||||||
@@ -1023,6 +1061,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
bootstrapping = undefined
|
bootstrapping = undefined
|
||||||
|
for (const pending of pendingMcpRefresh.values()) {
|
||||||
|
void Promise.all([
|
||||||
|
...(pending.status ? [result.location.mcp.refresh(pending.location)] : []),
|
||||||
|
result.location.mcp.resource.refresh(pending.location),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
pendingMcpRefresh.clear()
|
||||||
})
|
})
|
||||||
return bootstrapping
|
return bootstrapping
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import path from "path"
|
|||||||
import { onMount } from "solid-js"
|
import { onMount } from "solid-js"
|
||||||
import { createStore, produce, unwrap } from "solid-js/store"
|
import { createStore, produce, unwrap } from "solid-js/store"
|
||||||
import type { SessionPromptInput } from "@opencode-ai/client/promise"
|
import type { SessionPromptInput } from "@opencode-ai/client/promise"
|
||||||
|
import type { LocationRef } from "@opencode-ai/sdk/v2"
|
||||||
import type { Types } from "effect"
|
import type { Types } from "effect"
|
||||||
import { createSimpleContext } from "../context/helper"
|
import { createSimpleContext } from "../context/helper"
|
||||||
import { useTuiPaths } from "../context/runtime"
|
import { useTuiPaths } from "../context/runtime"
|
||||||
@@ -16,7 +17,14 @@ export type PastedText = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PromptInfo = Types.DeepMutable<SessionPromptInput["prompt"]> & {
|
type Prompt = Types.DeepMutable<SessionPromptInput["prompt"]>
|
||||||
|
|
||||||
|
export type PromptFile = NonNullable<Prompt["files"]>[number] & {
|
||||||
|
mcp?: { server: string; uri: string; location: LocationRef }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PromptInfo = Omit<Prompt, "files"> & {
|
||||||
|
files?: PromptFile[]
|
||||||
pasted: PastedText[]
|
pasted: PastedText[]
|
||||||
mode?: "normal" | "shell"
|
mode?: "normal" | "shell"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,63 @@ test("refreshes resources into reactive getters", async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("refreshes MCP resource catalogs after MCP 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")
|
||||||
|
|
||||||
|
resources = []
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_mcp_status",
|
||||||
|
created: 2,
|
||||||
|
type: "mcp.status.changed",
|
||||||
|
data: { server: "docs" },
|
||||||
|
})
|
||||||
|
await wait(() => requests === 3 && data.location.mcp.resource.catalog()?.resources.length === 0)
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("restores running manual compaction before applying live deltas", async () => {
|
test("restores running manual compaction before applying live deltas", async () => {
|
||||||
const events = createEventStream()
|
const events = createEventStream()
|
||||||
const calls = createFetch((url) => {
|
const calls = createFetch((url) => {
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { materializeMcpResources } from "../../../src/component/prompt/mcp-resource"
|
||||||
|
|
||||||
|
describe("MCP resource prompt attachments", () => {
|
||||||
|
test("materializes text and blob content while preserving one mention", async () => {
|
||||||
|
const calls: Array<{ server: string; uri: string; location: { directory: string } }> = []
|
||||||
|
const files = await materializeMcpResources(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
uri: "docs://readme",
|
||||||
|
name: "Readme",
|
||||||
|
description: "Project docs",
|
||||||
|
mention: { start: 0, end: 7, text: "@Readme" },
|
||||||
|
mcp: { server: "docs", uri: "docs://readme", location: { directory: "/tmp/project" } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
async (input) => {
|
||||||
|
calls.push(input)
|
||||||
|
return {
|
||||||
|
server: input.server,
|
||||||
|
uri: input.uri,
|
||||||
|
contents: [
|
||||||
|
{ type: "text", uri: input.uri, text: "hello", mimeType: "text/plain" },
|
||||||
|
{ type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(calls).toEqual([{ server: "docs", uri: "docs://readme", location: { directory: "/tmp/project" } }])
|
||||||
|
expect(files).toEqual([
|
||||||
|
{
|
||||||
|
uri: "data:text/plain;base64,aGVsbG8=",
|
||||||
|
name: "Readme",
|
||||||
|
description: "Project docs",
|
||||||
|
mention: { start: 0, end: 7, text: "@Readme" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uri: "data:image/png;base64,aGVsbG8=",
|
||||||
|
name: "Readme-2",
|
||||||
|
description: "Project docs",
|
||||||
|
mention: undefined,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("fails when a resource is unavailable", async () => {
|
||||||
|
await expect(
|
||||||
|
materializeMcpResources(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
uri: "docs://missing",
|
||||||
|
mcp: { server: "docs", uri: "docs://missing", location: { directory: "/tmp/project" } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
async () => null,
|
||||||
|
),
|
||||||
|
).rejects.toThrow("Unable to read MCP resource: docs:docs://missing")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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