MCP server
Every project speaks MCP, so an agent working on the project's code can ask the project's own database questions — and read the project's own explanation of it.
This is an engineering document. Specs are written for people building DataGripe: they record what a subsystem does and why the alternatives were rejected, and some of them describe things that are not built yet. If you are using DataGripe, the documentation is the place to be — it is maintained for you and this is not.
Supersedes: docs/rfc/mcp.md (promoted)
Goal
Every project speaks MCP, so an agent working on the project's code can ask the project's own database questions — and read the project's own explanation of it.
The explanation is the point. Schemas, tables and column names are
already discoverable by introspection, and they are the least useful
half of the knowledge. Which datasource is which, how two of them
relate, which table is authoritative and which is a cache, what
status = 4 means, what must never be run against the replica — that
lives in the workspace files and in the files under a datasource's path
pairs, and nowhere in any catalogue. MCP is how it reaches the model
instead of being re-guessed from field names every session.
Off by default, one endpoint per project, and read-only until somebody says otherwise.
Non-goals
- OAuth and the MCP authorization spec. A bearer token, minted in the app, revoked in the app.
- Streaming. Tool results are single JSON responses: no SSE, no
resumable streams, no progress notifications. A query that outruns
QUERY_TIMEOUT_MSfails the way it does in the editor. - Structure editing, row editing and document writing over MCP. Write mode permits query commits and explicitly enabled domain, sync and Git writes.
- Agent-facing gripes.
check_sqlwaits for the Phase 11 server-side runner. - Prompts, sampling, roots, elicitation.
- An MCP client. DataGripe does not call other MCP servers, and none of this is AI query generation in the editor — still a non-goal.
- Cross-project tools. One project per endpoint, so no tool takes a project argument.
Design
One endpoint per project
POST /mcp/<projectId>, where projectId is the workspace id. Two
projects means two entries in the client's config, each with its own
token. A single endpoint with a project argument was the alternative,
and it fails the way every "which one did you mean" argument fails: the
agent picks, and eventually it picks the production one. The scope is in
the URL, the settings and the token are per-project rows, and there is
nothing to guess.
GET and DELETE on the route are 405 with Allow: POST. None of
this rides the WebSocket protocol: an MCP client is not a browser
session.
Stateless JSON-RPC 2.0 over HTTP (apps/server/src/mcp/route.ts).
Methods answered:
| Method | Notes |
|---|---|
initialize |
echoes the client's protocolVersion when it sends one, else MCP_PROTOCOL_VERSION |
notifications/initialized |
accepted, 202, no body |
ping |
{} |
tools/list, tools/call |
the tool set below |
resources/list, resources/read |
the same reads, for clients where a human picks context |
Anything else is -32601. A JSON array body is refused: batching was
removed from the protocol, and one request per POST is the whole
transport. No Mcp-Session-Id is issued or required.
initialize returns serverInfo { name: "datagripe/<project>", version },
capabilities { tools: {}, resources: {} }, and instructions — the
project's briefing (below), which is the highest-leverage string in the
feature because every client hands it to the model before the first tool
call.
Authentication
Bearer token only. Cookies are ignored on this route: accepting the
session cookie would make a browser a usable MCP client and a CSRF
vector in the same stroke. An Origin header is fine when absent and
403 when present and not WEB_ORIGIN — the MCP guidance on local
servers and DNS rebinding, applied without breaking non-browser clients.
mcp_tokens (migration 0019): id, workspace_id, user_id, name,
token_hash, created_at, last_used_at, revoked_at. The value is
dgm_ plus 32 random bytes, base64url, SHA-256 at rest, shown once at
creation and never again — the same shape as sessions. There is no
expiry column: a token that dies mid-task is worse than one that lives
until somebody revokes it, so revocation is the control and
last_used_at is how you know whether to use it.
A token whose workspace_id does not match the URL gets 404, not
403: a project id you cannot prove is a project we do not discuss.
The effective role is resolved on every call and never stored:
- the minting user's current membership role — a token delegates one person's access, so demoting or removing them takes their agent's access with it, and that is the property that makes a token with no expiry acceptable;
- capped at
editor, neverowner. Nothing reachable over MCP mints a token, changes the mode, adds a member, approves a repository command, exports domains or adds a datasource — and a context that cannot holdownercannot grow a tool that does by accident.
Direct-in mode (AUTH_DISABLED) has no workspace_members rows at all,
so the stub workspace's owner counts as its owner. Everywhere else, a
missing membership row means the person was removed, and the token stops
working with a message that says so.
run_query is the only tool with a role rule of its own. In read-only
mode any member's token may run one: the statement is classified and
sandboxed, so it can do no more than the table view a viewer already
has. Committing is the app's execution.start, which is an editor
action — so read/write mode refuses a viewer's token, and says which of
the two things to change.
Settings
mcp_settings: workspace_id primary key, enabled default false,
mode default 'read-only', domains_enabled, sync_enabled and
git_enabled default false, updated_by, updated_at. Migration 0027
adds the three functionality switches without granting existing tokens
new write access. No row means
off, which is why nothing bootstraps one.
Five actions, all owner: mcp.status (three fields off the settings
row, for the section header — see "The panel"), mcp.settings (the
panel's whole state, including the token list), mcp.settings.set,
mcp.token.create, mcp.token.revoke. This is an owner-level decision
like repository trust, and the panel is absent for everybody else rather
than disabled with a tooltip.
Turning MCP off revokes nothing and deletes no token; a valid token for
a disabled project gets 403 naming the toggle. Flipping read/write
back to read-only applies from the next call — in-flight commits are not
unwound, and the panel does not pretend otherwise.
The deployment switch
MCP_ENABLED, default true. Not a mode-dependent default like
AUTH_DISABLED: the per-project toggle is the opt-in, and this is the
deployment's kill switch. Off means the route is absent (404 through
the normal not-found path) and so is the sidebar section.
Read-only
Three layers, in this order.
Classification (
apps/server/src/mcp/readonly.ts).sql-toolssplits the text and every statement must be a read:select,with,values,table,explain,show— and must contain no denied word at any nesting depth. It runs on tokens rather than text, so-- delete everythingis a comment,where note = 'drop table t'is a string, andselect "update" from tis a column.Depth matters:
WITH x AS (INSERT … RETURNING *) SELECT * FROM xstarts with an allowed word and writes, one paren deep.intois denied forSELECT … INTO new_table, andupdatedoubles as the check onSELECT … FOR UPDATE.The deny list is deliberately tight.
replaceis a string function andcommentis a plausible column name; a list long enough to catch them would refuse ordinary reads for nothing, and the leading-word check already coversCOMMENT ON. The whole table is asserted inreadonly.test.ts, both columns, because a change that quietly moves a statement from one to the other is the failure that matters.One read-only transaction per call, always rolled back.
ExecuteLimits.sandboxmakesbeginExecutionopen it andclose()roll it back unconditionally: PostgresBEGIN+SET TRANSACTION READ ONLY, MySQLSTART TRANSACTION READ ONLY, SQLitePRAGMA query_only = 1.SQLite is a pragma rather than a transaction because its client is shared per file: a
BEGINthere would be a transaction across everybody using that database, and a second overlapping session would fail on it.close()puts the pragma back.Postgres's cursor path already bracketed each statement in
BEGIN … ROLLBACK, and inside a sandbox that would have ended the session's own transaction — leaving every later statement in autocommit, which is the one thing the sandbox exists to prevent. Inside a sandbox it usesSAVEPOINT/RELEASEinstead, and rewinds to the savepoint whenDECLAREfails so the fallback to direct execution can still report the real error rather than "current transaction is aborted".The datasource's own constraints. A
readOnlyconnection still getsdefault_transaction_read_only = on, and the target role's grants still apply.
What ROLLBACK does not undo, stated here so nobody sells layer 2 as a
sandbox: sequence and identity advancement, setval, non-transactional
DDL on MySQL, anything a function reaches through dblink, postgres_fdw
or COPY TO PROGRAM, and every side effect outside the database. Layer 1
is what stops writes. Layer 2 catches a classifier miss — proven, not
assumed, in packages/database-adapters/src/postgres/sandbox.test.ts,
which runs writes straight at the adapter with the classifier out of the
way and checks that nothing survived — and layer 3 catches both.
statement_timeout is set as it is for the editor, and the transaction
lives exactly as long as the one tool call: a held snapshot blocks
vacuum, and an agent that wandered off must not be able to pin one open.
Read/write mode has no wrapper and no classifier: the normal execution path, with a history row, workspace-wide execution events and an audit line. The only difference from a person pressing Ctrl+Enter is who asked.
The mode is a ceiling, never a grant
Read/write does not override a datasource's own read only setting, and
it cannot: layer 3 is the datasource's, not ours. A project whose
datasources are read-only stays read-only over MCP however the panel is
set — the mode decides whether DataGripe adds a restriction, and the
datasource decides what the connection can do in the first place.
So there is no per-datasource exposure switch, and no plan for one. Which datasources a project contains, and which of them are read-only, is already a project decision made on the datasource's own page; a second place to answer it would mean two answers and eventually a disagreement between them. Every datasource in the project is reachable, every file already browsable in it is readable, and a datasource that nobody should reach does not belong in the project.
The panel says this rather than leaving it to be discovered: with read/write selected, the mode row names the datasources that are still read-only by their own setting.
Tools
Flat snake_case names, because the clients that prefix them with a
server name should still produce something readable. tools/list is
generated from the zod schemas (z.toJSONSchema), so the description an
agent reads and the validation it hits cannot drift apart.
| Tool | Arguments | Returns |
|---|---|---|
describe_project |
— | the orientation call: project name, mode, datasources (ref, name, engine, read-only, capabilities, description from .datagripe/config.yaml), each one's domain map, and the file index |
describe_domain |
{ name, datasource? } |
one domain's description and object list, for projects too big to inline |
list_docs |
{ datasource?, query? } |
the exposed files, filtered |
read_doc |
{ uri, offset? } |
text, capped per call, with truncated and nextOffset |
search_docs |
{ text, max? } |
uri, label, line number, line |
list_schemas |
{ datasource? } |
over the existing schema.children introspection and its cache |
list_objects |
{ datasource?, schema, kind? } |
tables, views, functions, procedures or sequences |
describe_object |
{ datasource?, schema, name, kind? } |
the object.describe payload: columns, keys, indexes, constraints, triggers, grants, DDL |
list_domains |
{ connectionRef } |
all domains, including hidden ones, and object assignments |
upsert_domain |
{ connectionRef, id?, name, colour, description?, includeData?, hidden?, sortOrder?, idempotencyKey } |
created or updated domain; omitted settings use defaults, so read before updating |
delete_domain |
{ connectionRef, id, idempotencyKey } |
removes the domain and tags, never database objects |
tag_objects |
{ connectionRef, targets, domainId, idempotencyKey } |
assigns objects; null domain removes assignments |
sync_datasource |
{ connectionRef, dryRun?, idempotencyKey } |
export preview by default; false writes the configured snapshot to files |
git_status |
{ connectionRef } |
fetches, then the repository status; a failed fetch comes back as fetchError beside the local status |
git_commit |
{ connectionRef, message, paths?, idempotencyKey } |
stages named paths, then commits the whole index, including previously staged files |
git_pull |
{ connectionRef, idempotencyKey } |
fetches, then fast-forwards or rebases unpushed commits onto the upstream; refuses on a conflict with nothing changed |
git_push |
{ connectionRef, setUpstream?, idempotencyKey } |
does what git_pull does, then pushes the current branch without force |
run_query |
{ datasource?, sql, maxRows? } |
{ columns, rows, rowCount, resultSets, statements, truncated, elapsedMs, committed } |
Domain management, sync and Git each require their own project opt-in.
Mutations additionally require read/write mode and an editor token.
Reads and sync previews work in read-only mode once enabled, subject to
the account capabilities: domain.manage for domain changes, sync.run
for sync, git.commit for Git status, commit and pull, and git.push
for push. Custom role restrictions are re-read on every authenticated call.
connectionRef comes from describe_project; Git requires a repository
datasource in this project and deployment Git enabled. Sync uses the
existing configured export target and host filesystem policy; it does
not touch Git. Commit and push are separate calls. Inspect
git_status before committing: the entire staged index is committed.
Git over MCP integrates; the sidebar does not. The sidebar's pull is
--ff-only and stops at anything more (docs/spec/git-datasources.md
"The repository section"), because the person pressing it has a
terminal. An agent does not, and a datasource repository is routinely
shared with people pushing migrations/ from their own checkouts: every
one of those puts the server's clone behind, and a clone with one
unpushed export commit is then wedged — its push rejected as
non-fast-forward until somebody logs in and rebases by hand. So
git_pull, and git_push before it pushes, fetch and then:
- fast-forward when there is nothing unpushed;
- otherwise
rebasethe unpushed commits onto the upstream. They have never left the server, so nothing published is rewritten; - refuse, with git's own words and nothing changed, when the rebase stops (it is aborted, so no conflicted tree is left behind), when a rebase is needed and tracked files have uncommitted changes (never a stash), or when incoming commits would rewrite a file with unsaved edits in DataGripe — the sidebar pull's guard.
Never a merge commit and never a force. A HEAD that moved broadcasts
repo.changed, exactly as a sidebar pull does, so open tabs re-check
their files. git_status fetches first, as the sidebar's refresh does,
so behind is about the remote as it is now. git_pull needs
git.commit, like the sidebar's pull: it reads the remote and writes
only to the clone.
Reuse an idempotencyKey only to retry the same completed mutation.
Domain settings and assignments use the same services as the Domains
tab, and sync exports them into the configured snapshot.
The domain map is in describe_project because the object-to-domain map
is the layout knowledge the catalogue does not hold: "which of these
forty tables are billing" is not answerable from information_schema,
and it is the first thing anybody needs. Hidden domains are excluded,
exactly as the export excludes them.
datasource accepts a ref or a name and defaults to the project's
default connection (or the only one, when there is only one); omitted
with no default is an error that lists the refs rather than saying "not
found". A datasource whose unavailable is set — an unset passwordEnv,
usually — is refused in its own words.
Files come back verbatim, never rendered: the model wants the
markdown source, and the sql fences in a runbook are the most useful
part of it. read_doc is capped at MCP_READ_MAX_BYTES per call, well
under the editor's MAX_FILE_BYTES, and says how to continue rather
than silently ending. search_docs is case-insensitive substring, not
regex — a tool an agent can get wrong in fifty ways reports "no matches"
when it means "bad pattern".
run_query caps rows at MCP_MAX_ROWS (200 by default; a larger
maxRows is clamped, not honoured) and bytes at MCP_MAX_BYTES. The
consumer is a context window, not a grid, so truncation says so and says
to aggregate or add LIMIT. Tool payloads serialize compact for the
same reason: pretty-printing a two-hundred-row result spends a third of
the window on whitespace.
The file index. Workspace documents, then every configured path
pair's tree, walked to six levels with a 400-entry cap per pair,
skipping .git, node_modules, dist, build, target, vendor and
friends, and listing only text extensions. A file-backed document is
listed once, as the file it came from: the artifact on disk is what a
teammate's checkout holds. A path pair that no longer resolves is
dropped from the index rather than failing it.
Errors. A malformed envelope, an unknown method or a bad project is a
JSON-RPC error. Everything a tool can be asked and fail at — no such
datasource, a refused write, a timeout, a missing file — is
isError: true with the message as text, so the model can correct
itself instead of the client reporting a transport failure. ServiceError
codes map straight onto that, carrying the message the app would show a
person.
Resources
A mirror of the reads, for clients where a human assembles context by
hand: datagripe://doc/<documentId> and
datagripe://file/<pathId>/<relative>. resources/list is the file
index; resources/read is read_doc under the same cap. Tools are
primary — agents drive tools, and a client with no resource support loses
nothing.
The briefing
instructions resolves in order:
.datagripe/config.yaml→mcp: { instructions: <repo-relative file> }, read through the same containment every other repo-declared path goes through (a committed symlink is not a read primitive);- a workspace file titled
AGENTS.mdorCLAUDE.md, case-insensitive; - nothing, and our words alone.
The repository wins because it travels with the clone: somebody who has just cloned the project has not written a workspace file yet.
Our block comes first and is always ours: the project name, the mode
in plain words, the datasource list, and one line saying to call
describe_project first. Two reasons for the order — truncation eats
the tail, and the sentence about what this mode does must not be the
part that gets eaten; and a file somebody edited must not be able to
describe a read-only project as writable. The project's prose follows,
under a line naming where it came from, capped at
MCP_INSTRUCTIONS_MAX_BYTES with a pointer to read_doc for the rest.
The panel
The sidebar retains an owner-only MCP Server header, its on/off switch, and its status pill. Clicking the title opens or focuses one MCP Server dock tab; it does not expand a sidebar body. The switch works independently of the title. A green frame marks an enabled server. The pill says no tokens, read only, or read/write while enabled.
The header loads only mcp.status, reading the original enabled/mode
columns and token count independently of the functionality migration.
For an owner, a pending or failed status read keeps the launcher visible;
it shows loading or status unknown, never a fabricated off state.
A retry action reloads status. Errors are retained for the management tab,
which also offers retry settings when its full read fails. Missing
functionality columns produce a migration-0027 explanation. A response
from a previous workspace is ignored after reset.
Opening the tab loads
mcp.settings, including file counts and token details. Neither surface
is offered once the server explicitly reports MCP_ENABLED is off. The tab also checks management
permission when restored from a saved layout.
The tab contains the server toggle, read-only/read-write selector, separate domain management, sync and Git functionality switches, token creation and revocation, endpoint, and copy-client-config action. Settings and tokens remain manageable while the server is off. Changes apply on the next call; in-flight operations are not unwound. Switching the server or mode does not reset the functionality switches.
A new token is revealed once; the client config includes it only while revealed, otherwise using a placeholder. Closing the tab clears the revealed value. Token rows show name and last use, with revocation confirmation. Datasource read-only constraints remain visible beside the query mode. Counts and briefing details use the endpoint's own knowledge readers.
Visibility inside the app
An MCP query is an execution. It gets its query_executions row, it
broadcasts the same events to the workspace, and the results panel and
history show it while it happens — the Phase 6d shape applied to a
non-human member. Migration 0019 adds query_executions.source
('editor' by default) and mcp_token_id, and the history list carries
source and via so a row reads dev@example.com · mcp · claude code on the laptop rather than leaving a teammate to wonder who the third
person in the project is.
The same change fixed a bug it would otherwise have inherited: the
history insert put connection_id in a uuid column for every
non-predefined source, so an execution against a git datasource
(git:<uuid>) failed on the history row before it ran. Only a managed
datasource goes in connection_id now; everything else is recorded as a
connection_ref, which is what the history view already falls back to
for a display name.
Audit lines: mcp.settings.change, mcp.token.create,
mcp.token.revoke, mcp.tools.call (tool, project, token, user — never
arguments, which contain data), mcp.auth.failure.
Rate limits, keyed per token rather than per user: mcp.tools.call
120/min and mcp.query 30/min — the same query ceiling a person has on
execution.start, so an agent in a loop cannot outrun the humans
sharing the datasource. MAX_CONCURRENT_QUERIES_PER_USER already
applies through the registry.
Configuration
| Variable | Default | Meaning |
|---|---|---|
MCP_ENABLED |
true |
deployment kill switch; off means the route and the panel are absent |
MCP_PUBLIC_URL |
WEB_ORIGIN |
what the panel tells people to point a client at |
MCP_MAX_ROWS |
200 |
ceiling for run_query, clamping maxRows |
MCP_MAX_BYTES |
1000000 |
serialized result cap per call |
MCP_READ_MAX_BYTES |
65536 |
read_doc / resources/read per call |
MCP_INSTRUCTIONS_MAX_BYTES |
16384 |
briefing cap |
MCP_PUBLIC_URL defaults to WEB_ORIGIN rather than the listening
port. The endpoint is served by the same process on the same origin as
the app, and WEB_ORIGIN is the address this deployment already tells
browsers to use and the one a proxy in front of it terminates; the port
is right only when nothing is in front, which is the case WEB_ORIGIN
also describes. Set MCP_PUBLIC_URL for the deployment that answers MCP
on a different hostname. In development the Vite server proxies /mcp
to the API for the same reason: the address the panel prints has to be
one that answers.
Timeout and concurrency come from the existing QUERY_* limits.
Where it lives
apps/server/src/mcp/:route.ts(envelope, auth, method dispatch),context.ts(token → project, role, mode),tools.ts(definitions, schemas, handlers),knowledge.ts(datasources, domain map, file index, reads, search),instructions.ts,service.ts(the panel's half),store.ts(settings and tokens),readonly.ts(layer 1)packages/contracts/src/mcp.ts, plusmcponrepoConfigSchemaandsource/viaonhistoryEntrySchemaExecuteLimits.sandbox, honoured by the three SQL adapters- migration
0019_mcp.sql apps/web/src/components/McpSection.tsx,apps/web/src/stores/mcp.ts, sidebar navigation inSidebarSections.tsxand tab opening inapp/viewPanels.ts
A note on bun --hot
Not MCP's, but found while building it and fixed alongside: hot reload
re-evaluates the entry point in the same process, so every save opened
another connection pool and another pair of intervals while the previous
ones stayed alive — measured at +10 connections per save, which empties
a max_connections = 100 cluster in six edits.
There is no hook for this: import.meta.hot is undefined under bun --hot (Bun 1.4 — it exists for the frontend dev server, not for server
code), and Bun.SQL's idleTimeout does not reap an abandoned pool's
sockets. globalThis does survive a reload, so apps/server/src/hot.ts
stashes a disposer per resource and the next evaluation runs them first.
The embedded cluster is handed forward instead of restarted.
Open questions
- Whether a live MCP token appears in presence ("Online") as a non-human participant. It would answer "who is that" without opening history; it also puts a robot in a list of people.
- Whether
search_docsshould also reach object comments and DDL. check_sql, and gripe findings attached torun_queryresults, once the Phase 11 server-side runner lands.- Whether read/write should also need a per-token opt-in, so one agent can write while another cannot.
- A
dry_run: trueargument in read/write mode — the useful half of rollback-as-read-only, with the honesty in the name. - Redis.
run_queryand the introspection tools followADAPTER_CAPABILITIES, so a Redis datasource exposes neither and says why. Whether it gets a command tool at all is its own question.
This page as Markdown, for an agent or a diff.