refactor(tools): rename all tools to snake_case for consistency

This commit is contained in:
YeonGyu-Kim
2025-12-04 22:54:13 +09:00
parent f78bdf6f67
commit ff3a7bfee0
12 changed files with 597 additions and 3 deletions

61
notepad.md Normal file
View File

@@ -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`
---

View File

@@ -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,
}

View File

@@ -8,6 +8,8 @@ interface ManagedClient {
client: LSPClient
lastUsedAt: number
refCount: number
initPromise?: Promise<void>
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<void> {
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)
})
}

View File

@@ -53,6 +53,17 @@ export async function withLspClient<T>(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)
}

229
src/tools/safe-grep/cli.ts Normal file
View File

@@ -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<GrepResult> {
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<never>((_, 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<GrepOptions, "context">): Promise<CountResult[]> {
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<never>((_, 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)}`)
}
}

View File

@@ -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

View File

@@ -0,0 +1,3 @@
import { safe_grep } from "./tools"
export { safe_grep }

View File

@@ -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)}`
}
},
})

View File

@@ -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
}

View File

@@ -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<string, GrepMatch[]>()
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")
}

6
test-rule.yml Normal file
View File

@@ -0,0 +1,6 @@
id: test-rule
message: Test rule
severity: info
language: JavaScript
rule:
pattern: console

1
test.js Normal file
View File

@@ -0,0 +1 @@
console.log("hello")