refactor(core): share web search plugin integration setup (#61162)

This commit is contained in:
Aiden Cline 2026-11-01 21:11:33 -06:00 • committed by GitHub
parent 7f117b189f
commit a809a4755f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 195 additions and 218 deletions

View file

@ -4,6 +4,7 @@ import { define } from "@opencode/plugin/effect/plugin"
import { Effect, Schema, Scope } from "effect"
import { HttpClient } from "effect/http"
import { WebSearchMcp } from "./mcp.js"
import { WebSearchProvider } from "./provider.js"
export const endpoint = "https://mcp.exa.ai/mcp"
@ -26,38 +27,24 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
id: "opencode.websearch.exa",
effect: Effect.fn("WebSearchExa.Plugin")(function* (ctx) {
const http = yield* HttpClient.HttpClient
yield* ctx.integration.transform((editor) => {
editor.update("exa", (integration) => (integration.name = "Exa"))
editor.method.update({
integrationID: "exa",
method: { type: "key" },
})
editor.method.update({
integrationID: "exa",
method: { type: "env", names: ["EXA_API_KEY"] },
})
})
yield* ctx.websearch.transform((editor) => {
editor.add({
id: "exa",
name: "Exa",
execute: (input) =>
Effect.gen(function* () {
const connection = yield* ctx.integration.connection.active("exa")
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
const url = new URL(endpoint)
if (credential?.type === "key") url.searchParams.set("exaApiKey", credential.key)
const result = yield* WebSearchMcp.call(
http,
url.toString(),
"web_search_exa",
{ input: McpInput, output: McpOutput },
{ query: input.query, numResults: 8 },
)
const content = result?.content.find((item) => item.text)
return content ? parseResults(content.text) : []
}),
})
yield* WebSearchProvider.register(ctx, {
id: "exa",
name: "Exa",
env: "EXA_API_KEY",
execute: (input, credential) =>
Effect.gen(function* () {
const url = new URL(endpoint)
if (credential?.type === "key") url.searchParams.set("exaApiKey", credential.key)
const result = yield* WebSearchMcp.call(
http,
url.toString(),
"web_search_exa",
{ input: McpInput, output: McpOutput },
{ query: input.query, numResults: 8 },
)
const content = result?.content.find((item) => item.text)
return content ? parseResults(content.text) : []
}),
})
}),
})

View file

@ -5,6 +5,7 @@ import { Effect, Option, Schema, Scope } from "effect"
import { HttpClient } from "effect/http"
import { App } from "../../app.js"
import { WebSearchMcp } from "./mcp.js"
import { WebSearchProvider } from "./provider.js"
export const endpoint = "https://mcp.firecrawl.dev/v2/mcp"
@ -37,48 +38,34 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
id: "opencode.websearch.firecrawl",
effect: Effect.fn("WebSearchFirecrawl.Plugin")(function* (ctx) {
const http = yield* HttpClient.HttpClient
yield* ctx.integration.transform((editor) => {
editor.update("firecrawl", (integration) => (integration.name = "Firecrawl"))
editor.method.update({
integrationID: "firecrawl",
method: { type: "key" },
})
editor.method.update({
integrationID: "firecrawl",
method: { type: "env", names: ["FIRECRAWL_API_KEY"] },
})
})
yield* ctx.websearch.transform((editor) => {
editor.add({
id: "firecrawl",
name: "Firecrawl",
execute: (input) =>
Effect.gen(function* () {
const connection = yield* ctx.integration.connection.active("firecrawl")
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
const result = yield* WebSearchMcp.call(
http,
endpoint,
"firecrawl_search",
{ input: McpInput, output: McpOutput },
{ query: input.query, limit: 8 },
{
"User-Agent": App.useragent(ctx.app),
...(credential?.type === "key" ? { Authorization: `Bearer ${credential.key}` } : {}),
},
)
const content = result?.content.find((item) => item.text)
const response = content ? Option.getOrUndefined(decodeSearchResponse(content.text)) : undefined
return (
response?.data.web.map((item) => ({
url: item.url,
...(item.title ? { title: item.title } : {}),
...(item.description ? { content: item.description } : {}),
time: {},
})) ?? []
)
}),
})
yield* WebSearchProvider.register(ctx, {
id: "firecrawl",
name: "Firecrawl",
env: "FIRECRAWL_API_KEY",
execute: (input, credential) =>
Effect.gen(function* () {
const result = yield* WebSearchMcp.call(
http,
endpoint,
"firecrawl_search",
{ input: McpInput, output: McpOutput },
{ query: input.query, limit: 8 },
{
"User-Agent": App.useragent(ctx.app),
...(credential?.type === "key" ? { Authorization: `Bearer ${credential.key}` } : {}),
},
)
const content = result?.content.find((item) => item.text)
const response = content ? Option.getOrUndefined(decodeSearchResponse(content.text)) : undefined
return (
response?.data.web.map((item) => ({
url: item.url,
...(item.title ? { title: item.title } : {}),
...(item.description ? { content: item.description } : {}),
time: {},
})) ?? []
)
}),
})
}),
})

View file

@ -5,6 +5,7 @@ import { Effect, Schema, Scope } from "effect"
import { HttpClient } from "effect/http"
import { App } from "../../app.js"
import { WebSearchMcp } from "./mcp.js"
import { WebSearchProvider } from "./provider.js"
export const endpoint = "https://search.parallel.ai/mcp"
@ -52,52 +53,38 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
id: "opencode.websearch.parallel",
effect: Effect.fn("WebSearchParallel.Plugin")(function* (ctx) {
const http = yield* HttpClient.HttpClient
yield* ctx.integration.transform((editor) => {
editor.update("parallel", (integration) => (integration.name = "Parallel"))
editor.method.update({
integrationID: "parallel",
method: { type: "key" },
})
editor.method.update({
integrationID: "parallel",
method: { type: "env", names: ["PARALLEL_API_KEY"] },
})
})
yield* ctx.websearch.transform((editor) => {
editor.add({
id: "parallel",
name: "Parallel",
execute: (input) =>
Effect.gen(function* () {
const connection = yield* ctx.integration.connection.active("parallel")
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
const result = yield* WebSearchMcp.call(
http,
endpoint,
"web_search",
{ input: McpInput, output: McpOutput },
{
objective: input.query,
search_queries: [input.query],
},
{
"User-Agent": App.useragent(ctx.app),
...(credential?.type === "key" ? { Authorization: `Bearer ${credential.key}` } : {}),
},
)
return (
result?.structuredContent.results.map((item) => {
const published = item.publish_date ? Date.parse(item.publish_date) : undefined
return {
url: item.url,
...(item.title ? { title: item.title } : {}),
...(item.excerpts.length ? { content: item.excerpts.join("\n\n") } : {}),
time: { ...(published !== undefined && Number.isFinite(published) ? { published } : {}) },
}
}) ?? []
)
}),
})
yield* WebSearchProvider.register(ctx, {
id: "parallel",
name: "Parallel",
env: "PARALLEL_API_KEY",
execute: (input, credential) =>
Effect.gen(function* () {
const result = yield* WebSearchMcp.call(
http,
endpoint,
"web_search",
{ input: McpInput, output: McpOutput },
{
objective: input.query,
search_queries: [input.query],
},
{
"User-Agent": App.useragent(ctx.app),
...(credential?.type === "key" ? { Authorization: `Bearer ${credential.key}` } : {}),
},
)
return (
result?.structuredContent.results.map((item) => {
const published = item.publish_date ? Date.parse(item.publish_date) : undefined
return {
url: item.url,
...(item.title ? { title: item.title } : {}),
...(item.excerpts.length ? { content: item.excerpts.join("\n\n") } : {}),
time: { ...(published !== undefined && Number.isFinite(published) ? { published } : {}) },
}
}) ?? []
)
}),
})
}),
})

View file

@ -0,0 +1,44 @@
export * as WebSearchProvider from "./provider.js"
import type { Context } from "@opencode/plugin/effect/plugin"
import type { Credential } from "@opencode/schema/credential"
import type { WebSearch } from "@opencode/schema/websearch"
import { Effect } from "effect"
/** Registers a web search provider backed by an API key integration with the same ID and name. */
export const register = Effect.fn("WebSearchProvider.register")(function* (
ctx: Context,
provider: {
readonly id: string
readonly name: string
readonly env: string
readonly execute: (
input: WebSearch.ProviderInput,
credential: Credential.Value | undefined,
) => Effect.Effect<readonly WebSearch.Result[], unknown>
},
) {
yield* ctx.integration.transform((editor) => {
editor.update(provider.id, (integration) => (integration.name = provider.name))
editor.method.update({
integrationID: provider.id,
method: { type: "key" },
})
editor.method.update({
integrationID: provider.id,
method: { type: "env", names: [provider.env] },
})
})
yield* ctx.websearch.transform((editor) => {
editor.add({
id: provider.id,
name: provider.name,
execute: (input) =>
Effect.gen(function* () {
const connection = yield* ctx.integration.connection.active(provider.id)
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
return yield* provider.execute(input, credential)
}),
})
})
})

View file

@ -4,6 +4,7 @@ import { define } from "@opencode/plugin/effect/plugin"
import { Duration, Effect, Schema, Scope } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"
import { App } from "../../app.js"
import { WebSearchProvider } from "./provider.js"
export const endpoint = "https://api.tavily.com/search"
@ -28,59 +29,45 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
id: "opencode.websearch.tavily",
effect: Effect.fn("WebSearchTavily.Plugin")(function* (ctx) {
const http = yield* HttpClient.HttpClient
yield* ctx.integration.transform((editor) => {
editor.update("tavily", (integration) => (integration.name = "Tavily"))
editor.method.update({
integrationID: "tavily",
method: { type: "key" },
})
editor.method.update({
integrationID: "tavily",
method: { type: "env", names: ["TAVILY_API_KEY"] },
})
})
yield* ctx.websearch.transform((editor) => {
editor.add({
id: "tavily",
name: "Tavily",
execute: (input) =>
Effect.gen(function* () {
const connection = yield* ctx.integration.connection.active("tavily")
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
const request = yield* HttpClientRequest.post(endpoint).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.setHeaders({
"User-Agent": App.useragent(ctx.app),
"X-Client-Name": "opencode2",
...(credential?.type === "key"
? { Authorization: `Bearer ${credential.key}` }
: { "X-Tavily-Access-Mode": "keyless" }),
}),
HttpClientRequest.schemaBodyJson(SearchRequest)({
query: input.query,
search_depth: "basic",
chunks_per_source: 3,
max_results: 8,
yield* WebSearchProvider.register(ctx, {
id: "tavily",
name: "Tavily",
env: "TAVILY_API_KEY",
execute: (input, credential) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.post(endpoint).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.setHeaders({
"User-Agent": App.useragent(ctx.app),
"X-Client-Name": "opencode2",
...(credential?.type === "key"
? { Authorization: `Bearer ${credential.key}` }
: { "X-Tavily-Access-Mode": "keyless" }),
}),
HttpClientRequest.schemaBodyJson(SearchRequest)({
query: input.query,
search_depth: "basic",
chunks_per_source: 3,
max_results: 8,
}),
)
const response = yield* HttpClient.withScope(HttpClient.filterStatusOk(http))
.execute(request)
.pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(SearchResponse)),
Effect.scoped,
Effect.timeoutOrElse({
duration: Duration.seconds(25),
orElse: () => Effect.fail(new Error("Tavily web search request timed out")),
}),
)
const response = yield* HttpClient.withScope(HttpClient.filterStatusOk(http))
.execute(request)
.pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(SearchResponse)),
Effect.scoped,
Effect.timeoutOrElse({
duration: Duration.seconds(25),
orElse: () => Effect.fail(new Error("Tavily web search request timed out")),
}),
)
return response.results.map((item) => ({
url: item.url,
title: item.title,
...(item.content ? { content: item.content } : {}),
time: {},
}))
}),
})
return response.results.map((item) => ({
url: item.url,
title: item.title,
...(item.content ? { content: item.content } : {}),
time: {},
}))
}),
})
}),
})

View file

@ -6,6 +6,7 @@ import { HttpClient, HttpClientRequest } from "effect/http"
import { App } from "../../app.js"
import { collectBoundedResponseBody } from "../../tool/http-body.js"
import { WebSearchMcp } from "./mcp.js"
import { WebSearchProvider } from "./provider.js"
export const endpoint = "https://agent.tinyfish.ai/mcp"
@ -50,53 +51,37 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
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 headers = {
"User-Agent": App.useragent(ctx.app),
...(credential?.type === "key"
? { "X-API-Key": credential.key }
: { "X-TinyFish-Access-Mode": "keyless" }),
}
// Keyed requests are rejected without the `Mcp-Session-Id` returned by `initialize`; keyless ones are not.
const session = credential?.type === "key" ? yield* initialize(http, headers) : undefined
const result = yield* WebSearchMcp.call(
http,
endpoint,
"search",
{ input: McpInput, output: McpOutput },
{ query: input.query },
{ ...headers, ...(session === undefined ? {} : { "Mcp-Session-Id": session }) },
)
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: {},
})) ?? []
)
}),
})
yield* WebSearchProvider.register(ctx, {
id: "tinyfish",
name: "TinyFish",
env: "TINYFISH_API_KEY",
execute: (input, credential) =>
Effect.gen(function* () {
const headers = {
"User-Agent": App.useragent(ctx.app),
...(credential?.type === "key" ? { "X-API-Key": credential.key } : { "X-TinyFish-Access-Mode": "keyless" }),
}
// Keyed requests are rejected without the `Mcp-Session-Id` returned by `initialize`; keyless ones are not.
const session = credential?.type === "key" ? yield* initialize(http, headers) : undefined
const result = yield* WebSearchMcp.call(
http,
endpoint,
"search",
{ input: McpInput, output: McpOutput },
{ query: input.query },
{ ...headers, ...(session === undefined ? {} : { "Mcp-Session-Id": session }) },
)
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: {},
})) ?? []
)
}),
})
}),
})