diff --git a/packages/mex-mcp/package.json b/packages/mex-mcp/package.json new file mode 100644 index 0000000..165929b --- /dev/null +++ b/packages/mex-mcp/package.json @@ -0,0 +1,30 @@ +{ + "name": "mex-mcp", + "version": "0.1.0", + "description": "MCP server for mex-agent — exposes check/log/timeline/heartbeat/read-file as native MCP tools", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "bin": { + "mex-mcp": "./dist/index.js" + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "start": "node dist/index.js" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "mex-agent": "workspace:*", + "zod": "^3.23.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsup": "^8.4.0", + "typescript": "^5.7.0" + } +} diff --git a/packages/mex-mcp/src/index.ts b/packages/mex-mcp/src/index.ts new file mode 100644 index 0000000..18aca3e --- /dev/null +++ b/packages/mex-mcp/src/index.ts @@ -0,0 +1,21 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { registerCheckTool } from "./tools/check.js"; +import { registerLogTool } from "./tools/log.js"; +import { registerTimelineTool } from "./tools/timeline.js"; +import { registerHeartbeatTool } from "./tools/heartbeat.js"; +import { registerReadFileTool } from "./tools/read-file.js"; + +const server = new McpServer({ + name: "mex-mcp", + version: "0.1.0", +}); + +registerCheckTool(server); +registerLogTool(server); +registerTimelineTool(server); +registerHeartbeatTool(server); +registerReadFileTool(server); + +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/packages/mex-mcp/src/tools/check.ts b/packages/mex-mcp/src/tools/check.ts new file mode 100644 index 0000000..b8d1f43 --- /dev/null +++ b/packages/mex-mcp/src/tools/check.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { findConfig, runDriftCheck } from "mex-agent"; + +export function registerCheckTool(server: McpServer) { + server.tool( + "mex_check", + "Run a drift check on the mex scaffold. Returns a DriftReport with a numeric score, issues list, and file count.", + { + projectRoot: z + .string() + .optional() + .describe("Absolute path to the project root. Defaults to cwd."), + }, + async ({ projectRoot }) => { + const root = projectRoot ?? process.cwd(); + const config = await findConfig(root); + if (!config) { + return { + content: [ + { + type: "text", + text: JSON.stringify({ error: "No mex config found", projectRoot: root }), + }, + ], + }; + } + const report = await runDriftCheck(config); + return { content: [{ type: "text", text: JSON.stringify(report, null, 2) }] }; + } + ); +} diff --git a/packages/mex-mcp/src/tools/heartbeat.ts b/packages/mex-mcp/src/tools/heartbeat.ts new file mode 100644 index 0000000..aa8dcfe --- /dev/null +++ b/packages/mex-mcp/src/tools/heartbeat.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { findConfig, checkHeartbeat } from "mex-agent"; + +export function registerHeartbeatTool(server: McpServer) { + server.tool( + "mex_heartbeat", + "Check the mex scaffold heartbeat. Returns ok status, stale files with age in days, and memory cleanup status.", + { + projectRoot: z + .string() + .optional() + .describe("Absolute path to the project root. Defaults to cwd."), + }, + async ({ projectRoot }) => { + const root = projectRoot ?? process.cwd(); + const config = await findConfig(root); + if (!config) { + return { + content: [ + { + type: "text", + text: JSON.stringify({ error: "No mex config found", projectRoot: root }), + }, + ], + }; + } + const result = checkHeartbeat(config, new Date()); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + ); +} diff --git a/packages/mex-mcp/src/tools/log.ts b/packages/mex-mcp/src/tools/log.ts new file mode 100644 index 0000000..11fdb7b --- /dev/null +++ b/packages/mex-mcp/src/tools/log.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { findConfig, appendEvent, readEvents, EVENT_KINDS } from "mex-agent"; +import type { EventKind } from "mex-agent"; + +export function registerLogTool(server: McpServer) { + server.tool( + "mex_log", + `Append an agent event to the mex log, or read recent events. Valid kinds: ${EVENT_KINDS.join(", ")}.`, + { + projectRoot: z + .string() + .optional() + .describe("Absolute path to the project root. Defaults to cwd."), + action: z.enum(["read", "write"]).default("read"), + kind: z + .string() + .optional() + .describe(`Event kind for write (one of: ${EVENT_KINDS.join(", ")}).`), + summary: z.string().optional().describe("Human-readable event summary for write."), + limit: z.number().int().positive().optional().default(20), + }, + async ({ projectRoot, action, kind, summary, limit }) => { + const root = projectRoot ?? process.cwd(); + const config = await findConfig(root); + if (!config) { + return { + content: [ + { + type: "text", + text: JSON.stringify({ error: "No mex config found", projectRoot: root }), + }, + ], + }; + } + if (action === "write") { + if (!kind || !summary) { + return { + content: [ + { type: "text", text: JSON.stringify({ error: "kind and summary are required for write" }) }, + ], + }; + } + await appendEvent(config, { kind: kind as EventKind, summary }); + return { content: [{ type: "text", text: JSON.stringify({ ok: true, kind, summary }) }] }; + } + const events = await readEvents(config, { limit }); + return { content: [{ type: "text", text: JSON.stringify(events, null, 2) }] }; + } + ); +} diff --git a/packages/mex-mcp/src/tools/read-file.ts b/packages/mex-mcp/src/tools/read-file.ts new file mode 100644 index 0000000..1caeaaa --- /dev/null +++ b/packages/mex-mcp/src/tools/read-file.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { findConfig } from "mex-agent"; + +export function registerReadFileTool(server: McpServer) { + server.tool( + "mex_read_file", + "Read a file from the mex scaffold directory (.mex/). Path is relative to .mex/ (e.g. 'AGENTS.md', 'context/stack.md').", + { + projectRoot: z + .string() + .optional() + .describe("Absolute path to the project root. Defaults to cwd."), + file: z + .string() + .describe("Path to the scaffold file relative to .mex/ (e.g. 'AGENTS.md', 'context/stack.md')."), + }, + async ({ projectRoot, file }) => { + const root = projectRoot ?? process.cwd(); + const config = await findConfig(root); + if (!config) { + return { + content: [ + { + type: "text", + text: JSON.stringify({ error: "No mex config found", projectRoot: root }), + }, + ], + }; + } + const fullPath = join(config.scaffoldRoot, file); + if (!existsSync(fullPath)) { + return { + content: [ + { + type: "text", + text: JSON.stringify({ + error: `File not found: ${file}`, + scaffoldRoot: config.scaffoldRoot, + }), + }, + ], + }; + } + const content = readFileSync(fullPath, "utf-8"); + return { content: [{ type: "text", text: content }] }; + } + ); +} diff --git a/packages/mex-mcp/src/tools/timeline.ts b/packages/mex-mcp/src/tools/timeline.ts new file mode 100644 index 0000000..3aa9d1e --- /dev/null +++ b/packages/mex-mcp/src/tools/timeline.ts @@ -0,0 +1,49 @@ +import { z } from "zod"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { findConfig, readEvents } from "mex-agent"; + +export function registerTimelineTool(server: McpServer) { + server.tool( + "mex_timeline", + "Read the mex event timeline, optionally filtered by kind or date range. Good for understanding what an agent did and when.", + { + projectRoot: z + .string() + .optional() + .describe("Absolute path to the project root. Defaults to cwd."), + kind: z + .string() + .optional() + .describe("Filter by event kind (e.g. 'session_start', 'checkpoint', 'note')."), + since: z + .string() + .optional() + .describe("ISO 8601 timestamp — return only events at or after this time."), + limit: z.number().int().positive().optional().default(50), + }, + async ({ projectRoot, kind, since, limit }) => { + const root = projectRoot ?? process.cwd(); + const config = await findConfig(root); + if (!config) { + return { + content: [ + { + type: "text", + text: JSON.stringify({ error: "No mex config found", projectRoot: root }), + }, + ], + }; + } + // over-fetch to allow client-side filtering without multiple round-trips + let events = await readEvents(config, { limit: limit * 4 }); + if (kind) events = events.filter((e) => e.kind === kind); + if (since) { + const sinceMs = new Date(since).getTime(); + events = events.filter((e) => new Date(e.timestamp).getTime() >= sinceMs); + } + return { + content: [{ type: "text", text: JSON.stringify(events.slice(0, limit), null, 2) }], + }; + } + ); +} diff --git a/packages/mex-mcp/tsconfig.json b/packages/mex-mcp/tsconfig.json new file mode 100644 index 0000000..2a61196 --- /dev/null +++ b/packages/mex-mcp/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/mex-mcp/tsup.config.ts b/packages/mex-mcp/tsup.config.ts new file mode 100644 index 0000000..491ede0 --- /dev/null +++ b/packages/mex-mcp/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { index: "src/index.ts" }, + format: ["esm"], + target: "node20", + outDir: "dist", + clean: true, + splitting: false, + sourcemap: true, + dts: true, + banner: { js: "#!/usr/bin/env node" }, +});