feat(tui): support MCP resources
This commit is contained in:
parent
e1d1352d8f
commit
988efb6217
10 changed files with 538 additions and 40 deletions
|
|
@ -310,6 +310,10 @@ import type {
|
|||
V2LocationGetResponses,
|
||||
V2McpListErrors,
|
||||
V2McpListResponses,
|
||||
V2McpResourceCatalogErrors,
|
||||
V2McpResourceCatalogResponses,
|
||||
V2McpResourceReadErrors,
|
||||
V2McpResourceReadResponses,
|
||||
V2ModelDefaultErrors,
|
||||
V2ModelDefaultResponses,
|
||||
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 {
|
||||
/**
|
||||
* List MCP servers
|
||||
|
|
@ -6991,6 +7063,11 @@ export class Mcp2 extends HeyApiClient {
|
|||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
private _resource?: Resource2
|
||||
get resource(): Resource2 {
|
||||
return (this._resource ??= new Resource2({ client: this.client }))
|
||||
}
|
||||
}
|
||||
|
||||
export class Credential extends HeyApiClient {
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@ export type Event =
|
|||
| EventTuiToastShow2
|
||||
| EventTuiSessionSelect2
|
||||
| EventMcpToolsChanged
|
||||
| EventMcpResourcesChanged
|
||||
| EventMcpStatusChanged
|
||||
| EventCommandExecuted
|
||||
| EventFileEdited
|
||||
|
|
@ -1603,6 +1604,13 @@ export type GlobalEvent = {
|
|||
server: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
type: "mcp.resources.changed"
|
||||
properties: {
|
||||
server: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
type: "mcp.status.changed"
|
||||
|
|
@ -2991,6 +2999,20 @@ export type ProviderNotFoundError = {
|
|||
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 = {
|
||||
_tag: "FormNotFoundError"
|
||||
id: string
|
||||
|
|
@ -3153,6 +3175,7 @@ export type V2Event =
|
|||
| TuiToastShow
|
||||
| TuiSessionSelect
|
||||
| McpToolsChanged
|
||||
| McpResourcesChanged
|
||||
| McpStatusChanged
|
||||
| CommandExecuted
|
||||
| FileEdited
|
||||
|
|
@ -5702,6 +5725,39 @@ export type McpServer = {
|
|||
integrationID?: string
|
||||
}
|
||||
|
||||
export type McpResourceTemplate = {
|
||||
server: string
|
||||
name: string
|
||||
uriTemplate: string
|
||||
description?: string
|
||||
mimeType?: string
|
||||
}
|
||||
|
||||
export type McpResourceCatalog = {
|
||||
resources: Array<McpResource2>
|
||||
templates: Array<McpResourceTemplate>
|
||||
}
|
||||
|
||||
export type 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 = {
|
||||
id: 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 = {
|
||||
id: string
|
||||
created: number
|
||||
|
|
@ -7760,6 +7829,14 @@ export type EventMcpToolsChanged = {
|
|||
}
|
||||
}
|
||||
|
||||
export type EventMcpResourcesChanged = {
|
||||
id: string
|
||||
type: "mcp.resources.changed"
|
||||
properties: {
|
||||
server: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventMcpStatusChanged = {
|
||||
id: string
|
||||
type: "mcp.status.changed"
|
||||
|
|
@ -8034,6 +8111,12 @@ export type SessionMessagesResponseV2 = {
|
|||
}
|
||||
}
|
||||
|
||||
export type McpServerNotFoundErrorV2 = {
|
||||
_tag: "McpServerNotFoundError"
|
||||
server: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export type SessionV2 = {
|
||||
id: string
|
||||
slug: string
|
||||
|
|
@ -8597,6 +8680,7 @@ export type V2EventV2 =
|
|||
| InstallationUpdateAvailableV2
|
||||
| VcsBranchUpdatedV2
|
||||
| McpStatusChangedV2
|
||||
| McpResourcesChangedV2
|
||||
| PermissionAskedV2
|
||||
| PermissionRepliedV2
|
||||
| QuestionAskedV2
|
||||
|
|
@ -9686,6 +9770,19 @@ export type EventLogSyncedV2 = {
|
|||
seq?: number
|
||||
}
|
||||
|
||||
export type McpResourceV2 = {
|
||||
server: string
|
||||
name: string
|
||||
uri: string
|
||||
description?: string
|
||||
mimeType?: string
|
||||
}
|
||||
|
||||
export type McpResourceCatalogV2 = {
|
||||
resources: Array<McpResourceV2>
|
||||
templates: Array<McpResourceTemplate>
|
||||
}
|
||||
|
||||
export type ProjectTimeV2 = {
|
||||
created: number
|
||||
updated: number
|
||||
|
|
@ -10615,6 +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 = {
|
||||
id: string
|
||||
created: number
|
||||
|
|
@ -16685,6 +16795,87 @@ export type V2McpListResponses = {
|
|||
|
||||
export type V2McpListResponse = V2McpListResponses[keyof V2McpListResponses]
|
||||
|
||||
export type V2McpResourceCatalogData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
location?: {
|
||||
directory?: string | null
|
||||
workspace?: string | null
|
||||
} | null
|
||||
}
|
||||
url: "/api/mcp/resource"
|
||||
}
|
||||
|
||||
export type V2McpResourceCatalogErrors = {
|
||||
/**
|
||||
* InvalidRequestError
|
||||
*/
|
||||
400: InvalidRequestErrorV2
|
||||
/**
|
||||
* UnauthorizedError
|
||||
*/
|
||||
401: UnauthorizedError
|
||||
}
|
||||
|
||||
export type V2McpResourceCatalogError = V2McpResourceCatalogErrors[keyof V2McpResourceCatalogErrors]
|
||||
|
||||
export type V2McpResourceCatalogResponses = {
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
200: {
|
||||
location: LocationInfoV2
|
||||
data: McpResourceCatalogV2
|
||||
}
|
||||
}
|
||||
|
||||
export type V2McpResourceCatalogResponse = V2McpResourceCatalogResponses[keyof V2McpResourceCatalogResponses]
|
||||
|
||||
export type 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 = {
|
||||
body?: never
|
||||
path: {
|
||||
|
|
|
|||
|
|
@ -207,7 +207,13 @@ export function Autocomplete(props: {
|
|||
props.setPrompt((draft) => {
|
||||
if (part.type === "file") {
|
||||
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) {
|
||||
const existing = files[existingIndex]
|
||||
if (existing?.mention) {
|
||||
|
|
@ -215,6 +221,7 @@ export function Autocomplete(props: {
|
|||
existing.mention.end = extmarkEnd
|
||||
existing.mention.text = virtualText
|
||||
}
|
||||
props.setExtmark({ type: "file", index: existingIndex }, extmarkId)
|
||||
return
|
||||
}
|
||||
if (part.value.mention) {
|
||||
|
|
@ -363,7 +370,7 @@ export function Autocomplete(props: {
|
|||
const options: AutocompleteOption[] = []
|
||||
const width = props.anchor().width - 4
|
||||
|
||||
for (const res of Object.values(sync.data.mcp_resource)) {
|
||||
for (const res of data.location.mcp.resource.catalog(location())?.resources ?? []) {
|
||||
options.push({
|
||||
display: Locale.truncateMiddle(res.name, width),
|
||||
// Match the name only; matching the URI caused unrelated fuzzy hits.
|
||||
|
|
@ -377,6 +384,7 @@ export function Autocomplete(props: {
|
|||
name: res.name,
|
||||
description: res.description,
|
||||
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 { createStore, produce, unwrap } from "solid-js/store"
|
||||
import { emptyPrompt, usePromptHistory, type PromptInfo, type PromptPartRef } from "../../prompt/history"
|
||||
import { materializeMcpResources } from "./mcp-resource"
|
||||
import { computePromptTraits } from "../../prompt/traits"
|
||||
import { expandPastedTextPlaceholders, expandTrackedPastedText } from "../../prompt/part"
|
||||
import { usePromptStash } from "../../prompt/stash"
|
||||
|
|
@ -164,9 +165,9 @@ export function Prompt(props: PromptProps) {
|
|||
const status = createMemo(() => data.session.status(props.sessionID ?? ""))
|
||||
const activeSubagents = createMemo(() => {
|
||||
if (!props.sessionID) return 0
|
||||
return data.session.family(props.sessionID).filter(
|
||||
(id) => id !== props.sessionID && data.session.status(id) === "running",
|
||||
).length
|
||||
return data.session
|
||||
.family(props.sessionID)
|
||||
.filter((id) => id !== props.sessionID && data.session.status(id) === "running").length
|
||||
})
|
||||
const runningShells = createMemo(
|
||||
() => 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 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 session = sessionID ? data.session.get(sessionID) : undefined
|
||||
let finishMoveProgress = false
|
||||
|
|
@ -1025,17 +1056,6 @@ export function Prompt(props: PromptProps) {
|
|||
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
|
||||
const currentMode = store.mode
|
||||
const editorSelection = editorContext()
|
||||
|
|
@ -1063,12 +1083,7 @@ export function Prompt(props: PromptProps) {
|
|||
command: inputText,
|
||||
})
|
||||
setStore("mode", "normal")
|
||||
} else if (
|
||||
inputText.startsWith("/") &&
|
||||
(data.location.command.list(currentLocation()) ?? []).some(
|
||||
(command) => command.name === inputText.split("\n")[0].split(" ")[0].slice(1),
|
||||
)
|
||||
) {
|
||||
} else if (isCommand) {
|
||||
move.startSubmit()
|
||||
// Parse command from first line, preserve multi-line content in arguments
|
||||
const firstLineEnd = inputText.indexOf("\n")
|
||||
|
|
@ -1077,25 +1092,25 @@ export function Prompt(props: PromptProps) {
|
|||
const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1)
|
||||
const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "")
|
||||
|
||||
void sdk.api.session
|
||||
const error = await sdk.api.session
|
||||
.command({
|
||||
sessionID,
|
||||
command: command.slice(1),
|
||||
arguments: args,
|
||||
agent: agent.id,
|
||||
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
|
||||
files: store.prompt.files,
|
||||
files,
|
||||
agents: store.prompt.agents,
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "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),
|
||||
)
|
||||
) {
|
||||
.then(
|
||||
() => undefined,
|
||||
(error) => error,
|
||||
)
|
||||
if (error) {
|
||||
toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" })
|
||||
return false
|
||||
}
|
||||
} else if (isSkill) {
|
||||
move.startSubmit()
|
||||
void sdk.api.session.skill({
|
||||
sessionID,
|
||||
|
|
@ -1135,7 +1150,7 @@ export function Prompt(props: PromptProps) {
|
|||
sessionID,
|
||||
prompt: {
|
||||
text: [...editorParts.map((part) => part.text), inputText].filter(Boolean).join("\n\n"),
|
||||
files: store.prompt.files,
|
||||
files,
|
||||
agents: store.prompt.agents,
|
||||
},
|
||||
})
|
||||
|
|
|
|||
32
packages/tui/src/component/prompt/mcp-resource.ts
Normal file
32
packages/tui/src/component/prompt/mcp-resource.ts
Normal file
|
|
@ -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,
|
||||
V2Event,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import type { ServerMcpResourceCatalogOutput, ServerMcpReadResourceOutput } from "@opencode-ai/client/promise"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useSDK } from "./sdk"
|
||||
|
|
@ -37,6 +38,7 @@ type LocationData = {
|
|||
command?: CommandV2Info[]
|
||||
integration?: IntegrationInfo[]
|
||||
mcp?: McpServer[]
|
||||
mcpResource?: ServerMcpResourceCatalogOutput["data"]
|
||||
model?: ModelV2Info[]
|
||||
provider?: ProviderV2Info[]
|
||||
reference?: ReferenceInfo[]
|
||||
|
|
@ -113,6 +115,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
let connectionGeneration = 0
|
||||
let statusChanges: Set<string> | undefined
|
||||
let bootstrapping: Promise<void> | undefined
|
||||
const pendingMcpRefresh = new Map<string, { location: LocationRef; status: boolean }>()
|
||||
|
||||
function setSessionStatus(sessionID: string, status: DataSessionStatus) {
|
||||
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,
|
||||
// so the mcp list refreshes here rather than off integration.updated.
|
||||
case "mcp.status.changed":
|
||||
if (bootstrapping) break
|
||||
void result.location.mcp.refresh(event.location)
|
||||
if (bootstrapping) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -938,9 +957,27 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
return store.location[locationKey(location ?? defaultLocation())]?.mcp
|
||||
},
|
||||
async refresh(ref?: LocationRef) {
|
||||
const result = await sdk.client.v2.mcp.list({ location: locationQuery(ref) }, { throwOnError: true })
|
||||
const key = locationKey(result.data.location)
|
||||
setStore("location", key, { ...store.location[key], mcp: result.data.data })
|
||||
const result = await sdk.api["server.mcp"].list({ location: locationQuery(ref) })
|
||||
const key = locationKey(result.location)
|
||||
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: {
|
||||
|
|
@ -1010,6 +1047,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
result.location.agent.refresh(),
|
||||
result.location.integration.refresh(),
|
||||
result.location.mcp.refresh(),
|
||||
result.location.mcp.resource.refresh(),
|
||||
result.location.model.refresh(),
|
||||
result.location.provider.refresh(),
|
||||
result.location.reference.refresh(),
|
||||
|
|
@ -1023,6 +1061,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
})
|
||||
.finally(() => {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import path from "path"
|
|||
import { onMount } from "solid-js"
|
||||
import { createStore, produce, unwrap } from "solid-js/store"
|
||||
import type { SessionPromptInput } from "@opencode-ai/client/promise"
|
||||
import type { LocationRef } from "@opencode-ai/sdk/v2"
|
||||
import type { Types } from "effect"
|
||||
import { createSimpleContext } from "../context/helper"
|
||||
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[]
|
||||
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 () => {
|
||||
const events = createEventStream()
|
||||
const calls = createFetch((url) => {
|
||||
|
|
|
|||
60
packages/tui/test/cli/tui/mcp-resource.test.ts
Normal file
60
packages/tui/test/cli/tui/mcp-resource.test.ts
Normal file
|
|
@ -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: [] })
|
||||
if (url.pathname === "/api/mcp")
|
||||
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
|
||||
if (url.pathname === "/api/mcp/resource")
|
||||
return json({
|
||||
location: { directory, project: { id: "proj_test", directory: worktree } },
|
||||
data: { resources: [], templates: [] },
|
||||
})
|
||||
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
|
||||
if (url.pathname === "/api/session/active") return json({ data: {} })
|
||||
if (/^\/api\/session\/[^/]+\/form$/.test(url.pathname)) return json({ data: [] })
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue