DeepSeek Harness向けSearXNG検索プラグインの自作とnpm公開
DeepSeek Harnessには公式のSearXNG検索プロバイダが存在しない。ctx.web seamに自作プロバイダを差し込み、npmで公開し、Web UIの設定カードまで実装した記録。available()の罠、exposedNamespacesの壁、クライアントバンドル契約など、ドキュメントに書かれていない実装上の落とし穴を実測ベースでまとめる。
DeepSeek Harness(以下 dsh)でWeb検索を使おうとすると、最初に壁に当たる。同梱されている検索プロバイダはDeepSeekのAPIを叩くものだけで、セルフホストのメタ検索エンジン SearXNG に繋ぐ実装が存在しない。npmを探しても @deepseek-ai/dsh-web-search-searxng は404が返る。
そこで自作して公開した。パッケージ名は dsh-web-search-searxng、リポジトリは chinng-inta/dsh-web-search-searxng である。
本稿はその開発記録だが、SearXNGのAPIをどう叩いたかという話は実はほとんど出てこない。分量の大半を占めたのはハーネス側の作法を読み解く作業だった。プロバイダ選択が黙って壊れる条件、設定をWeb UIに出すための遠回りな経路、破ると管理画面ごと落ちるバンドル契約。いずれも公式ドキュメントには書かれておらず、同梱パッケージのソースと実測から掘り起こすしかなかった。同じことをやろうとする人の時間が浮けばと思う。
背景:dshのweb seamと、空いていた席
dshのWeb機能は ctx.web という「seam(縫い目)」として抽象化されている。検索とフェッチという2つの操作を1つのseamに載せ、プロバイダ選択・キャンセル・エラー語彙・設定面の所有者を1箇所に集約する設計だ。
プロバイダが実装すべき契約は驚くほど小さい。
interface WebSearchProvider {
readonly id: string
available(): boolean // 安価なローカル判定。ネットワークを叩いてはならない
search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
}
返す型も素直だ。WebSearchResult は「プロバイダ生成の回答文(任意)」「引用可能なソース配列」「切り詰めフラグ」の3つ。ソース1件は url が必須で、title / snippet / publishedAt は任意である。任意になっているのは、プロバイダによっては存在しない情報を「でっち上げさせない」ためだと型定義のコメントに明記されている。
公式実装は deepseek / exa / perplexity の3つ。ここにSearXNGの席が空いていた。
そして重要なのは、プロバイダを1つ実装するだけで、モデル向けの web_search ツール・システムプロンプトの案内文・UIの検索結果カード・件数上限の切り詰めが全部ついてくるという点だ。ツール名やスキーマは dsh-tool-web が単独で所有しており、プロバイダ側はデータを返すだけでいい。
なぜ同梱プロバイダでは代用できないのか
同梱の web-search-deepseek は、名前から想像されるような検索APIクライアントではない。DeepSeekのAnthropic互換Messages APIに、ネイティブの web_search サーバーツールを付けてリクエストを投げる実装である。つまり1回の検索がまるごとモデル1ターンのコストとレイテンシを消費する。
baseURL をSearXNGのアドレスに書き換えても動かない。エンドポイントの形が根本的に違うからだ。ここは実際に試して確かめた。
対してSearXNGは純粋な取得エンドポイントである。モデル呼び出しゼロ、APIキー不要、応答は速い。セルフホストなので検索クエリが外部に漏れないという性質もある。
実装:マッピングは素直、罠は選択ロジックにあった
SearXNGのレスポンスを実測する
推測で書かず、実インスタンスに投げて形を確認した。
top-level: query, number_of_results, results, answers,
corrections, infoboxes, suggestions, unresponsive_engines
results[]: url, title, content, engine, publishedDate, score, category, ...
マッピングはほぼ無損失で収まった。
| SearXNG | seam側 |
|---|---|
results[].url |
sources[].url(必須。無い項目は捨てる) |
results[].title |
sources[].title |
results[].content |
sources[].snippet(文字数上限で切る) |
results[].publishedDate |
sources[].publishedAt |
answers[] |
content(空なら省略) |
メタ検索は複数エンジンの結果をマージするため同じURLが重複する。URLで重複排除している。
なお publishedDate はフィールドとしては存在するが、一般的なWeb検索クエリでは34件中0件しか埋まらなかった。ニュース系エンジンでは入るので、日付が必要なら categories: ['news'] で絞る必要がある。この手の「あるはずのフィールドが実際には空」という事実は、READMEの制限事項に明記しておいた。
検索条件を「モデルの引数」にしなかった理由
言語・エンジン・カテゴリといった絞り込みは、モデルが呼び出し時に指定するのではなくデプロイ設定に置いた。
seamの WebSearchRequest は意図的に query と maxResults だけで、recency(新しさ)やドメインフィルタといったプロバイダ中立な条件は「ExaとPerplexityの両方が誠実に扱えるようになるまで保留」と上流が明記している。ここを勝手に広げると、このプロバイダは他の実装と差し替えられなくなる。設定に置くことが、互換性を保つ条件だった。
最大の罠:available() が常にtrueを返す
ここが今回いちばん時間を使った箇所である。
seamのプロバイダ選択には次のルールがある。
- 明示的に
searchProviderが指定されていればそれを使う - 指定が無く、使えるプロバイダがちょうど1つなら自動選択
- 指定が無く、複数使えるなら
WEB_PROVIDER_AMBIGUOUSエラー
当初は「DeepSeek APIキーが無い環境ならDeepSeekプロバイダは available() === false になり、SearXNGが自動選択される」と考えて、プラグイン側からは選択を奪わない設計にした。プロバイダ選択の所有者は web 行1つであるべきで、プラグインが勝手に奪うのは設計思想に反すると考えたからだ。
しかし同梱プロバイダの実装を読んで撤回した。
available() {
return ((this.options.apiKey?.length ?? 0) > 0 || this.options.resolveApiKey !== void 0)
&& URL.canParse(this.options.baseURL) && ...
}
apply() が resolveApiKey(鍵を解決する関数)を常に渡している。したがって resolveApiKey !== undefined は常に真であり、APIキーが1つも設定されていなくても available() はtrueを返す。
つまり素のdshに自作プロバイダを追加すると「使えるプロバイダが2つ」となり、検索するたびに必ず WEB_PROVIDER_AMBIGUOUS で失敗する。「入れた瞬間に検索が壊れるプラグイン」になってしまう。
結論として、バンドル層で web.searchProvider: searxng を明示的に設定することにした。これは好みではなく必須である。バンドル層はユーザーのどの設定層よりも先に適用されるため、上書きは常に可能という点で設計思想との折り合いもつく。この経緯は根拠のコードごと設定ファイルのコメントに残した。
インスタンス側の「形だけ検証する」挙動
言語コードの扱いも実測した。SearXNG 2026.4.22 に対して各種の値を投げた結果が以下である。
| 送った値 | 応答 |
|---|---|
en |
200 |
en-US |
200 |
ja / zh-CN / zh-TW |
200 |
xx-YY(実在しないロケール) |
200 |
notalang |
400 Invalid value notalang for parameter language |
SearXNGは値の形だけを検証しており、実在するロケールかどうかは見ていない。xx-YY のように形さえ合っていれば素通りする。
これは後のUI設計に直結した。自由入力にすると jp のような打ち間違いが400にならず、黙って的外れな結果を返す。だからドロップダウンにした。
同時に、400が返るときSearXNGは本文に理由を書いているのに、実装がそれを捨てて「HTTP 400」としか言っていないことにも気づいた。UIから言語を触らせる以上まずいので、本文の理由を拾ってエラーメッセージに載せるよう直した。
変更前: SearXNG search failed with HTTP 400
変更後: SearXNG search failed with HTTP 400: Invalid value notalang for parameter language
なお、SearXNGは既定でJSON APIを無効にしている。settings.yml の search.formats に json を追加しないと、HTMLの検索結果ページが返ってくる。これは最も踏まれやすい設定ミスなので、パースエラーではなく「search.formats に json を足せ」という直接的なメッセージを出すようにしてある。
公開とリリース運用でつまずいたところ
プラグインはイメージに焼き込めない
コンテナ運用側の話になるが、重要な制約が1つある。プラグイン行の name は、dshのインストール配下ではなくプロファイルディレクトリからESM解決される。
イメージに npm install しても ERR_MODULE_NOT_FOUND で起動ごと落ちる。実際に一度落とした。プロファイルはnamed volume内にあり docker compose down -v で消えるため、「イメージに焼く」という解決策が使えない。
代わりに、起動のたびに冪等にプラグインを導入するスクリプトを噛ませた。まっさらな状態からでもプロファイル作成込みで4秒、導入済みなら1秒未満で抜ける。これで down -v 後の手作業が消えた。
npmのdist-tagが分裂している
peer dependencyのバージョン範囲を書くとき、@deepseek-ai/dsh-web の latest を見て ^0.0.1-rc.1 と書きかけた。しかし実際にdshが同梱しているのは 0.1.0-rc.6 である。
dist-tags: { latest: "0.0.1-rc.1", next: "0.1.0-rc.6" }
latestが古い系列を指したまま、実際に動いている系列は next タグの下にいる。 気づかずに ^0.0.1-rc.1 と書いていたら、同梱版と一致しないpeer範囲になっていた。
ちなみに公式の dsh-web-fetch-http は、まさにこのズレを踏んでいる。peer範囲が古い系列に取り残されていて、pnpm peers check が「conflicting peer」と報告する。実行時の解決先は単一インスタンスなので実害は無いが、この手のプレリリース運用ではdist-tagを鵜呑みにしないのが安全だ。
pnpmは公開直後のパッケージをゲートする
自作パッケージを公開して、すぐハーネスに入れようとしたときのこと。「公開直後は入れられないのでは」という懸念があったので事前に確認したところ、pnpm config get minimumReleaseAge は undefined を返した。それを見て「設定されていない=待機期間なし」と判断したが、これは誤りだった。
実際にインストールすると、pnpmはこう出力した。
Added 1 entry to minimumReleaseAgeExclude in pnpm-workspace.yaml
[email protected]
設定が空でもpnpm 11の内部デフォルトは効いている。ただし既定はstrictではないため、拒否ではなく「除外エントリを自動で書いて通す」挙動になる。作業は止まらないが、「引っかからない」のではなく「引っかかったが自動で通した」が正確なところだ。自作プラグインをpublishするたびに起きる。
Web UIに設定画面を足す
ここからが後半戦である。同じ環境で動いている他のサードパーティ製プラグインは、Settings画面に自前の設定カードを出していた。同じことをやりたい。
第一の壁:設定APIは許可リスト制
まず設定の置き場を決める必要があった。dshには ctx.settings という設定seamがあり、プラグインが名前空間を登録すると次の階層で解決される。
スキーマ既定値 → プラグイン行のconfig(合成base) → 設定ドキュメントのユーザー層
同梱のDeepSeekプロバイダと同じ installSettingsSection ヘルパを使えば、独自のファイル形式を作らずにこの階層へ乗れる。プロバイダには設定のスナップショットではなくサンク(関数)を渡し、検索ごとに投影する形にした。こうすると設定を編集した結果が再起動なしに次の検索へ届く。実インスタンスに対して、稼働中に設定を書き換えて反映されることを確認した。
問題はここからで、設定を登録しただけではWeb UIに出ない。API側が明示的な許可リストになっている。
function exposedNamespaces() { /* モデルプロバイダ + WEB_ + PRODUCT_ の3種のみ */ }
コメントには「a future registration does not become remotely readable or writable by default(将来の登録が既定で読み書き可能になることはない)」と、意図的な設計である旨が明記されている。サードパーティの名前空間はブラウザから触れない。
第二の壁:プラグイン自前のRPCエンドポイント
では既存プラグインはどうやっているのか。ソースを読むと答えが書いてあった。プラグインが自分でRPCエンドポイントを生やしている。
そして許可リストの回避が正当である理由も明記されていた。exposedNamespaces() のゲートはAPIプロキシ経路にしか掛からない。プラグイン自身のエンドポイントから、プロセス内で ctx.settings.update を呼ぶのは想定された経路である。
実装上の罠もコメントに残されていた。エンドポイントの登録にはデコレータ形式(@Remote マーカー)が使えない。マーカーの探索がモジュールプライベートなWeakMapを読む仕組みのため、プロファイル配下にインストールされたプラグインはそのテーブルを共有できず、エンドポイントが404になる。明示的なレジストリ登録の経路を使う必要がある。
この情報が無ければ、404の原因究明だけで半日は溶けていたはずだ。
結果、/api/web-search-searxng/{get,set,reset} が生えた。curlで単体検証できる。
get → 解決済み設定 + URLがどの層由来か(settings / environment / none)
set → 未知キーと型違反を拒否してから設定ドキュメントへ書き込み
reset → ユーザー層を消して合成既定へ戻す
get が「URLがどの層由来か」を返すのは、UIのためである。環境変数で動いている環境では入力欄が空でも正常なので、そこを「未設定」と表示すると嘘になる。
set が未知キーを拒否するのは、設定サービスがnon-strictで、知らないキーをそのまま保存してしまうからだ。保存はされるが誰も読まないので、ユーザーには「設定したのに何も起きない」に見える。
第三の壁:クライアントバンドル契約
カード本体はReactで書き、専用のバンドルとして出力する。ここにこのプロジェクトで最も危険な制約がある。
バンドルはローダの規約に従った形で自分を登録する必要があり、require してよいモジュールはローダのモジュールテーブルに載っている11個だけである。それ以外を値としてimportすると、ローダが答えられない require がブラウザに届く。
そして失敗はローカルに留まらない。プラグインの読み込みが全体ごと失敗し、ユーザーはWeb UIを開けなくなる。 カードが出ないのではなく、管理画面に入れなくなる。
これは仮定の話ではない。同じ環境で動いている2つのプラグインが、実際にこの壊れ方をしている。1つは登録時のモジュールIDが旧スコープ名のまま残っていたケース、もう1つはバージョンを上げた際にスキーマライブラリを外部参照するようになったケースである。どちらもホスト側は無傷なので、起動ログにもヘルスチェックにも出ない。ブラウザを開いて初めて分かる。
そこでビルドスクリプトに契約の機械的検証を組み込んだ。出力されたテキストから require(...) を全部拾い、許可リスト外があればビルドを失敗させる。登録IDがパッケージ名と一致しているかも確認する。
client bundle: lib/client/index.js (18.2 kB), externals used:
@deepseek-ai/dsh-client-runtime/client, @deepseek-ai/dsh-client-ui-primitives,
@deepseek-ai/dsh-client-web-react, react, react/jsx-runtime
typoとunusableなUIの間に立っているのはこのアサーションだけなので、儀式ではなく実務上の防壁である。
静かに壊れるバグ2件
実装中に踏んだもののうち、2つは「エラーにならずに壊れる」種類だった。
RPCの戻り値はエンベロープだった。 rpc.call() は値そのものではなく {ok, value} を返す。これを展開せずキャストしていたため、全フィールドが undefined になった。例外は飛ばない。結果として「入力欄が空で、読み取り専用の壊れたカード」として描画された。原因を「サーバー側が設定を返していない」と誤診しかけたが、curlでは正しい値が返っていたのでブラウザ側の問題だと切り分けられた。展開関数を型ごと用意し、ok: false は例外に変換するようにした。
CSSを同梱し忘れていた。 クラス名だけ書いてスタイルシートを一切バンドルしていなかったため、カードがブラウザ既定で描画された。タイトルと説明がくっつき、カードらしい枠も無い。動作は完全に正しいのに見た目だけ壊れている状態で、しかもこれを 0.3.0 として公開してしまった。0.3.1 で修正した。
修正では、色・角丸・境界をすべてシェルのデザイントークン参照にした。リテラルを書くとテーマ切り替えに追随しないためだ。隣のカードと数値を突き合わせ、幅・パディング・角丸・境界線・背景がすべて一致することを確認している。
ローカル検証で3回同じ罠を踏んだ
未公開バージョンをコンテナで試す作業で、繰り返し引っかかった点がある。
dsh plugin add <tarball> を実行すると、pnpmがファイルパスをバージョン指定に正規化する。 file:/…/x.tgz が "0.3.0" になり、以降は同バージョンのキャッシュから解決される。中身を差し替えてもファイル名を変えても反映されない。
「クライアント半分が配信されていない」と誤診し、ロスターやバンドル契約を疑って時間を使った。原因は単に古いバンドルが配信され続けていただけだった。ローカル検証ではバージョン番号自体を一意にする(0.3.0-rc.1 のように振る)必要がある。
さらに、この過程で古いtarballを消したまま再起動し、プロファイルが存在しないパスを参照して一時的に起動不能にもした。復旧はできたが、検証用の差し替えは「古いものを消す前に新しいものを入れる」順序を守るべきだった。
導入手順
dsh-web-search-searxng は npm に公開済みで、dshを直接インストールしている環境でもコンテナ環境でも同じコマンドで入る。導入経路は2つある。
npm から(推奨)
dsh plugin --profile web add dsh-web-search-searxng
パッケージが dsh.bundle を宣言しているため、このコマンドだけで有効化まで終わる。バンドル層がプロバイダ行の挿入と web.searchProvider: searxng の設定を持ち込むので、cordis.patch.yml に手で行を書き足す必要はない。
インスタンスの指定は環境変数か、Web UIの設定カードから行う。
export SEARXNG_URL=http://searxng.internal:8888
環境変数はあくまでフォールバックで、設定ドキュメントに baseURL があればそちらが優先される。
GitHub から
未公開の変更を試したい場合や、フォークを使いたい場合はリポジトリから直接入れられる。
dsh plugin --profile web add github:chinng-inta/dsh-web-search-searxng
ただし初回は必ず失敗する。理由は2段階ある。
git依存にはビルド成果物(lib/)が含まれていない。lib/ はリポジトリで無視しているためだ。そこでインストール時に自前でビルドする prepare スクリプトを用意しているのだが、pnpmはインストール時のスクリプト実行を既定でブロックする。サプライチェーン対策である。
そのため1回目は次のように止まり、許可すべきキーが表示される。
Add the package to "allowBuilds" in your project's pnpm-workspace.yaml
to allow it to run scripts. For example:
allowBuilds:
dsh-web-search-searxng@https://codeload.github.com/.../tar.gz/<commit-sha>: true
dsh側もこれを見越して、「git-hosted plugins build on install via their prepare script, which pnpm blocks until allowed」という案内を続けて出す。表示されたキーをプロファイルの pnpm-workspace.yaml に追記して再実行すれば通る。
allowBuilds:
"dsh-web-search-searxng@https://codeload.github.com/.../tar.gz/<commit-sha>": true
注意点として、このキーはコミットSHAを含む。別のリビジョンを入れ直すたびに変わるので、追記は都度必要になる。所要時間も実測でnpm経由が約2秒に対し、git経由は開発用ツールチェーンの取得とビルドが走るため約20秒かかった。
追随したい未公開の変更が特に無ければ、npm経由を選ぶのが素直である。
導入後の確認
どちらの経路でも、プロバイダ行が入ったかは合成後のツリーで確認できる。
dsh --profile web --dump-config
web 行に searchProvider: searxng が入り、web-search-searxng の行が追加されていれば導入は完了している。
影響・今後
得られたものは検索機能そのものより、dshのプラグイン作法の地図だったと思う。seamへの差し込み方、設定を階層に乗せる方法、Web UIへ到達する経路、破ってはいけないバンドル契約。これらはSearXNG固有ではなく、dshに何かを足そうとする全員が通る道である。
一方で、この上に成り立っている前提は脆い。dshはdeveloper previewでリリース候補ごとに破壊的変更が入る。latest と next のdist-tagが分裂していること自体がその表れだ。peer範囲の書き方1つで動かなくなるので、対応バージョンの表をREADMEに置き、ハーネス側のバージョンは固定しておくのが前提になる。
エコシステムの現状も正直に書いておくと、npm上のdsh向けサードパーティ製プラグインはまだ数えるほどしかない。スキャフォルダと履歴ビューア、それにいくつかの実装が点在する程度である。裏を返せば、空いている席はまだ多い。
よくある質問
SearXNGプロバイダを入れるだけで web_search が使えるようになるのか
プロバイダの導入だけでは足りない場合がある。モデルが見るツール集合は、ホスト側の構成とセッションのエージェントプリセットの合成で決まるためだ。プリセット側が web_search を供給していれば、プロバイダを差し替えるだけで裏側がSearXNGに切り替わる。ホスト側の構成でツール自体が無効化されている場合は、そちらの設定も戻す必要がある。
なぜMCP経由ではなくプロバイダとして実装したのか
MCPサーバー経由でも検索ツールをモデルに渡すことはできるし、実際に一時期はその構成で運用していた。しかしプロバイダとして実装すると、dsh標準の web_search ツール・システムプロンプトの案内文・UIの検索結果カード・件数上限の切り詰めがすべて無償でついてくる。ツール名がハーネス標準になるため、エージェントへの指示も「web_searchを使え」で済む。
公開直後のパッケージはすぐインストールできるのか
npmレジストリ自体は公開完了時点で配信を始めるため、npm install は即座に成功する。遅れるのはnpmjs.comのサイト内検索インデックスへの反映だけである。ただしpnpm 11は内部デフォルトで公開直後のパッケージをゲートしており、既定設定では拒否ではなく除外エントリを自動追記して通す挙動になる。設定値が空に見えても内部デフォルトは効いている点に注意が必要である。
クライアントバンドルの制約を破るとどうなるのか
そのプラグインのUIが出ないだけでは済まない。ローダが解決できない参照が1つでもあると、プラグインのクライアント読み込みが全体ごと失敗し、Web UI自体を開けなくなる。ホスト側は正常に動いているため起動ログにもヘルスチェックにも異常が出ず、ブラウザを開いて初めて判明する。ビルド時に出力を検査して、許可されたモジュール以外への参照があれば失敗させる仕組みを入れておくのが安全である。
SearXNG側に必要な設定はあるか
JSON APIの有効化が必須である。SearXNGは既定でJSON形式を無効にしているため、インスタンスの settings.yml で search.formats に json を追加する必要がある。有効になっていない場合はHTMLの検索結果ページが返るので、パースエラーではなく設定箇所を名指しするエラーメッセージを返すようにしてある。また公開インスタンスの多くはボット判定でプログラムからのアクセスを拒否するため、自前で立てたインスタンスを使うことを前提としている。
まとめ
自作プラグインの実装そのものは、契約が小さいこともあって難しくなかった。時間を使ったのは、ドキュメントに書かれていない前提を掘り起こす作業である。
振り返って、効いた判断は3つあった。
1つは推測せず実測したこと。SearXNGのレスポンス形状も、言語コードの検証挙動も、プロバイダ選択が壊れる条件も、すべて実際に投げて確かめた。特に available() が常にtrueを返す件は、ソースを読まなければ「鍵が無ければ自動選択されるはず」という誤った前提のまま公開していた。
2つめは既存の動く実装を読んだこと。設定をWeb UIに出す経路は、公式ドキュメントではなく既存プラグインのソースコメントに書かれていた。デコレータが使えない理由まで理由付きで残されており、これが無ければ404の原因究明で相当な時間を失っていた。
3つめは壊れ方の重さに応じて検証を機械化したこと。バンドル契約は破るとUI全体が落ちるうえ、ホスト側には何の兆候も出ない。だからビルド時のアサーションにした。人間の注意力に頼る場所ではない。
逆に反省点は、静かに壊れる不具合を2件とも「見えている症状」から誤診しかけたことだ。エンベロープの展開漏れは「サーバーが返していない」に見え、CSS欠落は「バンドルが読み込まれていない」に見えた。どちらも切り分けの起点を1つ手前に置けば早く着地できた。
参考
- SearXNG 公式リポジトリ — セルフホスト可能なメタ検索エンジン
- SearXNG 公式ドキュメント —
settings.ymlのsearch.formatsなど設定リファレンス - DeepSeek Harness — 本稿の対象ハーネス(developer preview)
- dsh-web-search-searxng — 本稿で開発したプラグイン