Skip to content
Merged
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
1,089 changes: 1,088 additions & 1 deletion package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"workspaces": [
"packages/*"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
Expand Down
30 changes: 30 additions & 0 deletions packages/mex-mcp/package.json
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"
}
}
21 changes: 21 additions & 0 deletions packages/mex-mcp/src/index.ts
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);
34 changes: 34 additions & 0 deletions packages/mex-mcp/src/tools/check.ts
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) }] };
}
);
}
34 changes: 34 additions & 0 deletions packages/mex-mcp/src/tools/heartbeat.ts
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) }] };
}
);
}
52 changes: 52 additions & 0 deletions packages/mex-mcp/src/tools/log.ts
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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Minor: kind is a free z.string(), but on write it flows into appendEvent -> normalizeKind, which throws on any value outside EVENT_KINDS — and that throw is outside the try/catch above, so mex_log({ action: "write", kind: "foo" }) crashes the tool instead of returning a structured error. Since EVENT_KINDS is already imported, restrict the schema:

kind: z.enum(EVENT_KINDS).optional().describe(...)

.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) }] };
}
);
}
61 changes: 61 additions & 0 deletions packages/mex-mcp/src/tools/read-file.ts
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 }] };
}
);
}
51 changes: 51 additions & 0 deletions packages/mex-mcp/src/tools/timeline.ts
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) }],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Logic: this returns the oldest events, not the most recent. readEvents returns entries in append order (oldest first), and there is no sort before slice(0, limit), so you get the oldest N — which contradicts the tool's "recent events" description. The CLI's runTimeline sorts descending by timestamp before slicing; mirror that:

events.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
// ...then slice(0, limit)

};
}
);
}
9 changes: 9 additions & 0 deletions packages/mex-mcp/tsconfig.json
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"]
}
13 changes: 13 additions & 0 deletions packages/mex-mcp/tsup.config.ts
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" },
});
Loading