{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "*Lab 4 Self-Driven Lab*\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",
    "## Challenge Outline\n",
    "\n",
    "Build a complete notebook that demonstrates the following outcomes:\n",
    "\n",
    "- **Define a strict tool:** write `create_ticket` with `strict: True`, an enum on severity, `additionalProperties: False`, and a description that names the trigger condition, then confirm schema-valid arguments.\n",
    "- **Force a misroute, then fix it:** add `search_tickets` with a description overlapping `search_docs`, watch Claude pick the wrong one on some phrasings, then rewrite both descriptions to state what each is for and not for.\n",
    "- **Handle a tool error:** make `get_stock_price` raise on an unknown ticker, return a `tool_result` with `is_error: True` and a helpful message, and confirm Claude recovers rather than fabricating a price.\n",
    "- **Prove the parallel rule:** ask Claude to compare two tickers so it emits two `tool_use` blocks, return both results in one user message, then deliberately omit one and observe the API reject the request.\n",
    "- **Stand up an MCP server:** wrap the inventory logic in a FastMCP server exposing one tool, one resource, and one prompt, and run it over stdio.\n",
    "- **Connect it from the Messages API:** call the deployed server with both `mcp_servers` and the matching `mcp_toolset`, then drop the toolset and confirm the validation error.\n",
    "\n",
    "Your solution should include enough code, output, or written observations to prove each outcome worked. Keep the notebook focused on final behavior and evidence rather than a guided walkthrough.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Student Workspace\n",
    "\n",
    "Use the sections below to build your solution. Each section maps to one required outcome from the challenge outline.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Part 1: Define a strict tool\n",
    "\n",
    "write `create_ticket` with `strict: True`, an enum on severity, `additionalProperties: False`, and a description that names the trigger condition, then confirm schema-valid arguments.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Part 1: Define a strict tool\n",
    "# Add your implementation, outputs, or notes here.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Part 2: Force a misroute, then fix it\n",
    "\n",
    "add `search_tickets` with a description overlapping `search_docs`, watch Claude pick the wrong one on some phrasings, then rewrite both descriptions to state what each is for and not for.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Part 2: Force a misroute, then fix it\n",
    "# Add your implementation, outputs, or notes here.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Part 3: Handle a tool error\n",
    "\n",
    "make `get_stock_price` raise on an unknown ticker, return a `tool_result` with `is_error: True` and a helpful message, and confirm Claude recovers rather than fabricating a price.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Part 3: Handle a tool error\n",
    "# Add your implementation, outputs, or notes here.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Part 4: Prove the parallel rule\n",
    "\n",
    "ask Claude to compare two tickers so it emits two `tool_use` blocks, return both results in one user message, then deliberately omit one and observe the API reject the request.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Part 4: Prove the parallel rule\n",
    "# Add your implementation, outputs, or notes here.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Part 5: Stand up an MCP server\n",
    "\n",
    "wrap the inventory logic in a FastMCP server exposing one tool, one resource, and one prompt, and run it over stdio.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Part 5: Stand up an MCP server\n",
    "# Add your implementation, outputs, or notes here.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Part 6: Connect it from the Messages API\n",
    "\n",
    "call the deployed server with both `mcp_servers` and the matching `mcp_toolset`, then drop the toolset and confirm the validation error.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Part 6: Connect it from the Messages API\n",
    "# Add your implementation, outputs, or notes here.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Verification Notes\n",
    "\n",
    "Summarize the evidence that each part worked. Capture API signals, validation outcomes, errors, recovery behavior, cost observations, or comparisons required by this lab.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Verification notes\n",
    "# Record the evidence that proves each lab outcome worked.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "\n",
    "## Answer Key\n",
    "\n",
    "The cells below contain the completed reference implementation/content for this lab. Use this section only after attempting the self-driven lab.\n"
   ]
  },
  {
   "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
}
