From f931c959f9f8804cec4c96f344bbd8a6b49a3728 Mon Sep 17 00:00:00 2001 From: monikagadage <292948075+monikagadage@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:01:25 -0700 Subject: [PATCH] Add an MCP server as a third front-end on the shared core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mcp_server/ exposes the coach over the Model Context Protocol so any MCP client (Claude Desktop, Claude Code, Cursor, VS Code) can run mock interviews against it, reusing the same retrieval, grading, persistence, and spaced-repetition logic that app.py and cli.py drive. - mcp_server/tools.py: the 9 tools as dependency-injected plain functions, kept off the LangChain/ChromaDB/mcp import chain so they unit-test with just pytest + a temp DB + a fake collection/LLM. - mcp_server/server.py: FastMCP wiring — tools, 3 resources (interview://topics, interview://progress, interview://question-bank/{topic}), a mock_interview prompt; ChromaDB collection and Groq client built lazily; clear RuntimeError if GROQ_API_KEY is missing. - python -m mcp_server (stdio) or --http (streamable-HTTP on MCP_HOST/PORT). Both verified with a real MCP client handshake. - topics.py, prompts.py: extracted so the CLI, the workflow graph, and the MCP server share one topic list and one set of prompt strings. - tests/: +16 (test_mcp_tools 11, test_mcp_server 5); CI now installs the lightweight mcp SDK so they run in the pytest-only job. 61 tests total. - mcp pinned >=1.6,<2 (v2 renamed FastMCP -> MCPServer). --- .github/workflows/ci.yml | 11 +- DESIGN.md | 60 ++++++++-- README.md | 73 +++++++++++-- cli.py | 8 +- graph/workflow.py | 22 +--- mcp_server/__init__.py | 11 ++ mcp_server/__main__.py | 3 + mcp_server/server.py | 229 +++++++++++++++++++++++++++++++++++++++ mcp_server/tools.py | 178 ++++++++++++++++++++++++++++++ prompts.py | 27 +++++ pyproject.toml | 1 + requirements.txt | 4 +- tests/test_mcp_server.py | 56 ++++++++++ tests/test_mcp_tools.py | 128 ++++++++++++++++++++++ topics.py | 21 ++++ uv.lock | 85 +++++++++++++++ 16 files changed, 870 insertions(+), 47 deletions(-) create mode 100644 mcp_server/__init__.py create mode 100644 mcp_server/__main__.py create mode 100644 mcp_server/server.py create mode 100644 mcp_server/tools.py create mode 100644 prompts.py create mode 100644 tests/test_mcp_server.py create mode 100644 tests/test_mcp_tools.py create mode 100644 topics.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e33b99e..3e759db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,9 +17,10 @@ jobs: with: python-version: ${{ matrix.python-version }} - # The selection + persistence logic is deliberately free of the heavy - # app stack (ChromaDB, LangChain, Streamlit), so CI runs it with just - # pytest. test_cli.py self-skips without the full deps; the complete - # 45-test suite runs locally via `uv run pytest`. - - run: pip install pytest + # The selection, persistence, and MCP-tool logic are deliberately free + # of the heavy app stack (ChromaDB, LangChain, Streamlit), so CI runs + # them with just pytest + the (lightweight) mcp SDK. test_cli.py + # self-skips without the full deps; the complete suite runs locally + # via `uv run pytest`. + - run: pip install pytest "mcp>=1.6,<2" - run: pytest tests/ -v diff --git a/DESIGN.md b/DESIGN.md index 59a395f..efb494f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -4,9 +4,9 @@ Architecture and data model for Java Interview Coach. See [README.md](README.md) ## Architecture overview -Two pipelines feed into two interchangeable UIs (Streamlit's `app.py`, or `cli.py`'s stdin -loop) that each drive the exact same LangGraph-style workflow — one button click, or one -`input()` call, at a time. +Two pipelines feed into three interchangeable front-ends (Streamlit's `app.py`, `cli.py`'s +stdin loop, or the `mcp_server/` MCP tools) that each drive the exact same LangGraph-style +workflow — one button click, one `input()` call, or one tool call, at a time. ```mermaid flowchart TD @@ -178,6 +178,42 @@ cumulative stats carry over between the two. and only the `evaluate` node's actual network call does. See Known limitations below for exactly how far this was verified to run in this environment. +### MCP server (round 3) + +`mcp_server/` exposes the coach over the [Model Context Protocol](https://modelcontextprotocol.io) +as a third front-end alongside Streamlit and the CLI, so any MCP client (Claude Desktop, +Claude Code, Cursor, VS Code) can run mock interviews using the same retrieval, grading, +persistence, and spaced-repetition logic. + +- **Layering.** `mcp_server/tools.py` is the tool logic as plain functions with heavy + dependencies passed in (`collection`, `llm`, `db_path`). It imports only `memory.store` + and `topics` at module level — deliberately *not* `graph.workflow` (which pulls the whole + LangChain/LangGraph chain) or `chromadb` or `mcp`. The 3-line RAG `query` call and the + evaluate/hint prompt text are inlined / moved to `prompts.py` so the module stays light. + Result: all nine tools are unit-tested with just `pytest` + a temp SQLite file + a fake + collection / fake LLM (`tests/test_mcp_tools.py`). +- **Wire layer.** `mcp_server/server.py` is the only file that imports `mcp`. It wraps each + `tools.py` function with `@mcp.tool()`, adds three resources + (`interview://topics`, `interview://progress`, `interview://question-bank/{topic}`) and a + `mock_interview` prompt, and owns the lazily-built ChromaDB collection and `ChatGroq` + client — so `list_tools` and the store-backed tools respond instantly and a missing + `GROQ_API_KEY` only surfaces (as a clear `RuntimeError`) if a grading tool is actually + called. +- **Transports.** `python -m mcp_server` runs stdio (local clients); `--http` runs + FastMCP's streamable-HTTP on `MCP_HOST`/`MCP_PORT` (default `127.0.0.1:8000/mcp`) for a + hosted deployment. Both were smoke-tested with a real MCP client handshake. +- **Sessions.** MCP tool calls are stateless, so `evaluate_answer` only records an attempt + when the caller threads through a `session_id` from `start_session` (plus a `topic`). + Without them it still grades, just doesn't persist — the `mock_interview` prompt tells the + client to always pass them. +- **`mcp` version.** Pinned to `>=1.6,<2`: v2.x renamed `FastMCP` to `MCPServer` and + changed the API, and the v1 `FastMCP` surface is what current client docs and examples + assume. + +`topics.py` and `prompts.py` were extracted in this round so the CLI, the workflow graph, +and the MCP server share one topic list and one set of prompt strings instead of three +copies drifting apart. + ## Notebooks vs. real modules | Still notebook-only | Promoted to a real module | @@ -188,9 +224,12 @@ exactly how far this was verified to run in this environment. ## Tests -`tests/test_store.py` and `tests/test_selection.py` are real `pytest` tests (40 total, all -passing as of this writing — run `uv run pytest tests/ -v` to reproduce), added in round 2. -They replace round 1's manual-script verification for these two modules: +`uv run pytest tests/ -v` runs 61 tests, all passing as of this writing. `test_store.py` +and `test_selection.py` (40 tests) were added in round 2 to replace round 1's manual-script +verification; `test_mcp_tools.py` and `test_mcp_server.py` (16 tests) were added in round 3 +with the MCP server. All of them except `test_cli.py` run with just `pytest` (+ the +lightweight `mcp` SDK for the two MCP files) — no ChromaDB, Groq key, network, or +`questions_db.json`. - `test_store.py` exercises `memory/store.py` end to end against a fresh temp SQLite file per test (`tmp_path`, via the `db_path` parameter every `store` function already accepts) @@ -216,7 +255,14 @@ They replace round 1's manual-script verification for these two modules: instead of looping, EOF-mid-answer still submitting what was typed) by monkeypatching `builtins.input`. It's the only I/O-free logic in `cli.py` — everything else in that file does real ChromaDB/Groq I/O, so it's verified by hand instead (see Known limitations). -- None of the three test files require `GROQ_API_KEY`, network access, or +- `test_mcp_tools.py` calls every MCP tool through `mcp_server/tools.py` with a fake + collection (returns a fixed candidate list) and a fake LLM (returns fixed content), plus a + temp SQLite file — covering topic accuracy roll-up, `get_interview_question` selection and + its empty-bank branch, `evaluate_answer` CORRECT/INCORRECT parsing and the persist-only- + when-scoped rule, and the spaced-repetition tools. `test_mcp_server.py` checks the FastMCP + registration (all nine tools, three resources, the prompt) and that a grading tool raises + a clear `GROQ_API_KEY` error when unset; it self-skips if the `mcp` SDK isn't installed. +- None of the committed test files require `GROQ_API_KEY`, network access, or `questions_db.json`. ## Known limitations diff --git a/README.md b/README.md index 8a3a680..e93fd2c 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,12 @@ pipeline**: semantic search over 1,715 real interview questions finds a relevant pool, then a difficulty- and history-aware ranker decides which one to actually ask. An LLM grades each answer against an ideal answer. Every attempt is persisted, so weak-topic weighting and a spaced-repetition -schedule accumulate across sessions. A Streamlit UI and a headless CLI share -the exact same core. +schedule accumulate across sessions. + +Three front-ends drive the identical core: a **Streamlit UI**, a **headless +CLI**, and an **MCP server** ([Model Context Protocol](https://modelcontextprotocol.io)) +that exposes the whole coach as tools any MCP client — Claude Desktop, Claude +Code, Cursor, VS Code — can run mock interviews against. ## The pipeline @@ -30,7 +34,7 @@ topic ─▶ ChromaDB semantic search ─▶ candidate pool ─▶ selection.py | **Selection layered on top of RAG, not replacing it** | Vector search is good at "relevant to this topic," bad at "the right difficulty for this user right now." `graph/selection.py` takes the retrieved pool and re-ranks it against persisted per-topic accuracy, so retrieval stays simple and the adaptivity is testable in isolation (no vector store needed). | | **Difficulty is an explicit heuristic, labelled as a proxy** | The 1,715-question bank has no difficulty labels. `_estimate_difficulty` approximates it from question length and phrasing ("why"/"how does X work"/internals vs. "what is"/"define"). Called out in code and docs so it isn't mistaken for ground truth. | | **Persistence is stdlib `sqlite3`, no ORM** | `st.session_state` dies with the process. `memory/store.py` writes every attempt (topic, question, correctness, timestamp, session id) so stats survive restarts. One small module, one file, fully unit-tested against temp DBs. | -| **UI and CLI call the identical core** | `app.py` (Streamlit) and `cli.py` both drive the same `graph/` and `memory/` modules — no business logic behind a Streamlit import. The CLI is both a scripting entry point and proof the split is real. | +| **One core, three front-ends** | `app.py` (Streamlit), `cli.py`, and `mcp_server/` all drive the same `graph/` + `memory/` modules — no business logic behind a Streamlit import. `mcp_server/tools.py` holds the tool logic as dependency-injected plain functions (kept off the LangChain/ChromaDB import chain), so it's unit-tested with just `pytest`; `mcp_server/server.py` is the only file that touches the MCP wire protocol. | | **Spaced repetition on the same file** | A post-answer confidence rating (Again/Hard/Good/Easy) schedules that exact question 1/3/7/14 days out; "Due for Review" mode resurfaces what's due instead of pulling from RAG. | See [DESIGN.md](DESIGN.md) for the full architecture and data model. @@ -64,10 +68,53 @@ echo "my answer\n\nquit" | uv run python cli.py --questions 5 # scripted Same prerequisites and same persistence as the Streamlit app — CLI and UI sessions share stats. +## MCP server + +Exposes the coach over the [Model Context Protocol](https://modelcontextprotocol.io) +so any MCP client can use it as a tool. + +```bash +uv run python -m mcp_server # stdio (for Claude Desktop / Cursor / VS Code) +uv run python -m mcp_server --http # streamable-HTTP on 127.0.0.1:8000/mcp +``` + +**Tools:** `list_topics`, `start_session`, `get_interview_question` (RAG + +adaptive selection), `evaluate_answer`, `get_hint`, `record_attempt`, +`rate_question`, `get_due_reviews`, `get_progress`. +**Resources:** `interview://topics`, `interview://progress`, +`interview://question-bank/{topic}`. +**Prompt:** `mock_interview(topic, num_questions)` — a template that drives a +full session through the tools. + +The ChromaDB collection and the Groq client are built lazily, so `list_tools` +and the store-backed tools respond instantly. `evaluate_answer` / `get_hint` +need `GROQ_API_KEY`; without it they return a clear message and everything +else still works. + +
+Claude Desktop / Cursor config + +`claude_desktop_config.json` (or `.cursor/mcp.json`): + +```json +{ + "mcpServers": { + "java-interview-coach": { + "command": "uv", + "args": ["--directory", "/ABS/PATH/TO/java-interview-coach", "run", "python", "-m", "mcp_server"], + "env": { "GROQ_API_KEY": "your_key_here" } + } + } +} +``` + +Claude Code: `claude mcp add java-interview-coach -- uv --directory /ABS/PATH run python -m mcp_server` +
+ ## Tests ```bash -uv run pytest tests/ # 45 tests +uv run pytest tests/ # 61 tests ``` - `test_store.py` — session/attempt CRUD, cumulative stats, recent-question @@ -75,11 +122,15 @@ uv run pytest tests/ # 45 tests ordering (all against temp SQLite files). - `test_selection.py` — difficulty heuristic, difficulty re-ranking, recently-asked filtering, auto-topic weighting. +- `test_mcp_tools.py` — every MCP tool via a fake collection / fake LLM and a + temp DB. +- `test_mcp_server.py` — the FastMCP wiring: all tools/resources/prompt + registered, graceful error without `GROQ_API_KEY`. - `test_cli.py` — `cli.py`'s stdin-parsing helper. -`test_store.py` and `test_selection.py` need only `pytest` — no ChromaDB, -Groq key, or network — which is what CI runs on 3.11 and 3.12. `test_cli.py` -self-skips unless the full app stack is installed. +Everything except `test_cli.py` runs with just `pytest` + the (lightweight) +`mcp` SDK — no ChromaDB, Groq key, or network — which is what CI runs on 3.11 +and 3.12. `test_cli.py` self-skips unless the full app stack is installed. ## Tech stack @@ -89,7 +140,7 @@ self-skips unless the full app stack is installed. | Agent framework | LangChain + LangGraph | | Vector store | ChromaDB | | Persistence | SQLite (stdlib `sqlite3`) | -| UI | Streamlit | +| Front-ends | Streamlit · CLI · MCP server (`mcp` SDK, FastMCP) | | Env / packaging | Python 3.11, `uv` | ## Project structure @@ -97,6 +148,8 @@ self-skips unless the full app stack is installed. ``` app.py Streamlit UI, wires graph nodes to session state cli.py headless practice loop: ask -> answer -> evaluate -> score +topics.py the fixed topic list, shared by every front-end +prompts.py evaluate / hint prompt text, shared by workflow.py and mcp_server corpus.py shared ChromaDB collection loader report.py exportable Markdown session report rag.ipynb fetches + parses the question corpus into questions_db.json @@ -106,6 +159,10 @@ graph/ selection.py difficulty-adaptive re-ranking on top of RAG retrieval memory/ store.py SQLite persistence for sessions / attempts / reviews + stats +mcp_server/ + tools.py tool logic as dependency-injected plain functions + server.py FastMCP wiring (tools / resources / prompt) + lazy deps + __main__.py `python -m mcp_server [--http]` tests/ pytest suite (see above) ``` diff --git a/cli.py b/cli.py index d9284f0..c26466d 100755 --- a/cli.py +++ b/cli.py @@ -35,13 +35,7 @@ from graph.selection import pick_topic_for_auto_mode from graph.workflow import build_nodes from memory import store - -AUTO = "auto" -TOPICS = [ - "OOP", "Java Core", "Java Collections", "Spring", - "JVM", "Multithreading", "Databases", "Java 8", - "Patterns", "Testing", -] +from topics import AUTO, TOPICS def _prompt_answer() -> str | None: diff --git a/graph/workflow.py b/graph/workflow.py index 93b61c2..61cc4f7 100644 --- a/graph/workflow.py +++ b/graph/workflow.py @@ -29,29 +29,13 @@ from graph.selection import select_question from graph.state import InterviewState from memory import store +from prompts import EVAL_PROMPT, HINT_PROMPT # RAG candidates fetched per question before the adaptive layer ranks them. QUESTION_POOL_SIZE = 12 -eval_prompt = ChatPromptTemplate.from_template(""" -You are a Java technical interviewer evaluating an answer. - -Question: {question} -Candidate's Answer: {answer} - -Respond with: -1. CORRECT or INCORRECT -2. Brief feedback (2-3 sentences) -3. Ideal answer in simple terms - -Start your response with either CORRECT or INCORRECT on the first line. -""") - -hint_prompt = ChatPromptTemplate.from_template(""" -You are a helpful Java tutor. -Give a short hint (2-3 sentences) for this question without giving away the answer. -Question: {question} -""") +eval_prompt = ChatPromptTemplate.from_template(EVAL_PROMPT) +hint_prompt = ChatPromptTemplate.from_template(HINT_PROMPT) def retrieve_candidates(collection, topic: str, n_results: int = QUESTION_POOL_SIZE) -> list[str]: diff --git a/mcp_server/__init__.py b/mcp_server/__init__.py new file mode 100644 index 0000000..7a1d499 --- /dev/null +++ b/mcp_server/__init__.py @@ -0,0 +1,11 @@ +"""MCP server for Java Interview Coach. + +Exposes the same retrieval + adaptive-selection + grading + spaced-repetition +core that the Streamlit app and the CLI drive, as Model Context Protocol +tools/resources/prompts — so any MCP client (Claude Desktop, Claude Code, +Cursor, VS Code) can run mock interviews against it. + +``mcp_server.tools`` holds the logic as plain functions (dependency-injected, +unit-testable). ``mcp_server.server`` wires them to FastMCP and owns the +lazily-built ChromaDB collection and Groq client. +""" diff --git a/mcp_server/__main__.py b/mcp_server/__main__.py new file mode 100644 index 0000000..62fdc30 --- /dev/null +++ b/mcp_server/__main__.py @@ -0,0 +1,3 @@ +from mcp_server.server import main + +main() diff --git a/mcp_server/server.py b/mcp_server/server.py new file mode 100644 index 0000000..5d4fa9e --- /dev/null +++ b/mcp_server/server.py @@ -0,0 +1,229 @@ +"""FastMCP wiring for Java Interview Coach. + +Run it: + + python -m mcp_server # stdio (Claude Desktop / Cursor / VS Code) + python -m mcp_server --http # streamable-HTTP on 127.0.0.1:8000/mcp + +The ChromaDB collection (a few seconds to embed 1,715 questions) and the +Groq client are built lazily on first use, so `list_tools` and the +store-backed tools respond instantly and a missing GROQ_API_KEY only +matters if you actually call a grading tool. +""" +from __future__ import annotations + +import os + +from dotenv import load_dotenv +from mcp.server.fastmcp import FastMCP + +from mcp_server import tools +from topics import TOPICS + +load_dotenv() + +mcp = FastMCP( + "java-interview-coach", + instructions=( + "Run mock Java technical interviews. Typical loop: start_session -> " + "get_interview_question -> (get_hint if the candidate is stuck) -> " + "evaluate_answer -> rate_question. Use get_due_reviews to resurface " + "questions that are due, and get_progress to report on weak topics." + ), + host=os.environ.get("MCP_HOST", "127.0.0.1"), + port=int(os.environ.get("MCP_PORT", "8000")), +) + +# ── lazily-built heavy dependencies ───────────────────────────────────── + +_collection = None +_llm = None + + +def _get_collection(): + global _collection + if _collection is None: + from corpus import load_collection + + _collection = load_collection() + return _collection + + +def _get_llm(): + global _llm + if _llm is None: + if not os.environ.get("GROQ_API_KEY"): + raise RuntimeError( + "GROQ_API_KEY is not set. The grading tools (evaluate_answer, " + "get_hint) need a Groq API key — add it to the MCP client's env " + "config or a .env file. Retrieval, progress, and spaced-repetition " + "tools work without one." + ) + from langchain_groq import ChatGroq + + _llm = ChatGroq(model="llama-3.3-70b-versatile") + return _llm + + +# ── tools ────────────────────────────────────────────────────────────── + +@mcp.tool() +def list_topics() -> list[dict]: + """List the Java practice topics with the user's running accuracy on each.""" + return tools.list_topics() + + +@mcp.tool() +def start_session() -> dict: + """Start a new practice session. Pass the returned session_id to + evaluate_answer so attempts are recorded against it.""" + return tools.start_session() + + +@mcp.tool() +def get_interview_question(topic: str, mode: str = "topic") -> dict: + """Get one interview question. mode="topic" uses the given topic; + mode="auto" ignores it and picks one weighted toward weak areas. + Returns {topic, question, estimated_difficulty}.""" + return tools.get_interview_question(_get_collection(), topic, mode=mode) + + +@mcp.tool() +def evaluate_answer( + question: str, answer: str, topic: str = "", session_id: str = "" +) -> dict: + """Grade an answer (CORRECT/INCORRECT + feedback + ideal answer). + Pass topic and session_id to record the attempt toward progress. + Needs GROQ_API_KEY.""" + return tools.evaluate_answer( + _get_llm(), + question, + answer, + topic=topic or None, + session_id=session_id or None, + ) + + +@mcp.tool() +def get_hint(question: str) -> dict: + """A short hint for a question that doesn't give away the answer. + Needs GROQ_API_KEY.""" + return tools.get_hint(_get_llm(), question) + + +@mcp.tool() +def record_attempt( + session_id: str, + topic: str, + question: str, + answer: str, + feedback: str, + is_correct: bool, +) -> dict: + """Persist an answered question directly (if grading happened elsewhere).""" + return tools.record_attempt( + session_id, topic, question, answer, feedback, is_correct + ) + + +@mcp.tool() +def rate_question(topic: str, question: str, rating: str) -> dict: + """Schedule a question's next spaced-repetition review. + rating is one of Again / Hard / Good / Easy.""" + return tools.rate_question(topic, question, rating) + + +@mcp.tool() +def get_due_reviews(topic: str = "") -> list[dict]: + """Questions whose spaced-repetition review date has arrived, most overdue first.""" + return tools.get_due_reviews(topic=topic or None) + + +@mcp.tool() +def get_progress() -> dict: + """All-time totals and the weakest topics by accuracy.""" + return tools.get_progress() + + +# ── resources ────────────────────────────────────────────────────────── + +@mcp.resource("interview://topics") +def topics_resource() -> str: + """The topic list as plain text.""" + return "\n".join(TOPICS) + + +@mcp.resource("interview://progress") +def progress_resource() -> str: + """A Markdown progress report.""" + s = tools.get_progress() + lines = [ + "# Java Interview Coach — progress", + "", + f"- Sessions: {s['total_sessions']}", + f"- Questions answered: {s['total_questions']}", + f"- Correct: {s['total_correct']}", + f"- Weakest topics: {', '.join(s['weakest_topics']) or '—'}", + "", + "| Topic | Attempts | Accuracy |", + "|---|---:|---:|", + ] + for topic, st in sorted(s["topic_stats"].items()): + lines.append(f"| {topic} | {st['total']} | {st['accuracy']:.0%} |") + return "\n".join(lines) + + +@mcp.resource("interview://question-bank/{topic}") +def question_bank_resource(topic: str) -> str: + """Every question in the bank for one topic (raw, no selection).""" + import json + from pathlib import Path + + path = Path(__file__).resolve().parent.parent / "questions_db.json" + if not path.exists(): + return ( + "questions_db.json is not built yet — run rag.ipynb once to fetch " + "the question bank (see README.md)." + ) + bank = json.loads(path.read_text()) + questions = bank.get(topic) + if questions is None: + return f"Unknown topic {topic!r}. Known: {', '.join(bank)}" + return "\n".join(f"- {q}" for q in questions) + + +# ── prompts ──────────────────────────────────────────────────────────── + +@mcp.prompt() +def mock_interview(topic: str = "auto", num_questions: int = 5) -> str: + """Template: run a full mock interview using this server's tools.""" + ask = 'get_interview_question with mode="auto"' if topic == "auto" else "get_interview_question" + return ( + f"Act as a Java technical interviewer. Run a {num_questions}-question " + f"mock interview on the topic '{topic}'.\n\n" + "1. Call start_session first.\n" + f"2. For each question: call {ask}, present it, wait for my answer, then " + "call evaluate_answer with the topic and session_id so it counts toward " + "my progress.\n" + "3. Only call get_hint if I explicitly ask for one.\n" + "4. After the last question, call get_progress and summarize how I did " + "and which topics to focus on.\n" + "5. Offer to rate_question any questions I want scheduled for review." + ) + + +def main() -> None: + import argparse + + parser = argparse.ArgumentParser(prog="mcp_server", description=__doc__) + parser.add_argument( + "--http", + action="store_true", + help="serve over streamable-HTTP instead of stdio", + ) + args = parser.parse_args() + mcp.run(transport="streamable-http" if args.http else "stdio") + + +if __name__ == "__main__": + main() diff --git a/mcp_server/tools.py b/mcp_server/tools.py new file mode 100644 index 0000000..5af4fab --- /dev/null +++ b/mcp_server/tools.py @@ -0,0 +1,178 @@ +"""Tool logic, independent of the MCP wire layer. + +Every function here is a plain callable with its heavy dependencies +(``collection``, ``llm``) passed in, so the store-backed tools can be unit +tested with nothing but ``pytest`` + a temp SQLite file, and the +retrieval/grading tools can be tested with a fake collection / fake LLM. +``mcp_server.server`` is the only place that builds the real ones. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from memory import store +from topics import TOPICS + +# How many RAG candidates to pull before the adaptive layer ranks them. +_POOL_SIZE = 12 + + +# ── store-backed tools (no API key, no vector store) ───────────────────── + +def list_topics(db_path: str | Path | None = None) -> list[dict[str, Any]]: + """The practice topics, each with the user's running accuracy.""" + stats = store.get_topic_stats(db_path=db_path) + out = [] + for topic in TOPICS: + s = stats.get(topic) + out.append( + { + "topic": topic, + "attempts": s["total"] if s else 0, + "accuracy": round(s["accuracy"], 2) if s else None, + } + ) + return out + + +def start_session(db_path: str | Path | None = None) -> dict[str, str]: + """Open a new practice session; pass its id to ``evaluate_answer`` to + have attempts recorded against it.""" + return {"session_id": store.start_session(db_path=db_path)} + + +def get_progress(db_path: str | Path | None = None) -> dict[str, Any]: + """All-time totals plus the weakest topics by accuracy.""" + return store.get_cumulative_stats(db_path=db_path) + + +def record_attempt( + session_id: str, + topic: str, + question: str, + answer: str, + feedback: str, + is_correct: bool, + db_path: str | Path | None = None, +) -> dict[str, bool]: + """Persist an answered question directly (when grading happened elsewhere).""" + store.record_attempt( + session_id=session_id, + topic=topic, + question=question, + answer=answer, + feedback=feedback, + is_correct=is_correct, + db_path=db_path, + ) + return {"recorded": True} + + +def rate_question( + topic: str, question: str, rating: str, db_path: str | Path | None = None +) -> dict[str, str]: + """Schedule a question's next spaced-repetition review from a confidence + rating: one of ``Again`` / ``Hard`` / ``Good`` / ``Easy``.""" + next_review_at = store.record_review(topic, question, rating, db_path=db_path) + return { + "topic": topic, + "question": question, + "rating": rating, + "next_review_at": next_review_at, + } + + +def get_due_reviews( + topic: str | None = None, db_path: str | Path | None = None +) -> list[dict[str, Any]]: + """Questions whose scheduled review date has arrived, most overdue first.""" + return store.get_due_questions(topic=topic, db_path=db_path) + + +# ── retrieval + grading tools (need a collection / an llm) ─────────────── + +def _retrieve_candidates(collection, topic: str, n_results: int = _POOL_SIZE) -> list[str]: + """The RAG step: semantic search against the ChromaDB collection. + + Inlined here (rather than imported from ``graph.workflow``) so this + module stays free of the LangChain/LangGraph import chain and the tool + logic is unit-testable with just a fake collection object. + """ + results = collection.query(query_texts=[topic], n_results=n_results) + documents = results.get("documents") or [[]] + return documents[0] + + +def get_interview_question( + collection, + topic: str, + mode: str = "topic", + db_path: str | Path | None = None, +) -> dict[str, Any]: + """RAG retrieval + difficulty-adaptive selection. + + ``mode="auto"`` ignores ``topic`` and picks one weighted toward the + user's weaker areas. + """ + from graph.selection import ( + estimate_difficulty, + pick_topic_for_auto_mode, + select_question, + ) + + chosen_topic = ( + pick_topic_for_auto_mode(TOPICS, db_path=db_path) if mode == "auto" else topic + ) + candidates = _retrieve_candidates(collection, chosen_topic) + if not candidates: + return {"topic": chosen_topic, "question": None, + "error": f"no questions in the bank for topic {chosen_topic!r}"} + + question = select_question(candidates, chosen_topic, db_path=db_path) + return { + "topic": chosen_topic, + "question": question, + "estimated_difficulty": round(estimate_difficulty(question), 2), + } + + +def evaluate_answer( + llm, + question: str, + answer: str, + topic: str | None = None, + session_id: str | None = None, + db_path: str | Path | None = None, +) -> dict[str, Any]: + """Grade an answer against an ideal answer (CORRECT / INCORRECT + feedback). + + If both ``topic`` and ``session_id`` are given, the attempt is persisted + so it counts toward progress and weak-topic tracking. + """ + from prompts import EVAL_PROMPT + + response = llm.invoke(EVAL_PROMPT.format(question=question, answer=answer)) + feedback = response.content + is_correct = feedback.strip().upper().startswith("CORRECT") + + if topic and session_id: + store.record_attempt( + session_id=session_id, + topic=topic, + question=question, + answer=answer, + feedback=feedback, + is_correct=is_correct, + db_path=db_path, + ) + + return {"is_correct": is_correct, "feedback": feedback, "recorded": bool(topic and session_id)} + + +def get_hint(llm, question: str) -> dict[str, str]: + """A short hint for a question that doesn't give away the answer.""" + from prompts import HINT_PROMPT + + response = llm.invoke(HINT_PROMPT.format(question=question)) + return {"hint": response.content} diff --git a/prompts.py b/prompts.py new file mode 100644 index 0000000..f824ff8 --- /dev/null +++ b/prompts.py @@ -0,0 +1,27 @@ +"""Prompt text for the evaluate / hint steps, in one place. + +``graph/workflow.py`` wraps these in ``ChatPromptTemplate`` for the +LangGraph nodes; ``mcp_server/tools.py`` uses ``str.format`` so it needs no +LangChain import. Same wording either way — the ``{question}`` / ``{answer}`` +placeholders are compatible with both. +""" + +EVAL_PROMPT = """ +You are a Java technical interviewer evaluating an answer. + +Question: {question} +Candidate's Answer: {answer} + +Respond with: +1. CORRECT or INCORRECT +2. Brief feedback (2-3 sentences) +3. Ideal answer in simple terms + +Start your response with either CORRECT or INCORRECT on the first line. +""" + +HINT_PROMPT = """ +You are a helpful Java tutor. +Give a short hint (2-3 sentences) for this question without giving away the answer. +Question: {question} +""" diff --git a/pyproject.toml b/pyproject.toml index b1bcaf8..4fc4765 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "langchain-google-genai>=4.2.5", "langchain-groq>=1.1.3", "langgraph>=1.2.6", + "mcp>=1.6,<2", "onnxruntime<1.20", "python-dotenv>=1.2.2", "streamlit>=1.58.0", diff --git a/requirements.txt b/requirements.txt index 7279254..95ae5cf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,9 @@ langgraph langchain +langchain-groq langchain-google-genai python-dotenv cryptography==41.0.7 chromadb -onnxruntime<1.20 \ No newline at end of file +onnxruntime<1.20 +mcp>=1.6,<2 diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py new file mode 100644 index 0000000..c03b52c --- /dev/null +++ b/tests/test_mcp_server.py @@ -0,0 +1,56 @@ +"""The FastMCP wiring: every tool/resource/prompt is registered, and the +store-backed tools work end to end through the server layer. + +Skips cleanly if the `mcp` SDK isn't installed (the pytest-only CI job). +""" +from __future__ import annotations + +import pytest + +pytest.importorskip("mcp", reason="the mcp SDK isn't installed") + +from mcp_server import server # noqa: E402 + + +EXPECTED_TOOLS = { + "list_topics", "start_session", "get_interview_question", "evaluate_answer", + "get_hint", "record_attempt", "rate_question", "get_due_reviews", "get_progress", +} + + +def test_all_expected_tools_are_registered(): + names = {t.name for t in server.mcp._tool_manager.list_tools()} + assert EXPECTED_TOOLS <= names + + +def test_resources_and_prompt_are_registered(): + templates = {t.uri_template for t in server.mcp._resource_manager.list_templates()} + fixed = {str(r.uri) for r in server.mcp._resource_manager.list_resources()} + assert "interview://question-bank/{topic}" in templates + assert {"interview://topics", "interview://progress"} <= fixed + assert "mock_interview" in {p.name for p in server.mcp._prompt_manager.list_prompts()} + + +def test_grading_tool_gives_a_clear_error_without_a_key(monkeypatch): + monkeypatch.delenv("GROQ_API_KEY", raising=False) + server._llm = None + with pytest.raises(RuntimeError, match="GROQ_API_KEY"): + server.get_hint("What is a JVM?") + + +def test_question_bank_resource(): + from pathlib import Path + + real = Path(server.__file__).resolve().parent.parent / "questions_db.json" + text = server.question_bank_resource("OOP") + if real.exists(): + assert text.startswith("- ") or "Unknown topic" in text + else: + # No bank built here — the resource says so instead of raising. + assert "not built yet" in text + + +def test_progress_resource_renders_markdown(tmp_path, monkeypatch): + monkeypatch.setattr("memory.store.DB_PATH", tmp_path / "h.db") + out = server.progress_resource() + assert out.startswith("# Java Interview Coach") diff --git a/tests/test_mcp_tools.py b/tests/test_mcp_tools.py new file mode 100644 index 0000000..277a6e3 --- /dev/null +++ b/tests/test_mcp_tools.py @@ -0,0 +1,128 @@ +"""Tests for mcp_server/tools.py. + +``mcp_server.tools`` is deliberately free of the LangChain / ChromaDB / mcp +import chain, so every tool is exercised here with just pytest + a temp +SQLite file and, where needed, a fake collection / fake LLM. +""" +from __future__ import annotations + +import pytest + +from mcp_server import tools +from topics import TOPICS + + +@pytest.fixture +def db(tmp_path): + return tmp_path / "hist.db" + + +# ── fakes ────────────────────────────────────────────────────────────── + +class _FakeCollection: + """Stands in for a ChromaDB collection: returns a fixed candidate list.""" + + def __init__(self, documents): + self._documents = documents + + def query(self, query_texts, n_results): + return {"documents": [self._documents[:n_results]]} + + +class _FakeLLM: + def __init__(self, content): + self._content = content + + def invoke(self, _prompt): + return type("Resp", (), {"content": self._content})() + + +# ── store-backed tools ───────────────────────────────────────────────── + +def test_list_topics_reports_zero_before_any_practice(db): + rows = tools.list_topics(db_path=db) + assert [r["topic"] for r in rows] == TOPICS + assert all(r["attempts"] == 0 and r["accuracy"] is None for r in rows) + + +def test_start_session_returns_an_id(db): + out = tools.start_session(db_path=db) + assert isinstance(out["session_id"], str) and out["session_id"] + + +def test_record_attempt_then_progress_and_topic_accuracy(db): + sid = tools.start_session(db_path=db)["session_id"] + tools.record_attempt(sid, "OOP", "What is encapsulation?", "hiding state", + "CORRECT ...", True, db_path=db) + tools.record_attempt(sid, "OOP", "What is a JVM?", "no idea", + "INCORRECT ...", False, db_path=db) + + progress = tools.get_progress(db_path=db) + assert progress["total_questions"] == 2 + assert progress["total_correct"] == 1 + + oop = next(r for r in tools.list_topics(db_path=db) if r["topic"] == "OOP") + assert oop["attempts"] == 2 + assert oop["accuracy"] == 0.5 + + +def test_rate_question_schedules_a_review_and_it_comes_due(db): + out = tools.rate_question("Spring", "What is a bean?", "Again", db_path=db) + assert out["next_review_at"] # Again -> 1 day out + # Nothing is due yet... + assert tools.get_due_reviews(db_path=db) == [] + + +def test_rate_question_rejects_an_unknown_rating(db): + with pytest.raises(ValueError): + tools.rate_question("Spring", "q", "Sometimes", db_path=db) + + +# ── retrieval + grading tools (fakes, still no heavy deps) ────────────── + +def test_get_interview_question_selects_from_the_candidate_pool(db): + pool = [ + "What is the difference between an interface and an abstract class?", + "Define polymorphism.", + "How does the JVM implement method dispatch under the hood?", + ] + out = tools.get_interview_question(_FakeCollection(pool), "OOP", db_path=db) + assert out["question"] in pool + assert 0.0 <= out["estimated_difficulty"] <= 1.0 + assert out["topic"] == "OOP" + + +def test_get_interview_question_auto_mode_picks_a_known_topic(db): + out = tools.get_interview_question( + _FakeCollection(["Define polymorphism."]), "ignored", mode="auto", db_path=db + ) + assert out["topic"] in TOPICS + + +def test_get_interview_question_handles_an_empty_bank(db): + out = tools.get_interview_question(_FakeCollection([]), "OOP", db_path=db) + assert out["question"] is None + assert "no questions" in out["error"] + + +def test_evaluate_answer_parses_correct_and_persists_when_scoped(db): + sid = tools.start_session(db_path=db)["session_id"] + llm = _FakeLLM("CORRECT\nGood, that's the idea.\nIdeal: ...") + out = tools.evaluate_answer(llm, "What is encapsulation?", "hiding internal state", + topic="OOP", session_id=sid, db_path=db) + assert out["is_correct"] is True + assert out["recorded"] is True + assert tools.get_progress(db_path=db)["total_questions"] == 1 + + +def test_evaluate_answer_does_not_persist_without_topic_and_session(db): + llm = _FakeLLM("INCORRECT\nNot quite.\nIdeal: ...") + out = tools.evaluate_answer(llm, "q", "a", db_path=db) + assert out["is_correct"] is False + assert out["recorded"] is False + assert tools.get_progress(db_path=db)["total_questions"] == 0 + + +def test_get_hint_returns_the_model_text(db): + out = tools.get_hint(_FakeLLM("Think about what 'private' buys you."), "q") + assert out["hint"].startswith("Think about") diff --git a/topics.py b/topics.py new file mode 100644 index 0000000..c654d9f --- /dev/null +++ b/topics.py @@ -0,0 +1,21 @@ +"""The fixed topic list, shared by every front-end (Streamlit, CLI, MCP). + +Kept in one module so adding a topic is a one-line change and the CLI, the +MCP server, and auto-mode weighting can't drift out of sync. +""" +from __future__ import annotations + +AUTO = "auto" + +TOPICS: list[str] = [ + "OOP", + "Java Core", + "Java Collections", + "Spring", + "JVM", + "Multithreading", + "Databases", + "Java 8", + "Patterns", + "Testing", +] diff --git a/uv.lock b/uv.lock index 8c201b8..57cdfa2 100644 --- a/uv.lock +++ b/uv.lock @@ -1057,6 +1057,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + [[package]] name = "huggingface-hub" version = "1.28.0" @@ -1197,6 +1206,7 @@ dependencies = [ { name = "langchain-google-genai" }, { name = "langchain-groq" }, { name = "langgraph" }, + { name = "mcp" }, { name = "onnxruntime" }, { name = "python-dotenv" }, { name = "streamlit" }, @@ -1217,6 +1227,7 @@ requires-dist = [ { name = "langchain-google-genai", specifier = ">=4.2.5" }, { name = "langchain-groq", specifier = ">=1.1.3" }, { name = "langgraph", specifier = ">=1.2.6" }, + { name = "mcp", specifier = ">=1.6,<2" }, { name = "onnxruntime", specifier = "<1.20" }, { name = "python-dotenv", specifier = ">=1.2.2" }, { name = "streamlit", specifier = ">=1.58.0" }, @@ -1600,6 +1611,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, ] +[[package]] +name = "mcp" +version = "1.29.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/48/0bb26fdfe7ac16875f534a101ce2405eae192bdef37e7451f2f4507c13ec/mcp-1.29.1.tar.gz", hash = "sha256:1967ba4c315f7a375146209949f45950d18b0efd2f913d7cf3400bc723ee5f04", size = 646823, upload-time = "2026-08-24T18:30:41.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/04/d6b4fb82eefe9e81807aabca1ac98f460ae0883974b83a997aaa20c52545/mcp-1.29.1-py3-none-any.whl", hash = "sha256:b6310eeb59153300c4ab8b9aec4c52f4819a2d6a8e429eb43d908bed7c783648", size = 224653, upload-time = "2026-08-24T18:30:39.573Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -3022,6 +3058,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pypika" version = "0.51.1" @@ -3095,6 +3145,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -3448,6 +3520,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "sse-starlette" +version = "3.4.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/e1/8a41e88e825ea26c44333897c7ffe35fe60153a2cfc097a5bd1d209ad281/sse_starlette-3.4.10.tar.gz", hash = "sha256:c6c87280d8feb4e55a8d79633782766b9cac6a26da5c79a145d00aa404117a86", size = 33720, upload-time = "2026-09-03T09:36:24.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/3c/96018a51c7301a64f7b0579d9ce8f9b69dd39ca8ed5aa100ba3feadee503/sse_starlette-3.4.10-py3-none-any.whl", hash = "sha256:710f5f5b0527409903a22a91699db02f76f4c2eb9204e882e4ee7cada76bdf75", size = 17120, upload-time = "2026-09-03T09:36:22.56Z" }, +] + [[package]] name = "stack-data" version = "0.6.3"