Skip to content

feat: add packages/mex-mcp — MCP server for mex-agent#84

Merged
theDakshJaitly merged 4 commits into
mex-memory:mainfrom
rudi193-cmd:feat/mex-mcp
Jul 6, 2026
Merged

feat: add packages/mex-mcp — MCP server for mex-agent#84
theDakshJaitly merged 4 commits into
mex-memory:mainfrom
rudi193-cmd:feat/mex-mcp

Conversation

@rudi193-cmd

Copy link
Copy Markdown
Contributor

Follows up on #81 and your design guidance.

What this adds

packages/mex-mcp — a TypeScript MCP server over stdio that lets AI agents call mex as native tools.

Five tools (v1 scope):

Tool Calls Returns
mex_check runDriftCheck DriftReport (score, issues, filesChecked)
mex_log readEvents / appendEvent events array / { ok }
mex_timeline readEvents + client filter events filtered by kind / since
mex_heartbeat checkHeartbeat HeartbeatResult (ok, staleFiles, memoryCleanupDue)
mex_read_file fs read raw scaffold file content

mex_sync is deferred — leaving it for when the structured return shape is clear.

Design choices (per your comment)

  • All tools import directly from mex-agent public API — no subprocess wrapping
  • stdio transport only
  • All responses are structured JSON (or raw text for mex_read_file)
  • projectRoot param on every tool (defaults to cwd) so agents can target any project

What needs discussion / maintainer input

  1. Workspace setup — the root package.json doesn't have workspaces yet. Should I add "workspaces": ["packages/*"] here, or do you prefer a different approach?
  2. readEvents opts — I'm passing { limit } to readEvents based on the exported LogOpts type; if the signature is different let me know and I'll adjust.
  3. appendEvent shape — calling as appendEvent(config, { kind, summary }); please correct if the actual signature differs.
  4. npm / pnpm / bunpackage.json uses workspace:* (pnpm-style); adjust if you prefer npm workspaces syntax.

Happy to co-develop this with you — treat this as a starting scaffold to iterate on.

Adds packages/mex-mcp with five MCP tools over stdio transport:
- mex_check     — runs runDriftCheck, returns DriftReport
- mex_log       — read/write via readEvents/appendEvent
- mex_timeline  — readEvents with kind + since filters
- mex_heartbeat — checkHeartbeat, returns HeartbeatResult
- mex_read_file — reads scaffold files relative to .mex/

All tools call the public mex-agent API directly (Option B, per mex-memory#81).
mex_sync deferred until structured return shape is settled.

Closes mex-memory#81

@theDakshJaitly theDakshJaitly left a comment

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.

Thanks for this — the shape is right (direct mex-agent imports, stdio, structured JSON) and matches the issue's design. No concerns about intent: I read all nine files, there are no network calls, lifecycle scripts, or anything unexpected. But as-is the package does not compile against the current mex-agent API, and there is one security issue to fix before merge. Details inline. Most of these are the call sites you flagged as guesses in the description, so this is just confirming the real signatures.

Comment thread packages/mex-mcp/src/tools/read-file.ts Outdated
],
};
}
const fullPath = join(config.scaffoldRoot, file);

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.

Security: path traversal. file is caller-supplied and joined onto scaffoldRoot with no containment check, so file = "../../../../etc/passwd" escapes .mex/ and reads arbitrary host files. Since this is an MCP tool driven by an LLM (which may act on untrusted content) and projectRoot is also caller-controlled, this is effectively arbitrary file read. Please resolve and assert the target stays inside the scaffold, e.g.:

import { resolve, sep } from "node:path";
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 }) }] };
}

Comment thread packages/mex-mcp/src/tools/log.ts Outdated
],
};
}
await appendEvent(config, { kind: kind as EventKind, summary });

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.

This will not compile / behaves wrong. The real signature is appendEvent(config, message: string, opts?: LogOpts): EventEntry — synchronous, and the message is a positional string. Passing { kind, summary } as the message is a type error; at runtime message becomes the object and kind silently defaults to "note" (kind lives in opts). Correct form:

appendEvent(config, summary, { kind });

(no await needed — it returns EventEntry, not a Promise.)

Comment thread packages/mex-mcp/src/tools/log.ts Outdated
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 });

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.

This will not compile. The real signature is readEvents(config): EventEntry[] — one argument, synchronous. Passing { limit } is a TS2554 ("expected 1 argument, got 2") and there is no LogOpts-style limit on this function. Because the arg is ignored, mex_log read would also return the entire unbounded log. Read all events and slice client-side if you want a limit:

const events = readEvents(config).slice(-limit);

Comment thread packages/mex-mcp/src/tools/check.ts Outdated
},
async ({ projectRoot }) => {
const root = projectRoot ?? process.cwd();
const config = await findConfig(root);

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.

findConfig is synchronous (findConfig(startDir?): MexConfig) and it throws when no .mex/ scaffold exists — it never returns null. So the if (!config) graceful branch below is dead code, and the no-config case throws uncaught out of the tool handler instead of returning the intended { error: "No mex config found" }. This same pattern is copied into all five tools (log, timeline, heartbeat, read-file). Suggest wrapping the lookup in try/catch and returning the error message as JSON:

let config;
try {
  config = findConfig(root);
} catch (e) {
  return { content: [{ type: "text", text: JSON.stringify({ error: (e as Error).message, projectRoot: root }) }] };
}

Comment thread packages/mex-mcp/src/tools/timeline.ts Outdated
};
}
// over-fetch to allow client-side filtering without multiple round-trips
let events = await readEvents(config, { limit: limit * 4 });

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.

Same readEvents signature issue as in log.ts — it takes only config and is synchronous, so { limit: limit * 4 } is a compile error and is ignored at runtime.

events = events.filter((e) => new Date(e.timestamp).getTime() >= sinceMs);
}
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)

Comment thread packages/mex-mcp/package.json Outdated
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0",
"mex-agent": "workspace:*",

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.

workspace:* will not resolve: the root package.json (which is mex-agent itself) has no workspaces field, and workspace:* is pnpm/yarn syntax while this repo uses npm. As written, npm install in this package fails. This is the open question from your description (items 1 and 4) — a maintainer decision, not a bug. Simplest path that matches the repo: add "workspaces": ["packages/*"] to the root package.json and change this to "mex-agent": "*" (npm workspace protocol).

…pace protocol

- findConfig is sync and throws (never returns null) - wrap in try/catch
  across all five tools instead of dead if (!config) branches
- appendEvent/readEvents are sync with (config, message, opts) /
  (config) signatures, not the (config, opts) shape used here
- timeline: sort events descending by timestamp before slicing so
  "recent events" actually returns the most recent, not the oldest
- read-file: resolve+contain file path inside scaffoldRoot to close a
  path traversal / arbitrary file read via the file argument
- workspace:* is pnpm/yarn syntax; this repo uses npm - add
  workspaces: ["packages/*"] to root package.json and use "*" for
  the mex-agent dependency

Verified: npm install resolves, tsc --noEmit clean, tsup build succeeds.
@rudi193-cmd

Copy link
Copy Markdown
Contributor Author

Pushed e953947 addressing every point in the review:

  • path traversal (read-file.ts): resolve file against scaffoldRoot and reject anything that escapes it, per your suggested fix.
  • log.ts appendEvent/readEvents: switched to the real sync signature — appendEvent(config, summary, { kind }), readEvents(config).slice(-limit).
  • timeline.ts: dropped the bogus { limit: limit * 4 } arg, now sorts descending by timestamp before slicing so it actually returns the most recent events (mirrors runTimeline's ordering).
  • findConfig dead-code branch: it's sync and throws rather than returning null, so I wrapped the lookup in try/catch and return the thrown message as the error payload — applied across all five tools (check, log, timeline, heartbeat, read-file), not just the one you flagged.
  • workspace:* protocol: added "workspaces": ["packages/*"] to the root package.json and changed the mex-mcp dependency to "mex-agent": "*" (npm workspace protocol, since this repo isn't pnpm/yarn).

Verified locally: npm install resolves the workspace dependency cleanly, tsc --noEmit is clean in packages/mex-mcp, and tsup builds both packages. Ready for another look.

@theDakshJaitly theDakshJaitly left a comment

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.

Thanks for the fast turnaround — this is a big improvement. I cloned the head commit and ran the real toolchain to verify, rather than eyeballing. Confirmed fixed and build-verified:

  • Path traversal in mex_read_file — the resolve + startsWith(base + sep) guard correctly rejects ../ and absolute paths.
  • appendEvent / readEvents / findConfig call sites now match the real mex-agent API. npm run build (tsup + DTS typecheck) passes clean; I sanity-checked it by reverting log.ts to the old calls and tsc immediately flagged TS2345/TS2554, so the green build is meaningful.
  • Timeline now sorts newest-first before slicing.

Two things remain — details inline. The workspace one is the important one: it installs green but does not actually do what the commit message says.

Comment thread packages/mex-mcp/package.json Outdated
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0",
"mex-agent": "*",

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.

This installs green but does not actually link the local package — so the "npm workspace protocol" fix does not do what it claims. mex-agent is the workspace root, not a packages/* member, so npm cannot satisfy this sibling dependency from the workspace and silently pulls the published mex-agent from the registry instead. npm ls mex-agent after install:

mex-agent@0.6.1  /path/to/repo          <- local workspace root
└─┬ mex-mcp@0.1.0 -> ./packages/mex-mcp
    └── mex-agent@0.6.2                  <- separate copy fetched from npm registry

So mex-mcp builds and runs against the published 0.6.2, not the local 0.6.1 source. It only compiles today because the published API still happens to match; the moment the local mex-agent API changes, mex-mcp keeps building against the stale registry copy until someone republishes — which is exactly the CLI/MCP drift #81 set out to eliminate.

The fix is structural: either move the core library into packages/mex-agent/ so it is a real workspace member that npm will symlink, or reference it explicitly with "mex-agent": "file:../..". Not a runtime blocker right now, but it should not merge advertised as a working workspace link when it is resolving from the registry.

.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

- mex-agent is the workspace root, not a packages/* member, so
  "mex-agent": "*" silently resolved from the npm registry instead of
  the local source. Point at it directly with file:../.. so npm
  symlinks the real local package (verified: node_modules/mex-agent
  is now a link).
- kind was an unvalidated z.string() that flows into
  appendEvent -> normalizeKind, which throws outside the try/catch on
  any value outside EVENT_KINDS, crashing the tool instead of
  returning a structured error. Restrict to z.enum(EVENT_KINDS).
@rudi193-cmd

Copy link
Copy Markdown
Contributor Author

Pushed 7d2b0e8 addressing both remaining points:

  • workspace link: mex-agent is the workspace root, not a packages/* member, so workspace:*/* couldn't be symlinked and silently pulled the published copy from npm. Went with your file:../.. option rather than restructuring the repo to move the core library into packages/mex-agent/ — smaller, in-scope fix. Verified node_modules/mex-agent is now a real link (not resolved from the registry): packages/mex-mcp/node_modules/mex-agent -> ../../...
  • log.ts kind validation: restricted the schema to z.enum(EVENT_KINDS) so invalid kinds are rejected by zod before they ever reach appendEvent -> normalizeKind, instead of throwing outside the try/catch.

npm install + tsup build (ESM + DTS) both clean on the head commit. Ready for another look.

@rudi193-cmd rudi193-cmd marked this pull request as ready for review July 6, 2026 01:08

@theDakshJaitly theDakshJaitly left a comment

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.

Approving. Verified the latest commit by cloning the head and running the real toolchain end to end, not just reading the diff.

All prior findings resolved and build-verified:

  • Security — mex_read_file path traversal: resolve(base, file) + startsWith(base + sep) guard correctly rejects ../ and absolute paths.
  • API correctness — appendEvent / readEvents / findConfig call sites match the real mex-agent API; timeline sorts newest-first; findConfig wrapped in try/catch across all five tools. npm run build (tsup + DTS typecheck) passes clean, and I confirmed the typecheck is meaningful by reverting to the old calls and watching tsc flag TS2345/TS2554.
  • Workspace link — file:../.. now produces a real local symlink (packages/mex-mcp/node_modules/mex-agent -> ../../..); npm ls shows mex-agent@0.6.2 -> ./ with no separate registry copy, and both typecheck and a runtime import('mex-agent') resolve against the local source. The CLI/MCP drift risk is closed.
  • log kind is now constrained with z.enum(EVENT_KINDS).

Merge from main is clean and none of the earlier fixes regressed. Nice work iterating on this.

Follow-up (not blocking): mex_sync is still deferred, and a README section documenting the server + client config would be good to add before or shortly after merge.

@theDakshJaitly theDakshJaitly merged commit 00e5735 into mex-memory:main Jul 6, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants