Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 92 additions & 2 deletions sessions/session-02-tools-boundaries/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,98 @@ 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.

## 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 <name> <url>`, 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.
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

Expand All @@ -24,11 +112,13 @@ 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)
```

## 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.
12 changes: 12 additions & 0 deletions sessions/session-02-tools-boundaries/live-demo/.mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"mcpServers": {
"linkedin": {
"command": "uv",
"args": ["run", "mcp/linkedin_server.py"],
"env": {
"LINKEDIN_ACCESS_TOKEN": "${LINKEDIN_ACCESS_TOKEN}",
"DRY_RUN": "true"
}
}
}
}
168 changes: 168 additions & 0 deletions sessions/session-02-tools-boundaries/live-demo/mcp/README.md
Original file line number Diff line number Diff line change
@@ -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=<a long hex 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.
Loading