From 00d44a65c859b67dd12b396b28ebcf1ebecf3e72 Mon Sep 17 00:00:00 2001 From: Sonika Janagill Date: Tue, 18 Aug 2026 17:03:43 +0100 Subject: [PATCH 1/2] feat: add LinkedIn MCP server demo with governance patterns and documentation --- .../session-02-tools-boundaries/README.md | 59 +++++- .../live-demo/.mcp.json | 12 ++ .../live-demo/mcp/README.md | 168 ++++++++++++++++++ .../live-demo/mcp/linkedin_server.py | 130 ++++++++++++++ 4 files changed, 367 insertions(+), 2 deletions(-) create mode 100644 sessions/session-02-tools-boundaries/live-demo/.mcp.json create mode 100644 sessions/session-02-tools-boundaries/live-demo/mcp/README.md create mode 100644 sessions/session-02-tools-boundaries/live-demo/mcp/linkedin_server.py diff --git a/sessions/session-02-tools-boundaries/README.md b/sessions/session-02-tools-boundaries/README.md index a789a9f..60aeaf5 100644 --- a/sessions/session-02-tools-boundaries/README.md +++ b/sessions/session-02-tools-boundaries/README.md @@ -11,10 +11,63 @@ An agent is only as useful as the tools you trust it with, and only as safe as t - Wire one MCP server, scoped to a single project (work tools never leak into hobby projects) - Add a pre-commit JSON hook that scans for secrets on every agent commit - Set permissions and a sandbox policy +- **Live demo:** a real LinkedIn MCP server (below), because none existed off the shelf + +## Tonight's demo: a LinkedIn MCP server, built because none existed + +There's no official LinkedIn MCP server. This one was built for the ADK social-poster agent (`social-spark`) and is a genuinely useful "why MCP" story: a capability that doesn't exist anywhere else, wrapped once so any MCP-aware surface can use it, not just the one agent that needed it first. + +**What it exposes** (`mcp/linkedin_server.py`, FastMCP over stdio): + +| Tool | What it does | Risk | +|---|---|---| +| `get_profile()` | Reads the authenticated member's id/name via OpenID userinfo | Read-only, low | +| `create_post(text, image_path?)` | Publishes to the member's live feed via the LinkedIn Posts API | Write, public, irreversible | + +**Two governance mechanisms already built in, which map straight onto tonight's concepts:** + +1. **`DRY_RUN` (default `true`)** — a hand-rolled hook. Every call to `create_post` checks this flag *before* touching the network; with it on, the server logs the payload and hands back a fake `post_url`, nothing reaches LinkedIn. It's not called a hook in the code, but that's exactly the pattern: a gate the agent cannot talk its way past, because the check lives in the tool, not in the prompt. +2. **The OAuth scope itself (`openid profile w_member_social`)** — permissions LinkedIn granted the token, independent of anything your agent config says. Two layers of "allow": what LinkedIn's token allows, and what your own agent config allows on top of that. + +**Live build sequence:** + +1. Start the server with `DRY_RUN=true` (the default) and run it through `npx @modelcontextprotocol/inspector uv run mcp/linkedin_server.py` so the room sees raw MCP tool calls, no agent involved yet. +2. Call `get_profile()` live — read-only, set to **allow** in the permission config. +3. Call `create_post()` — set to **ask**, so the agent proposes the post text and waits for a thumbs up before the (still dry-run) call fires. +4. Show the JSON hook layer: `DRY_RUN` is already a hook, but for the demo, formalise it as an explicit pre-call check in `agents.yaml` too, so the boundary lives in config, not just in one Python file someone could edit. +5. **Do not flip `DRY_RUN=false` live.** If you want to show a real post, do the one verified happy-path run before the session (per the server's own README), then reset it to `true` and leave it there for the demo. + +## MCP server vs. direct API call as a tool: which one, and when + +This is the real decision behind `linkedin_server.py`, worth naming explicitly tonight since it's the natural follow-on question after Session 1's skills. + +**Write it as a direct tool function inside the agent (no MCP) when:** +- Only one agent, in one framework, will ever call it +- It's a single function, low complexity, no need to run as its own process +- In-process latency matters more than portability +- You're happy for the capability's guardrails to live wherever that agent's code lives + +**Wrap it as an MCP server (what was actually built here) when:** +- The capability should be reusable outside the one agent that needed it first — this LinkedIn server can be dropped into Claude Code, Claude Desktop, another ADK agent, or tested standalone via `mcp-inspector`, without rewriting anything +- You want the governance (the `DRY_RUN` gate, the token scope, the permission checks) to travel *with* the capability, not be re-implemented per agent that uses it +- You expect more than one agent or team to eventually need the same tool +- Nothing off-the-shelf exists yet and you'd rather build the reusable version once + +Rule of thumb for the room: **skill first if it's judgment, MCP first if it's action nobody else has wired yet.** A one-off internal function is fine as a direct tool; a capability worth reusing earns the MCP wrapper. + +## MCP/Hooks (this session) vs. Agent Skills (Session 1): which one, and when + +Ties the two sessions together, which is worth spelling out explicitly since it's the exact confusion people hit: + +- **Agent Skill (Session 1)** is the *brain*: a reusable playbook, markdown, no side effects, portable as instructions the model loads by name. Use it to standardise judgment or process — e.g. "how WCC writes a LinkedIn post in our voice and structure." +- **MCP server (this session)** is the *hands*: real code, real API calls, real side effects in the world. Use it when the agent needs to actually *do* something outside the conversation — e.g. actually publish that post. +- **Permissions + hooks (this session)** are the *seatbelt* on those hands: they govern what the hands are allowed to do, and they're enforced outside the model, so the model can't reason its way past them. + +Concretely, in the `social-spark` project: a "write a WCC-voice LinkedIn post" Skill from Session 1's pattern would sit next to this LinkedIn MCP server. The Skill decides *what to write and how*; the MCP server is the only thing that can actually *publish* it; `DRY_RUN` plus an `ask` permission tier decide *whether it's allowed to, right now*. All three layers, one agent. ## Steal This -Project-scoped MCP, governance by default. +Project-scoped MCP, governance by default. Also worth stealing: a `DRY_RUN`-style flag baked into any tool with real-world side effects, checked in code, not just described in a prompt. ## Takeaway @@ -24,7 +77,7 @@ An agent that can act, but only where you've allowed it. ``` session-02-tools-boundaries/ -├── live-demo/ # MCP config and pre-commit hook built live +├── live-demo/ # MCP config and pre-commit hook built live, incl. linkedin_server.py demo ├── starter-template/ # Blank starting point to follow along └── participants/ # Submit your own version here (see badges/badge-criteria.md) ``` @@ -32,3 +85,5 @@ session-02-tools-boundaries/ ## Setup See [`getting-started/`](../../getting-started/) for the general prerequisites. A session-specific setup checklist is posted in [#ai-learning-series](https://womencodingcommunity.slack.com/archives/C09L9C3FJP7) the week before. + +If you're following along with the LinkedIn MCP demo specifically: you'll need `uv`, `fastmcp`, `httpx`, and (only if you want a real, non-dry-run post) a LinkedIn Developer app with the `Share on LinkedIn` and `Sign In with LinkedIn using OpenID Connect` products added. Full OAuth walkthrough lives in `mcp/README.md` next to the server. diff --git a/sessions/session-02-tools-boundaries/live-demo/.mcp.json b/sessions/session-02-tools-boundaries/live-demo/.mcp.json new file mode 100644 index 0000000..b0cfb06 --- /dev/null +++ b/sessions/session-02-tools-boundaries/live-demo/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "linkedin": { + "command": "uv", + "args": ["run", "mcp/linkedin_server.py"], + "env": { + "LINKEDIN_ACCESS_TOKEN": "${LINKEDIN_ACCESS_TOKEN}", + "DRY_RUN": "true" + } + } + } +} diff --git a/sessions/session-02-tools-boundaries/live-demo/mcp/README.md b/sessions/session-02-tools-boundaries/live-demo/mcp/README.md new file mode 100644 index 0000000..b3922bd --- /dev/null +++ b/sessions/session-02-tools-boundaries/live-demo/mcp/README.md @@ -0,0 +1,168 @@ +# LinkedIn MCP server + +FastMCP stdio server exposing `get_profile()` and `create_post(text, image_path?)`. +With `DRY_RUN=true` (the default) it logs the payload and returns a fake post URL — +nothing touches LinkedIn. Set `DRY_RUN=false` only for the single happy-path +verification, then flip it back. + +## Prerequisites + +| Tool | Needed for | Check | Install | +|---|---|---|---| +| [`uv`](https://docs.astral.sh/uv/) | Running the server / smoke tests | `uv --version` | `curl -LsSf https://astral.sh/uv/install.sh \| sh` | +| Node.js + `npx` | MCP Inspector only | `npx --version` | https://nodejs.org (or `brew install node`) | +| [Claude Code](https://claude.com/product/claude-code) | The live Claude Code demo | `claude --version` | `npm install -g @anthropic-ai/claude-code` | +| A LinkedIn Developer app + access token | Only if you want `get_profile`/`create_post` to hit the *real* API (dry-run needs none of this) | — | see "LinkedIn Developer app setup" below | + +Everything else (`fastmcp`, `httpx`) is resolved automatically by `uv` — see next section. + +No `requirements.txt` needed — dependencies (`fastmcp`, `httpx`) are declared inline at +the top of `linkedin_server.py` as [PEP 723 script metadata](https://peps.python.org/pep-0723/). +`uv` reads that block and resolves an ephemeral environment on the fly, but **only when the +script itself is the thing you hand to `uv run`** — `uv run mcp/linkedin_server.py`, not +`uv run python mcp/linkedin_server.py` (the latter runs `python` as the command, so uv never +looks at the script header and you'll get `ModuleNotFoundError: No module named 'fastmcp'`). + +```bash +# standalone smoke test (from the repo root) +DRY_RUN=true uv run mcp/linkedin_server.py # then speak MCP over stdio, or: +npx @modelcontextprotocol/inspector uv run mcp/linkedin_server.py +``` + +First run downloads `fastmcp`/`httpx` into a throwaway env (a few seconds); after that +`uv` caches it and startup is instant. No manual `pip install` step required. + +> Run only **one** of the two lines above at a time, not both back to back — the first +> starts the raw server and blocks the terminal waiting for JSON-RPC on stdin. It has +> nothing to show you and nothing to type into; typing anything (even a stray Enter) +> gets parsed as garbage input and logged as an error, harmlessly, on repeat. Exit with +> `Ctrl+C` (or `Ctrl+D` for a clean EOF shutdown). To actually see or click anything, use +> the Inspector line, or connect via Claude Code (below) — both speak proper JSON-RPC to +> it instead of a human typing into the pipe. +> +> If `Ctrl+C` doesn't kill it on the first press and you hit it again, you'll see a +> `KeyboardInterrupt` traceback and sometimes a `uv`-level `error: Failed to get PID of +> child process ... ESRCH: No such process`. Both are harmless shutdown noise (the second +> `Ctrl+C` racing an already-dying process) — not a crash, don't stop to debug it live. + +> If you ever edit the Command/Arguments fields directly in the Inspector UI (instead of +> just using the connection it auto-fills from the terminal launch), use an **absolute +> path** to `linkedin_server.py`, not the relative `mcp/linkedin_server.py`. A UI-triggered +> reconnect spawns a fresh process that isn't rooted in `live-demo/` the way the original +> terminal launch was, so the relative path fails with `Failed to spawn ... No such file or +> directory`. Simplest fix: don't touch the form — reload the exact +> `http://localhost:6274/?MCP_PROXY_AUTH_TOKEN=...` URL your terminal printed, it comes +> pre-filled with the working config. + +The Inspector line takes ~10–15s to come up (longer on the very first run, while `npx` +downloads the package) and then prints something like: + +``` +🚀 MCP Inspector is up and running at: + http://localhost:6274/?MCP_PROXY_AUTH_TOKEN= +``` + +**Use that exact URL from your terminal, token included** — it should also auto-open in +your browser. Auth is on by default in current Inspector versions, so navigating to the +bare `http://localhost:6274` without the token gets rejected/refused; that's the "I can't +access it" failure mode if you jump the gun before the URL prints, or paste the short form +from memory instead of copying the real line. Once it's open: click **Connect**, then +**Tools**, and `get_profile` / `create_post` show up as clickable forms with a response +pane underneath. + +## Connect it to Claude Code (the live demo) + +The project-scoped `.mcp.json` one level up (`live-demo/.mcp.json`) already declares this +server, `DRY_RUN=true`, with the token read from your shell env — nothing to type live. + +1. **Set the token in your shell** before opening Claude Code, so `.mcp.json`'s + `${LINKEDIN_ACCESS_TOKEN}` resolves (dry-run works fine with this unset or fake — + only needed if you want `get_profile` to hit the real userinfo endpoint): + ```bash + export LINKEDIN_ACCESS_TOKEN=... # optional for a pure dry-run demo + ``` +2. **Open `live-demo/` as the project root** (`cd sessions/session-02-tools-boundaries/live-demo && claude`). + Claude Code detects `.mcp.json` and prompts to approve the `linkedin` server — approve it + on camera, so the room sees the trust prompt, not just the result. +3. **Set permission tiers** in `.claude/settings.json` inside `live-demo/` before you start talking: + ```json + { + "permissions": { + "allow": ["mcp__linkedin__get_profile"], + "ask": ["mcp__linkedin__create_post"] + } + } + ``` +4. **Demo script, in order:** + - Ask: *"What's my LinkedIn profile?"* → `get_profile` fires with no prompt (allow tier) → + read out the dry-run id/name. + - Ask: *"Draft and post a LinkedIn update about tonight's session."* → Claude proposes post + text, then `create_post` pauses on the **ask** tier → approve it live → point out the + returned `post_url` is a fake `dryrun-` URN and `"dry_run": true` — nothing left the process. + - Optional: point at the terminal/log line `[linkedin-mcp] DRY_RUN create_post payload: ...` + as the receipt that the gate lived in the tool the whole time, not in the prompt. +5. **Do not set `DRY_RUN=false` or unset it during the session.** If you want to show a real + published post, do that once beforehand per the section below, screenshot it, then reset to + `true` and never touch it live. + +## LinkedIn Developer app setup + +1. **Create the app**: https://www.linkedin.com/developers/apps → *Create app*. + You need a LinkedIn *company page* to associate (create a dummy one if needed). +2. **Add products** (Products tab, both are instant self-serve approval): + - **Share on LinkedIn** → grants `w_member_social` + - **Sign In with LinkedIn using OpenID Connect** → grants `openid`, `profile` +3. **Auth tab**: note *Client ID* and *Client Secret*; add a redirect URL, e.g. + `http://localhost:3000/callback` (it never needs to serve anything). + +## Getting a 3-legged OAuth token + +1. Open in a browser (one line, fill in CLIENT_ID): + + ``` + https://www.linkedin.com/oauth/v2/authorization?response_type=code&client_id=CLIENT_ID&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback&scope=openid%20profile%20w_member_social + ``` + +2. Approve; you land on `localhost:3000/callback?code=...` (page won't load — + fine). Copy the `code` from the URL bar. **It expires in ~30 minutes.** + +3. Exchange it: + + ```bash + curl -X POST https://www.linkedin.com/oauth/v2/accessToken \ + -d grant_type=authorization_code \ + -d code=THE_CODE \ + -d client_id=CLIENT_ID \ + -d client_secret=CLIENT_SECRET \ + -d redirect_uri=http://localhost:3000/callback + ``` + +4. The response's `access_token` (valid ~60 days) — export it in your shell (the server + reads `os.environ` directly, no `.env` file is loaded): + + ```bash + export LINKEDIN_ACCESS_TOKEN=... + export DRY_RUN=true + ``` + +## Verify the happy path ONCE + +```bash +cd sessions/session-02-tools-boundaries/live-demo +DRY_RUN=false LINKEDIN_ACCESS_TOKEN=... uv run --with fastmcp --with httpx python -c " +import sys; sys.path.insert(0, 'mcp') +from linkedin_server import create_post +print(create_post('Testing my ADK DevCamp posting pipeline. If you can read this, it worked.')) +" +``` + +Two gotchas found while testing this locally, both already fixed above: +- Don't `from mcp.linkedin_server import ...` — the installed `mcp` SDK package (a + dependency of `fastmcp`) shadows the local `mcp/` folder since it has no + `__init__.py`. `sys.path.insert(0, 'mcp')` + `from linkedin_server import ...` + sidesteps the collision. +- `create_post` is called directly, not `create_post.fn(...)` — this fastmcp version + (3.4.x) returns the plain function from `@mcp.tool`, it doesn't wrap it in an object + with a `.fn` attribute. + +Then set `DRY_RUN=true` and leave it forever. diff --git a/sessions/session-02-tools-boundaries/live-demo/mcp/linkedin_server.py b/sessions/session-02-tools-boundaries/live-demo/mcp/linkedin_server.py new file mode 100644 index 0000000..1c3de30 --- /dev/null +++ b/sessions/session-02-tools-boundaries/live-demo/mcp/linkedin_server.py @@ -0,0 +1,130 @@ +"""LinkedIn MCP server (FastMCP, stdio). + +Tools: + get_profile() -> member id/name via OpenID userinfo + create_post(text, image_path=None) -> publish a post via the LinkedIn Posts API + +Env: + LINKEDIN_ACCESS_TOKEN 3-legged OAuth token (scopes: openid profile w_member_social) + DRY_RUN "true" (default): log the payload, return a fake URL, post nothing. + +See README.md in this directory for the LinkedIn Developer app setup. +""" + +# /// script +# dependencies = [ +# "fastmcp>=2.0", +# "httpx>=0.27", +# ] +# /// + +import logging +import os +import sys +import time +import uuid + +import httpx +from fastmcp import FastMCP + +API_BASE = "https://api.linkedin.com" +LINKEDIN_VERSION = "202506" # Posts API versioned header, YYYYMM + +logging.basicConfig(stream=sys.stderr, level=logging.INFO, format="[linkedin-mcp] %(message)s") +log = logging.getLogger(__name__) + +mcp = FastMCP("linkedin") + + +def _dry_run() -> bool: + return os.environ.get("DRY_RUN", "true").lower() != "false" + + +def _headers() -> dict: + token = os.environ.get("LINKEDIN_ACCESS_TOKEN", "") + return { + "Authorization": f"Bearer {token}", + "LinkedIn-Version": LINKEDIN_VERSION, + "X-Restli-Protocol-Version": "2.0.0", + "Content-Type": "application/json", + } + + +def _userinfo() -> dict: + resp = httpx.get(f"{API_BASE}/v2/userinfo", headers=_headers(), timeout=30) + resp.raise_for_status() + return resp.json() + + +@mcp.tool +def get_profile() -> dict: + """Returns the authenticated LinkedIn member's profile (id, name).""" + if _dry_run(): + log.info("DRY_RUN get_profile") + return {"status": "success", "sub": "dry-run-member-id", "name": "Dry Run User"} + info = _userinfo() + return {"status": "success", "sub": info["sub"], "name": info.get("name", "")} + + +def _upload_image(author_urn: str, image_path: str) -> str: + """Registers and uploads a local image, returns the image URN.""" + init = httpx.post( + f"{API_BASE}/rest/images?action=initializeUpload", + headers=_headers(), + json={"initializeUploadRequest": {"owner": author_urn}}, + timeout=30, + ) + init.raise_for_status() + value = init.json()["value"] + with open(image_path, "rb") as f: + up = httpx.put( + value["uploadUrl"], + content=f.read(), + headers={"Authorization": _headers()["Authorization"]}, + timeout=60, + ) + up.raise_for_status() + return value["image"] + + +@mcp.tool +def create_post(text: str, image_path: str | None = None) -> dict: + """Publishes a post to the authenticated member's LinkedIn feed. + + Args: + text: The full post text. + image_path: Optional absolute path to a local image to attach. + """ + if _dry_run(): + fake_urn = f"urn:li:share:dryrun-{uuid.uuid4().hex[:12]}" + log.info("DRY_RUN create_post payload: text=%r image_path=%r", text, image_path) + return { + "status": "success", + "dry_run": True, + "post_url": f"https://www.linkedin.com/feed/update/{fake_urn}/", + "note": "DRY_RUN=true, nothing was posted.", + } + + author_urn = f"urn:li:person:{_userinfo()['sub']}" + payload = { + "author": author_urn, + "commentary": text, + "visibility": "PUBLIC", + "distribution": {"feedDistribution": "MAIN_FEED", "targetEntities": [], "thirdPartyDistributionChannels": []}, + "lifecycleState": "PUBLISHED", + "isReshareDisabledByAuthor": False, + } + if image_path: + image_urn = _upload_image(author_urn, image_path) + payload["content"] = {"media": {"id": image_urn, "altText": "Post illustration"}} + + resp = httpx.post(f"{API_BASE}/rest/posts", headers=_headers(), json=payload, timeout=30) + resp.raise_for_status() + post_urn = resp.headers.get("x-restli-id", "") + ts = time.strftime("%Y-%m-%d %H:%M:%S") + log.info("posted %s at %s", post_urn, ts) + return {"status": "success", "dry_run": False, "post_url": f"https://www.linkedin.com/feed/update/{post_urn}/"} + + +if __name__ == "__main__": + mcp.run() # stdio transport From 230cf064ff4be44b0bc1f247bb2bf1e451e4f8c2 Mon Sep 17 00:00:00 2001 From: Sonika Janagill Date: Tue, 18 Aug 2026 17:22:29 +0100 Subject: [PATCH 2/2] feat: document MCP configuration patterns and add workspace-level agent settings --- .../session-02-tools-boundaries/README.md | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/sessions/session-02-tools-boundaries/README.md b/sessions/session-02-tools-boundaries/README.md index 60aeaf5..2d7bca7 100644 --- a/sessions/session-02-tools-boundaries/README.md +++ b/sessions/session-02-tools-boundaries/README.md @@ -65,6 +65,41 @@ Ties the two sessions together, which is worth spelling out explicitly since it' Concretely, in the `social-spark` project: a "write a WCC-voice LinkedIn post" Skill from Session 1's pattern would sit next to this LinkedIn MCP server. The Skill decides *what to write and how*; the MCP server is the only thing that can actually *publish* it; `DRY_RUN` plus an `ask` permission tier decide *whether it's allowed to, right now*. All three layers, one agent. +## The MCP configuration space: how many ways to wire one up + +Worth naming explicitly, since it looks like one skill ("configure an MCP server") but is +really four independent decisions — and most people's IDEs (Antigravity, Claude Code, +Claude Desktop, VS Code) already have several MCP servers configured, each mixing these +differently without it being obvious: + +1. **Transport** — how the client talks to the process: + - **stdio** — client spawns a local process, talks over its stdin/stdout. `linkedin_server.py` tonight. + - **Streamable HTTP** — client hits a URL. Current standard for remote/hosted servers. + - **SSE** — the deprecated predecessor to Streamable HTTP, still seen in older configs. + +2. **Runtime / packaging** — what actually gets executed: + - **Your own script directly** — `command: uv/python/node`, `args: path/to/script`. + - **npx/uvx-launched package** — `command: npx`, `args: ["-y", "@some/mcp-package"]`. Ephemeral, no local install step — most copy-paste "install this MCP server" instructions are this shape. + - **Docker container** — `command: docker`, `args: ["run", "--rm", "-i", "image:tag"]`. Still stdio underneath; Docker just isolates the runtime/deps instead of `uv`/`npx` resolving them. + - **Already-running remote service** — just a `serverUrl`. Nothing local to spawn; someone else owns its uptime. + +3. **Auth** — orthogonal to both of the above: + - **None** — trusted local process, no credential needed. + - **Static token/API key** — a secret in `env` (stdio) or an `Authorization` header (HTTP), like `LINKEDIN_ACCESS_TOKEN` tonight. Fetched/pasted once; the client never manages it. + - **OAuth** — for HTTP servers advertising OAuth metadata, the *client itself* runs the authorization-code flow: pops a browser login, catches the callback, stores and refreshes the token. This is what a server "being OAuth" in your IDE means — you clicked Connect and approved in a browser, never touched a token value. + +4. **Config scope** — where the declaration lives: + - **Global/user-level** — e.g. `~/.gemini/config/mcp_config.json` (Antigravity), applies to every project you open. + - **Project/workspace-scoped** — `.agents/mcp_config.json` or `.mcp.json`, checked into one repo. The "work tools never leak into hobby projects" pattern this session is built around. + - **Imperative/CLI-registered** — e.g. `claude mcp add --transport http `, writes the config for you instead of hand-editing JSON. + +`linkedin_server.py` tonight sits at one specific point in that space: **stdio + your own +script + static token via env + project-scoped**. A Docker-packaged internal tool is +typically **stdio + container + token-or-none + global**; a SaaS integration (Jira, +GitLab, Gmail) is typically **Streamable HTTP + remote service + OAuth + global**. Same +four axes, different combination every time — that's the thing to say out loud in the +room, since "configure an MCP server" sounds like one thing and isn't. + ## Steal This Project-scoped MCP, governance by default. Also worth stealing: a `DRY_RUN`-style flag baked into any tool with real-world side effects, checked in code, not just described in a prompt.