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
47 changes: 43 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ https://github.com/user-attachments/assets/a97a451f-c9b4-468d-b036-15b65fc13e79
| `semantic_code_search` | Search by meaning, not exact text. Uses embeddings over file headers/symbols and returns matched symbol definition lines. |
| `semantic_identifier_search` | Identifier-level semantic retrieval for functions/classes/variables with ranked call sites and line numbers. |
| `semantic_navigate` | Browse codebase by meaning using spectral clustering. Groups semantically related files into labeled clusters. |
| `web_search` | Search the web for external information using You.com Search API. Complements local semantic search with current web knowledge. |

### Analysis

Expand Down Expand Up @@ -268,6 +269,47 @@ Any endpoint implementing the [OpenAI Embeddings API](https://platform.openai.co

## Architecture

### Web Search Integration

The `web_search` tool provides external web knowledge to complement Context+'s local semantic search capabilities. It uses the You.com Search API to find current information on the web.

**Setup:**
- **Optional:** Set `YDC_API_KEY` environment variable for authenticated access
- **Keyless mode:** Works without API key (100 free searches per day per IP)
- **Error handling:** Graceful fallbacks with actionable error messages

**Usage examples:**

```javascript
// Search for current information about a technology
await mcpClient.callTool('web_search', {
query: 'TypeScript 5.0 new features',
count: 10
});

// Search specific domains
await mcpClient.callTool('web_search', {
query: 'React hooks patterns',
domains: ['react.dev', 'github.com'],
count: 5
});

// Get fresh results
await mcpClient.callTool('web_search', {
query: 'JavaScript security vulnerabilities',
freshness: 'week',
count: 8
});

// Combine with local search
const localResults = await mcpClient.callTool('semantic_code_search', {
query: 'authentication implementation'
});
const webResults = await mcpClient.callTool('web_search', {
query: 'authentication best practices 2024'
});
```

Three layers built with TypeScript over stdio using the Model Context Protocol SDK:

**Core** (`src/core/`) - Multi-language AST parsing (tree-sitter, 43 extensions), gitignore-aware traversal, Ollama vector embeddings with disk cache, wikilink hub graph, in-memory property graph with decay scoring.
Expand All @@ -280,10 +322,7 @@ Three layers built with TypeScript over stdio using the Model Context Protocol S

## Config

| Variable | Type | Default | Description |
| --------------------------------------- | ------------------------- | -------------------------------------- | ------------------------------------------------------------- |
| `CONTEXTPLUS_EMBED_PROVIDER` | string | `ollama` | Embedding backend: `ollama` or `openai` |
| `OLLAMA_EMBED_MODEL` | string | `nomic-embed-text` | Ollama embedding model |
| Variable | Type | Default | Description |\n| --------------------------------------- | ------------------------- | -------------------------------------- | ------------------------------------------------------------- |\n| `CONTEXTPLUS_EMBED_PROVIDER` | string | `ollama` | Embedding backend: `ollama` or `openai` |\n| `YDC_API_KEY` | string | - | You.com API key for web search (optional, falls back to keyless) |\n| `OLLAMA_EMBED_MODEL` | string | `nomic-embed-text` | Ollama embedding model |
| `OLLAMA_API_KEY` | string | - | Ollama Cloud API key |
| `OLLAMA_CHAT_MODEL` | string | `llama3.2` | Ollama chat model for cluster labeling |
| `CONTEXTPLUS_OPENAI_API_KEY` | string | - | API key for OpenAI-compatible provider (alias: `OPENAI_API_KEY`) |
Expand Down
30 changes: 30 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { listRestorePoints, restorePoint } from "./git/shadow.js";
import { semanticNavigate } from "./tools/semantic-navigate.js";
import { getFeatureHub } from "./tools/feature-hub.js";
import { toolUpsertMemoryNode, toolCreateRelation, toolSearchMemoryGraph, toolPruneStaleLinks, toolAddInterlinkedContext, toolRetrieveWithTraversal } from "./tools/memory-tools.js";
import { webSearch } from "./tools/web-search.js";

type AgentTarget = "claude" | "cursor" | "vscode" | "windsurf" | "opencode";

Expand Down Expand Up @@ -299,6 +300,35 @@ server.tool(
}), { useEmbeddingTracker: true }),
);

server.tool(
"web_search",
"Search the web for external information using You.com Search API. Complements local semantic search with current web knowledge. " +
"Supports both authenticated (YDC_API_KEY) and keyless operation (100 free searches/day).",
{
query: z.string().describe("The search query to find relevant web information."),
count: z.number().optional().describe("Number of results to return (1-20). Default: 10."),
offset: z.number().optional().describe("Number of results to skip (for pagination). Default: 0."),
country: z.string().optional().describe("Country code for localized results (e.g., 'us', 'uk'). Default: 'us'."),
domains: z.array(z.string()).optional().describe("Specific domains to search within (e.g., ['github.com', 'stackoverflow.com'])."),
freshness: z.string().optional().describe("Result freshness filter ('day', 'week', 'month', 'year')."),
search_lang: z.string().optional().describe("Search language (e.g., 'en', 'es', 'fr'). Default: 'en'."),
},
withRequestActivity(async ({ query, count, offset, country, domains, freshness, search_lang }) => ({
content: [{
type: "text" as const,
text: await webSearch({
query,
count,
offset,
country,
domains,
freshness,
search_lang,
}),
}],
})),
);

server.tool(
"get_blast_radius",
"Before deleting or modifying code, check the BLAST RADIUS. Traces every file and line where a specific symbol " +
Expand Down
142 changes: 142 additions & 0 deletions src/tools/web-search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// You.com web search integration for Context+ MCP server
// Provides external web knowledge to complement local semantic search

export interface WebSearchOptions {
query: string;
count?: number;
offset?: number;
country?: string;
domains?: string[];
freshness?: string;
search_lang?: string;
}

export interface WebSearchResult {
title: string;
url: string;
snippet: string;
thumbnail?: {
url: string;
};
}

export interface WebSearchResponse {
web?: {
results: WebSearchResult[];
};
news?: {
results: WebSearchResult[];
};
}

/**
* Performs web search using You.com Search API
* Falls back to keyless operation if no API key provided
*/
export async function webSearch(options: WebSearchOptions): Promise<string> {
const {
query,
count = 10,
offset = 0,
country = "us",
search_lang = "en",
domains,
freshness
} = options;

// Check for API key (optional)
const apiKey = process.env.YDC_API_KEY;

try {
// Build query parameters
const params = new URLSearchParams({
query,
count: Math.min(Math.max(count, 1), 20).toString(), // Clamp between 1-20
offset: Math.max(offset, 0).toString(),
country,
search_lang,
});

if (domains && domains.length > 0) {
params.append('domains', domains.join(','));
}
if (freshness) {
params.append('freshness', freshness);
}

// Make API request
const url = `https://api.you.com/v1/agents/search?${params}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'User-Agent': 'youdotcom-integration/forloopcodes-contextplus'
};

if (apiKey) {
headers['X-API-Key'] = apiKey;
}

const response = await fetch(url, {
method: 'GET',
headers
});

if (!response.ok) {
if (response.status === 401) {
throw new Error('Invalid API key. Check your YDC_API_KEY environment variable.');
}
if (response.status === 429) {
throw new Error('Rate limit exceeded. Consider setting YDC_API_KEY for higher quotas.');
}
throw new Error(`Web search failed: ${response.status} ${response.statusText}`);
}

const data = await response.json() as WebSearchResponse;

return formatSearchResults(data, query);

} catch (error) {
if (error instanceof Error) {
return `Web search error: ${error.message}`;
}
return 'Web search failed due to an unexpected error.';
}
}

/**
* Formats search results into a readable text format
*/
function formatSearchResults(data: WebSearchResponse, query: string): string {
const results: string[] = [`Web Search Results for: "${query}"\n`];

// Process web results
if (data.web?.results && data.web.results.length > 0) {
results.push('🔍 Web Results:');
data.web.results.forEach((result, index) => {
results.push(`${index + 1}. **${result.title}**`);
results.push(` URL: ${result.url}`);
if (result.snippet) {
results.push(` ${result.snippet.replace(/\n/g, ' ').trim()}`);
}
results.push('');
});
}

// Process news results
if (data.news?.results && data.news.results.length > 0) {
results.push('📰 News Results:');
data.news.results.forEach((result, index) => {
results.push(`${index + 1}. **${result.title}**`);
results.push(` URL: ${result.url}`);
if (result.snippet) {
results.push(` ${result.snippet.replace(/\n/g, ' ').trim()}`);
}
results.push('');
});
}

if (!data.web?.results?.length && !data.news?.results?.length) {
results.push('No web search results found. Try a different query.');
}

return results.join('\n');
}
114 changes: 114 additions & 0 deletions test/main/web-search.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { webSearch } from "../../build/tools/web-search.js";

describe("web-search", () => {
describe("webSearch function", () => {
it("is a function", () => {
assert.equal(typeof webSearch, "function");
});

it("returns a string", async () => {
const result = await webSearch({
query: "test search query"
});
assert.equal(typeof result, "string");
});

it("handles basic search query", async () => {
const result = await webSearch({
query: "TypeScript programming language",
count: 3
});

assert.equal(typeof result, "string");
assert.ok(result.length > 0);
assert.ok(result.includes("TypeScript programming language"));
});

it("handles empty results gracefully", async () => {
const result = await webSearch({
query: "xyzabc123nonexistentquery456def",
count: 1
});

assert.equal(typeof result, "string");
assert.ok(result.length > 0);
});

it("respects count parameter", async () => {
const result = await webSearch({
query: "JavaScript",
count: 5
});

assert.equal(typeof result, "string");
assert.ok(result.length > 0);
});

it("handles network errors gracefully", async () => {
// This test verifies error handling without requiring network access
const originalFetch = global.fetch;
global.fetch = () => Promise.reject(new Error("Network error"));

const result = await webSearch({
query: "test"
});

assert.equal(typeof result, "string");
assert.ok(result.includes("Web search error"));

global.fetch = originalFetch;
});

it("handles API errors gracefully", async () => {
// Mock a 429 rate limit response
const originalFetch = global.fetch;
global.fetch = () => Promise.resolve({
ok: false,
status: 429,
statusText: "Too Many Requests"
});

const result = await webSearch({
query: "test"
});

assert.equal(typeof result, "string");
assert.ok(result.includes("Rate limit exceeded"));

global.fetch = originalFetch;
});
});

describe("parameter validation", () => {
it("clamps count parameter to valid range", async () => {
// Test that count is properly clamped between 1-20
const result1 = await webSearch({
query: "test",
count: 0 // Should be clamped to 1
});

const result2 = await webSearch({
query: "test",
count: 50 // Should be clamped to 20
});

assert.equal(typeof result1, "string");
assert.equal(typeof result2, "string");
});

it("handles optional parameters", async () => {
const result = await webSearch({
query: "test",
country: "uk",
search_lang: "en",
freshness: "week",
domains: ["github.com", "stackoverflow.com"]
});

assert.equal(typeof result, "string");
assert.ok(result.length > 0);
});
});
});