AI Agent Access (MCP)
Serve your databases to Claude Code, Cursor, VS Code or any MCP client — read-only by default, granted per table, and fully recorded.
What It Is
While Sutido is running it can act as an MCP server: an AI coding agent connects to it and reaches the databases you have granted — nothing else. The agent never sees your connection strings or passwords; it talks to Sutido, and Sutido talks to the database.
The feature is off until you turn it on, and turning it on grants nothing. An empty configuration denies every path. You decide what is reachable, down to a single column.
Plan: AI agent access is part of Pro and Team, and of the 14-day trial. On Free the server does not start and every request is refused. The grant screen still works, so you can set the tree up first — it applies the moment a licence is active.
Turning It On
- Open File → MCP Access
- Grant something (see below) — with nothing granted, a connected agent sees an empty list
- Flip Proxy on and click Save
- The status line shows
listening on 127.0.0.1:7332
The server binds to 127.0.0.1 only — never to your network — and every request
needs a bearer token that Sutido generates and stores encrypted. Change the port in the same
header if 7332 is taken.
Granting Access
Access is a tree with four levels: connection → database → table → column. The screen walks it left to right; columns you have passed fold into rails you can click back into.
The three rules
- Nothing is granted until something grants it. There is no implicit "everything" anywhere.
- The deepest level that speaks wins. A grant on a connection reaches every table under it, and a table that says Denied takes itself back out.
- A grant only reaches downwards if it says so. Clear Apply to everything below to grant one level and nothing under it.
A denial always reaches down, whatever that checkbox says — a removal that removes nothing would not be a removal. A deeper explicit grant still overrides it, which is what makes exceptions-within-exceptions work.
How to: everything in one database except one table
- Select the connection, choose Read — Apply to everything below is on by default
- Drill into the database, then the table you want to keep back
- Choose Denied on that table
- Save
The table now shows an orange dot: granted further up, taken back out here. The exceptions tile counts it, so you can see at a glance that a carve-out exists without hunting for it.
Hiding a single column
Drill one level further and set a column to Denied. That column is then hidden three ways, and all three are needed:
- A statement that names it is refused — anywhere, including a
WHERE, anORDER BYor a function argument - It is stripped from results, because
SELECT *names no column - It is absent from the schema the agent can read — returning the name and refusing to select it would hand over the very thing you kept back
Nested fields count. A MongoDB field list gives paths, so Content.Name is an
ordinary thing to deny — and it is enforced as a path: stripped out of the subdocument it lives
in, including inside arrays, and a projection that names it is refused like any other.
Masking, when hiding is too much
Hiding removes a column from results and from the schema. That is right when the agent
has no business knowing the field exists — and wrong more often than it sounds. An agent asked
to explain a schema, write a migration or debug a join needs to know there is an
email on customers. Take it away and the model guesses, which is worse
than telling it plainly that it may not read the value.
So a column can be masked instead: still listed, still returned, every value
replaced with [masked].
- The replacement is always a string, whatever the value was. A masked number returned as
0is a number a model will average; a masked date as an epoch is one it will sort by - It does not inherit — a cascading mask on a table would quietly mask the primary key, so it is read from the column's own setting
- The same value always masks to the same nothing, so rows cannot be correlated on a field the model was told it could not read
- A statement that reaches for a masked column under another name —
SELECT email AS contact, or a Mongo$projectrename — is refused rather than quietly unmasked. The same rule stopsWHERE email = 'a-guess'being used as an oracle
Mask personal fields
Standing on a table there is a Mask personal fields action. It matches column
names — email, iban, phone,
firstName, dob, password and the like, in
snake_case and camelCase — and shows you exactly which ones it will
mark before it marks them.
It suggests; it does not decide. There is deliberately no detection based on the contents of a column: reading values to work out whether we are allowed to read them is the wrong shape of answer, and a pattern that catches most IBANs sells a confidence it has not earned. It errs towards over-marking for the same reason — a false positive costs you one click, a false negative costs you the data.
Patterns
A table entry may end in * — audit_* covers every table starting with
audit_, including ones created later. Use + pattern above a
column to add one. An exact name always beats a pattern, so you can still carve one table out
of a pattern that covers it.
What the Agent Can Do
Six tools. Five only read; the sixth is the only one that changes anything.
| Tool | Answers |
|---|---|
list_connections | Which connections are reachable, and at what level |
list_databases | Databases within one connection |
list_tables | Tables or collections within one database |
describe_table | Columns, indexes, row count |
query | One statement that reads. Capped at 200 rows. |
execute | One statement that writes. Needs a grant and your approval. |
Anything you have not granted is not listed at all, rather than listed and refused. That keeps the name out of the agent's context — and an agent that never sees a name never wastes a turn trying it.
Reads
A statement runs as a read only when Sutido can positively recognise it as one. That is deliberately strict, and the trade is one-sided: refusing a valid read costs one message, while running an unrecognised write costs data.
Things that look like reads and are not — all refused:
WITH gone AS (DELETE FROM orders RETURNING id) SELECT count(*) FROM gone
SELECT * INTO orders_copy FROM orders
SELECT lo_export(16420, '/tmp/out')
SELECT * FROM url('http://example.com/x', CSV)
db.orders.aggregate([{ $out: "snapshot" }])
db.orders.find({}).forEach(d => db.orders.deleteMany({ _id: d._id }))
So are stored procedures (CALL, EXEC) and Mongo's
runCommand — their bodies carry whatever someone put in them, past every check.
A keyword inside a comment or a string literal is data and does not trip anything.
Writes
A grant is permission to ask, not permission to proceed. A Write grant lets the agent put a statement in front of you; it does not mean the statement runs.
Sutido opens an approval window — its own, kept above other windows so it reaches you even
when Sutido is minimised behind the terminal your agent runs in. It shows the statement
verbatim, coloured so the WHERE (or its absence) is easy to find, and above it,
how big the change is:
⚠ Nothing narrows this. It applies to all 4,812 rows in orders.
That number comes from a real query run beforehand, not an estimate. The statements that cause
accidents are the honest ones — DELETE FROM orders says exactly what it does, and
nothing in it says all of them.
Three answers: Refuse, Allow once, or
Always allow — the last names the table it hands over, because it is a grant
on that table rather than on this statement, and it hands over exactly the kind of change you
approved: an always for an index change does not cover a later DELETE,
and one for a row change does not cover a later ALTER. Closing the window,
pressing Escape and the timeout all mean refused — there is no path where silence means yes.
A ring on the window counts the time down. The prompt waits five minutes by default — adjustable from one to sixty on the access screen — and if nobody answers it refuses itself. If your agent stops waiting first, the window is told: it stops being a question, says what happened, and can no longer approve anything. Either way nothing has run, the activity log says which of the two it was rather than that you declined, and asking the agent again brings a fresh prompt.
Everything you approve with Always appears in Approved at a prompt on the access screen, with the statement that was on screen at the time and one button to take it back.
Schema changes
Creating and dropping indexes needs Schema rather than Write — changing how a table is indexed is a different decision from changing its rows, and can make a busy database unusable for minutes without changing a byte.
Never, at any level
Some operations are refused whatever you grant, because the document changelog cannot bring them back:
DROP TABLE,DROP DATABASE,TRUNCATEALTER TABLE … DROP COLUMNandDROP CONSTRAINTGRANTandREVOKE- Mongo's
drop(),dropDatabase(),dropIndexes(),renameCollection
If you genuinely want one of those, run it yourself in the shell where the app's own confirmation applies.
Deletes stay recoverable
For MongoDB and Aerospike, documents are captured into the changelog before the statement runs — exactly as Sutido does for deletes you make yourself. If that capture fails, the reply says so rather than letting you believe it is undoable.
The Activity Log
Every request is recorded — not just refusals, and not just writes. The Activity tab shows each one with its verdict, the statement, how long it took, and how many rows came back.
A refusal names which rule refused it. "No grant covers this" and "this is granted further up and taken back out here" are different problems with different fixes, and a log that flattened them into "denied" would make you guess.
A request the proxy allowed and the database then rejected is marked db error rather than denied. A type mismatch or a missing column is not a permission problem, and a log that called it one would send you to check grants that were never the reason. The entry says so outright, and carries the database's own words in full.
Open any entry to see the whole statement — not a truncated line, and selectable, because the next thing you do with a query that failed is copy it somewhere and fix it.
Requests turned away at the door are recorded too: a bad token, or a request carrying a browser
Origin. Something tried to reach your databases and was refused — that is exactly
the entry you would go looking for afterwards.
Changing Your Mind
Grants take effect on the next request. There is no restart, and no need to reconnect the agent — the configuration is read fresh every time rather than cached. Revoking is immediate in the same way.
A connection you add later is invisible to the agent until you grant it, and one you delete disappears at once.
Security Notes
- The server listens on
127.0.0.1only, never on your network - Every request carries a bearer token, compared in constant time
- Requests carrying a browser
Originheader are refused — a web page you have open cannot drive the proxy - The token is stored with OS-level encryption, like every other secret Sutido keeps
- Rotate issues a new token and disconnects everything using the old one
Anything holding that token reaches everything you have granted, so treat it like a password — and rotate it if it ends up somewhere it should not be.
Next
Connecting AI Clients — the exact configuration for Claude Code, Claude Desktop, Cursor, VS Code, Windsurf, Gemini CLI and Codex.