From 4f884e60cc1f61e265ae9104e436a308700cfbc1 Mon Sep 17 00:00:00 2001 From: Tomas Tormo Date: Thu, 9 Jul 2026 22:57:49 +0200 Subject: [PATCH 1/5] feat(examples): scaffold gemini-buddy MCP server --- .../examples/gemini-buddy-mcp/Dockerfile | 17 ++++++++++++++ .../gemini-buddy-mcp/buddy_server.mjs | 23 +++++++++++++++++++ .../examples/gemini-buddy-mcp/package.json | 11 +++++++++ .../gemini-buddy-mcp/sam-node-config.yaml | 8 +++++++ 4 files changed, 59 insertions(+) create mode 100644 development/examples/gemini-buddy-mcp/Dockerfile create mode 100644 development/examples/gemini-buddy-mcp/buddy_server.mjs create mode 100644 development/examples/gemini-buddy-mcp/package.json create mode 100644 development/examples/gemini-buddy-mcp/sam-node-config.yaml diff --git a/development/examples/gemini-buddy-mcp/Dockerfile b/development/examples/gemini-buddy-mcp/Dockerfile new file mode 100644 index 00000000..b93b42b0 --- /dev/null +++ b/development/examples/gemini-buddy-mcp/Dockerfile @@ -0,0 +1,17 @@ +FROM node:22-slim + +# The buddy: Google's Gemini CLI. +RUN npm install -g @google/gemini-cli + +WORKDIR /srv +COPY package.json buddy_server.mjs ./ +RUN npm install + +# Dev-only: replace with a Google AI Studio free-tier key before `make kind`. +# Keep the edit local so it is never committed: +# git update-index --skip-worktree development/examples/gemini-buddy-mcp/Dockerfile +ENV GEMINI_API_KEY= +ENV GEMINI_CLI_TRUST_WORKSPACE=true + +EXPOSE 7780 +CMD ["node", "buddy_server.mjs"] diff --git a/development/examples/gemini-buddy-mcp/buddy_server.mjs b/development/examples/gemini-buddy-mcp/buddy_server.mjs new file mode 100644 index 00000000..276da5d2 --- /dev/null +++ b/development/examples/gemini-buddy-mcp/buddy_server.mjs @@ -0,0 +1,23 @@ +import express from "express"; +import { spawn } from "node:child_process"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { z } from "zod"; + +const PORT = 7780; +const MODEL = "gemini-3.1-flash-lite"; + +const server = new McpServer({ name: "gemini-buddy", version: "1.0.0" }); + +// Stateless Streamable HTTP: a fresh transport per request. +const app = express(); +app.use(express.json()); +app.post("/mcp", async (req, res) => { + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + res.on("close", () => { transport.close(); }); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); +}); +app.listen(PORT, "0.0.0.0", () => { + console.log(`gemini-buddy MCP server on :${PORT}/mcp`); +}); diff --git a/development/examples/gemini-buddy-mcp/package.json b/development/examples/gemini-buddy-mcp/package.json new file mode 100644 index 00000000..46d0759c --- /dev/null +++ b/development/examples/gemini-buddy-mcp/package.json @@ -0,0 +1,11 @@ +{ + "name": "gemini-buddy-mcp", + "version": "1.0.0", + "private": true, + "type": "module", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", + "express": "^4.19.0", + "zod": "^3.23.0" + } +} diff --git a/development/examples/gemini-buddy-mcp/sam-node-config.yaml b/development/examples/gemini-buddy-mcp/sam-node-config.yaml new file mode 100644 index 00000000..ff1b533b --- /dev/null +++ b/development/examples/gemini-buddy-mcp/sam-node-config.yaml @@ -0,0 +1,8 @@ +version: "v1alpha1" +attenuation: + policies: [] +services: + - type: "mcp" + name: "gemini-buddy" + description: "A friendly, funny Gemini-backed buddy you can chat with over multiple turns" + target_url: "http://127.0.0.1:7780/mcp" From 99c18621f6808d52f392b522dd59c201a1ad6fc3 Mon Sep 17 00:00:00 2001 From: Tomas Tormo Date: Thu, 9 Jul 2026 23:00:31 +0200 Subject: [PATCH 2/5] feat(examples): add gemini-buddy chat tool with per-session memory --- .../gemini-buddy-mcp/buddy_server.mjs | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/development/examples/gemini-buddy-mcp/buddy_server.mjs b/development/examples/gemini-buddy-mcp/buddy_server.mjs index 276da5d2..eadcf4c6 100644 --- a/development/examples/gemini-buddy-mcp/buddy_server.mjs +++ b/development/examples/gemini-buddy-mcp/buddy_server.mjs @@ -7,8 +7,74 @@ import { z } from "zod"; const PORT = 7780; const MODEL = "gemini-3.1-flash-lite"; +// The buddy's personality: warm, funny, talks like a friend. +const BUDDY_SYSTEM = + "You're a laid-back, funny buddy - not a formal assistant. " + + "Talk like a close friend hanging out: warm, casual, a little cheeky, quick with a joke. " + + "Use contractions and the odd emoji, and keep replies short and punchy. " + + "You'll get the conversation so far on stdin, lines prefixed 'User:' and 'Buddy:'. " + + "Reply with ONLY the buddy's next turn - no prefix, no narration."; + +// sessionId -> [{ role: "user" | "assistant", text }] +const sessions = new Map(); + +function transcript(id) { + if (!sessions.has(id)) sessions.set(id, []); + return sessions.get(id); +} + +// Render the running transcript to pipe to gemini on stdin. +function render(turns) { + return turns.map((t) => `${t.role === "user" ? "User" : "Buddy"}: ${t.text}`).join("\n"); +} + +// One non-interactive turn: persona via -p, transcript on stdin. +function runGemini(text) { + return new Promise((resolve, reject) => { + const child = spawn("gemini", ["-m", MODEL, "-p", BUDDY_SYSTEM]); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => { stdout += d; }); + child.stderr.on("data", (d) => { stderr += d; }); + child.on("error", reject); + child.on("close", (exitCode) => { + if (exitCode === 0) resolve(stdout.trim()); + else reject(new Error(stderr.trim() || `gemini exited with code ${exitCode}`)); + }); + child.stdin.write(text); + child.stdin.end(); + }); +} + const server = new McpServer({ name: "gemini-buddy", version: "1.0.0" }); +server.registerTool( + "chat", + { + description: + "Chat with your Gemini buddy. Send one message; the buddy remembers the " + + "conversation across calls (per session_id), so you never resend history. " + + "Returns the buddy's reply.", + inputSchema: { + message: z.string().describe("Your next message to the buddy."), + session_id: z.string().optional().describe("Conversation id; defaults to 'default'."), + }, + }, + async ({ message, session_id }) => { + const id = session_id ?? "default"; + const turns = transcript(id); + turns.push({ role: "user", text: message }); + try { + const reply = await runGemini(render(turns)); + turns.push({ role: "assistant", text: reply }); + return { content: [{ type: "text", text: reply }] }; + } catch (err) { + turns.pop(); // drop the turn we couldn't answer so it can be retried + return { content: [{ type: "text", text: String(err?.message ?? err) }], isError: true }; + } + }, +); + // Stateless Streamable HTTP: a fresh transport per request. const app = express(); app.use(express.json()); From f209d3b174531e42990bbcb5c8230303ba56c383 Mon Sep 17 00:00:00 2001 From: Tomas Tormo Date: Thu, 9 Jul 2026 23:04:03 +0200 Subject: [PATCH 3/5] feat(examples): add gemini-buddy reset tool --- .../examples/gemini-buddy-mcp/buddy_server.mjs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/development/examples/gemini-buddy-mcp/buddy_server.mjs b/development/examples/gemini-buddy-mcp/buddy_server.mjs index eadcf4c6..b55a5878 100644 --- a/development/examples/gemini-buddy-mcp/buddy_server.mjs +++ b/development/examples/gemini-buddy-mcp/buddy_server.mjs @@ -75,6 +75,21 @@ server.registerTool( }, ); +server.registerTool( + "reset", + { + description: "Forget a conversation and start fresh with your buddy.", + inputSchema: { + session_id: z.string().optional().describe("Conversation id to clear; defaults to 'default'."), + }, + }, + async ({ session_id }) => { + const id = session_id ?? "default"; + sessions.delete(id); + return { content: [{ type: "text", text: `Okay, wiped the '${id}' chat. Clean slate! 🧽` }] }; + }, +); + // Stateless Streamable HTTP: a fresh transport per request. const app = express(); app.use(express.json()); From f539d142fc0c5f8ce319191ed137701d844ba87b Mon Sep 17 00:00:00 2001 From: Tomas Tormo Date: Fri, 10 Jul 2026 12:17:54 +0200 Subject: [PATCH 4/5] fix(examples): guard gemini-buddy stdin against EPIPE crash --- development/examples/gemini-buddy-mcp/buddy_server.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/development/examples/gemini-buddy-mcp/buddy_server.mjs b/development/examples/gemini-buddy-mcp/buddy_server.mjs index b55a5878..138538cf 100644 --- a/development/examples/gemini-buddy-mcp/buddy_server.mjs +++ b/development/examples/gemini-buddy-mcp/buddy_server.mjs @@ -37,6 +37,7 @@ function runGemini(text) { child.stdout.on("data", (d) => { stdout += d; }); child.stderr.on("data", (d) => { stderr += d; }); child.on("error", reject); + child.stdin.on("error", reject); // fail the request instead of crashing on EPIPE child.on("close", (exitCode) => { if (exitCode === 0) resolve(stdout.trim()); else reject(new Error(stderr.trim() || `gemini exited with code ${exitCode}`)); From d68963139e91a340d09a75182267788231334912 Mon Sep 17 00:00:00 2001 From: Tomas Tormo Date: Fri, 10 Jul 2026 16:50:13 +0200 Subject: [PATCH 5/5] docs: add Gemini Buddy use-case page --- site/content/docs/use-cases/gemini-buddy.md | 173 ++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 site/content/docs/use-cases/gemini-buddy.md diff --git a/site/content/docs/use-cases/gemini-buddy.md b/site/content/docs/use-cases/gemini-buddy.md new file mode 100644 index 00000000..741b9dc7 --- /dev/null +++ b/site/content/docs/use-cases/gemini-buddy.md @@ -0,0 +1,173 @@ +--- +title: "Gemini Buddy" +linkTitle: "Gemini Buddy" +weight: 20 +--- + +Hold a real multi-turn conversation with a *second* LLM exposed as an ordinary +mesh service — where your agent never resends the conversation, because the +service remembers its own side. + +Source: [`development/examples/gemini-buddy-mcp/`](https://github.com/google/sam/tree/main/development/examples/gemini-buddy-mcp). + +## The idea + +Sometimes you want your agent to *talk to another model* — a second opinion, a +peer to argue a design with, a specialist that builds up its own context over +many turns. The naive way is to carry both conversations in the orchestrator: +your agent would have to store the other model's transcript and replay all of it +on every turn. + +This use case shows you don't have to. A **buddy** is a plain MCP service backed +by the Gemini CLI that **owns its own conversation**. Your agent sends one +message per turn and gets one reply; the buddy keeps the running transcript +server-side, keyed by a `session_id`. It's built from nothing but an ordinary +`sam-node` MCP service — the conversational evolution of the one-shot +[`code-reviewer`](https://github.com/google/sam/tree/main/development/examples/code-reviewer-mcp) +example. + +## Two conversations, two places + +The confusion this example clears up: talking to another LLM does *not* mean +maintaining two conversations from your side. + +- **Your conversation** (you ↔ your agent) lives in your agent's context. +- **The buddy's conversation** lives inside the buddy service process. + +When your agent calls the buddy's `chat` tool it sends **only the next message**, +never the history. The buddy appends it to that session's transcript, replays the +whole transcript *locally* to a one-shot `gemini -p` call, stores the reply, and +returns just that reply. The reply naturally becomes part of your agent's context +like any other tool result — so neither side ever double-maintains the other's +transcript. + +That makes the buddy service the **conversation owner** and the model a +swappable, stateless backend: the same service works for any CLI (`gemini`, +`claude`, `codex`, …) by changing one `spawn` line. + +## The pieces + +- **`gemini-buddy` service** — a normal MCP service exposing two tools: + - `chat` — takes `{ message, session_id? }` (session defaults to `"default"`), + appends the message to that session's in-memory transcript, runs one + `gemini -p` turn with the transcript on stdin, and returns the buddy's reply. + - `reset` — takes `{ session_id? }` and wipes that session so the next `chat` + starts fresh. +- **Orchestrator** — any mesh MCP client (an agent harness or a custom program). + It discovers the buddy with `find_remote_tools(gemini-buddy)` and drives it with + `call_remote_tool(peer, chat, …)` — one message at a time. + +## What you can do with it + +- **Second opinion / peer** — bounce a design or a tricky bug off a different + model across several turns without babysitting its context. +- **Specialist that accumulates context** — let the buddy build up domain + knowledge over many messages (walk it through a subsystem, then keep asking) + that your own agent never has to reload. +- **A reusable pattern** — swap the Gemini CLI for any other model CLI; the + service structure (own the transcript, shell a stateless one-shot per turn) is + identical. `reset` gives you clean, isolated sessions on demand. + +## Try it on kind + +The repository ships a [kind](https://kind.sigs.k8s.io/)-based local mesh that +brings the buddy up with one command. + +### 1. Set an LLM key for the buddy image + +The buddy shells out to the Gemini CLI, so set your API key on the API-key `ENV` +line in `development/examples/gemini-buddy-mcp/Dockerfile` before building (a free +Google AI Studio key is fine for the demo). + +### 2. Mesh layout + +Host the buddy on one node in `development/kind/mesh-config.yaml`: + +```yaml +node-a: # bare node (orchestrator entry) +node-b: gemini-buddy-mcp # the buddy +node-c: +node-d: +node-e: +``` + +### 3. Bring the mesh up and start a local orchestrator node + +```bash +make build # builds ./bin/sam-node (once) +make kind-up # hub + buddy (node-b) +make kind-local-node # local sam-node enrolled in the mesh — LEAVE RUNNING +``` + +`kind-local-node` runs in the foreground in its own shell and exposes the mesh +MCP tools at **`http://127.0.0.1:9099/mcp`** (bearer token `devtoken`) — no +`kubectl port-forward` needed. This local node is your orchestrator's entry point +into the mesh. + +### 4. Point your harness at the local node + +Add the local node as an MCP server in whatever harness you drive the mesh from. +The specifics differ per harness (some use a JSON/TOML config file, others a UI), +but the settings are always the same: + +- **Transport:** HTTP (Streamable HTTP / `http`) +- **URL:** `http://127.0.0.1:9099/mcp` +- **Header:** `Authorization: Bearer devtoken` + +For example, harnesses that use the common `mcpServers` JSON config (Claude Code, +Cursor, and others) would add: + +```json +{ + "mcpServers": { + "sam-mesh": { + "type": "http", + "url": "http://127.0.0.1:9099/mcp", + "headers": { "Authorization": "Bearer devtoken" } + } + } +} +``` + +Consult your harness's MCP documentation for its exact config format. Once +connected, the mesh exposes `find_remote_tools` and `call_remote_tool` as tools +your agent (or program) can call. + +### 5. Have a conversation + +Just tell your agent what you want — the mesh tool descriptions and the buddy's +own instructions steer it to discover the service and route each turn, so you +don't have to name any tools. A natural first prompt: + +> Find the `gemini-buddy` service in the mesh, then introduce yourself and tell it +> I'm planning a trip to Kyoto in spring and that I'm slightly obsessed with ramen. + +Then, in a *separate* message, prove the buddy remembers without you resending +anything: + +> Ask the buddy where I'm headed and what food I'm into. Don't remind it — just ask. + +The buddy answers correctly even though your agent never re-sent the first turn: +the transcript lives in the service, not in your context. Finally, wipe it: + +> Reset the conversation with the buddy, then ask it where I'm going again. + +After the reset it no longer knows — the service dropped that session's +transcript. + +Once you've got the hang of it, hand the whole loop to your agent and have fun +watching two models converse: + +> Find the `gemini-buddy` service on the SAM mesh, reset its session, then have a +> 10-turn conversation with it — one question per turn, each building on its last +> answer, and show me every question-and-answer pair as you go. + +## Configuration + +| var | default | used by | +|-----|---------|---------| +| `GEMINI_API_KEY` | *(placeholder — set in the Dockerfile)* | buddy | +| `GEMINI_CLI_TRUST_WORKSPACE` | `true` | buddy | + +The listen port (`7780`) and model (`gemini-3.1-flash-lite`) are constants at the +top of `buddy_server.mjs`; change them there if needed.