Book a call
LESSON16mVERIFIED 2026-08-03 · CLAUDE CODE 2.1.193 · CODEX CLI 0.146.0 · GITHUB COPILOT CLI 1.0.77 · ANTIGRAVITY CLI 1.1.10 · KIMI CODE CLI 0.31.1

Local or remote: where the trust boundary goes

A local MCP server runs as you, with your whole environment. A remote one gets whatever the model puts in an argument. Choosing on convenience is choosing blind.

The question that isn't about latency

You can install an MCP server by running a command with some arguments (potentially including an API key) locally or by just copying a URL, not needing to install anything and never having to track a version. People are usually lazy, so they choose the second option. Which is totally sensible in package managers but suboptimal here because what's dangerous in one way for one server might be dangerous in another way for another server:

  • you can give access to your SSH keys

  • you can provide an arbitrary string as an argument (which is whatever the model puts there)

Neither is worse than the other; it depends on the server.

"Local" is two different words

In this lesson we explore two dimensions that are sometimes mixed together, namely:

  • transport — how bytes travel from the harness to the server; the spec standardised two transports:

    • stdio — the harness starts the server as a subprocess and communicates with it via standard streams

    • Streamable HTTP — each message is an HTTP POST request to a single endpoint

  • location — what machine runs the code, and which credentials are available for this code during its run

These two dimensions should be separated and thinking they are bound together leads to making bad decisions:

  • you can have an HTTP server listening on 127.0.0.1 (remote transport) but fully local trust as it's the same machine and the same user

  • you can have stdio wrapper around a 3rd party API (npx -y @vendor/mcp-server) (local transport), at the same time giving access to your arguments to a different company and running the wrapper with your account

As this is a little hard to comprehend, let us conduct an experiment for you. We have created two almost identical probes — one based on stdio transport and the other one using Streamable HTTP. Both of them do one thing — ask the server for information it can access. Then we run both of these probes against Claude Code v2.1.193. The result is a single tool that reports about what the server process can see. The stdio-based version is the file printed at the end of this lesson.

When we run the stdio-based probe, it tells us this:

JSON
{
  "uid": 501,
  "user": "przemek",
  "cwd": "/private/tmp/local-or-remote-check",
  "ppid": 68357,
  "home_readable": true,
  "env_var_count": 62,
  "credential_shaped_env_vars": ["ACADEMY_DEMO_TOKEN", "GITLAB_TOKEN"]
}

When we run the Streamable HTTP probe against an HTTP server listening on 127.0.0.1 (and configured with type http and url), the only differences are the parent PID and the fact that it says "streamable-http" as the transport, like this:

JSON
{
  "uid": 501,
  "user": "przemek",
  "cwd": "/private/tmp/local-or-remote-check",
  "transport": "streamable-http",
  "home_readable": true,
  "env_var_count": 62,
  "credential_shaped_env_vars": ["ACADEMY_DEMO_TOKEN", "GITLAB_TOKEN"]
}

As you can see, the uid, the home folder, and environment variables are identical. The only thing that changes is the transport — which moved none of the trust.

In other words, what matters is whose machine runs the code, which credentials are available for the code during its run, and where arguments go — never stdio vs HTTP.

What you hand a local server

Let's have a look at what a local server can access:

  • It has the same uid as the harness. The spec's security guidance tells you to inform users that local servers run with client privileges.

  • Its home directory is accessible (including ~/.ssh) although it wasn't configured in any way. The spec advises clients to launch servers with restricted filesystem access and to give users a way to grant individual directories back, but these are recommendations for harness developers, not a config key you can set.

  • It sees 62 environment variables (including GITLAB_TOKEN — an active credential for another tool), despite the fact that we have configured the server with no env at all.

In our experiment we have even tried to add a single env variable using an env block and the count increased from 62 to 63 (we've seen SERVER_API_KEY there and haven't seen anything disappear).

JSON
{
  "env_var_count": 63,
  "credential_shaped_env_vars": ["ACADEMY_DEMO_TOKEN", "GITLAB_TOKEN", "SERVER_API_KEY"]
}

So it looks like on Claude Code the env block is a way to give the server its own key, not a way to hide secrets.

Two of the six document this differently:

  • Codex says that env_vars is a list of variables to allow and forward (and whitelist later in the document)

  • GitHub Copilot automatically inherits PATH but for everything else you need to provide it in the env entry

So both of these read narrower than Claude Code's, but we haven't tested whether they behave that way.

What's important is that if you install an MCP server using npx -y @vendor/mcp-server@latest, the @latest part means that the package is resolved at launch time so your review of one version is followed by running whatever is in a newer version.

Which leads us to the next point: the spec has a section called Local MCP Server Compromise, which exists to say that local is not the same as safe. It lists three attacks, and this is the second of them — an attacker distributing a malicious payload inside the server itself. The other two are an attacker putting a malicious startup command into a client configuration, and an attacker reaching an insecure local server left listening on localhost via DNS rebinding.

If it comes to the latter attack, the spec requires servers based on HTTP protocol to validate the Origin header and respond with 403 in case it's present and invalid and recommends setting up the server to listen on localhost only. But if you have a look at our demo implementation for Streamable HTTP, you can see it ignores the Origin header and responds with 200 and the tool list to a request that has some other Origin:

BASH
curl -s -i -X POST http://127.0.0.1:8931/mcp \
  -H 'Content-Type: application/json' \
  -H 'Origin: https://evil.example.com' \
  -d '{"jsonrpc":"2.0","id":9,"method":"tools/list"}'

So even though you set the server to bind to localhost, it's still an open socket, and validating the origin is the responsibility of the developer.

Generally, the spec recommends using stdio if you run a server locally because it limits access to the launching client; for HTTP-based servers it advises setting up an authorisation token or using a Unix domain socket.

What you hand a remote server

Let's now have a look at what a remote server can access: it doesn't have the uid nor accesses your home folder, and there are no environment variables. But it has two other things:

  • Your arguments — paths, snippets, error messages, etc. The thing is that MCP tools are model-driven by design so whatever the model puts in these arguments is not up to you — it's what it finds and uses from your codebase. If you wouldn't want to share it with the support team of this vendor, don't assume they won't be used if you install the server.

  • A token. This is where the more serious part begins. The spec has a number of requirements in place for the token:

    • Protected Resource Metadata (RFC 9728) to help clients find the authorisation server

    • Audience validation (RFC 8707) — the server must verify that the token was issued for it. It can't be used if it wasn't — every server is required to reject it.

The latter point is the one that matters. The tempting design is to accept a token meant for something else and forward it untouched to the API behind you, and the spec has a name for what that produces — the confused deputy problem, which it describes for proxy servers fronting 3rd party APIs.

The thing is that if a token is properly scoped and can be revoked by the provider, it's better than your personal access token which is stored in your shell's config file, keeps its scopes forever and is available to every single local server you install.

As of the 2026-07-28 revision, MCP is stateless — the initialise handshake was removed and now there's no Mcp-Session-Id header in Streamable HTTP requests. This means that if a server needs continuity it must mint an explicit handle which you pass back as an argument. The spec also says servers must not treat possession of a state handle as authentication, so even though it comes back looking like a session, treat it as a bearer value sitting in your transcript.

Also, the old HTTP+SSE transport was formally deprecated in the same revision (it had been on its way out since March 2025), so if you see guides that tell you to set sse as the type, they're outdated; use http instead.

The two bad quadrants

Combining these two dimensions results in four possible outcomes:

  • A local stdio-based proxy server for a 3rd party SaaS product installed with npx -y @vendor/mcp-server@latest and with your personal access token. This is the worst combination — you take the whole local blast radius and the whole wire disclosure, and get neither the containment of a real remote boundary nor a revocable token. And it's where every README-based npx snippet takes you.

  • A remote server for data that's already on your machine (e.g. hosted code search which involves sending your source code to a 3rd party so they can do what grep does). This is another suboptimal combination because if the data is already on your machine, it's reasonable to assume it should stay there.

The only sensible combinations are:

  • Local servers for things that can't be replaced with a hosted solution (or for which a hosted solution would be a totally different product) like filesystem operations, git commands, docker stuff, a local database, or internal tooling. And if you choose to go with a local server, the last thing to decide is whether you trust its code — you can either pin and read it or don't install it at all.

  • Remote servers for things that are in other systems of record like issues (already in your tracker) or errors (in an error tracking tool). If the data was already on the other side of the boundary, a scoped token is the cheapest way to access it.

Two questions, not a matrix

There are two questions you need to ask yourself before installing any MCP server:

  1. Would I be okay with running this vendor's code as myself today and after every silent update they ship? (the local question). If not, you can either pin and read their code or don't install it at all.

  2. Am I okay with everything the model can put in an argument reaching this vendor? (the remote question). This is not about a single file but about anything it finds in your codebase that you wouldn't want to share with the support team of this vendor.

If both answers are no, transport is not what you should change. You should probably not install the server.

IN YOUR HARNESS

In Claude Code

You choose the transport with a flag on claude mcp add, and if you don't set it you get stdio. Here I add a local server with an environment variable, forwarding everything after -- to the server as-is:

BASH
# local: everything after -- is handed to the server untouched
claude mcp add airtable --env AIRTABLE_API_KEY=YOUR_KEY -- npx -y airtable-mcp-server

Now, let's say I want to create a remote server — I just need to provide the transport:

BASH
# remote
claude mcp add --transport http sentry https://mcp.sentry.dev/mcp

There are few options for the flag, you can set it to stdio, sse or http, but sse is now only supported for a retired transport. What's more important is where the entry is defined, the transport matters less compared to that. There are three scopes you can define an entry in, and they are not merged — the highest-precedence entry wins whole:

ScopeApplies toShared with the teamKept in
local (default)this project onlyno~/.claude.json, under projects."/path/to/the/project"
projectthis project onlyyes, through version control.mcp.json at the project root
userall of your projectsno~/.claude.json

The order is local, then project, then user, then plugin servers, then claude.ai connectors.

Project scope is the one with a trust story attached, because a server definition can arrive in a pull request. Claude Code prompts for approval before it uses a project-scoped server from .mcp.json, and until you approve one it shows up as pending in claude mcp list and is not connected to. claude mcp reset-project-choices clears those decisions for the current project.

When it comes to remote servers, you can log in to a specific one with claude mcp login <name> which performs OAuth flow from the terminal or revoke access with claude mcp logout <name> (which actually removes the saved credentials). It's better to do it that way than removing a token sitting in some dotfile, and once you run claude inside a session you can just run /mcp.

Anthropic's MCP page says to verify you trust each server before connecting it, because servers that fetch external content can expose you to prompt injection risk.

If you were to create a JSON for a remote server manually, make sure to include type field next to url, otherwise it'll be treated as stdio so the entry won't be used. If that happens, you'll see the following message: MCP server "<name>" has a "url" but no "type"; add "type": "http" (or "sse" / "ws") to this entry. Before v2.1.202 the same error looked like this: command: expected string, received undefined (but the user never actually defined it).

JSON
{
  "mcpServers": {
    "sentry": { "type": "http", "url": "https://mcp.sentry.dev/mcp" }
  }
}

Finally, if you want to use servers from a specific JSON file, you can do it with --mcp-config <file> flag and if you set --strict-mcp-config too, no other sources of servers will be used. That pair is how the experiments in this lesson were run without touching a real config and you can always test an unknown server in a temporary directory really cheaply.

In Codex CLI

In config.toml you can define multiple MCP servers as well as other settings of Codex — either globally in ~/.codex/config.toml or for a specific project in .codex/config.toml (the latter is loaded only when the project is trusted). For instance:

  • Stdio — a command to run and list of its arguments, as well as a list of allowed environment variables (and their actual values nested):

TOML
[mcp_servers.context7]
command = "npx"
args = ["-y", "@upstash/context7-mcp"]
env_vars = ["LOCAL_TOKEN"]

[mcp_servers.context7.env]
MY_ENV_VAR = "MY_ENV_VALUE"
  • Streamable HTTP — similar to stdio but with an url field instead of command:

TOML
[mcp_servers.figma]
url = "https://mcp.figma.com/mcp"
bearer_token_env_var = "FIGMA_OAUTH_TOKEN"
http_headers = { "X-Figma-Region" = "us-east-1" }

And in particular, it's good that there is bearer_token_env_var — a way to configure what environment variable is used for the token but not the token itself (so you don't need to put it in the file that might be committed).

  • Auth — by default uses OAuth; you can do codex mcp login <server-name> and follow the flow.

  • Scoping — there are 2 more fields that allow you to limit what's been granted:

    • env_vars — an allowlist of environment variables that are allowed for a stdio server to access, this is the only tool that uses an "allowlist" term — the other 5 don't.

    • approval — can be set on a per-server and per-tool basis in the server entry:

TOML
[mcp_servers.figma]
url = "https://mcp.figma.com/mcp"
default_tools_approval_mode = "prompt"

[mcp_servers.figma.tools.get_file]
approval_mode = "auto"
  • Values — both default_tools_approval_mode and the per-tool approval_mode take one of four: auto, prompt, writes or approve.

  • Comparison — Antigravity and Kimi support setting a per-tool value too (but they have a separate file for it), so it's only Codex that allows setting it directly in the server entry and adding writes there; this way you can configure which tools are read-only (will run automatically) and which are write (will still ask for approval) within a single server.

  • Toggle — you can set enabled = false to disable a server without removing its entry (useful if you want to isolate which server is responsible for a certain behaviour).

  • Keep in mind — that config.toml file is used by Codex CLI, ChatGPT Desktop App and the IDE extension, so adding a new server there will affect all of these; it's not a decision you make in the terminal.

  • Handy — codex mcp list shows the servers with their names, urls, bearer-token variables, enabled statuses and auth methods; it's a quicker way to check what's already there than opening the TOML.

In GitHub Copilot CLI

The "run as local stdio process" vs "run as remote HTTP/SSE endpoint" split is something that Copilot's built-in help itself describes in its own words. It's basically a single config file with the same schema for both types of servers, where you choose between them using the type field like this:

JSON
{
  "mcpServers": {
    "playwright": {
      "type": "local",
      "command": "npx",
      "args": ["@playwright/mcp@latest"],
      "env": {},
      "tools": ["*"]
    },
    "context7": {
      "type": "http",
      "url": "https://mcp.context7.com/mcp",
      "headers": { "CONTEXT7_API_KEY": "YOUR-API-KEY" },
      "tools": ["*"]
    }
  }
}

The type field is quite flexible, with local and stdio being synonymous in terms of behaviour. The GitHub docs recommend using stdio for the sake of portability across VS Code, Copilot cloud agent, and other MCP clients. The http value means Streamable HTTP, while sse remains supported as a legacy option.

There are CLI subcommands for both:

BASH
copilot mcp add context7 -- npx -y @upstash/context7-mcp
copilot mcp add --transport http notion https://mcp.notion.com/mcp

The config is read from the following locations, with the project one taking precedence:

  • User config: ~/.copilot/mcp-config.json

  • Project config: .mcp.json or .github/mcp.json

  • The .vscode/mcp.json file is intentionally ignored

If you're to include a config, tools is the field that really matters. In the file it's an array, where ["*"] means "all" and you can list specific tools if needed. On the CLI, you can use --tools *, or comma-separated tool names, or an empty string for no tools. This is the only field that actually limits what a server can do (in terms of which tools it can operate with) instead of just determining how it's being run. That's why it's especially useful for remote servers, where it's the only way to control what they can do.

By default, Copilot ranks the origin of servers as follows:

  • Built-in — high

  • .github/mcp.json and .mcp.json — medium

  • User config (~/.copilot/mcp-config.json) — user-defined

  • Remote — low, always check

Underneath that sits a blunt rule: every call to any MCP tool needs explicit approval, including read-only calls against external services.

Due to the above, it's important to remember that project servers are loaded only after you accept the prompt to trust the project. So if a colleague in your team sees them working but you don't, it might be that you haven't trusted the folder yet — the project servers aren't being automatically loaded in untrusted directories, so what works for them won't work for you.

The same is true for copilot -p (prompt mode) — unless you set GITHUB_COPILOT_PROMPT_MODE_WORKSPACE_MCP=true it will behave the same way — load the servers in already trusted dirs and omit them otherwise. This is why the project's workspace servers might not be working on CI. You can check what's actually loaded with copilot mcp list.

In Cursor

There are two places for the configuration file in Cursor: ~/.cursor/mcp.json for everything globally and .cursor/mcp.json in every project, with exactly the same structure. For example:

JSON
{
  "mcpServers": {
    "local-tool": {
      "type": "stdio",
      "command": "npx",
      "args": ["-y", "mcp-server"],
      "env": { "API_KEY": "value" }
    },
    "remote-tool": {
      "url": "https://api.example.com/mcp",
      "headers": { "API_KEY": "value" }
    }
  }
}

Cursor supports three types of transport protocols for MCP tool — stdio, SSE and Streamable HTTP. The envFile property is available only for stdio servers as only the locally-started server has an environment to point to. There are a few built-in variables you can use in any of the fields (command, args, env, url, headers) to substitute them during the run — it's useful if you don't want to store secrets in version-controlled files:

  • ${env:VARIABLE_NAME} - to get an environment variable

  • ${workspaceFolder} - the path to the workspace folder of the project

  • ${userHome} - the home directory of the user

  • ${pathSeparator} - platform-specific file separator

By default, for security reasons, cursor asks for approval before running any tool using MCP. It also puts MCP tools under the same Run Modes as shell commands. From the CLI:

BASH
agent mcp list                    # what is configured, and its status
agent mcp list-tools <name>       # what it actually exposes, and the argument names
agent mcp login <name>            # OAuth against a remote server
agent mcp enable <name>           # add it to the local approved list
agent mcp disable <name>          # never loaded, never prompted for

Before you trust any remote server, run agent mcp list-tools on it — that's the difference between approving a name and reading the surface you approved. The servers live in .cursor/mcp.json, and that's the same file cursor-agent reads — there is no separate CLI config. So a server you add in the GUI is visible to every headless agent -p run from then on, which means the set of servers a script can reach is a set you last thought about in a settings pane. Worth an agent mcp list before you script anything.

On the docs themselves: type is described as required with the value "stdio", yet every example omits it and no type value is documented for HTTP or SSE. Follow the examples, tell the two apart by command versus url, and don't copy a "type": "http" line across from another harness assuming it means anything here.

Cursor's own warning is worth keeping to hand: MCP servers can access external services and execute code on your behalf, so understand what a server does before you install it.

In Antigravity CLI

There's no agy mcp subcommand — the 1.1.10 version has agy agent, agy agents, agy changelog, agy help, agy install, agy models, agy plugin, agy plugins, agy update. agy mcp --help outputs the top-level usage instead of throwing an error. To manage MCP servers you can run /mcp command in the Antigravity TUI or modify the configuration file.

The configuration is stored separately from the rest of the CLI settings:

  • For global servers: ~/.gemini/config/mcp_config.json

  • For per-workspace servers: .agents/mcp_config.json

  • The CLI's settings are in ~/.gemini/antigravity-cli/settings.json and contain only permission-related settings — no server definitions

  • OAuth tokens are stored in ~/.gemini/antigravity/mcp_oauth_tokens.json

Example configuration file containing a local command-based server (with an env var) and a remote server (with an auth header):

JSON
{
  "mcpServers": {
    "sqlite-explorer": {
      "command": "node",
      "args": ["/usr/local/bin/sqlite-mcp-server.js"],
      "env": { "SQLITE_DB_PATH": "/var/data/app.db" }
    },
    "my-remote-server": {
      "serverUrl": "https://api.example.com/mcp/",
      "headers": { "Authorization": "Bearer YOUR_API_TOKEN" }
    }
  }
}

One important thing — for the remote server, the address is in the serverUrl field, not url, as it's the case for the other 5 tools. This is the biggest difference compared to other tools and is documented, so if you lift an example from a Claude Code or Cursor config, its address will be sitting in url (or httpUrl) — fields this tool does not read.

There are three ways to authenticate:

  • The Antigravity way using Google application default credentials (authProviderType: google_credentials)

  • Using the oauth object with clientId and clientSecret, or the auto-registration

  • Using a fixed set of headers

Permissions are defined in ~/.gemini/antigravity-cli/settings.json as actions under allow, deny, ask. For example:

JSON
{
  "permissions": {
    "allow": ["mcp(sentry/get_issue)"],
    "ask": ["mcp(*)"]
  }
}

The priority is as follows: deny > ask > allow, regardless of the specificity of a rule. If none of these rules applies, it defaults to ask for MCP calls.

In the example above, the last line actually shoots itself in the foot, because due to the ask priority the wildcard from that very line "hijacks" the specific allow entry and so you get prompted for exactly the tool you wanted to pre-approve. To fix it, carve the wildcard down instead of trying to carve exceptions out of it.

In Kimi Code CLI

The first thing to be aware of is that there are two different tools in terms of what's used — the Python-based Kimi CLI and the rewritten Kimi Code CLI — so before you do anything, make sure which one you use.

  • In case of the old Kimi CLI, the list of servers is at ~/.kimi/mcp.json (you can check it with kimi mcp list subcommand)

  • For Kimi Code, it's either ~/.kimi-code/mcp.json or $KIMI_CODE_HOME/mcp.json, with the option to have a per-project version in .kimi-code/mcp.json in the root of your project

  • It's important because the folder names differ only by a hyphen, so if you don't see a server listed, make sure which file you point to

It's one file with a single top-level object. The type of transport is inferred based on the properties it has (rather than being explicitly declared):

JSON
{
  "mcpServers": {
    "filesystem": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
    },
    "linear": {
      "url": "https://mcp.linear.app/mcp"
    },
    "legacy-events": {
      "transport": "sse",
      "url": "https://mcp.example.com/sse"
    }
  }
}

As documented in the docs:

  • if there's a command property, it's a stdio server,

  • if there's a url property and no transport one, it's an HTTP server,

  • for the older SSE servers you need to set the transport property to "sse"

Additionally:

  • for remote servers you can define headers and bearerTokenEnvVar

  • for stdio servers you can set env and cwd properties,

  • for both types you can define enabled, enabledTools and disabledTools

Then, in the TUI:

  • to manage the config, go to /mcp-config — create, edit or remove servers

  • to see connection status of every server, go to /mcp

  • to log into servers requiring OAuth, go to /mcp-config login <server-name>

The permissions for these work the same way as for everything else — the identifiers are in the format of mcp__<server>__<tool>, you can use * and ** in the patterns, and the rules are defined in config.toml. For example:

TOML
[[permission.rules]]
decision = "allow"
pattern = "mcp__linear__*"

[[permission.rules]]
decision = "ask"
pattern = "mcp__**"

The first one is a wildcard allow for all tools of "linear". The second one is an ask for any tool of any server. The latter must go last in the list as the matching process goes from top to bottom and when it finds the first match, stops (so if you were to put this one above the previous rule, it would be too generic to work).

But the most important thing we want to point out is that the arguments you pass to tools are not part of the permission patterns. That means you can allow or deny a server's tool by name, but you can't constrain what it gets called with. In particular, for remote servers it means that if you add an allow rule, you give your blessing to share all the parameters the model comes up with with this server — so make sure to use it only for those you already trust with such data.

The same applies for the stdio entries defined in .kimi-code/mcp.json files within repositories: these run local commands when you start a session, so make sure you enable them only for repositories you really trust. It's like it's a build script — once you check out a repo, it can come with a definition of a server.

And the last thing is that we've checked all of this against the current docs but we were using only the old Python-based Kimi CLI on the machine we wrote this on so we didn't test any of the Kimi Code paths (only the Claude Code ones were).

What neither side buys you

In terms of security neither transport protects you from:

  • Untrusted input — the spec says behaviour descriptions should be considered untrusted unless they come from a trusted server, and that clients must treat tool annotations as untrusted unless the server is trusted. Sharper for remote servers, the spec also says their tool lists might be dependent on the request's authorisation and that it might change over time (without the client needing to prompt). So you were agreeing to the name and URL, not behaviour. This is what people often call rug pull, but this is a term the spec doesn't use and doesn't have a section for. The spec's answer to all of it is that there should always be a human in the loop able to deny a tool invocation. Which only works while the volume of invocations is small enough that you still read them — and that depends on how many servers you installed, not on their transport.

THE FILEmcp-probe/probe_server.py
PYTHON
#!/usr/bin/env python3
"""Minimal stdio MCP server. One tool: `probe`. Reports what the server process
can see about the machine it runs on. No dependencies, no network.

Register it in a scratch directory and ask your agent to call `probe`. It reports
NAMES of credential-shaped environment variables, never their values."""
import json
import os
import sys

TOOL = {
    "name": "probe",
    "description": "Report the uid, cwd and selected environment of the server process.",
    "inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
}


def probe():
    interesting = [k for k in os.environ if "TOKEN" in k or "SECRET" in k or "KEY" in k]
    return {
        "uid": os.getuid(),
        "user": os.environ.get("USER"),
        "cwd": os.getcwd(),
        "ppid": os.getppid(),
        "home_readable": os.access(os.path.expanduser("~/.ssh"), os.R_OK),
        "env_var_count": len(os.environ),
        "credential_shaped_env_vars": sorted(interesting),
    }


def send(msg):
    sys.stdout.write(json.dumps(msg) + "\n")
    sys.stdout.flush()


for line in sys.stdin:
    line = line.strip()
    if not line:
        continue
    req = json.loads(line)
    method, rid = req.get("method"), req.get("id")
    if method == "initialize":
        send({"jsonrpc": "2.0", "id": rid, "result": {
            "protocolVersion": req["params"]["protocolVersion"],
            "capabilities": {"tools": {}},
            "serverInfo": {"name": "probe", "version": "0.1.0"},
        }})
    elif method == "tools/list":
        send({"jsonrpc": "2.0", "id": rid, "result": {"tools": [TOOL]}})
    elif method == "tools/call":
        send({"jsonrpc": "2.0", "id": rid, "result": {
            "content": [{"type": "text", "text": json.dumps(probe(), indent=2)}]
        }})
    elif rid is not None:
        send({"jsonrpc": "2.0", "id": rid, "error": {"code": -32601, "message": "not found"}})
j / k to move between lessons