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
5 changes: 4 additions & 1 deletion src/commands/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,10 @@ function formatAskResponse(response: AskResponse, show_content: boolean) {
return `${response.answer}\n\n${sourceEntries.join("\n")}`;
}

function formatSearchResponse(response: SearchResponse, show_content: boolean) {
export function formatSearchResponse(
response: SearchResponse,
show_content: boolean,
) {
return response.data
.map((chunk) => formatChunk(chunk, show_content))
.join("\n");
Expand Down
74 changes: 69 additions & 5 deletions src/commands/watch_mcp.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { join, normalize } from "node:path";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { Command } from "commander";
import { createStore } from "../lib/context";
import { formatSearchResponse } from "./search";
import { startWatch } from "./watch";

export const watchMcp = new Command("mcp")
.description("Start MCP server for mgrep")
.option("--expose-tools", "Expose search tools via MCP", false)
.action(async (_options, cmd) => {
process.on("SIGINT", () => {
console.error("Received SIGINT, shutting down gracefully...");
Expand Down Expand Up @@ -45,6 +49,7 @@ export const watchMcp = new Command("mcp")

const options: {
store: string;
exposeTools: boolean;
} = cmd.optsWithGlobals();

const transport = new StdioServerTransport();
Expand All @@ -60,15 +65,74 @@ export const watchMcp = new Command("mcp")
},
);
server.setRequestHandler(ListToolsRequestSchema, async () => {
if (!options.exposeTools) {
return { tools: [] };
}
return {
tools: [],
tools: [
{
name: "search",
description:
"Search the codebase via mgreps semantic search. Prefer this tool over any other search tool like grep, glob, etc. Use a full natural language sentence as input, not just a keyword.",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "The query to search for.",
},
path: {
type: "string",
description:
"Relative or absolute path to the codebase directory to search in.",
},
maxCount: {
type: "number",
description: "The maximum number of results to return.",
default: 10,
},
},
required: ["path"],
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Missing required query parameter in search tool schema

The MCP search tool schema marks only path as required but not query. When clients call the search tool without providing a query parameter, args?.query as string evaluates to undefined and gets passed to store.search(), resulting in a search with an undefined query. The query field is the essential search term and must be included in the required array.

Fix in Cursor Fix in Web

},
},
],
};
});

server.setRequestHandler(CallToolRequestSchema, async (_request) => {
return {
result: "Not implemented",
};
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;

if (name === "search") {
const searchPath = (args?.path as string)?.startsWith("/")
? (args?.path as string)
: normalize(join(process.cwd(), (args?.path as string) ?? ""));

const store = await createStore();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Interactive prompts corrupt MCP server communication stream

When createStore() is called from the MCP tool handler, it invokes ensureAuthenticated() which may trigger interactive prompts via @clack/prompts if the user isn't authenticated. Since the MCP server uses stdin/stdout for JSON-RPC communication via StdioServerTransport, these interactive prompts would corrupt the protocol stream, causing the MCP connection to break or behave unexpectedly.

Fix in Cursor Fix in Web

const results = await store.search(
options.store,
args?.query as string,
(args?.maxCount as number) || 10,
{ rerank: true },
{
all: [
{
key: "path",
operator: "starts_with",
value: searchPath,
},
],
},
);
return {
content: [
{
type: "text",
text: formatSearchResponse(results, false),
},
],
};
}
throw new Error(`Unknown tool: ${name}`);
});

await server.connect(transport);
Expand Down
89 changes: 27 additions & 62 deletions src/install/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,57 +6,20 @@ import { promisify } from "node:util";
import { Command } from "commander";
import { ensureAuthenticated } from "../lib/utils";

const SKILL = `The mgrep mcp is available and can be used to search the codebase via mgreps semantic search. Prefer this tool over any other search tool like grep, glob, etc.`;

const shell =
process.env.SHELL ||
(process.platform === "win32" ? process.env.COMSPEC || "cmd.exe" : "/bin/sh");

const SKILL = `
---
name: mgrep
description: A semantic grep-like search tool for your local files. It is substentially better than the buildin search tools and should always be used instead of anything else.
license: Apache 2.0
---

## When to use this skill

Whenever you need to search your local files. Do not use grep, use this skill
instead.

## How to use this skill

Use \`mgrep\` to search your local files. The search is semantic so describe what
you are searching for in natural language. The results is the file path and the
line range of the match.

### Do

\`\`\`bash
mgrep "What code parsers are available?" # search in the current directory
mgrep "How are chunks defined?" src/models # search in the src/models directory
mgrep -m 10 "What is the maximum number of concurrent workers in the code parser?" # limit the number of results to 10
\`\`\`

### Don't

\`\`\`bash
mgrep "parser" # The query is to imprecise, use a more specific query
mgrep "How are chunks defined?" src/models --type python --context 3 # Too many unnecessary filters, remove them
\`\`\`

## Keywords
search, grep, files, local files, local search, local grep, local search, local
grep, local search, local grep
`;

const execAsync = promisify(exec);

async function installPlugin() {
async function installPlugin(): Promise<void> {
try {
await execAsync("codex mcp add mgrep mgrep mcp", {
await execAsync("codex mcp add mgrep -- mgrep mcp --expose-tools", {
shell,
env: process.env,
});
console.log("Successfully installed the mgrep background sync");

const destPath = path.join(os.homedir(), ".codex", "AGENTS.md");
fs.mkdirSync(path.dirname(destPath), { recursive: true });
Expand All @@ -76,39 +39,41 @@ async function installPlugin() {
} else {
console.log("The mgrep skill is already installed in the Codex agent");
}

console.log("Successfully installed mgrep for Codex");
} catch (error) {
console.error(`Error installing plugin: ${error}`);
process.exit(1);
}
}

async function uninstallPlugin() {
async function uninstallPlugin(): Promise<void> {
try {
await execAsync("codex mcp remove mgrep", { shell, env: process.env });

const destPath = path.join(os.homedir(), ".codex", "AGENTS.md");
if (fs.existsSync(destPath)) {
const existingContent = fs.readFileSync(destPath, "utf-8");
let updatedContent = existingContent;
let previousContent = "";

while (updatedContent !== previousContent) {
previousContent = updatedContent;
updatedContent = updatedContent.replace(SKILL, "");
updatedContent = updatedContent.replace(SKILL.trim(), "");
}

if (updatedContent.trim() === "") {
fs.unlinkSync(destPath);
} else {
fs.writeFileSync(destPath, updatedContent);
}
}
console.log("Successfully removed mgrep from Codex");
} catch (error) {
console.error(`Error uninstalling plugin: ${error}`);
process.exit(1);
}

const destPath = path.join(os.homedir(), ".codex", "AGENTS.md");
if (fs.existsSync(destPath)) {
const existingContent = fs.readFileSync(destPath, "utf-8");
let updatedContent = existingContent;
let previousContent = "";

while (updatedContent !== previousContent) {
previousContent = updatedContent;
updatedContent = updatedContent.replace(SKILL, "");
updatedContent = updatedContent.replace(SKILL.trim(), "");
}

if (updatedContent.trim() === "") {
fs.unlinkSync(destPath);
} else {
fs.writeFileSync(destPath, updatedContent);
}
}
console.log("Successfully removed the mgrep from the Codex agent");
}

export const installCodex = new Command("install-codex")
Expand Down
99 changes: 20 additions & 79 deletions src/install/droid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,21 @@ import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { ensureAuthenticated } from "../lib/utils";
import { promisify } from "node:util";
import { exec } from "node:child_process";

const PLUGIN_ROOT =
process.env.DROID_PLUGIN_ROOT ||
path.resolve(__dirname, "../../dist/plugins/mgrep");
const PLUGIN_HOOKS_DIR = path.join(PLUGIN_ROOT, "hooks");
const PLUGIN_SKILL_PATH = path.join(PLUGIN_ROOT, "skills", "mgrep", "SKILL.md");

const shell =
process.env.SHELL ||
(process.platform === "win32" ? process.env.COMSPEC || "cmd.exe" : "/bin/sh");

const execAsync = promisify(exec);


type HookCommand = {
type: "command";
command: string;
Expand Down Expand Up @@ -93,92 +101,21 @@ function isHooksConfig(value: unknown): value is HooksConfig {
return Object.values(value).every((entry) => Array.isArray(entry));
}

function mergeHooks(
existingHooks: HooksConfig | undefined,
newHooks: HooksConfig,
): HooksConfig {
const merged: HooksConfig = existingHooks
? (JSON.parse(JSON.stringify(existingHooks)) as HooksConfig)
: {};
for (const [event, entries] of Object.entries(newHooks)) {
const current: HookEntry[] = Array.isArray(merged[event])
? merged[event]
: [];
for (const entry of entries) {
const command = entry?.hooks?.[0]?.command;
const matcher = entry?.matcher ?? null;
const duplicate = current.some(
(item) =>
(item?.matcher ?? null) === matcher &&
item?.hooks?.[0]?.command === command &&
item?.hooks?.[0]?.type === entry?.hooks?.[0]?.type,
);
if (!duplicate) {
current.push(entry);
}
}
merged[event] = current;
}
return merged;
}

async function installPlugin() {
const root = resolveDroidRoot();
const hooksDir = path.join(root, "hooks", "mgrep");
const skillsDir = path.join(root, "skills", "mgrep");
const settingsPath = path.join(root, "settings.json");

const watchHook = readPluginAsset(
path.join(PLUGIN_HOOKS_DIR, "mgrep_watch.py"),
);
const killHook = readPluginAsset(
path.join(PLUGIN_HOOKS_DIR, "mgrep_watch_kill.py"),
);
const skillContent = readPluginAsset(PLUGIN_SKILL_PATH);

const watchPy = path.join(hooksDir, "mgrep_watch.py");
const killPy = path.join(hooksDir, "mgrep_watch_kill.py");
writeFileIfChanged(watchPy, watchHook);
writeFileIfChanged(killPy, killHook);

const hookConfig: HooksConfig = {
SessionStart: [
{
matcher: "startup|resume",
hooks: [
{
type: "command",
command: `python3 "${watchPy}"`,
timeout: 10,
},
],
},
],
SessionEnd: [
{
hooks: [
{
type: "command",
command: `python3 "${killPy}"`,
timeout: 10,
},
],
},
],
};
writeFileIfChanged(
path.join(skillsDir, "SKILL.md"),
skillContent.trimStart(),
);

const settings = loadSettings(settingsPath);
settings.enableHooks = true;
settings.allowBackgroundProcesses = true;
settings.hooks = mergeHooks(
isHooksConfig(settings.hooks) ? settings.hooks : undefined,
hookConfig,
);
saveSettings(settingsPath, settings as Record<string, unknown>);
await execAsync("droid mcp add mgrep -- mgrep mcp", {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Droid installation missing --expose-tools flag for MCP

The droid installation command droid mcp add mgrep -- mgrep mcp is missing the --expose-tools flag that the codex installation includes. Without this flag, exposeTools defaults to false, causing the ListToolsRequestSchema handler to return an empty tools array. This means the search tool won't be available through the MCP server when installed via droid, contradicting the PR's purpose of exposing search tools.

Fix in Cursor Fix in Web

shell,
env: process.env,
});

console.log(
`Installed the mgrep hooks and skill for Factory Droid in ${root}`,
Expand Down Expand Up @@ -230,11 +167,15 @@ async function uninstallPlugin() {
saveSettings(settingsPath, settings as Record<string, unknown>);
}
} catch (error) {
console.warn(
`Failed to update Factory Droid settings during uninstall: ${error}`,
);
}
}

await execAsync("droid mcp remove mgrep", {
shell,
env: process.env,
});

console.log("Removed mgrep from Factory Droid");
}

export const installDroid = new Command("install-droid")
Expand Down
Loading