Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ permissions:
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-D warnings"
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

jobs:
packaging:
Expand Down Expand Up @@ -134,7 +135,7 @@ jobs:
python3 -c "import sys; sys.exit(1 if float('${COV}') < 45.0 else 0)" \
|| (echo "::error::Coverage ${COV}% is below minimum 45%"; exit 1)
- name: Upload to Codecov
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@v5
with:
files: lcov.info
fail_ci_if_error: false
Expand Down Expand Up @@ -205,15 +206,18 @@ jobs:
target
key: ${{ runner.os }}-bench-${{ hashFiles('**/Cargo.lock') }}
- name: Run benchmarks
run: cargo bench --package fetchium-core -- --output-format bencher | tee output.txt
# Criterion uses its own harness — capture all output for the store step.
run: cargo bench --package fetchium-core 2>&1 | tee output.txt
- name: Store benchmark results
continue-on-error: true
uses: benchmark-action/github-action-benchmark@v1
with:
tool: 'cargo'
output-file-path: output.txt
alert-threshold: '120%'
comment-on-alert: true
fail-on-alert: false
fail-ci-if-error: false
github-token: ${{ secrets.GITHUB_TOKEN }}
auto-push: true

Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ on:
permissions:
contents: read

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

jobs:
deploy-production:
name: Deploy production stack
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ permissions:
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-D warnings"
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

jobs:
# ─────────────────────────────────────────────────────────────
Expand Down
88 changes: 83 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ Rust-native search, extraction, ranking, and synthesis — delivered as a CLI, a
[![Crates.io](https://img.shields.io/crates/v/fetchium-cli.svg)](https://crates.io/crates/fetchium-cli)
[![npm](https://img.shields.io/npm/v/fetchium-cli.svg)](https://www.npmjs.com/package/fetchium-cli)
[![Downloads](https://img.shields.io/crates/d/fetchium-cli.svg)](https://crates.io/crates/fetchium-cli)
[![Glama](https://glama.ai/mcp/servers/zuhabul/Fetchium/badges/score.svg)](https://glama.ai/mcp/servers/zuhabul/Fetchium)
[![smithery badge](https://smithery.ai/badge/fetchium)](https://smithery.ai/server/fetchium)

[Install](#installation) · [Architecture & innovations](#architecture--innovations) · [Quick start](#quick-start) · [For AI agents](#for-ai-agents) · [Docs](docs/)

Expand Down Expand Up @@ -203,12 +205,88 @@ Full command reference: [docs/guide/commands.md](docs/guide/commands.md).

## For AI agents

- **MCP server** (`fetchium-mcp`) exposes retrieval as Model Context Protocol tools for Codex,
Claude, and other MCP clients.
- **REST API** (`fetchium-api`) serves the same engine over HTTP — `fetchium serve`.
- **Adapters** for [LangChain](adapters/langchain) and [CrewAI](adapters/crewai) live in `adapters/`.
Fetchium ships a first-class **MCP server** — add it to any MCP-compatible client and your agent
can search, fetch, research, and watch YouTube/social content without custom glue code.

See [docs/guide/agent-integration.md](docs/guide/agent-integration.md).
### 30-second setup — Claude Desktop

Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or
`%APPDATA%\Claude\claude_desktop_config.json` (Windows):

```json
{
"mcpServers": {
"fetchium": {
"command": "fetchium",
"args": ["serve", "--mode", "mcp"]
}
}
}
```

No `fetchium` binary yet? Use `npx` for zero-install:

```json
{
"mcpServers": {
"fetchium": {
"command": "npx",
"args": ["-y", "fetchium-cli", "serve", "--mode", "mcp"]
}
}
}
```

### Cursor / Windsurf / VS Code (Cline)

Add the same block to your editor's MCP settings, or run:

```bash
fetchium serve --mode mcp --port 3001 # HTTP transport for editors that prefer it
```

Then point the editor at `http://localhost:3001/mcp`.

### One-click via Smithery

[![Install on Smithery](https://smithery.ai/badge/fetchium)](https://smithery.ai/server/fetchium)

```bash
npx -y @smithery/cli install fetchium --client claude
```

### Available MCP tools (12 total)

| Tool | What it does |
|------|-------------|
| `fetchium_search` | Multi-backend web search with HyperFusion ranking + dedup |
| `fetchium_fetch` | Query-aware content extraction with token budgeting |
| `fetchium_research` | Multi-source research with citations and evidence tracking |
| `fetchium_estimate` | Token-cost estimate for a URL (HEAD only, no download) |
| `fetchium_expand` | Expand a previous result to a deeper PDS tier |
| `youtube_search` | Search YouTube with VideoFusion ranking |
| `youtube_analyze` | Full video analysis: transcript, comments, credibility |
| `youtube_watch` | Summary + key moments for any YouTube URL |
| `youtube_transcript` | Raw transcript with timestamps and highlights |
| `social_research` | Trend research across Reddit, HN, Twitter, TikTok |
| `reddit_search` | Search Reddit posts and comments |
| `hackernews_search` | Search Hacker News stories and discussions |

### REST API

```bash
fetchium serve # start REST API on :3000
curl localhost:3000/v1/search -d '{"query":"rust async"}'
```

### LangChain / CrewAI adapters

```python
from fetchium_langchain import FetchiumSearchTool, FetchiumResearchTool
from fetchium_crewai import FetchiumSearchTool
```

See [docs/guide/agent-integration.md](docs/guide/agent-integration.md) for full examples.

## Configuration

Expand Down
20 changes: 20 additions & 0 deletions assets/logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 13 additions & 1 deletion crates/fetchium-core/src/embeddings/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

use crate::error::FetchiumError;
use once_cell::sync::Lazy;
use tokio::sync::Semaphore;
use tracing::{debug, warn};

/// Ollama endpoint (configurable via env var).
Expand All @@ -17,11 +18,15 @@ static OLLAMA_URL: Lazy<String> = Lazy::new(|| {
/// Embedding model to use.
const EMBED_MODEL: &str = "nomic-embed-text";

/// Ollama runs single-threaded inference — concurrent requests cause connection
/// errors on the second/third caller. One permit serializes embed calls.
static OLLAMA_SEMAPHORE: Lazy<Semaphore> = Lazy::new(|| Semaphore::new(1));

/// Shared async HTTP client (connection pooling).
static HTTP_CLIENT: Lazy<reqwest::Client> = Lazy::new(|| {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.pool_max_idle_per_host(4)
.pool_max_idle_per_host(1)
.build()
.expect("Failed to create HTTP client")
});
Expand Down Expand Up @@ -101,6 +106,13 @@ pub async fn embed_batch_async(texts: &[&str]) -> Result<Vec<Vec<f32>>, Fetchium
if texts.is_empty() {
return Ok(Vec::new());
}
// Ollama is single-threaded — concurrent requests cause connection errors.
// Acquire permit before sending; dropped automatically when the fn returns.
let _permit = OLLAMA_SEMAPHORE
.acquire()
.await
.map_err(|_| FetchiumError::Internal("Ollama semaphore closed".into()))?;

debug!(
"Embedding batch of {} texts via Ollama (async)",
texts.len()
Expand Down
21 changes: 16 additions & 5 deletions crates/fetchium-core/src/search/reddit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@ use tracing::debug;

/// Reddit search JSON endpoint.
const REDDIT_SEARCH: &str = "https://www.reddit.com/search.json";
/// Backoff window for Reddit rate-limit / anti-bot bursts.
const REDDIT_COOLDOWN_SECS: u64 = 180;
/// Backoff for 429 rate-limit (back off long enough for Reddit's window to reset).
const REDDIT_RATELIMIT_COOLDOWN_SECS: u64 = 180;
/// Backoff for 403 access-denied (IP-level block — retrying sooner doesn't help,
/// but waiting 3 minutes is too long; 30s matches the network-error cooldown).
const REDDIT_FORBIDDEN_COOLDOWN_SECS: u64 = 30;
static REDDIT_COOLDOWN_UNTIL_MS: AtomicU64 = AtomicU64::new(0);

#[derive(Debug, Deserialize)]
Expand Down Expand Up @@ -104,12 +107,20 @@ impl SearchBackend for RedditBackend {

if !resp.status().is_success() {
let status = resp.status();
if status.as_u16() == 429 || status.as_u16() == 403 {
let until = now_ms() + REDDIT_COOLDOWN_SECS * 1000;
if status.as_u16() == 429 {
let until = now_ms() + REDDIT_RATELIMIT_COOLDOWN_SECS * 1000;
REDDIT_COOLDOWN_UNTIL_MS.store(until, Ordering::Relaxed);
return Err(FetchiumError::Search(format!(
"Reddit HTTP {status} — cooling down for {}s",
REDDIT_COOLDOWN_SECS
REDDIT_RATELIMIT_COOLDOWN_SECS
)));
}
if status.as_u16() == 403 {
let until = now_ms() + REDDIT_FORBIDDEN_COOLDOWN_SECS * 1000;
REDDIT_COOLDOWN_UNTIL_MS.store(until, Ordering::Relaxed);
return Err(FetchiumError::Search(format!(
"Reddit HTTP {status} — cooling down for {}s",
REDDIT_FORBIDDEN_COOLDOWN_SECS
)));
}
debug!("Reddit non-success HTTP {status}, skipping");
Expand Down
47 changes: 26 additions & 21 deletions crates/fetchium-mcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,18 +106,18 @@ pub async fn run_mcp_stdio(config: FetchiumConfig) -> anyhow::Result<()> {
}
};

let response = handle_message(&line, &config, &http, &cache).await;

let json_out = serde_json::to_string(&response).unwrap_or_else(|e| {
format!(
r#"{{"jsonrpc":"2.0","id":null,"error":{{"code":-32603,"message":"{}"}}}}"#,
e
)
});
if let Some(response) = handle_message(&line, &config, &http, &cache).await {
let json_out = serde_json::to_string(&response).unwrap_or_else(|e| {
format!(
r#"{{"jsonrpc":"2.0","id":null,"error":{{"code":-32603,"message":"{}"}}}}"#,
e
)
});

let mut out = stdout.lock();
let _ = writeln!(out, "{json_out}");
let _ = out.flush();
let mut out = stdout.lock();
let _ = writeln!(out, "{json_out}");
let _ = out.flush();
}
}

eprintln!("[fetchium-mcp] Server shutting down.");
Expand Down Expand Up @@ -152,21 +152,32 @@ pub async fn run_mcp_http(config: FetchiumConfig, port: u16) -> anyhow::Result<(
Ok(())
}

/// Dispatch a single JSON-RPC message line and return the response.
/// Dispatch a single JSON-RPC message line. Returns `None` for notifications
/// (which must not receive a response per the JSON-RPC 2.0 / MCP spec).
async fn handle_message(
line: &str,
config: &FetchiumConfig,
http: &HttpClient,
cache: &MemoryCache,
) -> JsonRpcResponse {
) -> Option<JsonRpcResponse> {
let req: JsonRpcRequest = match serde_json::from_str(line) {
Ok(r) => r,
Err(e) => {
return JsonRpcResponse::err(Value::Null, -32700, format!("Parse error: {e}"));
return Some(JsonRpcResponse::err(
Value::Null,
-32700,
format!("Parse error: {e}"),
));
}
};

handle_request(req, config, http, cache).await
// JSON-RPC 2.0: notifications have no `id` and must not receive a response.
if req.id.is_none() {
eprintln!("[fetchium-mcp] notification: {}", req.method);
return None;
}

Some(handle_request(req, config, http, cache).await)
}

async fn handle_request(
Expand Down Expand Up @@ -197,12 +208,6 @@ async fn handle_request(
)
}

"notifications/initialized" => {
// Notification — no response needed; return empty result
eprintln!("[fetchium-mcp] initialized");
JsonRpcResponse::ok(id, Value::Null)
}

// List available tools
"tools/list" => JsonRpcResponse::ok(id, json!({ "tools": tools::tool_definitions() })),

Expand Down
1 change: 1 addition & 0 deletions fuzz/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ cargo-fuzz = true
libfuzzer-sys = "0.4"
fetchium-core = { path = "../crates/fetchium-core" }
serde_json = "1"
toml = "0.8"

# Fuzz is its own workspace so cargo-fuzz can find the targets
[workspace]
Expand Down
4 changes: 2 additions & 2 deletions fuzz/fuzz_targets/fuzz_config_parse.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
#![no_main]
// Fuzz TOML config parsing. Must NEVER panic.
use fetchium_core::config::HsxConfig;
use fetchium_core::config::FetchiumConfig;
use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
if let Ok(toml_str) = std::str::from_utf8(data) {
let _ = toml::from_str::<HsxConfig>(toml_str);
let _ = toml::from_str::<FetchiumConfig>(toml_str);
}
});
Loading
Loading