From ff3a7bfee05ad045cc1111f1de427a6226d27f9b Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 4 Dec 2025 22:54:13 +0900 Subject: [PATCH] refactor(tools): rename all tools to snake_case for consistency --- notepad.md | 61 ++++++++ src/tools/index.ts | 3 + src/tools/lsp/client.ts | 55 +++++++- src/tools/lsp/utils.ts | 11 ++ src/tools/safe-grep/cli.ts | 229 +++++++++++++++++++++++++++++++ src/tools/safe-grep/constants.ts | 99 +++++++++++++ src/tools/safe-grep/index.ts | 3 + src/tools/safe-grep/tools.ts | 40 ++++++ src/tools/safe-grep/types.ts | 39 ++++++ src/tools/safe-grep/utils.ts | 53 +++++++ test-rule.yml | 6 + test.js | 1 + 12 files changed, 597 insertions(+), 3 deletions(-) create mode 100644 notepad.md create mode 100644 src/tools/safe-grep/cli.ts create mode 100644 src/tools/safe-grep/constants.ts create mode 100644 src/tools/safe-grep/index.ts create mode 100644 src/tools/safe-grep/tools.ts create mode 100644 src/tools/safe-grep/types.ts create mode 100644 src/tools/safe-grep/utils.ts create mode 100644 test-rule.yml create mode 100644 test.js diff --git a/notepad.md b/notepad.md new file mode 100644 index 0000000..7bf1a0f --- /dev/null +++ b/notepad.md @@ -0,0 +1,61 @@ +# MCP Loader Plugin - Orchestration Notepad + +## Task Started +All tasks execution STARTED: Thu Dec 4 16:52:57 KST 2025 + +--- + +## Orchestration Overview + +**Todo List File**: ./tool-search-tool-plan.md +**Total Tasks**: 5 (Phase 1-5) +**Target Files**: +- `~/.config/opencode/plugin/mcp-loader.ts` - Main plugin +- `~/.config/opencode/mcp-loader.json` - Global config example +- `~/.config/opencode/plugin/mcp-loader.test.ts` - Unit tests + +--- + +## Accumulated Wisdom + +(To be populated by executors) + +--- + +## Task Progress + +| Task | Description | Status | +|------|-------------|--------| +| 1 | Plugin skeleton + config loader | pending | +| 2 | MCP server registry + lifecycle | pending | +| 3 | mcp_search + mcp_status tools | pending | +| 4 | mcp_call tool | pending | +| 5 | Documentation | pending | + +--- + + +## 2025-12-04 16:58 - Task 1 Completed + +### Summary +- Created `~/.config/opencode/plugin/mcp-loader.ts` - Plugin skeleton with config loader +- Created `~/.config/opencode/plugin/mcp-loader.test.ts` - 14 unit tests + +### Key Implementation Details +- Config merge: project overrides global for same server names, merges different +- Env var substitution: `{env:VAR}` → `process.env.VAR` +- Validation: type required, local needs command, remote needs url +- Empty config returns `{ servers: {} }` (not error) + +### Test Results +- 14 tests passed +- substituteEnvVars: 4 tests +- substituteHeaderEnvVars: 1 test +- loadConfig: 9 tests + +### Files Created +- `~/.config/opencode/plugin/mcp-loader.ts` +- `~/.config/opencode/plugin/mcp-loader.test.ts` + +--- + diff --git a/src/tools/index.ts b/src/tools/index.ts index 3b98687..45a2420 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -20,6 +20,8 @@ import { ast_grep_transform, } from "./ast-grep" +import { safe_grep } from "./safe-grep" + export const builtinTools = { lsp_hover, lsp_goto_definition, @@ -37,4 +39,5 @@ export const builtinTools = { ast_grep_languages, ast_grep_analyze, ast_grep_transform, + safe_grep, } diff --git a/src/tools/lsp/client.ts b/src/tools/lsp/client.ts index e245b6d..bd69d33 100644 --- a/src/tools/lsp/client.ts +++ b/src/tools/lsp/client.ts @@ -8,6 +8,8 @@ interface ManagedClient { client: LSPClient lastUsedAt: number refCount: number + initPromise?: Promise + isInitializing: boolean } class LSPServerManager { @@ -53,6 +55,9 @@ class LSPServerManager { let managed = this.clients.get(key) if (managed) { + if (managed.initPromise) { + await managed.initPromise + } if (managed.client.isAlive()) { managed.refCount++ managed.lastUsedAt = Date.now() @@ -63,18 +68,56 @@ class LSPServerManager { } const client = new LSPClient(root, server) - await client.start() - await client.initialize() + const initPromise = (async () => { + await client.start() + await client.initialize() + })() this.clients.set(key, { client, lastUsedAt: Date.now(), refCount: 1, + initPromise, + isInitializing: true, }) + await initPromise + const m = this.clients.get(key) + if (m) { + m.initPromise = undefined + m.isInitializing = false + } + return client } + warmupClient(root: string, server: ResolvedServer): void { + const key = this.getKey(root, server.id) + if (this.clients.has(key)) return + + const client = new LSPClient(root, server) + const initPromise = (async () => { + await client.start() + await client.initialize() + })() + + this.clients.set(key, { + client, + lastUsedAt: Date.now(), + refCount: 0, + initPromise, + isInitializing: true, + }) + + initPromise.then(() => { + const m = this.clients.get(key) + if (m) { + m.initPromise = undefined + m.isInitializing = false + } + }) + } + releaseClient(root: string, serverId: string): void { const key = this.getKey(root, serverId) const managed = this.clients.get(key) @@ -84,6 +127,12 @@ class LSPServerManager { } } + isServerInitializing(root: string, serverId: string): boolean { + const key = this.getKey(root, serverId) + const managed = this.clients.get(key) + return managed?.isInitializing ?? false + } + async stopAll(): Promise { for (const [, managed] of this.clients) { await managed.client.stop() @@ -278,7 +327,7 @@ export class LSPClient { const stderr = this.stderrBuffer.slice(-5).join("\n") reject(new Error(`LSP request timeout (method: ${method})` + (stderr ? `\nrecent stderr: ${stderr}` : ""))) } - }, 30000) + }, 15000) }) } diff --git a/src/tools/lsp/utils.ts b/src/tools/lsp/utils.ts index f8b5061..750dcf6 100644 --- a/src/tools/lsp/utils.ts +++ b/src/tools/lsp/utils.ts @@ -53,6 +53,17 @@ export async function withLspClient(filePath: string, fn: (client: LSPClient) try { return await fn(client) + } catch (e) { + if (e instanceof Error && e.message.includes("timeout")) { + const isInitializing = lspManager.isServerInitializing(root, server.id) + if (isInitializing) { + throw new Error( + `LSP server is still initializing. Please retry in a few seconds. ` + + `Original error: ${e.message}` + ) + } + } + throw e } finally { lspManager.releaseClient(root, server.id) } diff --git a/src/tools/safe-grep/cli.ts b/src/tools/safe-grep/cli.ts new file mode 100644 index 0000000..bbefd2d --- /dev/null +++ b/src/tools/safe-grep/cli.ts @@ -0,0 +1,229 @@ +import { spawn } from "bun" +import { + resolveGrepCli, + type GrepBackend, + DEFAULT_MAX_DEPTH, + DEFAULT_MAX_FILESIZE, + DEFAULT_MAX_COUNT, + DEFAULT_MAX_COLUMNS, + DEFAULT_TIMEOUT_MS, + DEFAULT_MAX_OUTPUT_BYTES, + RG_SAFETY_FLAGS, + GREP_SAFETY_FLAGS, +} from "./constants" +import type { GrepOptions, GrepMatch, GrepResult, CountResult } from "./types" + +function buildRgArgs(options: GrepOptions): string[] { + const args: string[] = [ + ...RG_SAFETY_FLAGS, + `--max-depth=${Math.min(options.maxDepth ?? DEFAULT_MAX_DEPTH, DEFAULT_MAX_DEPTH)}`, + `--max-filesize=${options.maxFilesize ?? DEFAULT_MAX_FILESIZE}`, + `--max-count=${Math.min(options.maxCount ?? DEFAULT_MAX_COUNT, DEFAULT_MAX_COUNT)}`, + `--max-columns=${Math.min(options.maxColumns ?? DEFAULT_MAX_COLUMNS, DEFAULT_MAX_COLUMNS)}`, + ] + + if (options.context !== undefined && options.context > 0) { + args.push(`-C${Math.min(options.context, 10)}`) + } + + if (options.caseSensitive) args.push("--case-sensitive") + if (options.wholeWord) args.push("-w") + if (options.fixedStrings) args.push("-F") + if (options.multiline) args.push("-U") + if (options.hidden) args.push("--hidden") + if (options.noIgnore) args.push("--no-ignore") + + if (options.fileType?.length) { + for (const type of options.fileType) { + args.push(`--type=${type}`) + } + } + + if (options.globs) { + for (const glob of options.globs) { + args.push(`--glob=${glob}`) + } + } + + if (options.excludeGlobs) { + for (const glob of options.excludeGlobs) { + args.push(`--glob=!${glob}`) + } + } + + return args +} + +function buildGrepArgs(options: GrepOptions): string[] { + const args: string[] = [...GREP_SAFETY_FLAGS, "-r"] + + if (options.context !== undefined && options.context > 0) { + args.push(`-C${Math.min(options.context, 10)}`) + } + + if (!options.caseSensitive) args.push("-i") + if (options.wholeWord) args.push("-w") + if (options.fixedStrings) args.push("-F") + + if (options.globs?.length) { + for (const glob of options.globs) { + args.push(`--include=${glob}`) + } + } + + if (options.excludeGlobs?.length) { + for (const glob of options.excludeGlobs) { + args.push(`--exclude=${glob}`) + } + } + + args.push("--exclude-dir=.git", "--exclude-dir=node_modules") + + return args +} + +function buildArgs(options: GrepOptions, backend: GrepBackend): string[] { + return backend === "rg" ? buildRgArgs(options) : buildGrepArgs(options) +} + +function parseOutput(output: string): GrepMatch[] { + if (!output.trim()) return [] + + const matches: GrepMatch[] = [] + const lines = output.split("\n") + + for (const line of lines) { + if (!line.trim()) continue + + const match = line.match(/^(.+?):(\d+):(.*)$/) + if (match) { + matches.push({ + file: match[1], + line: parseInt(match[2], 10), + text: match[3], + }) + } + } + + return matches +} + +function parseCountOutput(output: string): CountResult[] { + if (!output.trim()) return [] + + const results: CountResult[] = [] + const lines = output.split("\n") + + for (const line of lines) { + if (!line.trim()) continue + + const match = line.match(/^(.+?):(\d+)$/) + if (match) { + results.push({ + file: match[1], + count: parseInt(match[2], 10), + }) + } + } + + return results +} + +export async function runRg(options: GrepOptions): Promise { + const cli = resolveGrepCli() + const args = buildArgs(options, cli.backend) + const timeout = Math.min(options.timeout ?? DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS) + + if (cli.backend === "rg") { + args.push("--", options.pattern) + } else { + args.push("-e", options.pattern) + } + + const paths = options.paths?.length ? options.paths : ["."] + args.push(...paths) + const proc = spawn([cli.path, ...args], { + stdout: "pipe", + stderr: "pipe", + }) + + const timeoutPromise = new Promise((_, reject) => { + const id = setTimeout(() => { + proc.kill() + reject(new Error(`Search timeout after ${timeout}ms`)) + }, timeout) + proc.exited.then(() => clearTimeout(id)) + }) + + try { + const stdout = await Promise.race([new Response(proc.stdout).text(), timeoutPromise]) + const stderr = await new Response(proc.stderr).text() + const exitCode = await proc.exited + + const truncated = stdout.length >= DEFAULT_MAX_OUTPUT_BYTES + const outputToProcess = truncated ? stdout.substring(0, DEFAULT_MAX_OUTPUT_BYTES) : stdout + + if (exitCode > 1 && stderr.trim()) { + return { + matches: [], + totalMatches: 0, + filesSearched: 0, + truncated: false, + error: stderr.trim(), + } + } + + const matches = parseOutput(outputToProcess) + const filesSearched = new Set(matches.map((m) => m.file)).size + + return { + matches, + totalMatches: matches.length, + filesSearched, + truncated, + } + } catch (e) { + return { + matches: [], + totalMatches: 0, + filesSearched: 0, + truncated: false, + error: e instanceof Error ? e.message : String(e), + } + } +} + +export async function runRgCount(options: Omit): Promise { + const cli = resolveGrepCli() + const args = buildArgs({ ...options, context: 0 }, cli.backend) + + if (cli.backend === "rg") { + args.push("--count", "--", options.pattern) + } else { + args.push("-c", "-e", options.pattern) + } + + const paths = options.paths?.length ? options.paths : ["."] + args.push(...paths) + + const timeout = Math.min(options.timeout ?? DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS) + const proc = spawn([cli.path, ...args], { + stdout: "pipe", + stderr: "pipe", + }) + + const timeoutPromise = new Promise((_, reject) => { + const id = setTimeout(() => { + proc.kill() + reject(new Error(`Search timeout after ${timeout}ms`)) + }, timeout) + proc.exited.then(() => clearTimeout(id)) + }) + + try { + const stdout = await Promise.race([new Response(proc.stdout).text(), timeoutPromise]) + return parseCountOutput(stdout) + } catch (e) { + throw new Error(`Count search failed: ${e instanceof Error ? e.message : String(e)}`) + } +} diff --git a/src/tools/safe-grep/constants.ts b/src/tools/safe-grep/constants.ts new file mode 100644 index 0000000..c23004e --- /dev/null +++ b/src/tools/safe-grep/constants.ts @@ -0,0 +1,99 @@ +import { existsSync } from "node:fs" +import { join, dirname } from "node:path" +import { spawnSync } from "node:child_process" + +export type GrepBackend = "rg" | "grep" + +interface ResolvedCli { + path: string + backend: GrepBackend +} + +let cachedCli: ResolvedCli | null = null + +function findExecutable(name: string): string | null { + const isWindows = process.platform === "win32" + const cmd = isWindows ? "where" : "which" + + try { + const result = spawnSync(cmd, [name], { encoding: "utf-8", timeout: 5000 }) + if (result.status === 0 && result.stdout.trim()) { + return result.stdout.trim().split("\n")[0] + } + } catch { + // ignore + } + return null +} + +function getOpenCodeBundledRg(): string | null { + // OpenCode binary directory (where opencode executable lives) + const execPath = process.execPath + const execDir = dirname(execPath) + + const isWindows = process.platform === "win32" + const rgName = isWindows ? "rg.exe" : "rg" + + // Check common bundled locations + const candidates = [ + join(execDir, rgName), + join(execDir, "bin", rgName), + join(execDir, "..", "bin", rgName), + join(execDir, "..", "libexec", rgName), + ] + + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate + } + } + + return null +} + +export function resolveGrepCli(): ResolvedCli { + if (cachedCli) return cachedCli + + // Priority 1: OpenCode bundled rg + const bundledRg = getOpenCodeBundledRg() + if (bundledRg) { + cachedCli = { path: bundledRg, backend: "rg" } + return cachedCli + } + + // Priority 2: System rg + const systemRg = findExecutable("rg") + if (systemRg) { + cachedCli = { path: systemRg, backend: "rg" } + return cachedCli + } + + // Priority 3: grep (fallback) + const grep = findExecutable("grep") + if (grep) { + cachedCli = { path: grep, backend: "grep" } + return cachedCli + } + + // Last resort: assume rg is in PATH + cachedCli = { path: "rg", backend: "rg" } + return cachedCli +} + +export const DEFAULT_MAX_DEPTH = 20 +export const DEFAULT_MAX_FILESIZE = "10M" +export const DEFAULT_MAX_COUNT = 500 +export const DEFAULT_MAX_COLUMNS = 1000 +export const DEFAULT_CONTEXT = 2 +export const DEFAULT_TIMEOUT_MS = 60_000 +export const DEFAULT_MAX_OUTPUT_BYTES = 10 * 1024 * 1024 + +export const RG_SAFETY_FLAGS = [ + "--no-follow", + "--color=never", + "--no-heading", + "--line-number", + "--with-filename", +] as const + +export const GREP_SAFETY_FLAGS = ["-n", "-H", "--color=never"] as const diff --git a/src/tools/safe-grep/index.ts b/src/tools/safe-grep/index.ts new file mode 100644 index 0000000..72b0373 --- /dev/null +++ b/src/tools/safe-grep/index.ts @@ -0,0 +1,3 @@ +import { safe_grep } from "./tools" + +export { safe_grep } diff --git a/src/tools/safe-grep/tools.ts b/src/tools/safe-grep/tools.ts new file mode 100644 index 0000000..a0cf2da --- /dev/null +++ b/src/tools/safe-grep/tools.ts @@ -0,0 +1,40 @@ +import { tool } from "@opencode-ai/plugin/tool" +import { runRg } from "./cli" +import { formatGrepResult } from "./utils" + +export const safe_grep = tool({ + description: + "Fast content search tool with safety limits (60s timeout, 10MB output). " + + "Searches file contents using regular expressions. " + + "Supports full regex syntax (eg. \"log.*Error\", \"function\\s+\\w+\", etc.). " + + "Filter files by pattern with the include parameter (eg. \"*.js\", \"*.{ts,tsx}\"). " + + "Returns file paths with matches sorted by modification time.", + args: { + pattern: tool.schema.string().describe("The regex pattern to search for in file contents"), + include: tool.schema + .string() + .optional() + .describe("File pattern to include in the search (e.g. \"*.js\", \"*.{ts,tsx}\")"), + path: tool.schema + .string() + .optional() + .describe("The directory to search in. Defaults to the current working directory."), + }, + execute: async (args) => { + try { + const globs = args.include ? [args.include] : undefined + const paths = args.path ? [args.path] : undefined + + const result = await runRg({ + pattern: args.pattern, + paths, + globs, + context: 0, + }) + + return formatGrepResult(result) + } catch (e) { + return `Error: ${e instanceof Error ? e.message : String(e)}` + } + }, +}) diff --git a/src/tools/safe-grep/types.ts b/src/tools/safe-grep/types.ts new file mode 100644 index 0000000..c0ef2c7 --- /dev/null +++ b/src/tools/safe-grep/types.ts @@ -0,0 +1,39 @@ +export interface GrepMatch { + file: string + line: number + column?: number + text: string +} + +export interface GrepResult { + matches: GrepMatch[] + totalMatches: number + filesSearched: number + truncated: boolean + error?: string +} + +export interface GrepOptions { + pattern: string + paths?: string[] + globs?: string[] + excludeGlobs?: string[] + context?: number + maxDepth?: number + maxFilesize?: string + maxCount?: number + maxColumns?: number + caseSensitive?: boolean + wholeWord?: boolean + fixedStrings?: boolean + multiline?: boolean + hidden?: boolean + noIgnore?: boolean + fileType?: string[] + timeout?: number +} + +export interface CountResult { + file: string + count: number +} diff --git a/src/tools/safe-grep/utils.ts b/src/tools/safe-grep/utils.ts new file mode 100644 index 0000000..2cfc408 --- /dev/null +++ b/src/tools/safe-grep/utils.ts @@ -0,0 +1,53 @@ +import type { GrepResult, GrepMatch, CountResult } from "./types" + +export function formatGrepResult(result: GrepResult): string { + if (result.error) { + return `Error: ${result.error}` + } + + if (result.matches.length === 0) { + return "No matches found" + } + + const lines: string[] = [] + + lines.push(`Found ${result.totalMatches} match(es) in ${result.filesSearched} file(s)`) + if (result.truncated) { + lines.push("[Output truncated due to size limit]") + } + lines.push("") + + const byFile = new Map() + for (const match of result.matches) { + const existing = byFile.get(match.file) || [] + existing.push(match) + byFile.set(match.file, existing) + } + + for (const [file, matches] of byFile) { + lines.push(file) + for (const match of matches) { + lines.push(` ${match.line}: ${match.text.trim()}`) + } + lines.push("") + } + + return lines.join("\n") +} + +export function formatCountResult(results: CountResult[]): string { + if (results.length === 0) { + return "No matches found" + } + + const total = results.reduce((sum, r) => sum + r.count, 0) + const lines: string[] = [`Found ${total} match(es) in ${results.length} file(s):`, ""] + + const sorted = [...results].sort((a, b) => b.count - a.count) + + for (const { file, count } of sorted) { + lines.push(` ${count.toString().padStart(6)}: ${file}`) + } + + return lines.join("\n") +} diff --git a/test-rule.yml b/test-rule.yml new file mode 100644 index 0000000..bcb3083 --- /dev/null +++ b/test-rule.yml @@ -0,0 +1,6 @@ +id: test-rule +message: Test rule +severity: info +language: JavaScript +rule: + pattern: console diff --git a/test.js b/test.js new file mode 100644 index 0000000..7728117 --- /dev/null +++ b/test.js @@ -0,0 +1 @@ +console.log("hello")