Loading the notebook…
Loading the notebook…
Notebooks · In-depth guides · Guide 04
The Model Context Protocol (MCP) is a standard way to give an agent tools that live in another process: a local program that talks over stdin and stdout, or a hosted server over HTTP. shipit connects to either…
Run end to end on shipit-agent 3.0 with real model calls (Haiku 5.5 on Anthropic, Gemma 4 31B on Bedrock). Every output below is from that run.
import os
import sys
from pathlib import Path
from shipit_agent import Agent
from shipit_agent.llms import build_llm_from_env
from shipit_agent.llms.factory import build_llm_from_settings
os.environ["SHIPIT_ANTHROPIC_MODEL"] = "claude-haiku-5-5"
haiku = build_llm_from_env("anthropic")
gemma = build_llm_from_settings({"model": "google.gemma-4-31b"}, provider="bedrock")An MCP server over stdio reads one JSON-RPC request per line and writes one reply per line. This one keeps
team notes in notes.json and offers two tools, readable resources, and a prompt. In practice you'd use an SDK or an
existing server; writing one by hand shows there's no magic.
SERVER = '\n"""A tiny MCP server over stdio: team notes, with tools, resources and a prompt."""\nimport json\nimport sys\nfrom pathlib import Path\n\nSTORE = Path("notes.json")\nDEFAULT_NOTES = {\n "deploy": "Deploys go out Tuesdays and Thursdays at 14:00 UTC. Freeze the day before a holiday.",\n "oncall": "On-call rotates weekly on Monday. Page #oncall first, then the secondary.",\n "release": "Release notes live in CHANGELOG.md; tag as vX.Y.Z after the notes merge.",\n}\nTOOLS = [\n {"name": "search_notes", "description": "Search the team notes by keyword and return matching notes.",\n "inputSchema": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]},\n "annotations": {"readOnlyHint": True}},\n {"name": "add_note", "description": "Add or replace a team note under a topic.",\n "inputSchema": {"type": "object", "properties": {"topic": {"type": "string"}, "text": {"type": "string"}},\n "required": ["topic", "text"]}},\n]\n\n\ndef load():\n return json.loads(STORE.read_text()) if STORE.exists() else dict(DEFAULT_NOTES)\n\n\ndef handle(method, params):\n NOTES = load()\n if method == "initialize":\n return {"protocolVersion": "2025-06-18", "serverInfo": {"name": "notes", "version": "1.0"},\n "capabilities": {"tools": {}, "resources": {}, "prompts": {}},\n "instructions": "Team notes. Search before answering process questions."}\n if method == "tools/list":\n return {"tools": TOOLS}\n if method == "tools/call":\n name, args = params["name"], params.get("arguments", {})\n if name == "search_notes":\n q = args["query"].lower()\n hits = {k: v for k, v in NOTES.items() if q in k or q in v.lower()}\n text = json.dumps(hits) if hits else f"no note mentions {args[\'query\']!r}"\n else:\n NOTES[args["topic"]] = args["text"]\n STORE.write_text(json.dumps(NOTES))\n text = f"saved note {args[\'topic\']!r}"\n return {"content": [{"type": "text", "text": text}], "isError": False}\n if method == "resources/list":\n return {"resources": [{"uri": f"notes://{k}", "name": k, "mimeType": "text/plain"} for k in NOTES]}\n if method == "resources/read":\n key = params["uri"].removeprefix("notes://")\n return {"contents": [{"uri": params["uri"], "mimeType": "text/plain", "text": NOTES.get(key, "")}]}\n if method == "prompts/list":\n return {"prompts": [{"name": "handover", "description": "Write an on-call handover.",\n "arguments": [{"name": "who", "required": True}]}]}\n if method == "prompts/get":\n who = params.get("arguments", {}).get("who", "the next person")\n return {"messages": [{"role": "user", "content": {"type": "text",\n "text": f"Write a short on-call handover for {who}, using the oncall note."}}]}\n if method == "ping":\n return {}\n raise ValueError(f"unsupported method {method}")\n\n\nfor line in sys.stdin:\n request = json.loads(line)\n if "id" not in request:\n continue\n try:\n reply = {"result": handle(request["method"], request.get("params", {}))}\n except Exception as error:\n reply = {"error": {"code": -32601, "message": str(error)}}\n print(json.dumps({"jsonrpc": "2.0", "id": request["id"], **reply}), flush=True)\n'
Path("notes_server.py").write_text(SERVER)
print(len(SERVER.splitlines()), "lines written to notes_server.py")71 lines written to notes_server.py
RemoteMCPServer with an MCPStdioTransport starts the server as a subprocess. discover_tools() runs the
handshake and lists what it offers.
from shipit_agent import MCPStdioTransport, RemoteMCPServer
notes = RemoteMCPServer(name="notes", transport=MCPStdioTransport(command=[sys.executable, "notes_server.py"]))
for tool in notes.discover_tools():
print(f"{tool.name:13} {tool.description}")
print("server:", notes.server_info, "| says:", notes.instructions)search_notes Search the team notes by keyword and return matching notes.
add_note Add or replace a team note under a topic.
server: {'name': 'notes', 'version': '1.0'} | says: Team notes. Search before answering process questions.Each discovered tool is an ordinary shipit tool, with a schema and run().
from shipit_agent.tools.base import ToolContext
search = next(t for t in notes.discover_tools() if t.name == "search_notes")
print(search.run(ToolContext(prompt="demo"), query="deploy").text){"deploy": "Deploys go out Tuesdays and Thursdays at 14:00 UTC. Freeze the day before a holiday."}Pass servers in mcps=. The agent discovers their tools when the run starts and calls them like its own.
agent = Agent(llm=haiku, prompt="Answer from the team notes. Be brief.", mcps=[notes])
result = agent.run("When do deploys go out?")
print(result.output)
print("calls:", [(r.name, r.output[:60]) for r in result.tool_results])Deploys go out on Tuesdays and Thursdays at 14:00 UTC. Don't deploy the day before a holiday (freeze).
calls: [('search_notes', "no note mentions 'deploy schedule'"), ('search_notes', '{"deploy": "Deploys go out Tuesdays and Thursdays at 14:00 U')]Local tools and MCP tools mix freely in one agent.
from datetime import date
from shipit_agent.tools import FunctionTool
def today() -> str:
"""Today's date and weekday."""
return date(2026, 11, 9).strftime("%A %Y-%m-%d")
agent = Agent(llm=haiku, prompt="Answer from the team notes. Be brief.", mcps=[notes],
tools=[FunctionTool.from_callable(today, read_only=True)])
result = agent.run("Is there a deploy today? Check the date and the notes.")
print(result.output)
print("calls:", [r.name for r in result.tool_results])No. Today is Monday, 2026-11-09, and the team notes say deploys go out on Tuesdays and Thursdays at 14:00 UTC. The next scheduled deploy is Tuesday. calls: ['today', 'search_notes']
add_note has no read-only hint, so it's a write. With the default permission mode an agent would ask
first; Guide 09 covers that. Here the run is allowed to write.
writer = Agent(llm=haiku, prompt="Keep the team notes current. Be brief.", mcps=[notes],
permission_mode="bypassPermissions")
print(writer.run("Add a note under 'freeze': no deploys from Dec 20 to Jan 3.").output)
print(search.run(ToolContext(prompt="demo"), query="freeze").text)Added to the 'freeze' note: no deploys from Dec 20 to Jan 3.
{"deploy": "Deploys go out Tuesdays and Thursdays at 14:00 UTC. Freeze the day before a holiday.", "freeze": "No deploys from Dec 20 to Jan 3."}allowed_tools keeps a server read-only for an agent; blocked_tools removes named ones.
read_only = RemoteMCPServer(name="notes", transport=MCPStdioTransport(command=[sys.executable, "notes_server.py"]),
allowed_tools={"search_notes"})
print([t.name for t in read_only.discover_tools()])['search_notes']
Two servers can both have a search tool. include_server_in_tool_names=True shows the model
notes__search_notes while the server still receives search_notes.
named = RemoteMCPServer(name="notes", transport=MCPStdioTransport(command=[sys.executable, "notes_server.py"]),
include_server_in_tool_names=True)
print([t.name for t in named.discover_tools()])
print(Agent(llm=haiku, prompt="Be brief.", mcps=[named]).run("What does the oncall note say?").output)['notes__search_notes', 'notes__add_note']
The oncall note says: on-call rotates weekly on Monday. Page #oncall first, then the secondary.
Resources are documents a server exposes for reading, addressed by URI.
for r in notes.list_resources():
print(r.uri, "|", r.name)
print(notes.read_resource("notes://release"))notes://deploy | deploy notes://oncall | oncall notes://release | release notes://freeze | freeze Release notes live in CHANGELOG.md; tag as vX.Y.Z after the notes merge.
A server can ship prompt templates. get_prompt() fills one in, ready to send to an agent.
print([p.name for p in notes.list_prompts()])
handover = notes.get_prompt("handover", {"who": "Priya"})
print(handover)
print("\n" + Agent(llm=haiku, mcps=[notes]).run(handover).output)['handover'] Write a short on-call handover for Priya, using the oncall note.
I found one on-call note, and it's thin. It covers the rotation and first escalation step, but nothing about current incidents, open issues, or who the secondary is. ## Draft handover for Priya **On-call handover** - **Rotation:** On-call rotates weekly on Monday. - **Escalation:** Page #oncall first, then the secondary. - **Open incidents:** None are recorded in the notes. - **Secondary contact:** Not recorded. Confirm before the handover. - **Active issues or context:** Not recorded. Add anything in flight before handing over. I searched the notes for "oncall", "on-call handover", and "Priya". Only the rotation note came back. I didn't find any handover checklist or Priya-specific entry.
MCP calls appear in the stream like any tool call.
for e in Agent(llm=haiku, prompt="Be brief.", mcps=[notes]).stream("Who do I page first when on call?"):
if e.type == "tool_called":
print("call", e.payload["tool"], e.payload["arguments"])
elif e.type == "run_completed":
print(e.payload["output"])call search_notes {'query': 'on call page first'}
call search_notes {'query': 'on-call escalation'}call search_notes {'query': 'pager'}
call search_notes {'query': 'on-call'}Page **#oncall** first, then the secondary. That comes from the team note on on-call, which says the rotation changes weekly on Mondays. The note doesn't say who the secondary is, so check the current rotation if you need that name.
result = Agent(llm=gemma, prompt="Answer from the team notes. Be brief.", mcps=[notes]).run(
"Where do release notes go, and how do we tag a release?")
print(result.output)
print("calls:", [r.name for r in result.tool_results])Release notes go in `CHANGELOG.md`, and releases are tagged as `vX.Y.Z` after the notes have been merged. calls: ['search_notes', 'search_notes']
shipit ships 28 well-known servers you connect by name with connect_mcp(name, ...). Each entry knows its
launcher, its required environment variables, and whether it's hosted (HTTP) or local (stdio).
from shipit_agent import list_mcp_catalog
catalog = list_mcp_catalog()
print(len(catalog), "servers")
for c in catalog[:12]:
how = "hosted" if c.url else "stdio "
print(f"{c.name:16} {how} {c.auth:5} {c.description}")28 servers airtable stdio env Airtable bases, tables and records. asana hosted oauth Asana tasks, projects and portfolios. brave-search stdio env Web search via the Brave Search API. clickup hosted oauth ClickUp tasks, lists, docs and spaces. cloudflare hosted oauth Cloudflare Workers, KV, R2, D1 and observability. confluence hosted oauth Confluence pages and spaces (same grant as Jira). discord stdio env Post, read and manage Discord channels and threads. fetch stdio none Fetch a URL and return page content as markdown. filesystem stdio none Read/write files under the directories you pass as args. gdrive stdio env Search and read Google Drive files. github hosted oauth Repos, issues, PRs, code search and reviews on GitHub. gitlab stdio env GitLab projects, issues and merge requests.
Connecting one is a line. Local catalog servers start through npx or uvx, so they need Node or uv;
hosted ones that use per-user OAuth take that user's token:
files = connect_mcp("filesystem", args=["/path/to/project"])
github = connect_mcp("github", token=os.environ["GITHUB_TOKEN"])
agent = Agent(llm=llm, mcps=[files, github])
This guide doesn't run them, because each downloads its server package on first use.
for server in (notes, read_only, named):
server.close()
Path("notes_server.py").unlink()
Path("notes.json").unlink(missing_ok=True)RemoteMCPServer + MCPStdioTransport runs one locally.discover_tools() lists tools; mcps=[...] gives them to an agent, beside local tools.allowed_tools, blocked_tools and include_server_in_tool_names shape what the agent sees.list_resources, read_resource) and prompts (get_prompt).connect_mcp(name, ...) attaches any of the 28 catalog servers.Next: Guide 05 — connections and OAuth.