diff --git a/CLAUDE.md b/CLAUDE.md index 4ba25d0d..cf9196a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,11 +59,44 @@ npm run test:caching # Prompt caching npm run test:crossmodel # Cross-model thinking signatures npm run test:oauth # OAuth no-browser mode npm run test:cache-control # Cache control field stripping +npm run test:websearch # Web search MCP (Google Search grounding) # Run strategy unit tests (no server required) node tests/test-strategies.cjs ``` +## Web Search MCP Server + +An MCP server that provides Google Search grounding via Gemini through the proxy. + +**Setup:** Add to your Claude Code project config (`~/.claude.json` under `projects..mcpServers`): + +```json +{ + "mcpServers": { + "antigravity-search": { + "type": "stdio", + "command": "python3", + "args": ["./scripts/web_search_mcp.py"] + } + } +} +``` + +**Dependencies:** Install Python dependencies before first use: + +```bash +pip install -r scripts/requirements.txt +``` + +**How it works:** Sends queries to `gemini-3-flash` through the proxy with a `google_search` tool that activates Google Search grounding, plus a minimal thinking budget (`budget_tokens: 1`) for fast responses. Returns live search results, not training data. + +**Google Search Grounding (Proxy-level):** +- Any Anthropic-format request can enable grounding by including a tool named `google_search` or `googleSearchRetrieval` +- The proxy converts these to native Gemini `{ google_search: {} }` entries, separate from `functionDeclarations` +- Grounding cannot be mixed with function declarations in the same request (Cloud Code API limitation) +- Grounding is only supported on Gemini models + ## Architecture **Request Flow:** diff --git a/package.json b/package.json index 059a266e..c82c558e 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,8 @@ "test:emptyretry": "node tests/test-empty-response-retry.cjs", "test:sanitizer": "node tests/test-schema-sanitizer.cjs", "test:strategies": "node tests/test-strategies.cjs", - "test:cache-control": "node tests/test-cache-control.cjs" + "test:cache-control": "node tests/test-cache-control.cjs", + "test:websearch": "node tests/test-web-search.cjs" }, "keywords": [ "claude", diff --git a/scripts/requirements.txt b/scripts/requirements.txt new file mode 100644 index 00000000..698b4c8a --- /dev/null +++ b/scripts/requirements.txt @@ -0,0 +1,2 @@ +mcp~=1.0.0 +requests~=2.28.0 diff --git a/scripts/web_search_mcp.py b/scripts/web_search_mcp.py new file mode 100644 index 00000000..0568bde4 --- /dev/null +++ b/scripts/web_search_mcp.py @@ -0,0 +1,116 @@ +import sys +import json +import asyncio +import traceback +import os +import requests +from mcp.server import Server, NotificationOptions +from mcp.server.models import InitializationOptions +import mcp.server.stdio +import mcp.types as types + + +def get_proxy_config(): + """Read proxy URL and API key from Claude CLI settings.""" + config_path = os.path.join(os.path.expanduser("~"), ".claude", "settings.json") + try: + with open(config_path) as f: + config = json.load(f) + base_url = config.get("apiBaseUrl", "http://localhost:8080") + api_key = config.get("apiKey", "test") + return f"{base_url}/v1/messages", api_key + except Exception: + return "http://localhost:8080/v1/messages", "test" + + +PROXY_URL, API_KEY = get_proxy_config() + +app = Server("antigravity-search") + + +@app.list_tools() +async def list_tools() -> list[types.Tool]: + """List available tools.""" + return [ + types.Tool( + name="search", + description="Performs a web search via Gemini's Google Search grounding through the Antigravity Proxy.", + inputSchema={ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The search query" + } + }, + "required": ["query"] + } + ) + ] + + +def _call_proxy(query: str) -> str: + """Send a search query through the Antigravity Proxy using Google Search grounding.""" + headers = {"x-api-key": API_KEY, "Content-Type": "application/json"} + payload = { + "model": "gemini-3-flash", + "system": "You are a concise search assistant. Return ONLY factual results in 2-3 sentences with source URLs. No code, no filler.", + "messages": [{"role": "user", "content": query}], + "max_tokens": 512, + "thinking": {"budget_tokens": 1}, + "tools": [{"name": "google_search", "input_schema": {"type": "object"}}] + } + + response = requests.post(PROXY_URL, headers=headers, json=payload, timeout=60) + + if response.status_code != 200: + return f"Error: Proxy returned status {response.status_code} - {response.text}" + + data = response.json() + content_blocks = data.get("content", []) + text_parts = [block.get("text", "") for block in content_blocks if block.get("type") == "text"] + return "".join(text_parts) if text_parts else "No results found." + + +@app.call_tool() +async def call_tool(name: str, arguments: dict): + """Handle tool calls.""" + if name != "search": + raise ValueError(f"Unknown tool: {name}") + + query = arguments.get("query") + if not query: + raise ValueError("Missing required parameter 'query'") + + if len(query) > 500: + raise ValueError("Query too long (max 500 characters)") + + try: + result = await asyncio.to_thread(_call_proxy, query) + return [types.TextContent(type="text", text=result)] + except Exception as e: + sys.stderr.write(f"Search error: {traceback.format_exc()}\n") + sys.stderr.flush() + return [types.TextContent(type="text", text=f"Search failed: {str(e)}")] + + +async def main(): + sys.stderr.write("Starting Antigravity Search MCP Server...\n") + sys.stderr.flush() + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await app.run( + read_stream, + write_stream, + InitializationOptions( + server_name="antigravity-search", + server_version="1.1.0", + capabilities=app.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ) + ), + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/format/request-converter.js b/src/format/request-converter.js index 71426a36..f2896faf 100644 --- a/src/format/request-converter.js +++ b/src/format/request-converter.js @@ -202,43 +202,72 @@ export function convertAnthropicToGoogle(anthropicRequest) { // Convert tools to Google format if (tools && tools.length > 0) { - const functionDeclarations = tools.map((tool, idx) => { - // Extract name from various possible locations - const name = tool.name || tool.function?.name || tool.custom?.name || `tool-${idx}`; - - // Extract description from various possible locations - const description = tool.description || tool.function?.description || tool.custom?.description || ''; - - // Extract schema from various possible locations - const schema = tool.input_schema - || tool.function?.input_schema - || tool.function?.parameters - || tool.custom?.input_schema - || tool.parameters - || { type: 'object' }; - - // Sanitize schema for general compatibility - let parameters = sanitizeSchema(schema); - - // Apply Google-format cleaning for ALL models since they all go through - // Cloud Code API which validates schemas using Google's protobuf format. - // This fixes issue #82: /compact command fails with schema transformation error - // "Proto field is not repeating, cannot start list" for Claude models. - parameters = cleanSchema(parameters); - - return { - name: String(name).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64), - description: description, - parameters - }; - }); + // Separate Google Search grounding tools from regular function declarations. + // Clients signal grounding by including a tool named "google_search" or + // "googleSearchRetrieval" in the Anthropic tools array. These are converted + // to native Gemini grounding entries instead of functionDeclarations. + const GROUNDING_TOOL_NAMES = new Set(['google_search', 'googleSearchRetrieval']); + const regularTools = []; + const groundingEntries = []; + + for (const tool of tools) { + const name = tool.name || tool.function?.name || tool.custom?.name || ''; + if (GROUNDING_TOOL_NAMES.has(name)) { + groundingEntries.push({ google_search: {} }); + logger.debug('[RequestConverter] Google Search grounding enabled'); + } else { + regularTools.push(tool); + } + } - googleRequest.tools = [{ functionDeclarations }]; - logger.debug(`[RequestConverter] Tools: ${JSON.stringify(googleRequest.tools).substring(0, 300)}`); + const googleTools = []; + + if (regularTools.length > 0) { + const functionDeclarations = regularTools.map((tool, idx) => { + // Extract name from various possible locations + const name = tool.name || tool.function?.name || tool.custom?.name || `tool-${idx}`; + + // Extract description from various possible locations + const description = tool.description || tool.function?.description || tool.custom?.description || ''; + + // Extract schema from various possible locations + const schema = tool.input_schema + || tool.function?.input_schema + || tool.function?.parameters + || tool.custom?.input_schema + || tool.parameters + || { type: 'object' }; + + // Sanitize schema for general compatibility + let parameters = sanitizeSchema(schema); + + // Apply Google-format cleaning for ALL models since they all go through + // Cloud Code API which validates schemas using Google's protobuf format. + // This fixes issue #82: /compact command fails with schema transformation error + // "Proto field is not repeating, cannot start list" for Claude models. + parameters = cleanSchema(parameters); + + return { + name: String(name).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64), + description: description, + parameters + }; + }); + + googleTools.push({ functionDeclarations }); + } + + // Append grounding tools as separate entries in the tools array + googleTools.push(...groundingEntries); + + if (googleTools.length > 0) { + googleRequest.tools = googleTools; + logger.debug(`[RequestConverter] Tools: ${JSON.stringify(googleRequest.tools).substring(0, 300)}`); + } // For Claude models, set functionCallingConfig.mode = "VALIDATED" // This ensures strict parameter validation (matches opencode-antigravity-auth) - if (isClaudeModel) { + if (isClaudeModel && regularTools.length > 0) { googleRequest.toolConfig = { functionCallingConfig: { mode: 'VALIDATED' diff --git a/tests/test-web-search.cjs b/tests/test-web-search.cjs new file mode 100644 index 00000000..040a63fe --- /dev/null +++ b/tests/test-web-search.cjs @@ -0,0 +1,197 @@ +/** + * Web Search MCP Test + * + * Tests that the web search MCP server (scripts/web_search_mcp.py) works + * correctly when invoked through the Antigravity Proxy. + * + * Requires the proxy server to be running on port 8080. + * + * Verifies: + * 1. Google Search grounding returns live results via google_search tool + * 2. Minimal thinking budget works with grounding + * 3. Response format matches Anthropic Messages API + * 4. Grounding tool doesn't leak into functionDeclarations (no regression) + */ +const { makeRequest } = require('./helpers/http-client.cjs'); + +async function runTests() { + console.log('='.repeat(60)); + console.log('WEB SEARCH MCP TEST'); + console.log('Tests that Google Search grounding works via gemini-3-flash'); + console.log('='.repeat(60)); + console.log(''); + + let allPassed = true; + const results = []; + + // ===== TEST 1: Google Search grounding returns live results ===== + console.log('TEST 1: Google Search grounding returns live text content'); + console.log('-'.repeat(40)); + + try { + const result = await makeRequest({ + model: 'gemini-3-flash', + max_tokens: 512, + stream: false, + system: 'You are a concise search assistant. Return ONLY factual results in 2-3 sentences with source URLs. No code, no filler.', + thinking: { budget_tokens: 1 }, + tools: [{ name: 'google_search', input_schema: { type: 'object' } }], + messages: [ + { role: 'user', content: 'What is the current price of Bitcoin?' } + ] + }); + + const passed = result.statusCode === 200 + && result.content + && result.content.length > 0 + && result.content.some(b => b.type === 'text' && b.text && b.text.length > 10); + + const textBlock = result.content?.find(b => b.type === 'text'); + console.log(` Status: ${result.statusCode}`); + console.log(` Content blocks: ${result.content?.length || 0}`); + console.log(` Text preview: ${textBlock?.text?.substring(0, 120)}...`); + console.log(` Result: ${passed ? 'PASS ✓' : 'FAIL ✗'}`); + + if (!passed) allPassed = false; + results.push({ name: 'Google Search grounding', passed }); + } catch (error) { + console.log(` Error: ${error.message}`); + console.log(' Result: FAIL ✗'); + allPassed = false; + results.push({ name: 'Google Search grounding', passed: false }); + } + console.log(''); + + // ===== TEST 2: Grounding with minimal thinking budget ===== + console.log('TEST 2: Grounding with minimal thinking budget (budget_tokens: 1)'); + console.log('-'.repeat(40)); + + try { + const start = Date.now(); + const result = await makeRequest({ + model: 'gemini-3-flash', + max_tokens: 256, + stream: false, + thinking: { budget_tokens: 1 }, + tools: [{ name: 'google_search', input_schema: { type: 'object' } }], + messages: [ + { role: 'user', content: 'What year is it?' } + ] + }); + const elapsed = Date.now() - start; + + const passed = result.statusCode === 200 + && result.content + && result.content.some(b => b.type === 'text'); + + const textBlock = result.content?.find(b => b.type === 'text'); + console.log(` Status: ${result.statusCode}`); + console.log(` Elapsed: ${elapsed}ms`); + console.log(` Text: ${textBlock?.text?.substring(0, 100)}`); + console.log(` Result: ${passed ? 'PASS ✓' : 'FAIL ✗'}`); + + if (!passed) allPassed = false; + results.push({ name: 'Minimal thinking + grounding', passed }); + } catch (error) { + console.log(` Error: ${error.message}`); + console.log(' Result: FAIL ✗'); + allPassed = false; + results.push({ name: 'Minimal thinking + grounding', passed: false }); + } + console.log(''); + + // ===== TEST 3: Response format matches Anthropic Messages API ===== + console.log('TEST 3: Response format matches Anthropic Messages API'); + console.log('-'.repeat(40)); + + try { + const result = await makeRequest({ + model: 'gemini-3-flash', + max_tokens: 256, + stream: false, + thinking: { budget_tokens: 1 }, + messages: [ + { role: 'user', content: 'Hello' } + ] + }); + + const hasRole = result.role === 'assistant'; + const hasModel = typeof result.model === 'string'; + const hasContent = Array.isArray(result.content); + const hasUsage = result.usage && typeof result.usage.input_tokens === 'number'; + const passed = hasRole && hasModel && hasContent && hasUsage; + + console.log(` role: ${result.role} (${hasRole ? '✓' : '✗'})`); + console.log(` model: ${result.model} (${hasModel ? '✓' : '✗'})`); + console.log(` content: Array[${result.content?.length}] (${hasContent ? '✓' : '✗'})`); + console.log(` usage.input_tokens: ${result.usage?.input_tokens} (${hasUsage ? '✓' : '✗'})`); + console.log(` Result: ${passed ? 'PASS ✓' : 'FAIL ✗'}`); + + if (!passed) allPassed = false; + results.push({ name: 'Response format', passed }); + } catch (error) { + console.log(` Error: ${error.message}`); + console.log(' Result: FAIL ✗'); + allPassed = false; + results.push({ name: 'Response format', passed: false }); + } + console.log(''); + + // ===== TEST 4: google_search tool is not treated as a function declaration ===== + console.log('TEST 4: google_search tool is separated from function declarations'); + console.log('-'.repeat(40)); + + try { + // Send only google_search tool - should NOT return a tool_use call for "google_search" + // because it should be converted to native grounding, not a function declaration + const result = await makeRequest({ + model: 'gemini-3-flash', + max_tokens: 512, + stream: false, + thinking: { budget_tokens: 1 }, + tools: [{ name: 'google_search', input_schema: { type: 'object' } }], + messages: [ + { role: 'user', content: 'What is the latest news today?' } + ] + }); + + // Should return text content (grounding result), not a tool_use block + const hasText = result.content?.some(b => b.type === 'text' && b.text?.length > 10); + const hasToolUse = result.content?.some(b => b.type === 'tool_use' && b.name === 'google_search'); + const passed = result.statusCode === 200 && hasText && !hasToolUse; + + const textBlock = result.content?.find(b => b.type === 'text'); + console.log(` Status: ${result.statusCode}`); + console.log(` Has text content: ${hasText ? '✓' : '✗'}`); + console.log(` No google_search tool_use: ${!hasToolUse ? '✓' : '✗ (leaked as function call)'}`); + console.log(` Text preview: ${textBlock?.text?.substring(0, 120)}...`); + console.log(` Result: ${passed ? 'PASS ✓' : 'FAIL ✗'}`); + + if (!passed) allPassed = false; + results.push({ name: 'Grounding not leaked as function call', passed }); + } catch (error) { + console.log(` Error: ${error.message}`); + console.log(' Result: FAIL ✗'); + allPassed = false; + results.push({ name: 'Grounding not leaked as function call', passed: false }); + } + console.log(''); + + // ===== Summary ===== + console.log('='.repeat(60)); + console.log('SUMMARY'); + console.log('='.repeat(60)); + for (const r of results) { + console.log(` ${r.passed ? '✓' : '✗'} ${r.name}`); + } + const passCount = results.filter(r => r.passed).length; + console.log(`\n ${passCount}/${results.length} tests passed`); + console.log(` Overall: ${allPassed ? 'ALL PASSED ✓' : 'SOME FAILED ✗'}`); + + process.exit(allPassed ? 0 : 1); +} + +runTests().catch(err => { + console.error('Test runner error:', err); + process.exit(1); +});