diff --git a/packages/core/src/plugin/websearch/index.ts b/packages/core/src/plugin/websearch/index.ts index 58403e080aa2..8399904ece42 100644 --- a/packages/core/src/plugin/websearch/index.ts +++ b/packages/core/src/plugin/websearch/index.ts @@ -2,10 +2,12 @@ import { WebSearchExa } from "./exa.js" import { WebSearchFirecrawl } from "./firecrawl.js" import { WebSearchParallel } from "./parallel.js" import { WebSearchTavily } from "./tavily.js" +import { WebSearchTinyFish } from "./tinyfish.js" export const WebSearchPlugins = [ WebSearchExa.Plugin, WebSearchFirecrawl.Plugin, WebSearchParallel.Plugin, WebSearchTavily.Plugin, + WebSearchTinyFish.Plugin, ] as const diff --git a/packages/core/src/plugin/websearch/tinyfish.ts b/packages/core/src/plugin/websearch/tinyfish.ts new file mode 100644 index 000000000000..77d86acddc1d --- /dev/null +++ b/packages/core/src/plugin/websearch/tinyfish.ts @@ -0,0 +1,82 @@ +export * as WebSearchTinyFish from "./tinyfish.js" + +import { define } from "@opencode/plugin/effect/plugin" +import { Effect, Option, Schema, Scope } from "effect" +import { HttpClient } from "effect/unstable/http" +import { App } from "../../app.js" +import { WebSearchMcp } from "./mcp.js" + +export const endpoint = "https://agent.tinyfish.ai/mcp" + +const McpInput = Schema.Struct({ + query: Schema.String, +}) + +const McpOutput = Schema.Struct({ + content: Schema.Array(Schema.Struct({ type: Schema.Literal("text"), text: Schema.String })), +}) + +const SearchResponse = Schema.fromJsonString( + Schema.Struct({ + results: Schema.Array( + Schema.Struct({ + url: Schema.String, + title: Schema.String, + snippet: Schema.String, + }), + ), + }), +) +const decodeSearchResponse = Schema.decodeUnknownOption(SearchResponse) + +export const Plugin = define({ + id: "opencode.websearch.tinyfish", + effect: Effect.fn("WebSearchTinyFish.Plugin")(function* (ctx) { + const http = yield* HttpClient.HttpClient + yield* ctx.integration.transform((editor) => { + editor.update("tinyfish", (integration) => (integration.name = "TinyFish")) + editor.method.update({ + integrationID: "tinyfish", + method: { type: "key" }, + }) + editor.method.update({ + integrationID: "tinyfish", + method: { type: "env", names: ["TINYFISH_API_KEY"] }, + }) + }) + yield* ctx.websearch.transform((editor) => { + editor.add({ + id: "tinyfish", + name: "TinyFish", + execute: (input) => + Effect.gen(function* () { + const connection = yield* ctx.integration.connection.active("tinyfish") + const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined + const result = yield* WebSearchMcp.call( + http, + endpoint, + "search", + { input: McpInput, output: McpOutput }, + { query: input.query }, + { + "User-Agent": App.useragent(ctx.app), + ...(credential?.type === "key" + ? { "X-API-Key": credential.key } + : { "X-TinyFish-Access-Mode": "keyless" }), + }, + ) + const content = result?.content.find((item) => item.text) + const response = content ? Option.getOrUndefined(decodeSearchResponse(content.text)) : undefined + return ( + response?.results.map((item) => ({ + url: item.url, + title: item.title, + ...(item.snippet ? { content: item.snippet } : {}), + time: {}, + })) ?? [] + ) + }), + }) + }) + }), +}) diff --git a/packages/core/test/plugin/websearch.test.ts b/packages/core/test/plugin/websearch.test.ts index 4d8b60493eae..775f576df5c5 100644 --- a/packages/core/test/plugin/websearch.test.ts +++ b/packages/core/test/plugin/websearch.test.ts @@ -6,6 +6,7 @@ import { WebSearchExa } from "@opencode/core/plugin/websearch/exa" import { WebSearchFirecrawl } from "@opencode/core/plugin/websearch/firecrawl" import { WebSearchParallel } from "@opencode/core/plugin/websearch/parallel" import { WebSearchTavily } from "@opencode/core/plugin/websearch/tavily" +import { WebSearchTinyFish } from "@opencode/core/plugin/websearch/tinyfish" import { host, integrationHost, webSearchHost } from "./host" import { requests, signals, resetWebSearchFixture, webSearchIntegrationTest } from "./websearch-fixture" @@ -30,24 +31,26 @@ beforeEach(() => { const it = webSearchIntegrationTest describe("built-in web search providers", () => { - ;[WebSearchExa.Plugin, WebSearchParallel.Plugin, WebSearchFirecrawl.Plugin, WebSearchTavily.Plugin].forEach( - (plugin) => { - it.effect(`releases rate-limited HTTP requests for ${plugin.id} before caching their errors`, () => - Effect.gen(function* () { - resetWebSearchFixture("Rate limited", 429) - const integrations = yield* Integration.Service - const websearch = yield* WebSearch.Service - yield* plugin.effect( - host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }), - ) - yield* websearch.select("random") - expect(yield* websearch.query({ query: "limited" }).pipe(Effect.flip)).toBeInstanceOf(WebSearch.RequestError) - expect(signals).toHaveLength(1) - expect(signals[0]?.aborted).toBe(true) - }), - ) - }, - ) + ;[ + WebSearchExa.Plugin, + WebSearchParallel.Plugin, + WebSearchFirecrawl.Plugin, + WebSearchTavily.Plugin, + WebSearchTinyFish.Plugin, + ].forEach((plugin) => { + it.effect(`releases rate-limited HTTP requests for ${plugin.id} before caching their errors`, () => + Effect.gen(function* () { + resetWebSearchFixture("Rate limited", 429) + const integrations = yield* Integration.Service + const websearch = yield* WebSearch.Service + yield* plugin.effect(host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) })) + yield* websearch.select("random") + expect(yield* websearch.query({ query: "limited" }).pipe(Effect.flip)).toBeInstanceOf(WebSearch.RequestError) + expect(signals).toHaveLength(1) + expect(signals[0]?.aborted).toBe(true) + }), + ) + }) it.effect("registers a provider without an integration", () => Effect.gen(function* () { @@ -277,4 +280,84 @@ describe("built-in web search providers", () => { expect(requests[1]?.headers["x-tavily-access-mode"]).toBeUndefined() }), ) + + it.effect("registers TinyFish with keyless and keyed MCP search access", () => + Effect.gen(function* () { + resetWebSearchFixture( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { + content: [ + { + type: "text", + text: JSON.stringify({ + query: "effect typescript", + results: [ + { + position: 1, + site_name: "effect.website", + snippet: "Effect documentation", + title: "Effect", + url: "https://effect.website", + }, + ], + total_results: 1, + page: 0, + }), + }, + ], + }, + }), + ) + const integrations = yield* Integration.Service + const websearch = yield* WebSearch.Service + yield* WebSearchTinyFish.Plugin.effect( + host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }), + ) + + expect(yield* integrations.get(Integration.ID.make("tinyfish"))).toMatchObject({ + id: "tinyfish", + name: "TinyFish", + methods: [{ type: "key" }, { type: "env", names: ["TINYFISH_API_KEY"] }], + }) + expect(yield* websearch.query({ query: "effect typescript", providerID: WebSearch.ID.make("tinyfish") })).toEqual( + new WebSearch.Response({ + providerID: WebSearch.ID.make("tinyfish"), + results: [ + { + url: "https://effect.website", + title: "Effect", + content: "Effect documentation", + time: {}, + }, + ], + }), + ) + expect(requests[0]).toMatchObject({ + url: WebSearchTinyFish.endpoint, + headers: { "x-tinyfish-access-mode": "keyless" }, + body: { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "search", + arguments: { query: "effect typescript" }, + }, + }, + }) + expect(requests[0]?.headers.authorization).toBeUndefined() + + yield* integrations.connection.key({ + integrationID: Integration.ID.make("tinyfish"), + key: "tinyfish-secret", + }) + yield* websearch.query({ query: "effect typescript", providerID: WebSearch.ID.make("tinyfish") }) + expect(requests[1]).toMatchObject({ + headers: { "x-api-key": "tinyfish-secret" }, + }) + expect(requests[1]?.headers["x-tinyfish-access-mode"]).toBeUndefined() + }), + ) })