-
Notifications
You must be signed in to change notification settings - Fork 66
feat: add packages/mex-mcp — MCP server for mex-agent #84
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
db7d3f4
e953947
7d2b0e8
c27a885
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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": "file:../..", | ||
| "zod": "^3.23.0" | ||
| }, | ||
| "devDependencies": { | ||
| "@types/node": "^22.0.0", | ||
| "tsup": "^8.4.0", | ||
| "typescript": "^5.7.0" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| 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(); | ||
| let config; | ||
| try { | ||
| config = findConfig(root); | ||
| } catch (e) { | ||
| return { | ||
| content: [ | ||
| { | ||
| type: "text", | ||
| text: JSON.stringify({ error: (e as Error).message, projectRoot: root }), | ||
| }, | ||
| ], | ||
| }; | ||
| } | ||
| const report = await runDriftCheck(config); | ||
| return { content: [{ type: "text", text: JSON.stringify(report, null, 2) }] }; | ||
| } | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| 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(); | ||
| let config; | ||
| try { | ||
| config = findConfig(root); | ||
| } catch (e) { | ||
| return { | ||
| content: [ | ||
| { | ||
| type: "text", | ||
| text: JSON.stringify({ error: (e as Error).message, projectRoot: root }), | ||
| }, | ||
| ], | ||
| }; | ||
| } | ||
| const result = checkHeartbeat(config, new Date()); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| } | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| import { z } from "zod"; | ||
| import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | ||
| import { findConfig, appendEvent, readEvents, EVENT_KINDS } 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 | ||
| .enum(EVENT_KINDS) | ||
| .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(); | ||
| let config; | ||
| try { | ||
| config = findConfig(root); | ||
| } catch (e) { | ||
| return { | ||
| content: [ | ||
| { | ||
| type: "text", | ||
| text: JSON.stringify({ error: (e as Error).message, projectRoot: root }), | ||
| }, | ||
| ], | ||
| }; | ||
| } | ||
| if (action === "write") { | ||
| if (!kind || !summary) { | ||
| return { | ||
| content: [ | ||
| { type: "text", text: JSON.stringify({ error: "kind and summary are required for write" }) }, | ||
| ], | ||
| }; | ||
| } | ||
| const entry = appendEvent(config, summary, { kind }); | ||
| return { content: [{ type: "text", text: JSON.stringify({ ok: true, kind: entry.kind, summary }) }] }; | ||
| } | ||
| const events = readEvents(config).slice(-limit); | ||
| return { content: [{ type: "text", text: JSON.stringify(events, null, 2) }] }; | ||
| } | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| import { z } from "zod"; | ||
| import { existsSync, readFileSync } from "node:fs"; | ||
| import { resolve, sep } 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(); | ||
| let config; | ||
| try { | ||
| config = findConfig(root); | ||
| } catch (e) { | ||
| return { | ||
| content: [ | ||
| { | ||
| type: "text", | ||
| text: JSON.stringify({ error: (e as Error).message, projectRoot: root }), | ||
| }, | ||
| ], | ||
| }; | ||
| } | ||
| const base = resolve(config.scaffoldRoot); | ||
| const fullPath = resolve(base, file); | ||
| if (fullPath !== base && !fullPath.startsWith(base + sep)) { | ||
| return { | ||
| content: [ | ||
| { type: "text", text: JSON.stringify({ error: "Path escapes scaffold root", 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 }] }; | ||
| } | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| 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(); | ||
| let config; | ||
| try { | ||
| config = findConfig(root); | ||
| } catch (e) { | ||
| return { | ||
| content: [ | ||
| { | ||
| type: "text", | ||
| text: JSON.stringify({ error: (e as Error).message, projectRoot: root }), | ||
| }, | ||
| ], | ||
| }; | ||
| } | ||
| let events = readEvents(config); | ||
| 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); | ||
| } | ||
| events = events.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); | ||
| return { | ||
| content: [{ type: "text", text: JSON.stringify(events.slice(0, limit), null, 2) }], | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Logic: this returns the oldest events, not the most recent. events.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
// ...then slice(0, limit) |
||
| }; | ||
| } | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| { | ||
| "extends": "../../tsconfig.json", | ||
| "compilerOptions": { | ||
| "rootDir": "src", | ||
| "outDir": "dist" | ||
| }, | ||
| "include": ["src"], | ||
| "exclude": ["node_modules", "dist"] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" }, | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Minor:
kindis a freez.string(), but on write it flows intoappendEvent->normalizeKind, which throws on any value outside EVENT_KINDS — and that throw is outside the try/catch above, somex_log({ action: "write", kind: "foo" })crashes the tool instead of returning a structured error. Since EVENT_KINDS is already imported, restrict the schema: