feat(tui): support MCP resources

This commit is contained in:
Aiden Cline 2026-07-06 23:55:36 -05:00
commit 988efb6217
10 changed files with 538 additions and 40 deletions

View file

@ -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 {

View file

@ -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: {

View file

@ -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() },
},
})
},

View file

@ -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,
},
})

View 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()
}

View file

@ -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
}

View file

@ -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"
}

View file

@ -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) => {

View 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")
})
})

View file

@ -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: [] })