{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "*Lab 4 Answer Key*\n",
    "\n",
    "# Tools, Function Schemas & MCP\n",
    "\n",
    "**Objective:** author tools the model uses correctly, feel a misroute and fix it with words, handle a failure, then graduate a capability to MCP.\n",
    "\n",
    "The model never sees your code. It sees a name, a description, and a schema, and from those three strings it decides whether to call, which tool, and with what arguments. Tool authoring is prompt engineering with a JSON contract; this lab practices that, then graduates a capability from a custom tool to an MCP server.\n",
    "\n",
    "**Setup:** `pip install anthropic \"mcp[cli]\"` and set `ANTHROPIC_API_KEY` in your environment.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Part 1: Define a strict tool\n",
    "\n",
    "The description is a decision rule, not a docstring: name the trigger condition and the anti-condition. `strict: True` plus `additionalProperties: False` makes the API guarantee the arguments conform: no extra keys, no missing required fields.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import anthropic\n",
    "\n",
    "client = anthropic.Anthropic()\n",
    "\n",
    "create_ticket = {\n",
    "    \"name\": \"create_ticket\",\n",
    "    # The description is the trigger. Be prescriptive about WHEN, not just WHAT.\n",
    "    \"description\": (\n",
    "        \"Open a support ticket in the tracker. Call this when the user \"\n",
    "        \"reports a bug or asks to file an issue and has described the problem. \"\n",
    "        \"Do NOT use this for feature requests or questions.\"\n",
    "    ),\n",
    "    \"strict\": True,   # exact validation: enforced against the schema below\n",
    "    \"input_schema\": {\n",
    "        \"type\": \"object\",\n",
    "        \"properties\": {\n",
    "            \"title\": {\"type\": \"string\", \"description\": \"One-line summary of the issue.\"},\n",
    "            \"severity\": {\n",
    "                \"type\": \"string\",\n",
    "                \"enum\": [\"low\", \"medium\", \"high\", \"critical\"],   # not free text\n",
    "                \"description\": \"Business impact. 'critical' = outage or data loss.\",\n",
    "            },\n",
    "            \"component\": {\n",
    "                \"type\": \"string\",\n",
    "                \"description\": \"Affected subsystem, e.g. 'billing' or 'auth'.\",\n",
    "            },\n",
    "        },\n",
    "        \"required\": [\"title\", \"severity\"],   # component is optional\n",
    "        \"additionalProperties\": False,       # required for strict mode\n",
    "    },\n",
    "}\n",
    "\n",
    "response = client.messages.create(\n",
    "    model=\"claude-opus-4-8\",\n",
    "    max_tokens=1024,\n",
    "    tools=[create_ticket],\n",
    "    messages=[{\"role\": \"user\", \"content\":\n",
    "        \"The checkout page 500s whenever a coupon code is applied. Please file it.\"}],\n",
    ")\n",
    "\n",
    "block = next(b for b in response.content if b.type == \"tool_use\")\n",
    "# block.input is already a parsed dict -- never string-match the serialized JSON.\n",
    "print(block.name, block.input)\n",
    "assert block.input[\"severity\"] in (\"low\", \"medium\", \"high\", \"critical\")\n",
    "assert set(block.input) <= {\"title\", \"severity\", \"component\"}   # no invented keys\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Part 2: Force a misroute, then fix it\n",
    "\n",
    "Two tools with overlapping descriptions force Claude to guess, and it guesses wrong a predictable fraction of the time. The fix is disambiguation in the description, not in the code: you fix a code-level symptom with a prose-level change.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def make_search_tools(docs_desc, tickets_desc):\n",
    "    schema = {\n",
    "        \"type\": \"object\",\n",
    "        \"properties\": {\"query\": {\"type\": \"string\", \"description\": \"Search terms.\"}},\n",
    "        \"required\": [\"query\"],\n",
    "    }\n",
    "    return [\n",
    "        {\"name\": \"search_docs\", \"description\": docs_desc, \"input_schema\": schema},\n",
    "        {\"name\": \"search_tickets\", \"description\": tickets_desc, \"input_schema\": schema},\n",
    "    ]\n",
    "\n",
    "\n",
    "def route(tools, prompt, runs=5):\n",
    "    \"\"\"Which tool does Claude pick for this phrasing? Count over several runs.\"\"\"\n",
    "    picks = {}\n",
    "    for _ in range(runs):\n",
    "        r = client.messages.create(\n",
    "            model=\"claude-opus-4-8\", max_tokens=512, tools=tools,\n",
    "            messages=[{\"role\": \"user\", \"content\": prompt}],\n",
    "        )\n",
    "        for b in r.content:\n",
    "            if b.type == \"tool_use\":\n",
    "                picks[b.name] = picks.get(b.name, 0) + 1\n",
    "    return picks\n",
    "\n",
    "\n",
    "# WEAK: overlapping descriptions. \"find the login bug\" is ambiguous between them.\n",
    "weak = make_search_tools(\n",
    "    docs_desc=\"Search the knowledge base.\",\n",
    "    tickets_desc=\"Search support records.\",\n",
    ")\n",
    "print(\"weak routing: \", route(weak, \"Find the login bug.\"))\n",
    "\n",
    "# STRONG: each states what it is FOR and NOT for. The boundary does the routing.\n",
    "strong = make_search_tools(\n",
    "    docs_desc=(\n",
    "        \"Search product documentation and how-to guides. Use this for questions \"\n",
    "        \"about how a feature works. Do NOT use this for customer-reported issues; \"\n",
    "        \"use search_tickets for those.\"\n",
    "    ),\n",
    "    tickets_desc=(\n",
    "        \"Search customer-reported issues and bug reports in the support tracker. \"\n",
    "        \"Use this to find existing reports of a bug or incident. Do NOT use this \"\n",
    "        \"for product documentation; use search_docs for that.\"\n",
    "    ),\n",
    ")\n",
    "print(\"strong routing:\", route(strong, \"Find the login bug.\"))\n",
    "# Expect the weak pair to split between tools across runs and the strong pair\n",
    "# to route consistently to search_tickets.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Part 3: Handle a tool error\n",
    "\n",
    "When a tool fails, do not swallow the exception and return a plausible-looking empty result; that teaches Claude the call succeeded. Return the failure with `is_error: True` and a readable message so Claude can retry, switch tools, or tell the user.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "get_stock_price = {\n",
    "    \"name\": \"get_stock_price\",\n",
    "    \"description\": (\n",
    "        \"Get the current market price for a publicly traded stock. Call this \"\n",
    "        \"whenever the user asks about a current or recent share price or quote.\"\n",
    "    ),\n",
    "    \"input_schema\": {\n",
    "        \"type\": \"object\",\n",
    "        \"properties\": {\"ticker\": {\"type\": \"string\",\n",
    "                                  \"description\": \"Stock ticker symbol, e.g. 'AAPL'.\"}},\n",
    "        \"required\": [\"ticker\"],\n",
    "    },\n",
    "}\n",
    "\n",
    "KNOWN = {\"AAPL\": 227.40, \"MSFT\": 512.10}\n",
    "\n",
    "\n",
    "def look_up_price(ticker):\n",
    "    if ticker not in KNOWN:\n",
    "        raise KeyError(f\"ticker '{ticker}' not found. Did you mean 'AAPL'?\")\n",
    "    return KNOWN[ticker]\n",
    "\n",
    "\n",
    "messages = [{\"role\": \"user\", \"content\": \"What's APPL trading at?\"}]  # typo on purpose\n",
    "response = client.messages.create(\n",
    "    model=\"claude-opus-4-8\", max_tokens=1024, tools=[get_stock_price], messages=messages,\n",
    ")\n",
    "messages.append({\"role\": \"assistant\", \"content\": response.content})\n",
    "\n",
    "tool_results = []\n",
    "for block in response.content:\n",
    "    if block.type == \"tool_use\":\n",
    "        try:\n",
    "            price = look_up_price(block.input[\"ticker\"])\n",
    "            result = {\"type\": \"tool_result\", \"tool_use_id\": block.id,\n",
    "                      \"content\": f\"{block.input['ticker']}: ${price}\"}\n",
    "        except Exception as e:\n",
    "            result = {\"type\": \"tool_result\", \"tool_use_id\": block.id,\n",
    "                      \"content\": f\"Error: {e}\",\n",
    "                      \"is_error\": True}   # surfaced, not swallowed\n",
    "        tool_results.append(result)\n",
    "messages.append({\"role\": \"user\", \"content\": tool_results})\n",
    "\n",
    "followup = client.messages.create(\n",
    "    model=\"claude-opus-4-8\", max_tokens=1024, tools=[get_stock_price], messages=messages,\n",
    ")\n",
    "# Claude should now retry with the corrected ticker or ask the user --\n",
    "# not fabricate a price for a ticker that does not exist.\n",
    "print(followup.stop_reason)\n",
    "for b in followup.content:\n",
    "    if b.type == \"tool_use\":\n",
    "        print(\"retried with:\", b.input)\n",
    "    elif b.type == \"text\":\n",
    "        print(b.text)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Part 4: Prove the parallel rule\n",
    "\n",
    "Parallel tool use is on by default: one assistant turn can request several tools at once. Return every `tool_result` in ONE user message, one per `tool_use_id`; omit one and the API rejects the request.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "messages = [{\"role\": \"user\", \"content\": \"Compare Apple and Microsoft stock right now.\"}]\n",
    "response = client.messages.create(\n",
    "    model=\"claude-opus-4-8\", max_tokens=1024, tools=[get_stock_price], messages=messages,\n",
    ")\n",
    "tool_uses = [b for b in response.content if b.type == \"tool_use\"]\n",
    "print(f\"tool_use blocks in one turn: {len(tool_uses)}\")   # expect 2\n",
    "\n",
    "messages.append({\"role\": \"assistant\", \"content\": response.content})\n",
    "\n",
    "all_results = [{\n",
    "    \"type\": \"tool_result\",\n",
    "    \"tool_use_id\": b.id,\n",
    "    \"content\": f\"{b.input['ticker']}: ${look_up_price(b.input['ticker'])}\",\n",
    "} for b in tool_uses]\n",
    "\n",
    "# WRONG: omit one result and the API rejects the turn -- every tool_use_id\n",
    "# must be answered.\n",
    "try:\n",
    "    client.messages.create(\n",
    "        model=\"claude-opus-4-8\", max_tokens=1024, tools=[get_stock_price],\n",
    "        messages=messages + [{\"role\": \"user\", \"content\": all_results[:1]}],\n",
    "    )\n",
    "except anthropic.BadRequestError as e:\n",
    "    print(\"omitting a tool_result rejected:\", e.message)\n",
    "\n",
    "# RIGHT: all results, one user message.\n",
    "messages.append({\"role\": \"user\", \"content\": all_results})\n",
    "followup = client.messages.create(\n",
    "    model=\"claude-opus-4-8\", max_tokens=1024, tools=[get_stock_price], messages=messages,\n",
    ")\n",
    "print(next(b.text for b in followup.content if b.type == \"text\"))\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Part 5: Stand up an MCP server\n",
    "\n",
    "Reach for MCP when reusability and independent maintenance are the point: several Claude applications need the same capability and one team should own and deploy it separately. A server exposes tools (actions), resources (readable data), and prompts (reusable templates).\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "%%writefile inventory_server.py\n",
    "# pip install \"mcp[cli]\"\n",
    "from mcp.server.fastmcp import FastMCP\n",
    "\n",
    "mcp = FastMCP(\"inventory\")\n",
    "\n",
    "FAKE_INVENTORY = {\"SKU-4471\": 12, \"SKU-9002\": 0}\n",
    "\n",
    "\n",
    "@mcp.tool()\n",
    "def check_stock(sku: str) -> str:\n",
    "    \"\"\"Return live on-hand quantity for a SKU from the internal inventory API.\n",
    "    Call this when the user asks whether an item is in stock or how many remain.\"\"\"\n",
    "    qty = FAKE_INVENTORY.get(sku)\n",
    "    return f\"{sku}: {qty} on hand\" if qty is not None else f\"{sku}: unknown SKU\"\n",
    "\n",
    "\n",
    "@mcp.resource(\"inventory://catalog\")\n",
    "def catalog() -> str:\n",
    "    \"\"\"The full product catalog as readable context.\"\"\"\n",
    "    return \"\\n\".join(f\"{sku}: {qty} on hand\" for sku, qty in FAKE_INVENTORY.items())\n",
    "\n",
    "\n",
    "@mcp.prompt()\n",
    "def restock_report(warehouse: str) -> str:\n",
    "    \"\"\"Reusable prompt template for a restock summary.\"\"\"\n",
    "    return f\"Summarize items below the reorder threshold in {warehouse}.\"\n",
    "\n",
    "\n",
    "if __name__ == \"__main__\":\n",
    "    mcp.run(transport=\"stdio\")   # or a streamable HTTP transport for networked use\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Run it locally with `python inventory_server.py` (the client launches it as a subprocess over stdio), or inspect it interactively with `mcp dev inventory_server.py`. The three decorators map to the three MCP primitives: `@mcp.tool()` is an action, `@mcp.resource()` is readable data, and `@mcp.prompt()` is a reusable template. Note that the tool's docstring plays the same role a tool description does: it names the trigger condition.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Part 6: Connect it from the Messages API\n",
    "\n",
    "The MCP connector takes two coordinated pieces: `mcp_servers` says where the server is, and an `mcp_toolset` entry in `tools` says which server's tools to expose. Omitting either is a validation error.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Requires the server deployed at an HTTPS URL (stdio is for local clients).\n",
    "response = client.beta.messages.create(\n",
    "    model=\"claude-opus-4-8\",\n",
    "    max_tokens=2048,\n",
    "    betas=[\"mcp-client-2025-11-20\"],\n",
    "    # BOTH halves are required.\n",
    "    mcp_servers=[{\n",
    "        \"type\": \"url\",\n",
    "        \"name\": \"inventory\",\n",
    "        \"url\": \"https://mcp.internal.example.com/inventory\",\n",
    "    }],\n",
    "    tools=[{\n",
    "        \"type\": \"mcp_toolset\",\n",
    "        \"mcp_server_name\": \"inventory\",   # must equal the server name above\n",
    "    }],\n",
    "    messages=[{\"role\": \"user\", \"content\": \"Do we have SKU-4471 in stock?\"}],\n",
    ")\n",
    "for b in response.content:\n",
    "    if b.type == \"text\":\n",
    "        print(b.text)\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# COUNTER-EXAMPLE: declare the server but drop the toolset half.\n",
    "# This is rejected as a validation error -- both halves or a 400.\n",
    "try:\n",
    "    client.beta.messages.create(\n",
    "        model=\"claude-opus-4-8\",\n",
    "        max_tokens=1024,\n",
    "        betas=[\"mcp-client-2025-11-20\"],\n",
    "        mcp_servers=[{\n",
    "            \"type\": \"url\",\n",
    "            \"name\": \"inventory\",\n",
    "            \"url\": \"https://mcp.internal.example.com/inventory\",\n",
    "        }],\n",
    "        # tools=[...mcp_toolset...] deliberately missing\n",
    "        messages=[{\"role\": \"user\", \"content\": \"Do we have SKU-4471 in stock?\"}],\n",
    "    )\n",
    "except anthropic.BadRequestError as e:\n",
    "    print(\"missing mcp_toolset rejected:\", e.message)\n"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.11.0"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
