Project MCP
- Category: Engine
point0-project-mcp is an MCP server shipped
with @point0/engine. Point it at your project and an agent (Claude Code,
Cursor, …) gets four tools for navigating the codebase: list every
point, find one by URL, see what a file compiles to on the server vs.
the client, and trace why one file imports another. It reads a generated
meta.ts, so it answers questions about your points, not generic ones.
// .mcp.json (Claude Code) — or .cursor/mcp.json for Cursor (byte-identical)
{
"mcpServers": {
"point0-project": { "command": "bun", "args": ["run", "mcp:project"] },
"point0-docs": { "command": "bun", "args": ["run", "mcp:docs"] },
},
}// package.json — the script the config above runs
"mcp:project": "point0-project-mcp --meta ./src/generated/point0/meta.ts",create-point0-app writes both files for you, so a fresh app has the project
MCP (and the docs MCP) wired out of the box.
Wiring it in
The config calls bun run mcp:project, not the bin directly. That indirection
matters: the script runs with the working directory set to the project root, so
a relative --meta path resolves correctly and the workspace
point0-project-mcp bin is found.
// .mcp.json — Claude Code reads this from the project root
{
"mcpServers": {
"point0-project": { "command": "bun", "args": ["run", "mcp:project"] },
},
}The same JSON goes in .cursor/mcp.json for Cursor — the two files are
identical; only the location differs.
create-point0-app writes only these two files — the Claude Code and Cursor
configs. Other MCP clients (VS Code, Windsurf, Zed, …) aren't scaffolded, but
the server is plain stdio: give any stdio-MCP client the same command/args
(bun run mcp:project) in its own config location.
NAME GOTCHA. Three names look alike, don't mix them up: the bin is
point0-project-mcp; the server's advertised name ispoint0-project(the key undermcpServers); the docs server is a separate bin,point0-docs-mcp. An olderpoint0-mcpbin name shows up in some configs in the wild — it does not exist. Usepoint0-project-mcp.
The --meta flag
The server takes one or more --meta <path> flags and nothing else:
point0-project-mcp --meta ./src/generated/point0/meta.ts# multiple metas — one MCP across several apps in a monorepo
point0-project-mcp --meta ./apps/web/src/generated/point0/meta.ts \
--meta ./apps/admin/src/generated/point0/meta.tsRules, enforced at parse time:
- At least one
--metais required — none throwsAt least one '--meta <path>' flag is required. - Each
--metamust be followed by a path — a missing value, or a value that starts with--, throwsEach '--meta' flag must be followed by a path. - A relative path resolves against the working directory, which is why you
launch via
bun run mcp:project(cwd = project root).
There are no other flags — no --help, no --version. Anything that isn't a
--meta pair is ignored.
CONNECT GOTCHA.
--metais validated lazily: the server connects fine even with no--meta, and the "required" error only surfaces on the first tool call. A clean connection does not mean the flags are right.
The meta.ts it reads
The MCP does not analyze your source live. It loads a generated meta.ts — a
snapshot of every point: id, type, name, tags, route, endpoint, source position,
validity, SSR flag, parents, layouts. You produce it by setting generate.meta
in the engine config:
// src/engine.ts
export const engine = Engine.create({
file: import.meta.url,
generate: {
meta: './generated/point0/meta.ts', // the file the MCP reads
assetsTypes: './generated/point0/assets.d.ts',
},
// …
})Run point0 generate (your app's generate script) once and the
meta appears. It lives under src/generated/, which is gitignored — so a fresh
checkout must run generate before the MCP can answer.
You do not restart the MCP after regenerating. The server is long-lived; it
re-checks each meta's modification time on every tool call and re-imports it
when it changed. After point0 generate rewrites the meta, the next tool call
sees the new points.
Four tools
list_points — list with filters
Lists points, with filtering, pagination, and optional field selection.
// arguments
{ "type": "page", "ssr": true, "fields": ["id", "name", "route"], "limit": 50 }// structuredContent
{
"points": [
/* … */
],
"total": 12,
"hasMore": false,
"nextOffset": null,
}limit defaults to 100, offset to 0. hasMore is true when
total > offset + limit, and nextOffset is the offset to pass next (otherwise
absent). The result also comes back as pretty-printed JSON text, so an agent
that ignores structuredContent still reads it.
get_point — first match
Returns the first point matching the filters — same filter shape as
list_points, minus limit/offset. This is the tool an agent reaches for
when you hand it a concrete coordinate and ask "what point is this?": here's my
URL, find me the page, or find me the layout named dashboard.
Ask for one field and you get just that — the cheapest way to resolve a URL to a point id:
// arguments — "here's my URL, find me the page serving it"
{ "url": "/mcp", "fields": ["id"] }// structuredContent
{ "id": "root:page:mcpPage" }Drop fields and the full point comes back — the same snapshot list_points
returns, so an agent can read everything it knows about that point in one call:
// arguments — "find me the layout named dashboard, with all its details"
{ "type": "layout", "name": "dashboard" }// structuredContent — the full point record
{
"id": "root:layout:dashboard",
"scope": "root",
"type": "layout",
"name": "dashboard",
"tags": ["root", "layout"],
"description": "App shell with the sidebar nav.",
"route": "/dashboard",
"endpoint": null,
"pos": { "file": "/abs/src/layouts/dashboard.tsx", "line": 4, "column": 0 },
"valid": true,
"errors": [],
"ssr": true,
"parents": [
{ "id": "root:root:root", "scope": "root", "type": "root", "name": "root" },
],
"layouts": [],
}That record is what makes the MCP useful: from a single URL or name the agent
learns the point's id, its source pos (file + line, so it can jump straight
to the definition), whether it server-renders (ssr), its endpoint (HTTP
method + route) if it has one, and the parents/layouts it sits inside. A
query or mutation comes back with its endpoint
({ "method": "POST", "route": "/_point0/root/mutation/sign-in" }) instead of a
page route.
No match is not an error: the text comes back as "null" and
structuredContent is absent.
compile — see the compiled output
Shows what a file compiles to, using the compiler options from your engine.
Useful for seeing which server-only or client-only code is stripped — the same
transform as point0 compile.
// arguments
{ "file": "src/mcp.points.tsx", "side": "server", "scope": "root" }// structuredContent.code — server side: the `.lets` page/action stay as runtime calls
import { root } from './lib/root.js';
export const mcpPage = root.lets('page', 'mcpPage', '/mcp').tag('root', 'page').page(() => <div>MCP page</div>);
export const mcpActionAction = root.lets("action", "mcpAction", 'GET', '/api/mcp').tag('root', 'action').action(() => ({
ok: true
}));side ('server' / 'client') and scope are optional — Point0 infers them
when it can. hmr: true turns on the HMR fix in the output; it defaults to
off here (unlike the CLI, which inherits the engine's hmrFix when you pass
no flag). A relative file resolves against the working directory. A compile
error is surfaced as a thrown MCP error.
trace — follow an import chain
Traces how one file reaches an import target, through the compiler — useful for understanding why a file pulls in another (and catching a server util that leaked to the client).
// arguments
{
"target": "./mcp-target.js",
"source": "src/mcp-source.ts",
"side": "server",
"scope": "root",
}// structuredContent — each line is "file:line:column"
{ "trace": ["/abs/src/mcp-source.ts:3:0", "/abs/src/mcp-mid.ts:1:0", "…"] }source resolves against the working directory if relative. cwd is optional —
it defaults to the engine file's directory, not the process cwd. If no chain
is found, the tool throws Trace was not found.
Reference
--meta
| Flag | Meaning |
|---|---|
--meta <path> | A generated meta.ts to load. Repeatable. At least one required. Relative paths resolve against the working directory. |
No other flags exist (--help / --version are not handled). Validation is
lazy: errors surface on the first tool call, not at connect.
Tools
| Tool | Does | Key inputs |
|---|---|---|
list_points | List points with filtering + pagination | filter fields, fields, limit (100), offset (0) |
get_point | First point matching the filter | filter fields, fields |
compile | Compiled output of a file | file*, side, scope, hmr, engineFile |
trace | Import chain from a source to a target | target, source, side, scope, cwd, engineFile |
* = required.
Filters (shared by list_points + get_point)
| Filter | Type | Matches |
|---|---|---|
id | string | exact point id |
ids | string[] | id is in the list |
tags | string | string[] | string = any tag equals it; array = all required |
scope | string | exact scope |
type | string | exact type — page / layout / query / mutation / action / … |
name | string | exact name |
route | string | exact route definition |
url | string | full URL or path, matched against point.route via route0 |
endpointMethod | string | exact endpoint HTTP method |
endpointRoute | string | exact endpoint route definition |
endpointUrl | string | full URL or path, matched against the endpoint route |
valid | boolean | point validity |
ssr | boolean | SSR flag |
file | string | exact source file path (point.pos.file) |
parentId | string | one of the point's parents has this id |
layoutId | string | one of the point's layouts has this id |
fields selects which point fields come back. Selectable: id, scope,
type, name, tags, description, route, endpoint, pos, valid,
errors, ssr, parents, layouts. The import thunk is never selectable
and never appears in output.
compile / trace engine resolution
Both tools import the real engine from the meta, then pick a side and scope.
engineFile— required only when several metas each carry an engine; with one engine it's inferred. An unknown file, or zero engines, throws.side/scope— inferred when unambiguous. A scope available on both server and client without an explicitsidethrows, as does a missing scope.- Compiler disabled for the chosen side/scope throws
Compiler is disabled for <side> scope "<scope>". - Both set
NODE_ENVtodevelopmentwhen it's unset, and throw on a value outsideproduction/development/test. (list_points/get_pointnever touchNODE_ENV.)
Compiling against a built project
compile and trace apply the same transform as a dev build by default. To run
the built transform instead — the same as point0 compile --built — pass
built: true on the call:
{ "file": "src/pages/home.tsx", "built": true } // compile / trace inputIt mirrors the CLI flag, per call. (POINT0_BUILT=true in the server's env is
the fallback default when the input is omitted.)
See also
Enjoying Point0?
Star Point0
Start0
YouTube
Discord
Telegram
of the Lord Jesus Christ ☦️
With love for developers
of all backgrounds around the world ❤️