@kagan-sh/opensearch 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CONTRIBUTING.md CHANGED
@@ -70,7 +70,7 @@ This repo uses `semantic-release` on `main`.
70
70
 
71
71
  - Prefer Conventional Commits for merge commits and direct commits to `main`
72
72
  - Run `bun run release:dry-run` locally if you want to preview the next release
73
- - npm publishing is configured for GitHub Actions trusted publishing via OIDC for `@kagan-sh/opensearch`
73
+ - npm publishing now uses GitHub Actions trusted publishing via OIDC for `@kagan-sh/opensearch`
74
74
  - The npm package must be linked to this repository under the `kagan_sh` publisher account before the first release
75
75
  - Local `semantic-release --dry-run` still fails auth checks unless GitHub and npm credentials are present; the OIDC npm path is validated in GitHub Actions, not in a regular local shell
76
76
 
@@ -95,7 +95,7 @@ This repo uses `semantic-release` on `main`.
95
95
  - CI workflow: `.github/workflows/ci.yml`
96
96
  - Release workflow: `.github/workflows/release.yml`
97
97
  - GitHub releases are automated with `semantic-release`
98
- - npm publishing is ready for trusted publishing via OIDC once the package is configured on npm
98
+ - npm publishing is configured to publish through OIDC without a long-lived npm token
99
99
 
100
100
  ## Pull requests
101
101
 
package/README.md CHANGED
@@ -1,11 +1,12 @@
1
1
  <p align="center">
2
2
  <picture>
3
- <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/kagan-sh/opensearch/main/.github/assets/logo-dark.svg">
4
- <source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/kagan-sh/opensearch/main/.github/assets/logo-light.svg">
5
- <img alt="OpenSearch — evidence-backed search for OpenCode" src="https://raw.githubusercontent.com/kagan-sh/opensearch/main/.github/assets/logo-dark.svg" width="100%">
3
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/kagan-sh/opensearch/main/.github/assets/mark-dark.svg">
4
+ <source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/kagan-sh/opensearch/main/.github/assets/mark-light.svg">
5
+ <img alt="OpenSearch — evidence-backed search for OpenCode" src="https://raw.githubusercontent.com/kagan-sh/opensearch/main/.github/assets/mark-dark.svg" width="100%">
6
6
  </picture>
7
7
  </p>
8
8
  <p align="center">
9
+ <a href="https://www.npmjs.com/package/@kagan-sh/opensearch"><img src="https://img.shields.io/npm/v/%40kagan-sh%2Fopensearch?style=for-the-badge" alt="npm"></a>
9
10
  <a href="https://github.com/kagan-sh/opensearch/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/kagan-sh/opensearch/ci.yml?style=for-the-badge&label=CI" alt="CI"></a>
10
11
  <a href="https://kagan-sh.github.io/opensearch/"><img src="https://img.shields.io/badge/docs-github%20pages-181717?style=for-the-badge&logo=github" alt="Docs"></a>
11
12
  <a href="https://opensource.org/license/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge" alt="License: MIT"></a>
@@ -14,77 +15,78 @@
14
15
  <h3 align="center">
15
16
  <a href="https://kagan-sh.github.io/opensearch/">Docs</a> ·
16
17
  <a href="https://kagan-sh.github.io/opensearch/quickstart/">Quickstart</a> ·
17
- <a href="https://kagan-sh.github.io/opensearch/reference/result-contract/">Result Contract</a> ·
18
- <a href="https://github.com/kagan-sh/opensearch/issues/new?template=feature-request.yml">Feature Requests</a>
18
+ <a href="https://kagan-sh.github.io/opensearch/guides/searxng/">SearXNG Setup</a> ·
19
+ <a href="https://kagan-sh.github.io/opensearch/reference/result-contract/">Reference</a> ·
20
+ <a href="https://raw.githubusercontent.com/kagan-sh/opensearch/main/SKILL.md">LLM Quick Reference</a>
19
21
  </h3>
20
22
 
21
23
  ---
22
24
 
23
- `@kagan-sh/opensearch` is an OpenCode plugin for broad investigation. It searches session history, the live web, and public code in parallel, then returns a structured evidence-backed response your agent can act on.
25
+ `@kagan-sh/opensearch` is an evidence-backed search tool for AI coding agents. It searches the live web and public code in parallel, then returns structured JSON your agent can act on.
26
+
27
+ Works as an **OpenCode plugin** and as a **Claude Code MCP server**.
24
28
 
25
29
  ## Install
26
30
 
27
- Add the plugin to `opencode.json`:
31
+ ### Claude Code (MCP)
28
32
 
29
- ```json
30
- {
31
- "$schema": "https://opencode.ai/config.json",
32
- "plugin": ["@kagan-sh/opensearch"]
33
- }
33
+ ```bash
34
+ claude mcp add opensearch -- npx -y @kagan-sh/opensearch
34
35
  ```
35
36
 
36
- OpenCode installs npm plugins automatically at startup.
37
-
38
- Full docs: **[kagan-sh.github.io/opensearch](https://kagan-sh.github.io/opensearch/)**.
37
+ To enable web search via SearXNG:
39
38
 
40
- ## Configuration
41
-
42
- Control runtime behavior with environment variables:
43
-
44
- - `OPENSEARCH_SYNTH=true|false`
45
- - `OPENSEARCH_DEPTH=quick|thorough`
46
- - `OPENSEARCH_SOURCE_SESSION=true|false`
47
- - `OPENSEARCH_SOURCE_WEB=true|false`
48
- - `OPENSEARCH_SOURCE_CODE=true|false`
49
- - `OPENSEARCH_WEB_KEY=<exa_api_key>`
50
- - `EXA_API_KEY=<exa_api_key>`
51
-
52
- `OPENSEARCH_WEB_KEY` takes precedence over `EXA_API_KEY`. Web search is skipped when neither key is set.
53
-
54
- ## Tool
55
-
56
- The plugin exposes a single tool: `opensearch`.
57
-
58
- Arguments:
59
-
60
- - `query: string`
61
- - `sources?: ("session" | "web" | "code")[]`
62
- - `depth?: "quick" | "thorough"`
39
+ ```bash
40
+ claude mcp add opensearch \
41
+ -e OPENSEARCH_WEB_URL=http://localhost:8080 \
42
+ -- npx -y @kagan-sh/opensearch
43
+ ```
63
44
 
64
- The tool returns strict JSON with `status`, `answer`, `confidence`, `evidence[]`, `sources[]`, `followups[]`, and `meta`.
45
+ Or add it to `.mcp.json` in your project root:
65
46
 
66
- `meta` includes:
47
+ ```json
48
+ {
49
+ "mcpServers": {
50
+ "opensearch": {
51
+ "command": "npx",
52
+ "args": ["-y", "@kagan-sh/opensearch"],
53
+ "env": {
54
+ "OPENSEARCH_WEB_URL": "http://localhost:8080"
55
+ }
56
+ }
57
+ }
58
+ }
59
+ ```
67
60
 
68
- - `sources_requested`
69
- - `sources_queried`
70
- - `sources_yielded`
71
- - `sources_unavailable[]`
72
- - `source_errors[]`
61
+ See the [Claude Code install guide](https://kagan-sh.github.io/opensearch/guides/claude-code/) for full details.
73
62
 
74
- Invalid plugin config is reported explicitly instead of being ignored.
63
+ ### OpenCode (plugin)
75
64
 
76
- ## Contributing
65
+ Add the plugin to `opencode.json`:
77
66
 
78
- For local source development, validation commands, and release workflow details, see `CONTRIBUTING.md`.
67
+ ```json
68
+ {
69
+ "$schema": "https://opencode.ai/config.json",
70
+ "plugin": ["@kagan-sh/opensearch"]
71
+ }
72
+ ```
79
73
 
80
- ## Documentation
74
+ OpenCode installs npm plugins automatically at startup.
81
75
 
82
- Published docs live at `https://kagan-sh.github.io/opensearch/`. MkDocs source lives in `docs/` with site config in `mkdocs.yml`.
76
+ Full docs: **[kagan-sh.github.io/opensearch](https://kagan-sh.github.io/opensearch/)**. Web search uses a self-hosted `SearXNG` instance; setup lives in the docs.
83
77
 
84
- ## Skill
78
+ ## License
85
79
 
86
- The repo includes `SKILL.md` for agent environments that support installable skills. It increases the chance that agents reach for `opensearch` on research-heavy prompts, but it does not force tool selection.
80
+ [MIT](LICENSE)
87
81
 
88
- ## License
82
+ ---
89
83
 
90
- MIT
84
+ <p align="center">
85
+ <a href="https://www.star-history.com/#kagan-sh/opensearch&type=date">
86
+ <picture>
87
+ <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=kagan-sh/opensearch&type=date&theme=dark" />
88
+ <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=kagan-sh/opensearch&type=date" />
89
+ <img alt="Star History" src="https://api.star-history.com/svg?repos=kagan-sh/opensearch&type=date" width="600" />
90
+ </picture>
91
+ </a>
92
+ </p>
package/SKILL.md CHANGED
@@ -2,7 +2,7 @@
2
2
  name: opensearch
3
3
  description: Use OpenSearch for broad, evidence-backed investigation across session history, the web, and public code when a user asks to search, research, compare sources, or gather official docs and examples.
4
4
  license: MIT
5
- compatibility: Requires OpenCode with @kagan-sh/opensearch installed. Web results need OPENSEARCH_WEB_KEY or EXA_API_KEY.
5
+ compatibility: Works as an OpenCode plugin or a Claude Code MCP server. The web source requires a SearXNG instance and OPENSEARCH_WEB_URL. In Claude Code, only web and code sources are available.
6
6
  metadata:
7
7
  version: 0.0.1
8
8
  ---
@@ -42,7 +42,7 @@ Use this skill when the task is primarily about finding, comparing, and synthesi
42
42
 
43
43
  ## Source Selection Guide
44
44
 
45
- - `session` for prior decisions, earlier runs, and local conversation history
45
+ - `session` for prior decisions, earlier runs, and local conversation history (OpenCode only)
46
46
  - `web` for official docs, changelogs, and current vendor guidance
47
47
  - `code` for public implementation examples and real usage patterns
48
48
 
package/dist/config.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Config, SourceId } from "./schema";
2
2
  export declare function defaultConfig(): Config;
3
+ export declare function mcpDefaultConfig(): Config;
3
4
  export declare function parsePluginConfig(input: unknown): Config | undefined;
4
5
  export declare function isSourceAvailable(config: Config, source: SourceId): boolean;
5
6
  export declare function resolveSources(config: Config, requested?: SourceId[]): {
package/dist/config.js CHANGED
@@ -26,7 +26,7 @@ export function defaultConfig() {
26
26
  session: parseBoolean("OPENSEARCH_SOURCE_SESSION", process.env.OPENSEARCH_SOURCE_SESSION, true),
27
27
  web: {
28
28
  enabled: parseBoolean("OPENSEARCH_SOURCE_WEB", process.env.OPENSEARCH_SOURCE_WEB, true),
29
- key: process.env.OPENSEARCH_WEB_KEY ?? process.env.EXA_API_KEY,
29
+ url: process.env.OPENSEARCH_WEB_URL,
30
30
  },
31
31
  code: parseBoolean("OPENSEARCH_SOURCE_CODE", process.env.OPENSEARCH_SOURCE_CODE, true),
32
32
  },
@@ -34,6 +34,20 @@ export function defaultConfig() {
34
34
  synth: parseBoolean("OPENSEARCH_SYNTH", process.env.OPENSEARCH_SYNTH, true),
35
35
  };
36
36
  }
37
+ export function mcpDefaultConfig() {
38
+ return {
39
+ sources: {
40
+ session: false,
41
+ web: {
42
+ enabled: parseBoolean("OPENSEARCH_SOURCE_WEB", process.env.OPENSEARCH_SOURCE_WEB, true),
43
+ url: process.env.OPENSEARCH_WEB_URL,
44
+ },
45
+ code: parseBoolean("OPENSEARCH_SOURCE_CODE", process.env.OPENSEARCH_SOURCE_CODE, true),
46
+ },
47
+ depth: parseDepth(process.env.OPENSEARCH_DEPTH),
48
+ synth: false,
49
+ };
50
+ }
37
51
  export function parsePluginConfig(input) {
38
52
  if (!input || typeof input !== "object" || !("opensearch" in input)) {
39
53
  return undefined;
@@ -50,7 +64,7 @@ export function isSourceAvailable(config, source) {
50
64
  if (source === "session")
51
65
  return config.sources.session;
52
66
  if (source === "web") {
53
- return config.sources.web.enabled && Boolean(config.sources.web.key);
67
+ return config.sources.web.enabled && Boolean(config.sources.web.url);
54
68
  }
55
69
  return config.sources.code;
56
70
  }
package/dist/index.js CHANGED
@@ -4,6 +4,19 @@ import { noResultsResult, noSourcesResult, rawResultsResult, runSourceSearches,
4
4
  import { DEPTHS, ResultSchema, SOURCE_IDS, } from "./schema";
5
5
  import { synthesize } from "./synth";
6
6
  const BRAND = "OpenSearch";
7
+ const TAGLINE = "evidence-backed search";
8
+ function sourceBadge(source) {
9
+ if (source === "session")
10
+ return "SESSION";
11
+ if (source === "web")
12
+ return "WEB";
13
+ return "CODE";
14
+ }
15
+ function sourceBadges(sources) {
16
+ if (sources.length === 0)
17
+ return ["OFFLINE"];
18
+ return sources.map(sourceBadge);
19
+ }
7
20
  function serialize(result) {
8
21
  return JSON.stringify(result, null, 2);
9
22
  }
@@ -22,28 +35,32 @@ function describeSources(sources) {
22
35
  return "session + web + code";
23
36
  }
24
37
  function runningTitle(sources) {
25
- return `${BRAND} · searching ${describeSources(sources)}`;
38
+ return `${BRAND} // scanning ${describeSources(sources)}`;
26
39
  }
27
40
  function doneTitle(result) {
28
41
  if (result.status === "no_sources")
29
- return `${BRAND} · unavailable`;
42
+ return `${BRAND} // unavailable`;
30
43
  if (result.status === "no_results")
31
- return `${BRAND} · no matches`;
44
+ return `${BRAND} // no matches`;
32
45
  if (result.status === "raw") {
33
- return `${BRAND} · ${result.meta.sources_yielded} raw result${result.meta.sources_yielded === 1 ? "" : "s"}`;
46
+ return `${BRAND} // ${result.meta.sources_yielded} raw result${result.meta.sources_yielded === 1 ? "" : "s"}`;
34
47
  }
35
48
  if (result.status === "raw_fallback")
36
- return `${BRAND} · evidence fallback`;
37
- return `${BRAND} · ${result.meta.sources_yielded} result${result.meta.sources_yielded === 1 ? "" : "s"}`;
49
+ return `${BRAND} // evidence fallback`;
50
+ return `${BRAND} // ${result.meta.sources_yielded} result${result.meta.sources_yielded === 1 ? "" : "s"}`;
38
51
  }
39
52
  function toolMetadata(input) {
40
53
  return {
41
54
  brand: BRAND,
55
+ brand_tagline: TAGLINE,
56
+ brand_origin: "@kagan-sh/opensearch",
57
+ brand_surface: "plugin",
42
58
  phase: input.phase,
43
59
  query: previewQuery(input.query),
44
60
  depth: input.depth,
45
61
  sources: input.sources,
46
62
  source_summary: describeSources(input.sources),
63
+ source_badges: sourceBadges(input.sources),
47
64
  ...(input.result
48
65
  ? {
49
66
  status: input.result.status,
@@ -84,7 +101,7 @@ export const OpensearchPlugin = async (ctx) => {
84
101
  if (input.toolID !== "opensearch")
85
102
  return;
86
103
  output.description =
87
- "OpenSearch: use for broad investigation when the user asks to search, compare evidence, gather official docs, or combine session, web, and code results.";
104
+ "OpenSearch // evidence-backed search across session history, SearXNG web search, and public code. Use it for broad investigation, comparison, docs gathering, and cross-source research.";
88
105
  },
89
106
  "tool.execute.after": async (input, output) => {
90
107
  if (input.tool !== "opensearch")
@@ -110,7 +127,7 @@ export const OpensearchPlugin = async (ctx) => {
110
127
  },
111
128
  tool: {
112
129
  opensearch: tool({
113
- description: "Universal intelligent search. Queries session history, web, and code in parallel. Returns structured evidence-backed answer.",
130
+ description: "OpenSearch // evidence-backed search. Queries session history, SearXNG web search, and public code in parallel, then returns a structured answer with explicit status and source metadata.",
114
131
  args: {
115
132
  query: tool.schema.string().describe("What to search for"),
116
133
  sources: tool.schema
@@ -185,7 +202,7 @@ export const OpensearchPlugin = async (ctx) => {
185
202
  }
186
203
  if (cfg.synth) {
187
204
  context.metadata({
188
- title: `${BRAND} · synthesizing evidence`,
205
+ title: `${BRAND} // assembling evidence`,
189
206
  metadata: {
190
207
  ...toolMetadata({
191
208
  phase: "synthesizing",
@@ -194,6 +211,7 @@ export const OpensearchPlugin = async (ctx) => {
194
211
  sources: resolved.sources,
195
212
  }),
196
213
  raw_results: search.raw.length,
214
+ status_note: "assembling evidence",
197
215
  },
198
216
  });
199
217
  try {
package/dist/mcp.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/mcp.js ADDED
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
+ import { mcpDefaultConfig, resolveSources } from "./config";
6
+ import { noResultsResult, noSourcesResult, rawResultsResult, runSourceSearches, } from "./orchestrator";
7
+ import { SOURCE_IDS } from "./schema";
8
+ function serialize(value) {
9
+ return JSON.stringify(value, null, 2);
10
+ }
11
+ function parseSourceIds(raw) {
12
+ if (!Array.isArray(raw))
13
+ return undefined;
14
+ return raw.filter((s) => typeof s === "string" && SOURCE_IDS.includes(s));
15
+ }
16
+ const config = mcpDefaultConfig();
17
+ const server = new Server({ name: "opensearch", version: "0.0.1" }, { capabilities: { tools: {} } });
18
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
19
+ tools: [
20
+ {
21
+ name: "opensearch",
22
+ description: "Evidence-backed search across SearXNG web and public code. Returns structured JSON with sources, relevance scores, and follow-up suggestions.",
23
+ inputSchema: {
24
+ type: "object",
25
+ properties: {
26
+ query: {
27
+ type: "string",
28
+ description: "What to search for",
29
+ },
30
+ sources: {
31
+ type: "array",
32
+ items: { type: "string", enum: ["web", "code"] },
33
+ description: "Sources to query. Defaults to all enabled.",
34
+ },
35
+ depth: {
36
+ type: "string",
37
+ enum: ["quick", "thorough"],
38
+ description: "Search depth. Default: quick",
39
+ },
40
+ },
41
+ required: ["query"],
42
+ },
43
+ },
44
+ ],
45
+ }));
46
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
47
+ if (request.params.name !== "opensearch") {
48
+ return {
49
+ isError: true,
50
+ content: [{ type: "text", text: `Unknown tool: ${request.params.name}` }],
51
+ };
52
+ }
53
+ const args = request.params.arguments ?? {};
54
+ const query = typeof args.query === "string" ? args.query : "";
55
+ const depth = args.depth === "thorough" ? "thorough" : config.depth;
56
+ const start = Date.now();
57
+ const requested = parseSourceIds(args.sources);
58
+ const resolved = resolveSources(config, requested);
59
+ if (resolved.sources.length === 0) {
60
+ const result = noSourcesResult({
61
+ query,
62
+ start,
63
+ requested: resolved.requested,
64
+ unavailable: resolved.unavailable,
65
+ });
66
+ return { content: [{ type: "text", text: serialize(result) }] };
67
+ }
68
+ const search = await runSourceSearches({
69
+ directory: process.cwd(),
70
+ config,
71
+ query,
72
+ depth,
73
+ sources: resolved.sources,
74
+ });
75
+ if (search.raw.length === 0) {
76
+ const result = noResultsResult({
77
+ query,
78
+ start,
79
+ requested: resolved.requested,
80
+ queried: resolved.sources,
81
+ unavailable: resolved.unavailable,
82
+ sourceErrors: search.sourceErrors,
83
+ });
84
+ return { content: [{ type: "text", text: serialize(result) }] };
85
+ }
86
+ const result = rawResultsResult({
87
+ query,
88
+ start,
89
+ requested: resolved.requested,
90
+ queried: resolved.sources,
91
+ unavailable: resolved.unavailable,
92
+ sourceErrors: search.sourceErrors,
93
+ raw: search.raw,
94
+ status: "raw",
95
+ });
96
+ return { content: [{ type: "text", text: serialize(result) }] };
97
+ });
98
+ const transport = new StdioServerTransport();
99
+ await server.connect(transport);
@@ -2,7 +2,7 @@ import type { createOpencodeClient } from "@opencode-ai/sdk";
2
2
  import type { Config, RawResult, Result, Source, SourceError, SourceId, Synthesis } from "./schema";
3
3
  export declare function normalize(raw: RawResult): Source;
4
4
  export declare function runSourceSearches(input: {
5
- client: ReturnType<typeof createOpencodeClient>;
5
+ client?: ReturnType<typeof createOpencodeClient>;
6
6
  directory: string;
7
7
  config: Config;
8
8
  query: string;
@@ -1,5 +1,6 @@
1
1
  import { searchCode } from "./sources/code";
2
2
  import { searchSessions } from "./sources/session";
3
+ import { failure } from "./sources/shared";
3
4
  import { searchWeb } from "./sources/web";
4
5
  function clampUnit(value) {
5
6
  return Math.max(0, Math.min(1, value));
@@ -40,10 +41,13 @@ function createResult(input) {
40
41
  export async function runSourceSearches(input) {
41
42
  const outcomes = await Promise.all(input.sources.map((source) => {
42
43
  if (source === "session") {
44
+ if (!input.client) {
45
+ return failure("session", "unavailable", "Session search requires the OpenCode runtime.");
46
+ }
43
47
  return searchSessions(input.client, input.directory, input.query, input.depth);
44
48
  }
45
49
  if (source === "web") {
46
- return searchWeb(input.query, input.config.sources.web.key, input.depth);
50
+ return searchWeb(input.query, input.config.sources.web.url, input.depth);
47
51
  }
48
52
  return searchCode(input.query, input.depth);
49
53
  }));
@@ -61,7 +65,7 @@ export function noSourcesResult(input) {
61
65
  sources: [],
62
66
  followups: [
63
67
  "Enable at least one search source in opensearch config",
64
- "Set OPENSEARCH_WEB_KEY or EXA_API_KEY to enable web search",
68
+ "Set OPENSEARCH_WEB_URL to enable web search",
65
69
  ],
66
70
  meta: resultMeta({
67
71
  query: input.query,
package/dist/schema.d.ts CHANGED
@@ -346,27 +346,27 @@ export declare const ConfigSchema: z.ZodObject<{
346
346
  session: z.ZodBoolean;
347
347
  web: z.ZodObject<{
348
348
  enabled: z.ZodBoolean;
349
- key: z.ZodOptional<z.ZodString>;
349
+ url: z.ZodOptional<z.ZodString>;
350
350
  }, "strict", z.ZodTypeAny, {
351
351
  enabled: boolean;
352
- key?: string | undefined;
352
+ url?: string | undefined;
353
353
  }, {
354
354
  enabled: boolean;
355
- key?: string | undefined;
355
+ url?: string | undefined;
356
356
  }>;
357
357
  code: z.ZodBoolean;
358
358
  }, "strict", z.ZodTypeAny, {
359
359
  session: boolean;
360
360
  web: {
361
361
  enabled: boolean;
362
- key?: string | undefined;
362
+ url?: string | undefined;
363
363
  };
364
364
  code: boolean;
365
365
  }, {
366
366
  session: boolean;
367
367
  web: {
368
368
  enabled: boolean;
369
- key?: string | undefined;
369
+ url?: string | undefined;
370
370
  };
371
371
  code: boolean;
372
372
  }>;
@@ -377,7 +377,7 @@ export declare const ConfigSchema: z.ZodObject<{
377
377
  session: boolean;
378
378
  web: {
379
379
  enabled: boolean;
380
- key?: string | undefined;
380
+ url?: string | undefined;
381
381
  };
382
382
  code: boolean;
383
383
  };
@@ -388,7 +388,7 @@ export declare const ConfigSchema: z.ZodObject<{
388
388
  session: boolean;
389
389
  web: {
390
390
  enabled: boolean;
391
- key?: string | undefined;
391
+ url?: string | undefined;
392
392
  };
393
393
  code: boolean;
394
394
  };
package/dist/schema.js CHANGED
@@ -82,7 +82,7 @@ export const ConfigSchema = z
82
82
  web: z
83
83
  .object({
84
84
  enabled: z.boolean(),
85
- key: z.string().optional(),
85
+ url: z.string().url().optional(),
86
86
  })
87
87
  .strict(),
88
88
  code: z.boolean(),
@@ -1,3 +1,3 @@
1
1
  import type { Depth } from "../schema";
2
2
  import { type SourceSearchOutcome } from "./shared";
3
- export declare function searchWeb(query: string, key: string | undefined, depth: Depth): Promise<SourceSearchOutcome>;
3
+ export declare function searchWeb(query: string, baseUrl: string | undefined, depth: Depth): Promise<SourceSearchOutcome>;
@@ -1,46 +1,38 @@
1
1
  import { failure, messageFromError } from "./shared";
2
- export async function searchWeb(query, key, depth) {
2
+ export async function searchWeb(query, baseUrl, depth) {
3
3
  const source = "web";
4
- if (!key) {
5
- return failure(source, "unavailable", "Web source requires OPENSEARCH_WEB_KEY or EXA_API_KEY.");
4
+ if (!baseUrl) {
5
+ return failure(source, "unavailable", "Web source requires OPENSEARCH_WEB_URL.");
6
6
  }
7
7
  try {
8
- const res = await fetch("https://api.exa.ai/search", {
9
- method: "POST",
8
+ const url = new URL("search", baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`);
9
+ url.searchParams.set("q", query);
10
+ url.searchParams.set("format", "json");
11
+ const res = await fetch(url, {
10
12
  headers: {
11
- "x-api-key": key,
12
- "Content-Type": "application/json",
13
+ Accept: "application/json",
13
14
  },
14
- body: JSON.stringify({
15
- query,
16
- numResults: depth === "quick" ? 5 : 10,
17
- type: "auto",
18
- contents: {
19
- text: {
20
- maxCharacters: 1000,
21
- },
22
- },
23
- }),
24
15
  });
25
16
  if (!res.ok) {
26
- return failure(source, "request_failed", `Exa search failed with status ${res.status}.`);
17
+ return failure(source, "request_failed", res.status === 403
18
+ ? "SearXNG search failed with status 403. Enable JSON output on the instance."
19
+ : `SearXNG search failed with status ${res.status}.`);
27
20
  }
28
21
  const body = (await res.json());
29
22
  if (!Array.isArray(body.results)) {
30
- return failure(source, "invalid_response", "Exa search returned an invalid payload.");
23
+ return failure(source, "invalid_response", "SearXNG search returned an invalid payload.");
31
24
  }
32
- const list = body.results;
25
+ const limit = depth === "quick" ? 5 : 10;
26
+ const list = body.results.slice(0, limit);
33
27
  return {
34
28
  source,
35
29
  results: list.map((item, i) => ({
36
- id: item.id ?? `web-${i}`,
30
+ id: `web-${i}`,
37
31
  type: source,
38
32
  title: item.title ?? item.url ?? "Untitled",
39
- snippet: (item.text ?? "").slice(0, 700),
33
+ snippet: (item.content ?? item.url ?? "").slice(0, 700),
40
34
  url: item.url,
41
- relevance: typeof item.score === "number"
42
- ? Math.min(1, Math.max(0, item.score))
43
- : 0.5,
35
+ relevance: Math.max(0.1, 1 - i / Math.max(1, list.length)),
44
36
  timestamp: item.publishedDate
45
37
  ? Date.parse(item.publishedDate) || undefined
46
38
  : undefined,
@@ -48,6 +40,6 @@ export async function searchWeb(query, key, depth) {
48
40
  };
49
41
  }
50
42
  catch (error) {
51
- return failure(source, "request_failed", `Exa search failed: ${messageFromError(error)}`);
43
+ return failure(source, "request_failed", `SearXNG search failed: ${messageFromError(error)}`);
52
44
  }
53
45
  }
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@kagan-sh/opensearch",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "OpenCode plugin that combines session, web, and code search",
5
+ "license": "MIT",
5
6
  "homepage": "https://kagan-sh.github.io/opensearch/",
6
7
  "repository": {
7
8
  "type": "git",
@@ -13,11 +14,19 @@
13
14
  "type": "module",
14
15
  "main": "dist/index.js",
15
16
  "types": "dist/index.d.ts",
17
+ "bin": {
18
+ "opensearch-mcp": "./dist/mcp.js"
19
+ },
16
20
  "exports": {
17
21
  ".": {
18
22
  "types": "./dist/index.d.ts",
19
23
  "import": "./dist/index.js",
20
24
  "default": "./dist/index.js"
25
+ },
26
+ "./mcp": {
27
+ "types": "./dist/mcp.d.ts",
28
+ "import": "./dist/mcp.js",
29
+ "default": "./dist/mcp.js"
21
30
  }
22
31
  },
23
32
  "files": [
@@ -28,6 +37,7 @@
28
37
  "LICENSE"
29
38
  ],
30
39
  "scripts": {
40
+ "mcp": "bun run src/mcp.ts",
31
41
  "build": "bun tsc",
32
42
  "typecheck": "bun tsc --noEmit",
33
43
  "test": "vitest run",
@@ -39,6 +49,7 @@
39
49
  "release:dry-run": "semantic-release --dry-run"
40
50
  },
41
51
  "dependencies": {
52
+ "@modelcontextprotocol/sdk": "^1.27.1",
42
53
  "@opencode-ai/plugin": "1.2.27",
43
54
  "@opencode-ai/sdk": "1.2.27",
44
55
  "zod": "^3.24.0",