From 0b1e31775711860e169c84df29b49f1fe7d5bd6c Mon Sep 17 00:00:00 2001 From: Digvijay Chauhan Date: Thu, 12 Nov 2026 15:18:08 +0100 Subject: [PATCH] Inventory Planning Agentic: Challenge 6 MCP client so the stdio server isn't run by hand - Add src/mcp_client.py: launches mcp_server.py over stdio and calls inject_signal - Challenge 6 Part C, walkthrough, READMEs: use the client; explain the 'Invalid JSON' error - Cap mcp<2 (2.x removes FastMCP); lock still pins 1.30.0 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../03_Inventory_Planning_Agentic/README.md | 1 + .../challenges/challenge-06.md | 37 +++++++++++--- .../src/README.md | 1 + .../src/mcp_client.py | 51 +++++++++++++++++++ .../src/mcp_server.py | 8 +-- .../src/pyproject.toml | 2 +- .../03_Inventory_Planning_Agentic/src/uv.lock | 2 +- .../walkthrough/challenge-06/solution-06.md | 14 ++++- 8 files changed, 101 insertions(+), 15 deletions(-) create mode 100644 03-Azure/01-04-AI/03_Inventory_Planning_Agentic/src/mcp_client.py diff --git a/03-Azure/01-04-AI/03_Inventory_Planning_Agentic/README.md b/03-Azure/01-04-AI/03_Inventory_Planning_Agentic/README.md index 41b4ce741..eeb1f9f4f 100644 --- a/03-Azure/01-04-AI/03_Inventory_Planning_Agentic/README.md +++ b/03-Azure/01-04-AI/03_Inventory_Planning_Agentic/README.md @@ -199,6 +199,7 @@ src/ workflow.py Stretch: the loop as one sequential workflow live.py Stretch: change-feed watcher — the loop reacts to Cosmos inserts mcp_server.py Stretch: MCP server exposing inject_signal (external trigger) + mcp_client.py Stretch: MCP client that launches the server and calls inject_signal ui/ Minimal planner console (FastAPI + static HTML) — the only UI challenges/ Challenge instructions walkthrough/ Reference solutions (one per challenge) diff --git a/03-Azure/01-04-AI/03_Inventory_Planning_Agentic/challenges/challenge-06.md b/03-Azure/01-04-AI/03_Inventory_Planning_Agentic/challenges/challenge-06.md index 6c4ae4d6c..97ac00432 100644 --- a/03-Azure/01-04-AI/03_Inventory_Planning_Agentic/challenges/challenge-06.md +++ b/03-Azure/01-04-AI/03_Inventory_Planning_Agentic/challenges/challenge-06.md @@ -79,18 +79,35 @@ The write can come from anywhere — including an **MCP** client (GitHub Copilot another agent). [`src/mcp_server.py`](../src/mcp_server.py) exposes one tool, `inject_signal`, that writes to the same `signals` container. -1. From `src/`, run the MCP server. It uses **stdio transport** — it talks over standard - input/output, **not a network port**, so there's nothing to `curl`; an MCP client - launches and speaks to the process directly. +The server uses **stdio transport**: it talks JSON-RPC over standard input/output, **not a +network port**. You don't run it on its own — an **MCP client** starts it as a child process +and speaks to it. [`src/mcp_client.py`](../src/mcp_client.py) is a minimal client that does +exactly that. + +1. Keep the planner console running in its terminal. Open a **second terminal** (**+** in + the terminal panel) and go to `src/`: + ```bash + cd src + ``` +2. Call the tool through MCP — the client launches `mcp_server.py`, lists its tools and calls + **`inject_signal`**: ```bash - uv run python mcp_server.py + uv run python mcp_client.py "Competitor out of stock on chainsaws in the West" --region West + ``` + You should see: + ```text + Connected to MCP server 'zava-signals' - tools: inject_signal + Injected signal SIG-… — the planner loop will react automatically. ``` -2. Point an MCP client at that command (or use an MCP client session to call the tool). - Invoke **`inject_signal`** with a headline, e.g. *"Competitor out of stock on - chainsaws in the West"*. 3. Switch to the console — it **reacts on its own** to the MCP-driven insert, runs the loop, and waits at the gate. An external system just drove your inventory loop. +> [!IMPORTANT] +> **Don't run `uv run python mcp_server.py` by itself.** It starts and waits silently for +> JSON-RPC messages from a client; pressing **Enter** sends an empty line and prints +> `Invalid JSON: EOF while parsing a value … input_value='\n'`. That's expected — use +> `mcp_client.py` (or another MCP client) instead. Stop a stray server with **Ctrl+C**. + ### Part D — (Go further) any producer, same reaction The trigger is **decoupled** from the loop: the watcher reacts to the *data*, not to @@ -117,7 +134,8 @@ Explorer, a `az cosmosdb`/SDK script, or a scheduled job — and watch the conso | Steps error out mid-run | The auto-run uses the same agents as Challenges 2–4 — make sure they work manually first, and that `az login` is valid. | | SSE doesn't update the UI | Some corporate proxies buffer `text/event-stream`; open the forwarded port directly, or check the browser console for the `/api/events` connection. | | Watcher dot goes gray mid-session | The SSE link dropped; it **reconnects automatically** and the dot re-greens on the next event. If it stays gray, reload the page. | -| MCP client can't call the tool | Run `uv run python mcp_server.py` from `src/`, and make sure `.env` + `az login` are in that shell (the tool writes to Cosmos). | +| `Invalid JSON: EOF while parsing a value … input_value='\n'` | You ran `mcp_server.py` directly and pressed Enter. It's a stdio server that waits for a client — press **Ctrl+C** and use `uv run python mcp_client.py ""` instead (Part C). | +| `mcp_client.py` fails calling the tool | Run it from `src/` (where `uv run` finds the project), check `COSMOS_ENDPOINT` in `src/.env`, and make sure `az login` is valid (the tool writes to Cosmos). | ## 🚀 Go further @@ -127,6 +145,9 @@ Explorer, a `az cosmosdb`/SDK script, or a scheduled job — and watch the conso — same pattern, detached from the console. Note the added Function App infra. - Give the MCP server a second tool, `list_exposure`, so an external agent can *ask* before it *injects*. +- Connect a full MCP host instead of the script: register the server in **GitHub Copilot + Chat** (agent mode) as a **stdio** server with command `uv` and arguments + `run python mcp_server.py` (working directory `src/`), then ask Copilot to inject a signal. ## 📚 Learning resources diff --git a/03-Azure/01-04-AI/03_Inventory_Planning_Agentic/src/README.md b/03-Azure/01-04-AI/03_Inventory_Planning_Agentic/src/README.md index 79860d93a..2658baa01 100644 --- a/03-Azure/01-04-AI/03_Inventory_Planning_Agentic/src/README.md +++ b/03-Azure/01-04-AI/03_Inventory_Planning_Agentic/src/README.md @@ -54,6 +54,7 @@ again. | `workflow.py` | Stretch: the whole loop as one callable (used by `/api/run-loop`) | | `live.py` | Stretch (Challenge 6): Cosmos change-feed watcher — auto-runs the loop when a signal is written, streamed to the UI over SSE, stopping at the human gate | | `mcp_server.py` | Stretch (Challenge 6): MCP server exposing `inject_signal` so an external client can trigger the loop | +| `mcp_client.py` | Stretch (Challenge 6): minimal MCP client that launches `mcp_server.py` over stdio and calls `inject_signal` | | `ui/` | Minimal FastAPI + static-HTML planner console — the only way in | > The store is provisioned by `labautomation` (per-attendee Cosmos DB, keyless). The diff --git a/03-Azure/01-04-AI/03_Inventory_Planning_Agentic/src/mcp_client.py b/03-Azure/01-04-AI/03_Inventory_Planning_Agentic/src/mcp_client.py new file mode 100644 index 000000000..d8b753fae --- /dev/null +++ b/03-Azure/01-04-AI/03_Inventory_Planning_Agentic/src/mcp_client.py @@ -0,0 +1,51 @@ +"""Minimal MCP client for Challenge 6: launch ``mcp_server.py`` and call ``inject_signal``. + +``mcp_server.py`` uses the stdio transport, so it can't be run on its own and typed +into: an MCP client starts it as a child process and exchanges JSON-RPC over its +stdin/stdout. This script is that client. + +Run it from ``src/`` in a second terminal while the planner console is running: + + uv run python mcp_client.py "Competitor out of stock on chainsaws in the West" + uv run python mcp_client.py "Heatwave intensifies" --category outdoor_power_tools --region "Pacific Northwest" +""" + +from __future__ import annotations + +import argparse +import asyncio +import sys +from pathlib import Path + +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + +SERVER = Path(__file__).with_name("mcp_server.py") + + +async def inject(headline: str, category: str, region: str) -> int: + params = StdioServerParameters(command=sys.executable, args=[str(SERVER)], cwd=str(SERVER.parent)) + async with stdio_client(params) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + tools = [tool.name for tool in (await session.list_tools()).tools] + print(f"Connected to MCP server 'zava-signals' - tools: {', '.join(tools)}") + result = await session.call_tool( + "inject_signal", {"headline": headline, "category": category, "region": region} + ) + for item in result.content: + print(getattr(item, "text", item)) + return 1 if result.isError else 0 + + +def main() -> None: + parser = argparse.ArgumentParser(description="Call the inject_signal MCP tool.") + parser.add_argument("headline", help='The market signal, e.g. "Competitor out of stock on chainsaws in the West"') + parser.add_argument("--category", default="outdoor_power_tools") + parser.add_argument("--region", default="Pacific Northwest") + args = parser.parse_args() + sys.exit(asyncio.run(inject(args.headline, args.category, args.region))) + + +if __name__ == "__main__": + main() diff --git a/03-Azure/01-04-AI/03_Inventory_Planning_Agentic/src/mcp_server.py b/03-Azure/01-04-AI/03_Inventory_Planning_Agentic/src/mcp_server.py index 8fe08b194..1eb563007 100644 --- a/03-Azure/01-04-AI/03_Inventory_Planning_Agentic/src/mcp_server.py +++ b/03-Azure/01-04-AI/03_Inventory_Planning_Agentic/src/mcp_server.py @@ -8,11 +8,13 @@ sense → plan → propose and stops at the human-approval gate. No agent is called here — the tool only writes the signal; the reactive loop does the rest. -Run it (stdio transport, from the ``src/`` folder): +Run it (stdio transport) through an MCP client — not on its own. From the ``src/`` folder: - uv run python mcp_server.py + uv run python mcp_client.py "Competitor out of stock on chainsaws in the West" -Then point an MCP client at that command. The app must be running and Cosmos configured +``mcp_client.py`` starts this server as a child process and calls ``inject_signal``. Running +``python mcp_server.py`` directly just waits for JSON-RPC on stdin (pressing Enter gives an +"Invalid JSON" error). The app must be running and Cosmos configured (``COSMOS_ENDPOINT`` in ``.env`` + ``az login``) for the write — and the reaction — to work. """ diff --git a/03-Azure/01-04-AI/03_Inventory_Planning_Agentic/src/pyproject.toml b/03-Azure/01-04-AI/03_Inventory_Planning_Agentic/src/pyproject.toml index 577bdfbf2..d8d6d2401 100644 --- a/03-Azure/01-04-AI/03_Inventory_Planning_Agentic/src/pyproject.toml +++ b/03-Azure/01-04-AI/03_Inventory_Planning_Agentic/src/pyproject.toml @@ -22,7 +22,7 @@ dependencies = [ "python-dotenv>=1.0.1", # Model Context Protocol server — exposes `inject_signal` so an external MCP # client can drop a market signal into Cosmos and trigger the loop (Challenge 5). - "mcp>=1.2.0", + "mcp>=1.2.0,<2", ] # This is an application, not an installable library — uv manages the environment diff --git a/03-Azure/01-04-AI/03_Inventory_Planning_Agentic/src/uv.lock b/03-Azure/01-04-AI/03_Inventory_Planning_Agentic/src/uv.lock index 301b98e11..d4e4d2b4e 100644 --- a/03-Azure/01-04-AI/03_Inventory_Planning_Agentic/src/uv.lock +++ b/03-Azure/01-04-AI/03_Inventory_Planning_Agentic/src/uv.lock @@ -30,7 +30,7 @@ requires-dist = [ { name = "azure-cosmos", specifier = ">=4.9.0" }, { name = "azure-identity", specifier = ">=1.19.0" }, { name = "fastapi", specifier = ">=0.115.0" }, - { name = "mcp", specifier = ">=1.2.0" }, + { name = "mcp", specifier = ">=1.2.0,<2" }, { name = "openai", specifier = ">=1.100.0" }, { name = "pydantic", specifier = ">=2.7.0" }, { name = "python-dotenv", specifier = ">=1.0.1" }, diff --git a/03-Azure/01-04-AI/03_Inventory_Planning_Agentic/walkthrough/challenge-06/solution-06.md b/03-Azure/01-04-AI/03_Inventory_Planning_Agentic/walkthrough/challenge-06/solution-06.md index a34074144..3f06f7a1b 100644 --- a/03-Azure/01-04-AI/03_Inventory_Planning_Agentic/walkthrough/challenge-06/solution-06.md +++ b/03-Azure/01-04-AI/03_Inventory_Planning_Agentic/walkthrough/challenge-06/solution-06.md @@ -64,10 +64,20 @@ def inject_signal(headline, category="outdoor_power_tools", region="Pacific Nort return f"Injected signal {doc['signalId']} — the planner loop will react automatically." ``` -Run it with `uv run python mcp_server.py` (stdio) and point any MCP client at it. Calling -`inject_signal` writes to Cosmos → the console's watcher reacts → the loop runs → it stops +It's a **stdio** server, so an MCP client must launch it. [`src/mcp_client.py`](../../src/mcp_client.py) +is that client — from `src/`, in a second terminal while the console runs: + +```bash +uv run python mcp_client.py "Competitor out of stock on chainsaws in the West" --region West +``` + +Calling `inject_signal` writes to Cosmos → the console's watcher reacts → the loop runs → it stops at the gate. An external system, speaking a standard protocol, drove your inventory loop. +> **Coach note:** if a team runs `uv run python mcp_server.py` on its own and presses Enter, +> they get `Invalid JSON: EOF while parsing a value … input_value='\n'`. That's not a bug — +> the server is waiting for a client. Have them **Ctrl+C** and use `mcp_client.py`. + ## Interactive planning & the informed gate The reactive loop produces a **draft**; the planner refines it in natural language before