The MCP rule file
ObjectExplorer can expose what it already knows how to do — list, describe, summarise, query — to an agent over MCP. The app is the one holding the sign-ins, the cache and the object tree, so the agent never gets a credential and never talks to a provider: it asks this app, and this app decides.
What it may ask for is one file:
~/.objectexplorer/mcp.yamlNo file means the MCP server is off. That is the whole safety story in one line: nothing is reachable until it is written down here.
Settings → MCP writes this file, and the file is watched, so editing it in an editor and editing it in the dialog are the same act.
The rules are the file's, not an agent's
Every rule here applies to every agent. There is one set of roots, one set of limits, one PII floor, and turning on a second client does not mean writing them again.
A rule may then name the agents it is for:
agents: [claudeCode]Without that line the rule is for everyone; with it, only for those. It is allowed on a root under roots and on approve — the two places where one client plausibly differs from another. The names are the clients the app installs into: claudeCode, codex.
The whole file
version: 1
# The server itself. It binds to the loopback address only, in every mode — the desktop app,
# `npx objectexplorer`, the browser front door.
server:
enabled: false
host: 127.0.0.1
port: 0 # 0 = ride the app's own port, at /api/mcp
token: 7f2a1c4e9b8d3a6f5e0c2b7d4a9f1e83 # generated on the first write; the app's only secret
install:
claudeCode: true # write the entry into that client's own MCP config
codex: false # ticking a client also lets it initialize at all
# What the window does while an agent is working. Following opens each object the agent touches,
# debounced, so a burst of calls does not flick through twenty objects. Escape stops following;
# the log keeps recording either way.
observe:
follow: true
debounceMs: 400
# The access log is always written, synchronously, before the answer leaves. This only says how
# long it is kept.
log:
keepDays: 30
# What may be called at all.
tools: [listRoots, listObjects, describeObject, columnSummary, query]
# How much may be taken. Counted on the way out — what the agent received.
limits:
rowsPerCall: 10000
bytesPerCall: 32MB
bytesPerSession: 1GB
bytesPerDay: 4GB
callsPerMinute: 60
objectsPerListing: 1000
maxObjectBytes: 256MB # an object bigger than this is never opened for an agent
# When a human is asked first. One list for every root.
approve:
tools: [query]
paths: ["^raw/"]
timeoutSeconds: 300
onTimeout: deny
remember: session # never | session | day
# The PII floor, by column name, for every agent, every root and every share link. `action`
# defaults to `fpe`.
# A pattern is not anchored: a column whose name has the word in it anywhere is caught, so one
# rule covers email, emailAddress and billingEmail. Edited in Settings -> PII.
columns:
- match: "email"
- match: "(phone|mobile)"
- match: "(ssn|personalNumber|nationalId)"
- match: "(cardNumber|iban|accountNumber)"
- match: "^salary$"
action: drop
- match: "^customerName$"
action: mask
begin: 4 # the characters a mask hides, from begin up to end
end: 6
# The same floor, by what a value says rather than what its column is called. What these match
# inside a free-text field is replaced; the rest of the field is left alone.
text:
- match: "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}"
- match: "\\b\\d{6,8}[-+]\\d{4}\\b"
- match: "\\b\\d{3}-\\d{2}-\\d{4}\\b"
- match: "\\+\\d{1,3}[ -]?\\d{1,4}([ -]?\\d{2,4}){2,3}"
# Denied everywhere, in every root. These are the paths nobody meant to share.
deny:
- "(^|/)\\.env(\\.|$)"
- "(secret|credential|password|token)"
- "\\.(pem|key|p12|pfx|kdbx)$"
- "(^|/)\\.(git|ssh|aws|azure)/"
# The roots. Each one is reachable in full, minus the deny rules above. Nothing outside this
# list is reachable at all.
roots:
- providerType: gcs
root: sales-lake
- providerType: folder
root: reference
agents: [claudeCode] # this one root is Claude Code's aloneHow one call is decided
Every call — a listing, a query, a byte range — goes through the same seven steps, in this order. The first deny ends it.
| # | Step | Denied when |
|---|---|---|
| 1 | server | server.enabled is false, or the request did not come from 127.0.0.1 |
| 2 | agent | the bearer token is not one this app issued |
| 3 | tool | the tool is not in tools |
| 4 | root | no entry under roots names that providerType + root, for this agent |
| 5 | path | any regex in deny matches |
| 6 | limit | this call, this session or today would go over a limits number |
| 7 | approve | the human said no, or said nothing for timeoutSeconds |
Then the call runs, and on the way back the result passes through columns.
Default deny: no roots means nothing is reachable. Nothing is granted by omission. A root that is listed is reachable in full — what narrows it is deny, which is one list for every root rather than a set of patterns per root: a rule about .env is the same rule wherever the file is.
One gate, not one per tool
The check sits at the door, before anything is read — not in each tool. A tool is a request to this app's own REST API and nothing more, so there is nothing in one to bypass: what it may ask for was decided before it asked. That matters most for query, which names its objects inside SQL — a statement naming a denied path is denied at the same step 5 as a byte range would be. There is no second rule engine to keep in sync with the first.
What the gate binds is /api/mcp. A program on this machine calling /api/read or /api/duckdb/query itself is not an agent and is not held to this file — the local API has always been the app's own, and only this machine can reach it.
What a denial looks like
A listing hides what it may not show, and says how many it hid. An agent that cannot see exports/hr/ should not learn the folder exists from a hole in the list.
A direct call on a denied path is refused by name: denied: exports/hr/staff.parquet (rule: deny ^exports/hr/). An agent that asked for something specific has already guessed the name, and a vague answer only makes it try again.
Identity: agent, and session
server.token is the bearer token, and it is the one secret this feature has. The app generates it the first time it writes this file and copies it into each client's own config when that client is ticked — nobody types it, and nobody pastes it. Changing it here and saving cuts off every client until they are ticked again.
One token, not one per client: the token says the caller is a program this machine let in, and the client's own name says which program. Both are in the file, in one place, which is the point.
Two things are recorded, and they are what the log is split by:
- agent — which client is calling: the name it gives at
initialize, checked against the clients ticked underinstall.claudeCode,codex. It is what a root'sagents:line names, and what the activity strip colours. - session — one MCP connection, from
initializeto the socket closing. A newclaudein a new terminal is a new session under the same agent.
~/.objectexplorer/mcp/<agent>/<session>.ndjsonOne line per call, written and flushed before the answer is sent, so a killed process still has everything it answered. The line holds the call as it arrived, the decision and which rule made it, the counted rows and bytes, and a digest of what went back — never the payload itself. The UI is not in this path at all: it subscribes to the same events and draws them, and a window that is closed, slow or observing something else changes nothing about what was written.
Path rules
providerType is the name the app addresses that storage by — folder, gcs, s3, azure, onelake — the same word as the first segment of /api/<providerType>/.
Regexes, matched against the path relative to the root, POSIX separators, no leading slash, folders ending in /. They are ordinary JavaScript regexes, matched case-insensitively — a rule written for secret has to catch Secret too, and one that did not would be a rule that looks like it works.
delta/orders/part-0001.parquet
exports/2026/03/deny and approve.paths are lists, and any one match is enough. deny always wins, in every root, which is what makes it a floor no root can raise.
One thing a listing does on its own: a folder is named with its trailing slash whichever way the agent wrote it, so data/sales and data/sales/ meet the same rule. What comes back is decided child by child — a root with a deny rule on exports/hr/ lists everything else and says how many it hid.
Limits — how much may be taken
Counted on the way out: what the agent received, not what the app read. A cache hit costs the same budget as a download, because the agent got the same data.
| Limit | Counted per |
|---|---|
rowsPerCall | one call |
bytesPerCall | one call |
bytesPerSession | one MCP connection |
bytesPerDay | agent, local midnight |
callsPerMinute | agent, sliding minute |
objectsPerListing | one listing |
maxObjectBytes | the object itself |
A call over rowsPerCall is truncated and says so. A call that would cross bytesPerSession or bytesPerDay is denied rather than truncated — a budget that silently shrinks every answer is worse than one that stops.
Column rules — the PII floor
Two lists, for every agent and every root, edited in Settings → PII: columns matches a column's name, and text matches what a value says — see PII rules. A share link reads the same two lists. The rest of this section is columns. It is applied to every value leaving the app that has a column name: query results, columnSummary, the rows getObject reads from a table, and the column list describeObject answers with. First matching rule wins, matched case-insensitively against the column name, and a column matching nothing is returned as it is.
| Action | What comes back | Keeps |
|---|---|---|
fpe | same length, same alphabet, a different value | format, joins |
hash | 16 hex characters, HMAC-SHA256(key, value) | joins, counts |
mask | the characters from begin up to end hidden, the rest kept | shape |
drop | the column is not in the result at all | nothing |
fpe is the default, and a rule that names no action gets it. It is the only one that leaves the data still looking like data: a masked column breaks a join and makes every distinct count 1, a hashed one turns a card number into something no analysis can sanity-check, and both make an agent ask for the raw file instead. Format-preserving encryption keeps the length, the alphabet and the equality — so a query still groups, still joins, still counts — and gives up nothing real.
Where it cannot preserve the format — a float, a timestamp, a blob — the value is hashed instead, which is the one place an action is decided for you. Every encrypted column is named as encrypted by describeObject and beside the results of query, so an agent knows an id it is holding is not the real id and never quotes it back to a human as one. FPE and hash share one key, kept in ~/.objectexplorer/mcp/key — never in this file, and never off the machine. It is per install, so a value is stable across sessions and means nothing anywhere else. A share link goes through the same rules under a key of its own, made for that one share.
A column rule cannot touch a line of a text file — there is no column name to key on. That is what text is for: searchText hits and the text getObject returns go through it. A tree or a window of bytes from getObject goes through neither, which is why a starting file leaves getObject off.
Approve — the human in the loop
approve:
tools: [query]
paths: ["^raw/"]
timeoutSeconds: 300
onTimeout: deny
remember: session
agents: [codex] # optional: only this client is asked aboutA call that matches raises a prompt in the window naming the agent, the tool and the path. Nothing runs while it is up. No answer within timeoutSeconds is a denial, recorded as decision: timeout — an agent waiting on a laptop that went to sleep gets a no, not the data.
remember: session carries one yes to identical calls for the rest of that session. never asks every time. There is no "remember forever": that is what removing the rule is.
Replay, and recheck
A session log is not only a record; it is something to run again — in two different senses, and they are two different words here.
Replay is the window opening again what the agent opened, in the order it happened: a folder it listed is navigated to, an object it read or queried is opened, a call it was refused opens nothing, because there was never anything to see. It asks the rules for nothing — it is this app showing a person what an agent saw.
The steps are listed in a panel in the top-right corner while it runs, marking the one it is on, and the panel stays after the last step until it is dismissed — Escape, which also stops a run in progress, or the ✕.
Recheck re-issues the recorded calls against today's rules and reports which decisions moved.
The list of sessions is the way into both — agent, when, how many calls, how much data.
claudeCode 2026-09-05 14:02 41 calls 18 MB ok 39 · denied 2
claudeCode 2026-09-04 09:31 12 calls 2 MB ok 12
codex 2026-09-03 16:44 7 calls 0 B denied 7Opening one opens a tab, not a panel in the dialog: a session is thousands of lines of trace and a sequence diagram to go with it. The tab draws the client's own transcript — user, assistant, tools, and, when Claude Code was run through a capturing proxy, every LLM round trip as well — and marks each tool step with what it actually reached here: the object, the rows, or the rule that refused it. Under the diagram is the same thing as a table, and either one opens the object on a click.
Nothing in the protocol joins the two accounts — a transcript is a file the client writes for itself, and MCP never mentions it — so they are matched by time and by content: a transcript whose tool steps name our tools with the same arguments, within a minute of the calls we logged, is that session's.
Rechecking one re-issues the recorded calls, in order, against today's rules — so a rule file that was just tightened can be checked against the traffic it will actually meet, and a denial that surprised someone can be reproduced on demand. Each call is compared with what happened the first time, and the ones that changed are what the replay is for:
listObjects sales-lake exports/ allowed → allowed
query sales-lake delta/orders/ allowed → denied (deny ^exports/hr/)A recheck is an ordinary set of calls: it is logged as its own session, marked as a recheck of the one it came from, and it obeys every limit and every approval the live path obeys. It is not a way to re-run something the rules no longer permit.
The tools
Seven, each ticked on its own under tools.
| Tool | What it answers | Rules on the way out |
|---|---|---|
listRoots | which providers and roots are reachable | the gate |
listObjects | the children of one path, and how many were hidden | the gate, per child |
describeObject | size, time, format and columns | columns (names) |
columnSummary | per-column statistics, measured after the rules | columns |
searchText | lines matching a pattern, in one object or one folder | text |
query | one SQL statement over objects named by URI | columns |
getObject | one object: rows, text, a tree or a window of bytes | columns or text, or none for a tree or bytes |
maxObjectBytes is checked with a HEAD before getObject or searchText reads anything that is not a table, so an object over the limit costs one metadata request rather than a download. A table is windowed by rowsPerCall instead.
Plotting and model training live in the page, not in the server, so neither is a tool.
When there is no file
The server is off, and Settings → MCP offers to write a starting file: every root already in the window, tools: [listRoots, listObjects, describeObject, columnSummary, searchText] — no query and no getObject — the four column rules and four text rules shown above, the deny list above, lower limits (1000 rows and 8 MB a call, 256 MB a session, 1 GB a day, 500 objects a listing), an empty approve, and the door shut with no client installed. Everything else is a deliberate line someone typed.
Open questions
callsPerMinuteon a denial. A refused call still costs a rule evaluation. Counting denials toward the rate limit is what stops a probe; not counting them is friendlier to a confused agent. Proposal: count them.
Next: the MCP endpoint.