From 2d0ddd620f7929026afd6edfe0a36660b3cd621c Mon Sep 17 00:00:00 2001 From: Erik Engervall Date: Tue, 1 Sep 2026 10:04:29 +0900 Subject: [PATCH] feat(core): add firecrawl developer search provider Second Firecrawl provider that passes categories: ["developer"] to the same firecrawl_search call, searching an index of GitHub issues, merged pull requests, READMEs and docs instead of the open web. Shares the execute path with the existing provider, and works without an API key. Co-authored-by: Cursor Co-Authored-By: Claude Fable 5.1 --- .../core/src/plugin/websearch/firecrawl.ts | 65 +++++++++++-------- packages/core/test/plugin/websearch.test.ts | 6 +- .../session-ui/src/tools/tool-renderer.tsx | 8 ++- packages/tui/src/util/tool-display.ts | 8 ++- packages/tui/test/util/tool-display.test.ts | 8 +++ 5 files changed, 63 insertions(+), 32 deletions(-) diff --git a/packages/core/src/plugin/websearch/firecrawl.ts b/packages/core/src/plugin/websearch/firecrawl.ts index 1ea260e0c5da..6d8a01fc319c 100644 --- a/packages/core/src/plugin/websearch/firecrawl.ts +++ b/packages/core/src/plugin/websearch/firecrawl.ts @@ -1,6 +1,7 @@ export * as WebSearchFirecrawl from "./firecrawl.js" import { define } from "@opencode/plugin/effect/plugin" +import type { WebSearch } from "@opencode/schema/websearch" import { Effect, Option, Schema, Scope } from "effect" import { HttpClient } from "effect/unstable/http" import { App } from "../../app.js" @@ -11,6 +12,7 @@ export const endpoint = "https://mcp.firecrawl.dev/v2/mcp" const McpInput = Schema.Struct({ query: Schema.String, limit: Schema.Number.pipe(Schema.optional), + categories: Schema.Array(Schema.String).pipe(Schema.optional), }) const McpOutput = Schema.Struct({ @@ -48,36 +50,47 @@ export const Plugin = define({ method: { type: "env", names: ["FIRECRAWL_API_KEY"] }, }) }) + const search = + (categories?: readonly string[]) => + (input: WebSearch.ProviderInput): Effect.Effect => + 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, ...(categories ? { categories } : {}) }, + { + "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* 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: {}, - })) ?? [] - ) - }), + execute: search(), + }) + // The developer category resolves against an index of GitHub issues, merged + // pull requests, READMEs, and curated documentation rather than the open web. + editor.add({ + id: "firecrawl-developer", + name: "Firecrawl Developer", + execute: search(["developer"]), }) }) }), diff --git a/packages/core/test/plugin/websearch.test.ts b/packages/core/test/plugin/websearch.test.ts index 775f576df5c5..92ff7bdeb4dd 100644 --- a/packages/core/test/plugin/websearch.test.ts +++ b/packages/core/test/plugin/websearch.test.ts @@ -46,8 +46,10 @@ describe("built-in web search providers", () => { 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) + // A rate-limited provider is put on cooldown and the query fails over to the + // next one, so a plugin that registers several providers makes several requests. + expect(signals).toHaveLength((yield* websearch.providers()).length) + expect(signals.every((signal) => signal.aborted)).toBe(true) }), ) }) diff --git a/packages/session-ui/src/tools/tool-renderer.tsx b/packages/session-ui/src/tools/tool-renderer.tsx index c2ae0a2ba6b0..311ac6bf3f9b 100644 --- a/packages/session-ui/src/tools/tool-renderer.tsx +++ b/packages/session-ui/src/tools/tool-renderer.tsx @@ -265,9 +265,11 @@ function webSearchProviderLabel(provider: unknown, i18n: ReturnType, omit: read return `[${entries.map(([key, value]) => `${key}=${String(value)}`).join(", ")}]` } +// Capitalizing the id is enough for single-word providers; anything hyphenated +// needs a spelling here or it renders as "Firecrawl-developer". +const WEB_SEARCH_PROVIDER_NAMES: Record = { + "firecrawl-developer": "Firecrawl Developer", +} + export function webSearchProviderName(provider: unknown) { if (typeof provider !== "string" || !provider) return "" - return `${provider[0].toUpperCase()}${provider.slice(1)}` + return WEB_SEARCH_PROVIDER_NAMES[provider] ?? `${provider[0].toUpperCase()}${provider.slice(1)}` } export function webSearchProviderLabel(provider: unknown) { diff --git a/packages/tui/test/util/tool-display.test.ts b/packages/tui/test/util/tool-display.test.ts index 1cd28b15d156..4d74414a2466 100644 --- a/packages/tui/test/util/tool-display.test.ts +++ b/packages/tui/test/util/tool-display.test.ts @@ -5,6 +5,7 @@ import { primitiveInputSummary, toolDisplayMetadata, webSearchProviderLabel, + webSearchProviderName, } from "../../src/util/tool-display" test("normalizes shared tool primitives", () => { @@ -19,11 +20,18 @@ test("normalizes shared tool primitives", () => { expect(primitiveInputSummary({ path: "src/a.ts", line: 2 }, ["path"])).toBe("[line=2]") }) +test("webSearchProviderName spells hyphenated providers", () => { + expect(webSearchProviderName("firecrawl")).toBe("Firecrawl") + expect(webSearchProviderName("firecrawl-developer")).toBe("Firecrawl Developer") + expect(webSearchProviderName("")).toBe("") +}) + describe("webSearchProviderLabel", () => { test("labels known providers", () => { expect(webSearchProviderLabel("parallel")).toBe("Web Search via Parallel") expect(webSearchProviderLabel("exa")).toBe("Web Search via Exa") expect(webSearchProviderLabel("firecrawl")).toBe("Web Search via Firecrawl") + expect(webSearchProviderLabel("firecrawl-developer")).toBe("Web Search via Firecrawl Developer") expect(webSearchProviderLabel("tavily")).toBe("Web Search via Tavily") })