From 5d2c94247de0b501ab02bc5457a70dfba1f9fedd Mon Sep 17 00:00:00 2001 From: georgeatparallel <297992784+georgeatparallel@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:35:49 +0000 Subject: [PATCH] feat: add Parallel MCP web search backend --- .env.example | 7 +- README.en.md | 9 + src/services/webSearch/localWebSearch.ts | 6 +- src/services/webSearch/parallelWebSearch.ts | 91 ++++++ src/services/webSearch/searchMode.ts | 23 ++ src/tools/WebSearchTool/WebSearchTool.ts | 35 +++ src/tools/WebSearchTool/parallel.test.ts | 311 ++++++++++++++++++++ 7 files changed, 478 insertions(+), 4 deletions(-) create mode 100644 src/services/webSearch/parallelWebSearch.ts create mode 100644 src/services/webSearch/searchMode.ts create mode 100644 src/tools/WebSearchTool/parallel.test.ts diff --git a/.env.example b/.env.example index f883865e..66df4a7a 100644 --- a/.env.example +++ b/.env.example @@ -50,4 +50,9 @@ # 通用设置(建议始终开启) # ============================================================ DISABLE_TELEMETRY=1 -CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \ No newline at end of file +CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 + +# Web search backend: parallel (free Search MCP, no search API key), +# local (local search engines), or native (model provider search). +# Unset: preserve configured providers; use Parallel when unconfigured. +# CYBERCODE_WEB_SEARCH_MODE=parallel diff --git a/README.en.md b/README.en.md index 5ef82e5b..78d0d1cf 100644 --- a/README.en.md +++ b/README.en.md @@ -138,6 +138,15 @@ Code Graph is local and cross-platform. Once its global switch is enabled, curre The same tools, memory, desktop workflow, and permissions remain available across the official Claude path, Anthropic-compatible endpoints, OpenAI-compatible providers, and local models. CyberCode also includes a provider-neutral local WebSearch fallback with live search, domain filters, caching, and a direct GitHub Trending path for models whose native browsing is unavailable or unreliable. +For free web search without a search account or API key, use [Parallel Search MCP](https://docs.parallel.ai/integrations/mcp/search-mcp), powered by Fast mode: + +```bash +CYBERCODE_WEB_SEARCH_MODE=parallel ./bin/cybercode +``` + +Parallel handles the built-in `WebSearch` tool with source links and domain filters, under the existing tool permissions. It is also selected when no provider endpoint, cloud provider or credentials are configured. Existing configured providers keep their current search behavior. Set `CYBERCODE_WEB_SEARCH_MODE=local` for the local engines (including direct GitHub Trending), or `native` for the model provider's search. Parallel search sends the query and an opaque conversation identifier to `search.parallel.ai`; anonymous usage has service limits, and errors are returned without switching providers. Page fetching continues to use the existing `WebFetch` tool. + + diff --git a/src/services/webSearch/localWebSearch.ts b/src/services/webSearch/localWebSearch.ts index a5ced236..cecc520d 100644 --- a/src/services/webSearch/localWebSearch.ts +++ b/src/services/webSearch/localWebSearch.ts @@ -582,11 +582,11 @@ function matchesDomainRule(url: URL, rule: DomainRule): boolean { return hostMatches && (!rule.path || url.pathname.startsWith(rule.path)) } -export function filterSearchResultsByDomain( - results: LocalWebSearchHit[], +export function filterSearchResultsByDomain( + results: T[], allowedDomains: string[] = [], blockedDomains: string[] = [], -): LocalWebSearchHit[] { +): T[] { const allowed = allowedDomains .map(parseDomainRule) .filter((rule): rule is DomainRule => rule !== null) diff --git a/src/services/webSearch/parallelWebSearch.ts b/src/services/webSearch/parallelWebSearch.ts new file mode 100644 index 00000000..720302ba --- /dev/null +++ b/src/services/webSearch/parallelWebSearch.ts @@ -0,0 +1,91 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import { z } from 'zod/v4' +import { getProxyFetchOptions } from '../../utils/proxy.js' +import { filterSearchResultsByDomain } from './localWebSearch.js' + +const SEARCH_URL = 'https://search.parallel.ai/mcp' +const SEARCH_TIMEOUT_MS = 15_000 +const responseSchema = z.object({ + results: z.array(z.object({ + url: z.string(), + title: z.string().nullish(), + excerpts: z.array(z.string()), + })), +}) + +export async function searchParallelWeb(options: { + query: string + sessionId: string + allowedDomains?: string[] + blockedDomains?: string[] + signal: AbortSignal +}) { + const signal = AbortSignal.any([ + options.signal, + AbortSignal.timeout(SEARCH_TIMEOUT_MS), + ]) + signal.throwIfAborted() + const client = new Client({ name: 'cybercode', version: MACRO.VERSION }) + const transport = new StreamableHTTPClientTransport(new URL(SEARCH_URL), { + requestInit: { + ...getProxyFetchOptions(), + headers: { + 'User-Agent': `CyberCode/${MACRO.VERSION} (+https://github.com/wk42worldworld/cybercode)`, + }, + }, + // Bind the entire operation, including discovery, to cancellation/timeout. + fetch: (url, init) => fetch(url, { + ...init, + signal: AbortSignal.any([signal, ...(init?.signal ? [init.signal] : [])]), + }), + }) + const query = options.allowedDomains?.length + ? `${options.query} (${options.allowedDomains.map(domain => `site:${domain}`).join(' OR ')})` + : options.query + + try { + await client.connect(transport, { signal }) + const result = await client.callTool({ + name: 'web_search', + arguments: { + objective: options.query, + search_queries: [query], + session_id: options.sessionId, + }, + }, undefined, { signal }) + const text = result.content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('\n') + if (result.isError) { + throw new Error(`Parallel web search failed: ${text.slice(0, 1000)}`) + } + const response = responseSchema.parse(result.structuredContent ?? JSON.parse(text)) + const seen = new Set() + const hits = response.results.flatMap(result => { + try { + const url = new URL(result.url) + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return [] + if (seen.has(url.href)) return [] + seen.add(url.href) + return [{ + title: result.title || url.hostname, + url: url.href, + description: result.excerpts.join('\n'), + source: url.hostname, + engine: 'parallel' as const, + }] + } catch { + return [] + } + }) + return filterSearchResultsByDomain( + hits, + options.allowedDomains, + options.blockedDomains, + ).slice(0, 10) + } finally { + await client.close() + } +} diff --git a/src/services/webSearch/searchMode.ts b/src/services/webSearch/searchMode.ts new file mode 100644 index 00000000..f78164e5 --- /dev/null +++ b/src/services/webSearch/searchMode.ts @@ -0,0 +1,23 @@ +import { getAuthTokenSource, getApiKeyFromConfigOrMacOSKeychain } from '../../utils/auth.js' +import { getApiKeyFromFileDescriptor } from '../../utils/authFileDescriptor.js' +import { getAPIProvider } from '../../utils/model/providers.js' + +/** Only a fresh, unconfigured installation changes its search backend. */ +export function shouldUseParallelWebSearch(): boolean { + const mode = process.env.CYBERCODE_WEB_SEARCH_MODE?.trim().toLowerCase() + if (mode) return mode === 'parallel' + + // Startup projects saved provider settings into these same environment values. + if (process.env.ANTHROPIC_BASE_URL || getAPIProvider() !== 'firstParty') { + return false + } + + // Inspect credential sources without executing an apiKeyHelper. Preserve even + // an unapproved env key: configuring a credential is an intentional choice. + return !( + process.env.ANTHROPIC_API_KEY || + getAuthTokenSource().hasToken || + getApiKeyFromFileDescriptor() || + getApiKeyFromConfigOrMacOSKeychain()?.key + ) +} diff --git a/src/tools/WebSearchTool/WebSearchTool.ts b/src/tools/WebSearchTool/WebSearchTool.ts index 9936feaa..d0682149 100644 --- a/src/tools/WebSearchTool/WebSearchTool.ts +++ b/src/tools/WebSearchTool/WebSearchTool.ts @@ -1,3 +1,5 @@ +import { createHash } from 'node:crypto' +import { getSessionId } from '../../bootstrap/state.js' import type { BetaContentBlock, BetaWebSearchTool20250305, @@ -11,6 +13,8 @@ import { z } from 'zod/v4' import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' import { queryModelWithStreaming } from '../../services/api/claude.js' import { searchLocalWeb } from '../../services/webSearch/localWebSearch.js' +import { searchParallelWeb } from '../../services/webSearch/parallelWebSearch.js' +import { shouldUseParallelWebSearch } from '../../services/webSearch/searchMode.js' import { buildTool, type ToolCallProgress, @@ -332,6 +336,37 @@ export const WebSearchTool = buildTool({ const startTime = performance.now() const { query } = input + if (shouldUseParallelWebSearch()) { + // Snapshot conversation identity before any await. Subagents keep separate + // research sessions, even when background tasks survive a main-chat reset. + const sessionId = context.agentId + ? createHash('sha256').update(context.agentId).digest('hex') + : getSessionId() + const progressId = `parallel-web-search-${Math.round(startTime)}` + onProgress?.({ + toolUseID: progressId, + data: { type: 'query_update', query }, + }) + const content = await searchParallelWeb({ + query, + sessionId, + allowedDomains: input.allowed_domains, + blockedDomains: input.blocked_domains, + signal: context.abortController.signal, + }) + onProgress?.({ + toolUseID: progressId, + data: { type: 'search_results_received', resultCount: content.length, query }, + }) + return { + data: { + query, + results: [{ tool_use_id: progressId, content }], + durationSeconds: (performance.now() - startTime) / 1000, + }, + } + } + if (!shouldUseNativeWebSearch()) { return { data: await runLocalWebSearch( diff --git a/src/tools/WebSearchTool/parallel.test.ts b/src/tools/WebSearchTool/parallel.test.ts new file mode 100644 index 00000000..bee8fd8b --- /dev/null +++ b/src/tools/WebSearchTool/parallel.test.ts @@ -0,0 +1,311 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test' +import { getSessionId, regenerateSessionId, switchSession } from '../../bootstrap/state.js' +import type { ToolUseContext } from '../../Tool.js' +import { clearOAuthTokenCache, getApiKeyFromConfigOrMacOSKeychain } from '../../utils/auth.js' +import { enableConfigs, saveGlobalConfig } from '../../utils/config.js' +import { shouldUseParallelWebSearch } from '../../services/webSearch/searchMode.js' +import { WebSearchTool } from './WebSearchTool.js' +import { runToolUse } from '../../services/tools/toolExecution.js' +import { getDefaultAppState } from '../../state/AppStateStore.js' +import { createAssistantMessage } from '../../utils/messages.js' +import { createAgentId } from '../../utils/uuid.js' +import { plainTextStorage } from '../../utils/secureStorage/plainTextStorage.js' +import { resetSettingsCache, setSessionSettingsCache } from '../../utils/settings/settingsCache.js' + +const envKeys = [ + 'CYBERCODE_WEB_SEARCH_MODE', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_API_KEY', + 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN', + 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', + 'CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR', 'CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR', + 'CLAUDE_CODE_API_KEY_HELPER', 'CLAUDE_CODE_REMOTE', +] +const originalEnv = Object.fromEntries(envKeys.map(key => [key, process.env[key]])) +const originalFetch = globalThis.fetch +const hits = [ + { title: 'Bun documentation', url: 'https://bun.sh/docs', excerpts: ['Run TypeScript with Bun.'] }, + { title: 'Subdomain', url: 'https://api.bun.sh/docs', excerpts: ['API documentation.'] }, + { title: 'Other', url: 'https://example.com/other', excerpts: ['Other source.'] }, + { title: 'Unsafe', url: 'javascript:alert(1)', excerpts: [] }, + { title: 'Credentials', url: 'https://user:secret@example.com/', excerpts: [] }, + { title: 'Duplicate', url: 'https://bun.sh/docs', excerpts: [] }, +] +type Request = { url: string; headers: Headers; method?: string; params?: any } +let requests: Request[] = [] +let toolResult: any +let hangingMethod: string | undefined +let httpStatus = 200 + +function installMcpFixture() { + globalThis.fetch = (async (url, init) => { + const body = init?.body ? JSON.parse(String(init.body)) : {} + requests.push({ url: String(url), headers: new Headers(init?.headers), ...body }) + if (body.method === hangingMethod) { + return await new Promise((_resolve, reject) => { + const signal = init!.signal! + if (signal.aborted) reject(signal.reason) + else signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + } + if (!body.id && body.id !== 0) return new Response(null, { status: 202 }) + if (httpStatus !== 200) return new Response('Search service unavailable', { status: httpStatus }) + const result = body.method === 'initialize' + ? { protocolVersion: '2025-03-26', capabilities: { tools: {} }, serverInfo: { name: 'fixture', version: '1' } } + : toolResult + return Response.json({ jsonrpc: '2.0', id: body.id, result }) + }) as typeof fetch +} + +function context(agentId?: string, controller = new AbortController()): ToolUseContext { + return { abortController: controller, agentId } as ToolUseContext +} + +async function search(input: { query: string; allowed_domains?: string[]; blocked_domains?: string[] } = { query: 'Bun TypeScript documentation' }, ctx = context()) { + return WebSearchTool.call(input, ctx, undefined as never, undefined as never) +} + +beforeEach(() => { + for (const key of envKeys) delete process.env[key] + clearOAuthTokenCache() + getApiKeyFromConfigOrMacOSKeychain.cache.clear() + requests = [] + hangingMethod = undefined + httpStatus = 200 + toolResult = { content: [{ type: 'text', text: JSON.stringify({ results: hits }) }] } + installMcpFixture() +}) + +afterEach(() => { + globalThis.fetch = originalFetch + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } +}) + +describe('Parallel through the built-in WebSearch tool', () => { + test('fresh no-key configuration returns linked results to the model', async () => { + expect(shouldUseParallelWebSearch()).toBe(true) + const result = await search() + const output = WebSearchTool.mapToolResultToToolResultBlockParam(result.data, 'test-tool') + expect(output.content).toContain('https://bun.sh/docs') + expect(output.content).toContain('Run TypeScript with Bun.') + expect(output.content).not.toContain('javascript:') + expect(output.content).not.toContain('user:secret') + expect(result.data.results[0]).toMatchObject({ content: [ + { title: 'Bun documentation', engine: 'parallel' }, + { title: 'Subdomain', engine: 'parallel' }, + { title: 'Other', engine: 'parallel' }, + ] }) + expect(requests.some(r => r.method === 'initialize')).toBe(true) + expect(requests.some(r => r.method === 'tools/call')).toBe(true) + for (const request of requests) { + expect(request.url).toBe('https://search.parallel.ai/mcp') + expect(request.headers.get('User-Agent')).toContain('CyberCode/') + expect(request.headers.has('Authorization')).toBe(false) + } + expect(requests.find(r => r.method === 'tools/call')?.params).toEqual({ + name: 'web_search', + arguments: { objective: 'Bun TypeScript documentation', search_queries: ['Bun TypeScript documentation'], session_id: getSessionId() }, + }) + }) + + test('filters allowed subdomains and blocked domains before model consumption', async () => { + const allowed = await search({ query: 'Bun docs', allowed_domains: ['bun.sh'] }) + const content = WebSearchTool.mapToolResultToToolResultBlockParam(allowed.data, 'allowed').content + expect(content).toContain('https://api.bun.sh/docs') + expect(content).not.toContain('example.com') + expect(requests.find(r => r.method === 'tools/call')?.params.arguments.search_queries).toEqual(['Bun docs (site:bun.sh)']) + const blocked = await search({ query: 'Bun docs', blocked_domains: ['bun.sh'] }) + const filtered = WebSearchTool.mapToolResultToToolResultBlockParam(blocked.data, 'blocked').content + expect(filtered).not.toContain('https://bun.sh') + expect(filtered).not.toContain('https://api.bun.sh') + expect(filtered).toContain('https://example.com/other') + }) + + test('accepts structured MCP output and empty results', async () => { + toolResult = { content: [], structuredContent: { results: [] } } + const result = await search() + expect(WebSearchTool.mapToolResultToToolResultBlockParam(result.data, 'empty').content).toContain('No links found.') + }) + + test('reports MCP errors and malformed responses without falling back', async () => { + process.env.CYBERCODE_WEB_SEARCH_MODE = 'parallel' + toolResult = { content: [{ type: 'text', text: 'Anonymous quota exceeded' }], isError: true } + await expect(search()).rejects.toThrow('Anonymous quota exceeded') + toolResult = { content: [{ type: 'text', text: '{"unexpected":true}' }] } + await expect(search()).rejects.toThrow() + expect(requests.every(r => r.url === 'https://search.parallel.ai/mcp')).toBe(true) + }) + + test('surfaces HTTP failures without another backend request', async () => { + httpStatus = 503 + await expect(search()).rejects.toThrow() + expect(requests.every(r => r.url === 'https://search.parallel.ai/mcp')).toBe(true) + }) + + test('cancels discovery and search, and does not start an already-aborted call', async () => { + const aborted = new AbortController() + aborted.abort() + await expect(search(undefined, context(undefined, aborted))).rejects.toThrow() + expect(requests).toHaveLength(0) + for (const method of ['initialize', 'tools/call']) { + hangingMethod = method + const controller = new AbortController() + const pending = search(undefined, context(undefined, controller)) + await Bun.sleep(10) + controller.abort() + await expect(pending).rejects.toThrow() + } + }) + + test('bounds the whole operation including discovery to 15 seconds', async () => { + const timeout = AbortSignal.timeout.bind(AbortSignal) + const timeoutSpy = spyOn(AbortSignal, 'timeout').mockImplementation(ms => { + expect(ms).toBe(15_000) + return timeout(10) + }) + try { + for (const method of ['initialize', 'tools/call']) { + hangingMethod = method + await expect(search()).rejects.toThrow() + } + } finally { + timeoutSpy.mockRestore() + } + }) + + test('keeps conversation identity on follow-up, retry and concurrent subagent calls', async () => { + await search() + await search() + const id = getSessionId() + toolResult = { content: [{ type: 'text', text: 'Quota exceeded' }], isError: true } + await expect(search()).rejects.toThrow() + toolResult = { content: [{ type: 'text', text: JSON.stringify({ results: hits }) }] } + await search() + expect(requests.filter(r => r.method === 'tools/call').map(r => r.params.arguments.session_id)).toEqual([id, id, id, id]) + requests = [] + const agentA = createAgentId() + const agentB = createAgentId() + await Promise.all([search(undefined, context(agentA)), search(undefined, context(agentB))]) + regenerateSessionId() + await Promise.all([search(undefined, context(agentB)), search(undefined, context(agentA))]) + const ids = requests.filter(r => r.method === 'tools/call').map(r => r.params.arguments.session_id) + expect(ids[0]).not.toBe(ids[1]) + expect(ids[0]).toBe(ids[3]) + expect(ids[1]).toBe(ids[2]) + expect(ids[0]).toMatch(/^[a-f0-9]{64}$/) + }) + + test('snapshots independent sessions before await and changes identity on new chat', async () => { + const firstId = getSessionId() + const first = search() + const secondId = regenerateSessionId() + const second = search() + await Promise.all([first, second]) + const ids = requests.filter(r => r.method === 'tools/call').map(r => r.params.arguments.session_id) + expect(ids).toEqual([firstId, secondId]) + expect(secondId).not.toBe(firstId) + switchSession(firstId) + await search() + expect(requests.filter(r => r.method === 'tools/call').at(-1)?.params.arguments.session_id).toBe(firstId) + }) +}) + +describe('preserved search choices', () => { + test('explicit modes override automatic selection', () => { + for (const mode of ['local', 'native']) { + process.env.CYBERCODE_WEB_SEARCH_MODE = mode + expect(shouldUseParallelWebSearch()).toBe(false) + } + process.env.ANTHROPIC_API_KEY = 'configured-test-key' + process.env.CYBERCODE_WEB_SEARCH_MODE = ' Parallel ' + expect(shouldUseParallelWebSearch()).toBe(true) + }) + + test('keeps configured endpoints, cloud providers and environment credentials', () => { + for (const key of ['ANTHROPIC_BASE_URL', 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN', 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY']) { + process.env[key] = key === 'ANTHROPIC_BASE_URL' ? 'https://custom.example/anthropic' : '1' + clearOAuthTokenCache() + expect(shouldUseParallelWebSearch()).toBe(false) + delete process.env[key] + clearOAuthTokenCache() + } + }) + + test('keeps saved OAuth and helper configuration without executing the helper', () => { + expect(plainTextStorage.update({ claudeAiOauth: { + accessToken: 'saved-test-oauth', scopes: ['user:inference'], + } }).success).toBe(true) + clearOAuthTokenCache() + try { + expect(shouldUseParallelWebSearch()).toBe(false) + } finally { + plainTextStorage.delete() + clearOAuthTokenCache() + } + setSessionSettingsCache({ settings: { apiKeyHelper: 'exit 99' }, errors: [] }) + try { + expect(shouldUseParallelWebSearch()).toBe(false) + expect(requests).toHaveLength(0) + } finally { + resetSettingsCache() + } + }) + + test('keeps saved API credentials', () => { + enableConfigs() + saveGlobalConfig(config => ({ ...config, primaryApiKey: 'saved-test-key' })) + getApiKeyFromConfigOrMacOSKeychain.cache.clear() + try { + expect(shouldUseParallelWebSearch()).toBe(false) + } finally { + saveGlobalConfig(config => ({ ...config, primaryApiKey: undefined })) + getApiKeyFromConfigOrMacOSKeychain.cache.clear() + } + }) +}) + + +describe('normal tool dispatcher permissions', () => { + async function dispatch(available: boolean, allowed: boolean) { + let appState = getDefaultAppState() + const toolUse = { type: 'tool_use' as const, id: 'dispatch-search', name: 'WebSearch', input: { query: 'Bun TypeScript documentation' } } + const assistant = createAssistantMessage({ content: [toolUse] }) + const ctx = { + ...context(), + options: { + tools: available ? [WebSearchTool] : [], commands: [], debug: false, + mainLoopModel: 'test-model', verbose: false, + thinkingConfig: { type: 'disabled' }, mcpClients: [], mcpResources: {}, + isNonInteractiveSession: true, agentDefinitions: { activeAgents: [], allAgents: [] }, + }, + getAppState: () => appState, + setAppState: update => { appState = update(appState) }, + setInProgressToolUseIDs: () => {}, setResponseLength: () => {}, + updateFileHistoryState: () => {}, updateAttributionState: () => {}, + messages: [assistant], + } as ToolUseContext + const updates = [] + for await (const update of runToolUse(toolUse, assistant, async (_tool, input) => allowed + ? { behavior: 'allow', updatedInput: input } + : { behavior: 'deny', message: 'Search denied for this session.' }, ctx)) { + updates.push(update) + } + return JSON.stringify(updates) + } + + test('denied or unavailable WebSearch sends no MCP request', async () => { + expect(await dispatch(true, false)).toContain('Search denied for this session.') + expect(requests).toHaveLength(0) + expect(await dispatch(false, true)).toContain('No such tool available') + expect(requests).toHaveLength(0) + }) + + test('authorized WebSearch dispatch returns source links', async () => { + const output = await dispatch(true, true) + expect(output).toContain('https://bun.sh/docs') + expect(output).toContain('Run TypeScript with Bun.') + expect(output).not.toContain('tool_use_error') + expect(requests.some(r => r.method === 'tools/call')).toBe(true) + }) +})
CyberCode provider configuration
Provider control in the app
Configure hosted or local endpoints, test connections, and choose defaults without leaving the desktop workflow.