Explorar o código

fix(opencode): resolve MCP resource content

Aiden Cline hai 2 meses
pai
achega
325f105009

+ 7 - 2
packages/opencode/src/session/message-v2.ts

@@ -219,8 +219,13 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
             type: "text",
             text: part.text,
           })
-        // text/plain and directory files are converted into text parts, ignore them
-        if (part.type === "file" && part.mime !== "text/plain" && part.mime !== "application/x-directory") {
+        // Text, directories, and MCP references are resolved before provider conversion.
+        if (
+          part.type === "file" &&
+          part.mime !== "text/plain" &&
+          part.mime !== "application/x-directory" &&
+          part.source?.type !== "resource"
+        ) {
           if (options?.stripMedia && isMedia(part.mime)) {
             userMessage.parts.push({
               type: "text",

+ 16 - 6
packages/opencode/src/session/prompt.ts

@@ -728,7 +728,16 @@ export const layer = Layer.effect(
             const exit = yield* mcp.readResource(clientName, uri).pipe(Effect.exit)
             if (Exit.isSuccess(exit)) {
               const content = exit.value
-              if (!content) throw new Error(`Resource not found: ${clientName}/${uri}`)
+              if (!content) {
+                pieces.push({
+                  messageID: info.id,
+                  sessionID: input.sessionID,
+                  type: "text",
+                  synthetic: true,
+                  text: `Failed to read MCP resource ${part.filename}: resource not found or could not be read`,
+                })
+                return pieces
+              }
               const items = Array.isArray(content.contents) ? content.contents : [content.contents]
               for (const c of items) {
                 if ("text" in c && c.text) {
@@ -740,13 +749,14 @@ export const layer = Layer.effect(
                     text: c.text,
                   })
                 } else if ("blob" in c && c.blob) {
-                  const mime = "mimeType" in c ? c.mimeType : part.mime
+                  const mime = ("mimeType" in c ? c.mimeType : undefined) ?? part.mime
                   pieces.push({
                     messageID: info.id,
                     sessionID: input.sessionID,
-                    type: "text",
-                    synthetic: true,
-                    text: `[Binary content: ${mime}]`,
+                    type: "file",
+                    mime,
+                    filename: part.filename,
+                    url: `data:${mime};base64,${c.blob}`,
                   })
                 }
               }
@@ -992,7 +1002,7 @@ export const layer = Layer.effect(
       )
 
       const parts = yield* Effect.forEach(resolvedParts, (part) =>
-        part.type === "file" && part.mime.startsWith("image/")
+        part.type === "file" && part.source?.type !== "resource" && part.mime.startsWith("image/")
           ? image.normalize(part).pipe(
               Effect.catchIf(
                 (error) => error instanceof Image.ResizerUnavailableError,

+ 37 - 0
packages/opencode/test/session/message-v2.test.ts

@@ -319,6 +319,43 @@ describe("session.message-v2.toModelMessage", () => {
     ])
   })
 
+  test("does not forward resolved MCP resource references as file downloads", async () => {
+    const messageID = "m-user"
+    const input: SessionV1.WithParts[] = [
+      {
+        info: userInfo(messageID),
+        parts: [
+          {
+            ...basePart(messageID, "p1"),
+            type: "text",
+            text: "# Resource contents",
+            synthetic: true,
+          },
+          {
+            ...basePart(messageID, "p2"),
+            type: "file",
+            mime: "text/markdown",
+            filename: "guide",
+            url: "opencode-fixture://guide",
+            source: {
+              type: "resource",
+              clientName: "resource-only-fixture",
+              uri: "opencode-fixture://guide",
+              text: { value: "@fixture-guide", start: 0, end: 14 },
+            },
+          },
+        ] as SessionV1.Part[],
+      },
+    ]
+
+    expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([
+      {
+        role: "user",
+        content: [{ type: "text", text: "# Resource contents" }],
+      },
+    ])
+  })
+
   test("converts assistant tool completion into tool-call + tool-result messages with attachments", async () => {
     const userID = "m-user"
     const assistantID = "m-assistant"

+ 117 - 26
packages/opencode/test/session/prompt.test.ts

@@ -108,28 +108,32 @@ function errorTool(parts: SessionV1.Part[]) {
   return part?.state.status === "error" ? (part as ErrorToolPart) : undefined
 }
 
-const mcp = Layer.succeed(
-  MCP.Service,
-  MCP.Service.of({
-    status: () => Effect.succeed({}),
-    clients: () => Effect.succeed({}),
-    tools: () => Effect.succeed({}),
-    prompts: () => Effect.succeed({}),
-    resources: () => Effect.succeed({}),
-    add: () => Effect.succeed({ status: { status: "disabled" as const } }),
-    connect: () => Effect.void,
-    disconnect: () => Effect.void,
-    getPrompt: () => Effect.succeed(undefined),
-    readResource: () => Effect.succeed(undefined),
-    startAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"),
-    authenticate: () => Effect.die("unexpected MCP auth in prompt-effect tests"),
-    finishAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"),
-    removeAuth: () => Effect.void,
-    supportsOAuth: () => Effect.succeed(false),
-    hasStoredTokens: () => Effect.succeed(false),
-    getAuthStatus: () => Effect.succeed("not_authenticated" as const),
-  }),
-)
+function makeMcp(readResource: MCP.Interface["readResource"] = () => Effect.succeed(undefined)) {
+  return Layer.succeed(
+    MCP.Service,
+    MCP.Service.of({
+      status: () => Effect.succeed({}),
+      clients: () => Effect.succeed({}),
+      tools: () => Effect.succeed({}),
+      prompts: () => Effect.succeed({}),
+      resources: () => Effect.succeed({}),
+      add: () => Effect.succeed({ status: { status: "disabled" as const } }),
+      connect: () => Effect.void,
+      disconnect: () => Effect.void,
+      getPrompt: () => Effect.succeed(undefined),
+      readResource,
+      startAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"),
+      authenticate: () => Effect.die("unexpected MCP auth in prompt-effect tests"),
+      finishAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"),
+      removeAuth: () => Effect.void,
+      supportsOAuth: () => Effect.succeed(false),
+      hasStoredTokens: () => Effect.succeed(false),
+      getAuthStatus: () => Effect.succeed("not_authenticated" as const),
+    }),
+  )
+}
+
+const mcp = makeMcp()
 
 const lsp = Layer.succeed(
   LSP.Service,
@@ -163,7 +167,9 @@ const blockingProcessor = Layer.succeed(
   }),
 )
 
-function makePrompt(input?: { processor?: "blocking" }) {
+type PromptOptions = { processor?: "blocking"; mcp?: ReturnType<typeof makeMcp> }
+
+function makePrompt(input?: PromptOptions) {
   const deps = Layer.mergeAll(
     Session.defaultLayer,
     Snapshot.defaultLayer,
@@ -176,7 +182,7 @@ function makePrompt(input?: { processor?: "blocking" }) {
     Config.defaultLayer,
     ProviderSvc.defaultLayer,
     lsp,
-    mcp,
+    input?.mcp ?? mcp,
     FSUtil.defaultLayer,
     BackgroundJob.defaultLayer,
     status,
@@ -229,16 +235,40 @@ function makePrompt(input?: { processor?: "blocking" }) {
   )
 }
 
-function makeHttp(input?: { processor?: "blocking" }) {
+function makeHttp(input?: PromptOptions) {
   return Layer.mergeAll(TestLLMServer.layer, makePrompt(input))
 }
 
-function makeHttpNoLLMServer(input?: { processor?: "blocking" }) {
+function makeHttpNoLLMServer(input?: PromptOptions) {
   return makePrompt(input)
 }
 
 const it = testEffect(makeHttp())
 const noLLMServer = testEffect(makeHttpNoLLMServer())
+const resourceNoLLMServer = testEffect(
+  makeHttpNoLLMServer({
+    mcp: makeMcp((_clientName, uri) =>
+      Effect.succeed({
+        contents:
+          uri === "opencode-fixture://guide"
+            ? [
+                {
+                  uri,
+                  mimeType: "text/markdown",
+                  text: "# MCP resource fixture",
+                },
+              ]
+            : [
+                {
+                  uri,
+                  mimeType: "image/png",
+                  blob: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
+                },
+              ],
+      }),
+    ),
+  }),
+)
 const raceNoLLMServer = testEffect(makeHttpNoLLMServer({ processor: "blocking" }))
 const unix = process.platform !== "win32" ? it.instance : it.instance.skip
 const unixNoLLMServer = process.platform !== "win32" ? noLLMServer.instance : noLLMServer.instance.skip
@@ -2020,6 +2050,67 @@ noLLMServer.instance(
   { config: cfg },
 )
 
+resourceNoLLMServer.instance(
+  "resolves MCP resource text and blobs without treating custom URIs as files",
+  () =>
+    Effect.gen(function* () {
+      const prompt = yield* SessionPrompt.Service
+      const sessions = yield* Session.Service
+      const session = yield* sessions.create({})
+
+      const message = yield* prompt.prompt({
+        sessionID: session.id,
+        agent: "build",
+        noReply: true,
+        parts: [
+          {
+            type: "file",
+            mime: "text/markdown",
+            url: "opencode-fixture://guide",
+            filename: "fixture-guide",
+            source: {
+              type: "resource",
+              clientName: "resource-only-fixture",
+              uri: "opencode-fixture://guide",
+              text: { value: "@fixture-guide", start: 0, end: 14 },
+            },
+          },
+          {
+            type: "file",
+            mime: "image/png",
+            url: "opencode-fixture://pixel",
+            filename: "fixture-pixel",
+            source: {
+              type: "resource",
+              clientName: "resource-only-fixture",
+              uri: "opencode-fixture://pixel",
+              text: { value: "@fixture-pixel", start: 15, end: 29 },
+            },
+          },
+        ],
+      })
+
+      expect(message.parts.some((part) => part.type === "text" && part.text === "# MCP resource fixture")).toBe(true)
+      expect(
+        message.parts.some(
+          (part) => part.type === "file" && part.source?.type === "resource" && part.url === "opencode-fixture://pixel",
+        ),
+      ).toBe(true)
+      expect(
+        message.parts.some(
+          (part) =>
+            part.type === "file" &&
+            !part.source &&
+            part.mime === "image/png" &&
+            part.url.startsWith("data:image/png;base64,"),
+        ),
+      ).toBe(true)
+
+      yield* sessions.remove(session.id)
+    }),
+  { config: cfg },
+)
+
 noLLMServer.instance(
   "keeps stored part order stable when file resolution is async",
   () =>