Scoping and auth: what a server should refuse to do
An MCP server's credential is its real permission set — what the specification makes a server refuse, and how to scope one from your harness.
"What can this server do" is the wrong question
Attaching an MCP server to a harness isn't about introducing new powers but about introducing a new actor that can call things. Every exposed tool becomes callable by every model for which the next step is determined based on its context (the prompt you wrote, an issue it just opened, output of a failed test, a website it visited, README of a dependency it installed etc). And this context isn't authenticated — everything that's part of it goes in as input.
Assessing a server is therefore about what it refuses when the request looks reasonable. Destroying production by design isn't a reasonable request.
For instance — if you were to attach a server with run_query tool to your harness and provide a model with an instruction like "remove duplicate rows" and a connection string with superuser rights, it could just do it.
The credential is the permission
Capabilities come from the credentials, not from the labels. Tool name, description, annotations are about the intent; the credential is about the reach. If there's a conflict — it's the credential's call, and it decides after the fact, no noise involved.
We have a few standard annotation fields defined — readOnlyHint, destructiveHint, idempotentHint and openWorldHint. In terms of schema, every ToolAnnotations property is a hint that you shouldn't believe when comes from an untrusted server; tools page says clients MUST treat annotations as untrusted unless they trust the server. Default values are also important to understand — readOnlyHint defaults to false, destructiveHint defaults to true. Harnesses handle it differently — Codex tells you that every tool with destructive annotation will ask you for approval, but there's no protocol-level enforcement in place.
So a "search_orders" tool that is documented read-only (but connects with a role that can DELETE) but can delete things is a deletion tool awaiting its call.
The right thing to do in such cases is to narrow the credential rather than improve the wording —
Postgres role with no own objects and SELECT on three views instead of the app's account
GitHub token for a single repository instead of for every repository in the world
absolute path as a root that the server won't leave instead of $HOME
separate credentials for read tools and for write tools so a single bug can't turn reads into writes
All of these things are robust even against an adversary having tampered with the model, the wording isn't.
Refuse by construction, not by instruction
You want to design the refusals into the structure rather than communicate them in guidance. The description is itself a prompt — hence why it works and why it doesn't work as a guard. Telling a model to only do read-only queries or not to use the production schema is about a model for which an attacker could be providing the context.
Structural refusals are about the answer being "no" before the question even comes — three tactics cover most servers:
Divide by the consequence of error rather than by the system. Reflexively, people attach one server per system (Postgres, GitHub, CRM), but what's sensible is splitting things based on what a wrong call does — read server with read-only credential and write server with scoped write credential are two different risk units, which you can toggle on/off for each project and in each session.
Eliminate the dangerous parameter.
run_sql(query)always has the connection's full power for any query that anybody can think of, whileget_orders_by_customer(customer_id, limit)only has what's encoded in it; less flexibility is the goal, as the general purpose tool is always an attractive surface for future exploits.Require sth a model can't provide on the destructive path:
A credential that you have in config only during the 20 minutes of migration
Tool that displays a plan for a human to run instead of actually running it
An additional approval happening outside of the session
The above are our personal preferences, not the spec's text. What follows is the spec's text.
The four refusals the specification actually requires
As of 2026-07-28, the authorization layer in MCP is optional, but for an HTTP-transport server that is protected, it's an OAuth 2.1 resource server, which means four refusals — three MUSTs and a strong SHOULD.
A token minted for somebody else:
Servers MUST check that access tokens contain their audience (so the tokens are issued for them)
Servers MUST return HTTP 401 if they see an invalid or expired token
Servers MUST accept only tokens valid for their own resources, and MUST NOT accept or relay any others
Token passthrough is a thing — a server taking the client's token and relaying it to the downstream API. This is explicitly prohibited, servers MUST NOT accept tokens not issued specifically for them: the downstreams might do rate limits based on audience, they might validate requests based on audience, they might monitor traffic based on audience — all of that becomes subverted if you introduce a relay. The audit record will no longer say who the real actor was, and a stolen token will have a relay to be extracted.
If a request comes with insufficient scope:
The SHOULD, not MUST, and the one that most implementations get wrong by either returning 401 or a bare 500 — the spec asks for a 403:
Describing the missing scope
Pointing to protected-resource metadata
Providing a human-readable explanation
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope",
scope="files:write",
resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
error_description="File write permission required for this operation"Such refusal is actionable — if it says the operation needs a certain scope, the client can do step-up authorisation and retry, rather than giving up. The spec requires that every scope an operation needs should be present in a single challenge, not one per round trip.
If a server can't map handle to the caller:
As of 2026-07-28, MCP is stateless — it got rid of its initialize handshake, Mcp-Session-Id header and the protocol-level session. So if a server needs to maintain cross-call state it issues an explicit handle, returned as a plain tool argument so the handle passes through the model's context and can be relayed by anything in that context.
The spec requires that servers doing authorisation MUST check every inbound request and MUST NOT accept having a handle as proof of identity. We recommend setting up a <user_id>:<handle> binding on the server side, associating the user id with a verified token rather than with an argument, and not accepting this handle from anybody else.
If input wasn't validated:
Four lines on the tools page that most servers ignore:
Validate every tool input
Implement proper access controls
Rate limit tool invocations
Sanitise tool outputs
stdio doesn't get an auth layer, on purpose
Stdio is opt-out of the authorisation layer. Most servers people attach to their harnesses are local STDIO processes, and the spec explicitly steps aside — STDIO implementations SHOULD NOT follow it and should read credentials from environment instead. Which makes sense, because there's no network boundary to protect, but the problem only moves:
Where does the credential come from? If it's an env variable the config sets, you want to reference it rather than put the value in the config file (which becomes a place secrets live)
What's the process run with? Usually it's your privileges, and not well-isolated on your machine — the spec advises client authors to create their own sandbox with minimal default permissions but that's a SHOULD addressed to vendors, and Codex says its sandbox doesn't apply here; tool call is secured by a gate, the process isn't
The spec advises the same for server authors: use STDIO if you want to limit the server's reach to the MCP client, and if it listens on HTTP locally, make it require an authorisation token or connect over unix domain socket with restricted access.
The attack isn't "they have my bearer token", it's "I added npx -y some-server@latest to my config and it resolves to whatever the latest tag points at today, run as me, reachable from ~/.aws and my ssh keys".
Securing the trust boundary is a separate topic; here — pin the version, look what you're running, or don't run it.
Ask for the smallest scope, then escalate
Ask for the least amount of scope and elevate if needed. Servers with scopes typically ask for everything at once because it feels weird to make a second call, but the security best-practices companion document has an alternative: start with a minimal set — like mcp:tools-basic from the sample, only covering low-risk discovery and read operations — and elevate for specific privileged operations via targeted WWW-Authenticate challenges on their first attempt.
Most servers in production today make these mistakes:
List every possible scope under
scopes_supportedUse wildcards or catch-all scopes like *, all or full-access
Package multiple unrelated privileges to preempt future challenges
Include the entire
scopes_supportedlist with everyWWW-AuthenticatechallengeAccept any scopes listed in a token without verifying them on the server side
The last point is what trips people who did everything else right — the scope in a token is a general grant, not an information about whether this particular caller can access this specific row; it's the server that decides.
A refusal is an interface
A refusal is a kind of an interface — there are two types of "no"s, and more depends on the difference than seems: JSON-RPC error responses are about issues with the request — unknown tool, malformed call or server failure. Tool's code errors are regular results with isError: true, which the spec describes as holding actionable feedback models can use to self-correct and try again with different parameters.
Clients SHOULD pass tool code errors back to models so they can self-correct, which means that the refusal text isn't a log entry but the model's next input. For instance:
{
"resultType": "complete",
"content": [{
"type": "text",
"text": "Refused: write_file is limited to /srv/app/content. Got /etc/nginx/nginx.conf. Nothing outside that root is writable by this server; ask the operator to widen ALLOWED_ROOT if the file really belongs there."
}],
"isError": true
}vs
{
"resultType": "complete",
"content": [{ "type": "text", "text": "Error" }],
"isError": true
}The latter does nothing and makes the model look for a different way (shell, alternative tool, wrapper script etc) as it doesn't imply intentional closure; the former refusal communicates the limit and the approved option so the model knows it's done. A refusal that says "Error" is an invitation to explore.
Becoming a confused deputy by accident
We included this one because people do make it even if they try hard. If you were to create a proxy server in front of some third-party API and want it to support MCP clients, you might have a single hardcoded client id with that API's authorisation server and let MCP clients to register their own redirect URIs.
Then, a user could legitimately authorise once, the third-party authorisation server would set a consent cookie against your static client id, and later an attacker could register a client with their host as the redirect URI and send this user a link — the cookie will survive and the authorisation code will end up with the attacker.
The spec says (as MUSTs, generalisable) that:
You need to keep a per-user registry of approved client ids and consult it before relaying anything to the third party
You need to compare
redirect_uristrictly by value, never using wildcards or patternsYou need to generate state per request, persist it only after the user has really granted the consent, make it one-use-only and short-lived, and reject any callbacks with missing or different state
For non-proxy creators — the same thing applies; authorisation you establish in one request can never stand in for another. Act on your own identity.
Client-side allowlists are not server-side access control
Every harness has a name-based allow/deny for MCP tools, and all of it is good to configure — it's a safety net, limits the sprawl, and is the quickest thing you can do. It's not a boundary.
For instance, Cursor says it twice — generally, that allowlists are best-effort and not a security boundary (easily bypassable by determined agents or prompt injection, should be used together with hooks etc), and in the MCP allowlist reference that they and autoRun instructions are best-effort convenience, not security guarantees.
Codex says their connectors, MCP servers, and browser surfaces don't use permission profiles so it doesn't include things like writable roots and network rules for shell commands in their security model; it's about the shell commands, not about the server processes.
The client decides if sth is called, the server decides what can be done — and the server has the credential.
In Claude Code
Claude Code allows you to define four different types of controls (in different layers if you like): which servers are loaded, what tools from these servers the model is allowed to use, what OAuth scopes are requested and what environment variables will be accessible within the process.
Which servers load.
The first one — servers — is about controlling which servers are actually loaded. We can define a server locally in this project (and so it will be visible only in this project), in the .mcp.json file in the root of the repository (in such case it will be shared for everybody who uses this project) or using an MCP user scope (in such case the server will be available in every project). All local and MCP user-scoped servers are stored in ~/.claude.json, and for the .mcp.json file it's just this file. This is important to emphasise as a lot of people often mix MCP local scope with settings.local.json — the docs have a separate note about that.
When you define a server (or even a single one) in .mcp.json file in the root of the repository, you will be asked for your consent before the server is actually used. This is because we want to make sure you're aware if somebody adds an MCP server to a project. If you were to clone this project and run some command, you wouldn't like the process to start on your machine without your permission. Thanks to that first prompt, you can see what's new in this project and decide whether you're okay with it. So in other words, if you add a server to .mcp.json file in the root of the repository, it will appear in listings etc but won't do anything until you approve it.
Here's an example of the CLI showing details for a single server:
$ claude mcp get docs-ro
docs-ro:
Scope: Project config (shared via .mcp.json)
Status: ⏸ Pending approval (run `claude` to approve)
Type: stdio
Command: npx
Args: -y some-docs-server --root ./docs
Environment:
API_TOKEN=${DOCS_TOKEN}The last thing worth mentioning is that you can use reset-project-choices subcommand if you want to go back and think about every approval choice in your project.
Another way to not load any servers defined in .mcp.json files is to run claude with --strict-mcp-config flag without specifying any --mcp-config. We document it here.
Which tools.
The second type of controls are tool-level permissions. Permissions can be defined for an MCP server at three levels of granularity:
Using a server name
Using a server name and glob (which means the same as using a server name)
Using fully qualified server-plus-tool name
These rules are being evaluated in order of deny, ask, allow — first match wins, and more specific rule can't beat an earlier one. The only exception is that both deny and ask allow unanchored globs while allow does not. It means for example that if you define MCP-wide deny rule using an unanchored glob, it will disable every single tool from every server. If you use a bare name in deny you won't just be disabling the tool — it will actually remove it from the model's context. The docs say allow only supports tool-name globs after an mcp__<server>__ literal prefix; unanchored allow globs are skipped with a warning and do not approve anything. This is just something to keep in mind if you want to create some deny-everything-then-re-allow config.
{
"permissions": {
"deny": ["mcp__*"],
"allow": ["mcp__docs-ro__search_*", "mcp__docs-ro__get_page"]
}
}Which scopes.
The third type of controls are OAuth scopes. If you define oauth.scopes inside an entry for a remote server in .mcp.json, it will control what Claude Code will ask the authorization server for. This way you can make sure your project uses only security-team-approved subset of scopes if the upstream authorization server provides more.
{
"mcpServers": {
"slack": {
"type": "http",
"url": "https://mcp.slack.com/mcp",
"oauth": { "scopes": "channels:read chat:write search:read" }
}
}
}Which secrets.
Last but not least — the fourth type of controls are environment variables. If you define a server in an .mcp.json file, you can reference any secret defined there using ${VAR} and ${VAR:-default} syntax (in command, args, env, url or headers). In such case, your config points to the secret rather than storing it. If you forget to configure one of these variables, Claude Code will fall back to loading the server anyway and show a warning, so this is a fails-open scenario.
For example:
$ claude mcp list
docs-ro: npx -y some-docs-server --root ./docs - ⏸ Pending approval (run `claude` to approve)
MCP config diagnostics ⚠
[Contains warnings] Project config (shared via .mcp.json)
Location: /private/tmp/scoping-and-auth-check/cc/.mcp.json
└ [Warning] [docs-ro] mcpServers.docs-ro: Missing environment variables: DOCS_TOKENThe gotcha.
It's worth keeping in mind that there are two types of configs — .mcp.json files and settings — and they're reloaded on different occasions. The settings are being watched and re-read on the fly (the docs say this even includes permissions), while .mcp.json files are being parsed once at the beginning of the session. This means that if you modify a permission, it will be reflected in the session immediately, but if you modify a server entry — it won't.
That's why if you make any changes to the .mcp.json file, remember to restart before making any conclusions.
In Codex CLI
The config file is in ~/.codex/config.toml (or in .codex/config.toml if you have a project trusted by Codex). For every server, there's an entry like this:
[mcp_servers.my_server]In this file, there's a single source of truth, used by the CLI, the IDE extension and the ChatGPT desktop app. If you change sth in one place, it will be reflected everywhere else, which is both an advantage and potential hazard
Per-server tool filtering
One of the features that hasn't got much traction is the possibility to configure tools for a particular server. Here's a sample entry:
[mcp_servers.docs-ro]
command = "npx"
args = ["-y", "some-docs-server"]
enabled_tools = ["search_docs", "get_page"]
disabled_tools = ["delete_page"]
default_tools_approval_mode = "writes"The above config defines an allow list (enabled_tools), a deny list (disabled_tools, being evaluated after the allow list so the denial is applied if there's a conflict) and a default tool approval mode for tools that are not mentioned in either of these lists. The possible values are:
auto
prompt
writes
approve
If you set it to writes, any tool that isn't read-only will require your approval during the session (so you'll be prompted with an additional message if you enable a tool that isn't on the allow list or deny list and isn't read-only)
If you want to change the default for a single tool, you can do this:
[mcp_servers.my_server]
tools.server-entry-a.approval_mode = "approve"Credentials
What's more, instead of including credentials in the config file, Codex passes them by reference. Here are the config keys for the most popular types:
Stdio servers (local pipes):
env - you can define environment variables directly here
env_vars- you can pass variables from Codex's environment to the server here
For HTTP servers:
bearer_token_env_var- you can define an environment variable here that contains the token used in the Authorization headerhttp_headers- you can set fixed values for specific HTTP headersenv_http_headers- similar to the above, but for HTTP headers that are taken from the environment
Codex masks these values in its output:
$ codex mcp list
Name Command Args Env Cwd Status Auth
docs-ro npx -y some-docs-server DOCS_ROOT=***** - enabled UnsupportedAs you can see, it hides the value of the env variable. Also, as Stdio servers don't support OAuth via local pipes, you can see that the Auth column says "Unsupported" here:
OAuth
What's more, Codex is one of the few CLIs that implements RFC 8707 out of the box: You can set a custom value for the oauth_resource parameter (part of RFC 8707) during the login process with: codex mcp add --oauth-resource=my-oauth-resource
If you run this command, you will be able to set it on the servers level as well:
[mcp_servers.my_server]
oauth_resource = "my-oauth-resource"Similarly, you can define the scopes that will be requested when authenticating with this server: codex mcp add --oauth-client-id=my-client-id
If you run this command, you'll see a new option on the servers level:
[mcp_servers.my_server]
scopes = ["scope-1", "scope-2"]The above values are optional. If a server returns scopes_supported, Codex will prefer them over the configured ones. That means that if you set scopes, they become a lower bound rather than a cap
Two things that may be surprising
The first one is that MCP servers aren't part of the "sandbox". The "Permissions" section of Codex's docs lists all the surfaces that are not covered by permission profiles and which have their own native controls. They include connectors, MCP servers and "browser/computer use" surface. Therefore, if you enable sandbox mode or set writable roots in the config file, it will only limit what you can do with the shell and what files you can edit, not what a server process does behind the scenes during a tool call
The second thing that may come as a surprise is that despite the name, the "approvals" feature in Codex isn't just about approvals. It's also about annotations. If a tool declares an annotation as destructive, it will always require your approval if you use it (even if it says read-only, for example). We'd say that if a tool advertises both destructive and read-only, the destructive one is the one to believe
Pitfall
The last thing to keep in mind is how servers are handled at the beginning of the session. If you set enabled = false in the config file, the server won't be started at all but its entry will still be there. If you set required = true, the session won't start if the enabled servers can't initialise. We'd set it to true for anything security-relevant, since the default is the opposite. If a server can't be started, the user will have a missing capability and the session will run without the tool that you think is there to enforce sth
In GitHub Copilot CLI
2 separate systems are commonly used simultaneously and then mixed — visibility filtering (what tools the model sees) and approval control (which visible tools get to act without prompting). The distinction is described in copilot help permissions:
• --available-tools, --excluded-tools — set what the model sees
• --allow-tool, --deny-tool — set what tools are allowed to act without prompting (approval prompts) on the visible tools level, you cannot surface tools that have been already filtered away using visibility filtering
The MCP permission syntax is a server name followed by a tool (if provided):
<mcp-server-name>(tool-name?)
Exactly matches a specific tool from a specific MCP server, or all tools
from that server if omitted.If there's a tool provided it means this permission allows a single tool on a single server. If there's no tool provided it means all tools on a server are allowed. For example, allowing an entire server and then denying a single destructive tool from it:
--allow-tool='docs-ro'
--deny-tool='docs-ro(delete_page)'The deny is prioritised over allow, even with --allow-all-tools. This is important because the only thing that's really bullet-proof enough to base a policy on is the deny list — if you do --allow-all-tools and forget to add a deny for a destructive tool, it will still get added as long as it's defined in a server.
Apart from CLI flags there are other ways to set up the scope of what MCP sees:
• User-level configuration (in ~/.copilot/mcp-config.json)
• Workspace-level configuration (either .mcp.json or .github/mcp.json)
• Plugins
The tools array on a server entry is an enforced allowlist, it's not a way to communicate what you think. For example:
{
"mcpServers": {
"docs-ro": {
"type": "local",
"command": "npx",
"args": ["-y", "some-docs-server"],
"tools": ["search_docs"],
"env": { "DOCS_ROOT": "/tmp/docs" }
}
}
}If you run copilot mcp get you can see it's enabled and has a single tool:
$ copilot mcp get docs-ro
docs-ro
Status: Enabled
Type: local
Command: npx -y some-docs-server
Environment:
DOCS_ROOT: ***
Tools: search_docs
Source: Workspace (/private/tmp/scoping-and-auth-check/cop/.mcp.json)The same can be achieved using copilot mcp add, which takes the same filter as --tools.
This way you can allow all tools on a server (--tools "*"), or only some of them (comma-separated names, eg --tools "tool1,tool2"), or none at all (then the server is there and connected, but doesn't expose any tools until you change it).
Finally, two more rarely-used flags that are also about the scope:
• --secret-env-vars — hides selected env vars from the terminal and the MCP servers' environment, showing only their names in the output. It's useful to make sure your shell credentials don't get passed to every stdio server you start.
• --allow-all-mcp-server-instructions is needed because by default initialization instructions are allowed only from allowlisted servers. Servers can inject text into the system prompt, not all of them are supposed to be able to do that by default.
You can disable a single MCP server using --disable-mcp-server <name>, and the bundled GitHub server using --disable-builtin-mcps. You can also configure the default tool subset for the bundled GitHub server; to make it bigger you can use --add-github-mcp-tool, --add-github-mcp-toolset, or --enable-all-github-mcp-tools. The default toolset is the restrictive stance, these flags are a way to opt-out from it.
The copilot help permissions page says --allow-all and --yolo are equivalent to --allow-all-tools --allow-all-paths --allow-all-urls, and --allow-all-tools is required for non-interactive runs. It means that once you run something with the script, the approval prompts go away, and the only controls left are the deny list and the tools filter on a per-server level. This is why it's best to define these before writing the pipeline, not after.
In Cursor
Two types of controls when it comes to MCP servers: (a) if the server is allowed to connect at all, (b) if for the server that connected, given tool runs without prompt.
Connection
All the configuration is stored in either <project>/.cursor/mcp.json or ~/.cursor/mcp.json files, the latter config is used everywhere. If it's a new server, by default it will be unloaded, until you approve it. For example:
$ agent mcp list
docs-ro: not loaded (needs approval)disable — disables this server from connecting and displays no prompts at all if it does connect (you can also enable a server to allow it to connect), useful if you don't want the server to connect at all; in case of a server you don't want to connect, it's best to use disable as then there's nothing more you can click "yes" on in tiredness.
list-tools — shows what tools are available on the server and their arguments (the inventory), so you can decide if you want to allow it, but it requires that the server is already loaded so if it's not approved it won't work, showing this message:
$ agent mcp list-tools docs-ro
Failed to list tools: Failed to load MCP 'docs-ro': MCP server "docs-ro" has not been approvedAnd as the connection level of approval happens first, only afterwards you can control every tool individually in terms of which ones are allowed and which aren't. To approve a server for usage with MCP you can run:
--approve-mcps — approves all servers that are MCP servers; similar to --force.
Per-tool
This is achieved by including the following entries in either <project>/.cursor/cli.json or ~/.cursor/cli-config.json files: Mcp(server:tool). For example:
{
"permissions": {
"deny": ["Mcp(*:*)"],
"allow": ["Mcp(docs-ro:search_docs)", "Mcp(docs-ro:get_page)"]
}
}In case of the above config, you can't call any MCP methods on this project but you can use two tools called "docs-ro:search_docs" and "docs-ro:get_page". You can use wildcards on either side (for example: *) but the vendor recommends to be careful with the ":". The denylist has higher priority than the allowlist.
In the IDE you can create a separate file for this — ~/.cursor/permissions.json or <workspace>/.cursor/permissions.json with an mcpAllowlist key and values as "server:tool". But there's no deny key, so not all CLI configuration options are available in the IDE.
Secrets
In general, to access secrets you can use envFile: <project>/.env but for remote MCP servers it doesn't work. To set secrets on a remote server you should use interpolation and access values like ${env:NAME} (it works in command, args, env, url, headers).
For the remote MCP servers the official way is to define credentials there — CLIENT_ID, CLIENT_SECRET, scopes. If you don't include the latter it automatically uses scopes_supported so the broadest grant is given by default — specify them explicitly.
Caveat (quoting the vendor):
The allowlist is a best-effort feature and does not act as a security boundary; determined agents or prompt injection could still circumvent it. It's therefore important to combine it with other security controls such as hooks.
That's true for every harness mentioned here, it's just that Cursor has a doc about it. So the allowlist is just an extra convenience layer on top of what really decides about the security: the key that the server holds and what the server is not willing to do — set the boundary there and use the allowlist against your mistakes.
In Antigravity CLI
2 files for configuration are used in Antigravity, one for defining servers and another for setting access rules, but often it's not obvious at first that it's these two different files so the configuration of the servers is mixed with the access rules which isn't right
~/.gemini/config/mcp_config.jsonor.agents/mcp_config.json(in a workspace) for servers~/.gemini/antigravity-cli/settings.jsonfor access rules (including ones for the MCP tools)
The server declaration. To define a server you can use a mcpServers object in this config file, it's the same in Antigravity 2.0, in the IDE and in the CLI. There's only one field that's problematic:
{
"mcpServers": {
"docs-ro": {
"command": "node",
"args": ["/usr/local/bin/docs-mcp-server.js"],
"env": { "DOCS_ROOT": "/srv/app/content" },
"disabledTools": ["delete_page"]
},
"my-remote-server": {
"serverUrl": "https://api.example.com/mcp/",
"headers": { "Authorization": "Bearer YOUR_API_TOKEN" }
}
}
}This is how you could configure a local stdio server (the first entry, using command, args and env — it also omits one of the tools) and a remote HTTPS server with an auth header (the second entry, using serverUrl):
The docs say that for the remote servers you need to specify the serverUrl property. They also explicitly state that the url/httpUrl properties aren't supported any more so you shouldn't use them. But there's no word what happens if you do anyway.
You can disable an entire server using the disabled field:
{
"mcpServers": {
"docs-ro": {
"command": "node",
"args": ["/usr/local/bin/docs-mcp-server.js"],
"env": { "DOCS_ROOT": "/srv/app/content" },
"disabled": true,
"disabledTools": ["delete_page"]
}
}
}You can also use disabledTools property to name tools that should be excluded from a server:
{
"mcpServers": {
"my-remote-server": {
"serverUrl": "https://api.example.com/mcp/",
"headers": { "Authorization": "Bearer YOUR_API_TOKEN" },
"disabledTools": ["delete_page"]
}
}
}This way the delete_page tool will be omitted from this server. disabledTools is the only per-tool-level option, there's no allowlist (inverse of a blacklist) so if you need to exclude specific tools you need to list them in this field as an exception and keep it updated when new tools are introduced by the server.
The permission side. The syntax of the access rules is the same throughout this harness — an action(target) expression, where action is mcp and target is a server name and a tool name separated by a slash:
mcp(server/tool)— single tool on a single servermcp(server/*)— all tools on one servermcp(*)— all MCP tools across all servers
{
"permissions": {
"allow": ["mcp(linter/*)"],
"deny": ["mcp(deploy/*)"]
}
}This is an example of the access rules allowing tools from one server and denying tools from another server. You can also use mcp(server) (no tool name) and mcp(server.tool) (with a dot instead of a slash) but this isn't documented, just the slash version.
By default if you haven't configured the MCP tools they work in the Ask mode which means that they will ask the user for permission before they are used. That's sensible, so it's good to have a configuration option that allows to change it to Allow or Deny.
The harness-wide order is deny over ask over allow (resolved by the type of the rule rather than how specific its pattern is) which means that if you set:
{
"permissions": {
"allow": ["mcp(linter/*)"]
}
}the tools from the linter server will be allowed, but if you add:
{
"permissions": {
"ask": ["mcp(*)"]
}
}they will be in the Ask mode despite being under the Allow rule for their server as the general ask rule is more restrictive.
The same applies everywhere else in the tool. It's good to keep it in mind.
Auth for remote servers. Authorization for remote servers works (and can be configured) but the Antigravity team seems to have assumed that the tool will be used with the GUI so the focus is on dynamic client registration process which runs automatically. If you want to use fixed credentials you need to define a oauth object with clientId and clientSecret properties or set the authProviderType property to google_credentials to use the Application Default Credentials. The tokens are stored in ~/.gemini/antigravity/mcp_oauth_tokens.json file.
But there's a problem — the only documented way to finalise the authorisation is by using the Agent Settings panel (in the IDE or in Antigravity 2.0). In version 1.1.10 of the CLI there's no CLI-native login command, nor an agy mcp subcommand at all so it seems that at this point the tool can't perform a new OAuth flow on its own, you can't run it from a headless box.
The gotcha. The last thing is that the configuration of the servers is meant to be shared — as the docs say there's a single standardised format used by Antigravity 2.0, IDE and CLI so defining a server in the config for the CLI will configure it for all three of them too. The access rules on the other hand are specific to the CLI and the docs don't mention that they can be shared across these three products.
Therefore it might be useful to remember that there are two separate configuration files, one for defining servers and another for setting access rules for them. If you decide to open the config file make sure which of them you're looking at as you won't see both types of settings in one place.
In Kimi Code CLI
The name "Kimi Code CLI" is used for two different things — the old Python build (installed with uv, lives under ~/.kimi/) and the new Node.js rebuild (npm package @moonshot-ai/kimi-code, lives under ~/.kimi-code/). All that follows is about the latter, it's the only one that has access to the tool-level MCP permissions. If you installed the old build you need to be aware that it can only set the global "on" or "off" switch for MCP, so every time you want to use it you need to approve it manually. If you see the version number 1.48.0 and a location under ~/.local/share/uv/ it means you're using the old build and most of what's described here won't work for you.
If you want to migrate from the old build to the new one, keep in mind that they can't share their OAuth sessions and MCP authorizations with each other, so after switching you'll need to authorize every server on your own.
The server definitions are always stored in mcp.json file, never in config.toml. It's located under:
~/.kimi-code/mcp.json— if you want it to apply for all projects.kimi-code/mcp.json— if you want it to be scoped to a single projectThe project-level definition takes precedence if there's the same-named one in both places
{
"mcpServers": {
"docs-ro": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/srv/app/content"],
"enabledTools": ["read_file", "list_directory"]
},
"linear": {
"url": "https://mcp.linear.app/mcp",
"bearerTokenEnvVar": "LINEAR_TOKEN"
}
}
}For every server, you can list which tools it can use with enabledTools and which ones — it can't use — with disabledTools. The transport type is inferred like this:
If
commandis defined — stdioIf
urlis defined buttransportisn't — HTTPIn the older SSE-based version you needed to specify the
transport: "sse"explicitly
You can also define static authentication headers in the headers field or use an env-var for a token with the bearerTokenEnvVar field, using the latter is better though so you can keep secrets out of the files.
If your server requires OAuth you can run /mcp-config login <server-name> to start the flow in the browser.
The permissions are defined in config.toml. You can have multiple rules there, they're being evaluated in order and the first one that matches gets applied. The outcome is either allowing or denying access or asking the user for their opinion. Here's an example of allowing everything coming from a GitHub server and disallowing one filesystem write tool:
[[permission.rules]]
decision = "allow"
pattern = "mcp__github__*"
[[permission.rules]]
decision = "deny"
pattern = "mcp__filesystem__write_file"You can also define a scope for each rule — turn-override, session-runtime, project or user. It's also good to set a reason field with some explanation, it's there just in case you or someone else will be wondering about these permissions in the future.
The tools are addressed like mcp__<server>__<tool>, with support for globs (* and **). The rules work based on tool names, so they can't compare it to arguments, which means that if you have a db query tool you can't allow its SELECT operation but disallow the DELETE. This is why if you want to do sth like this you need to create two separate tools on the server. The same is from the opposite perspective — we came to the same conclusion in the shared part of the lesson.
If there's no rule for a specific call the user gets asked and can choose to approve it for the current session which then makes future calls of the same type get automatically approved until the session ends.
The gotcha. Last but not least, if you read Moonshot's own docs you'll find an advice to keep the manual approval for risky operations (like file writes or command executions) and avoid using mcp__*, which is a wildcard that matches everything. This is because it's the shortest thing to type so people often pre-approve everything that way, including tools they didn't know about or future ones.
Where to start
No formal audit programme required. Just four steps:
Examine what each server's credential actually allows, not what its tools say — this is the actual permission set, and often a mind-blowing one
Separate everything that both reads and writes into two things and assign them separate credentials
Refuse to use any config you didn't write (this one can come via an already-committed config file, so it's a choice, not a default; both Claude Code and Cursor have gates around this per server, report it in their output, others go by the directory trust and Kimi Code's documentation explicitly says that project-level STDIO entries are commands the session runs at the start — find out which case applies before you clone)
Provoke a denied call and look what the model got back; if it doesn't have an error message saying "you can't", the model will waste tokens trying to find a way