Repository: qte77/context-engineering-template
Files analyzed: 103
Estimated tokens: 233.4k
Directory structure:
└── qte77-context-engineering-template/
├── README.md
├── AGENTS.md
├── CHANGELOG.md
├── CLAUDE.md
├── LICENSE
├── Makefile
├── mkdocs.yaml
├── pyproject.toml
├── uv.lock
├── .env.example
├── .gitmessage
├── assets/
│ └── images/
│ ├── example_execute_feature_mcp_client_created.PNG
│ ├── example_execute_feature_mcp_server_created.PNG
│ ├── example_execute_feature_mcp_server_PRP_update_pyproject.PNG
│ ├── example_execute_feature_mcp_server_PRP_update_pyproject_diff.PNG
│ └── example_generate_feature_mcp_server_PRP.PNG
├── context/
│ └── templates/
│ ├── feature_base.md
│ └── prp_base.md
├── docs/
│ ├── llms.txt
│ └── architecture/
│ └── sequence_diagram.mermaid
├── examples/
│ └── mcp-server-client/
│ ├── README.md
│ ├── Makefile
│ ├── pyproject.toml
│ ├── uv.lock
│ ├── assets/
│ │ └── images/
│ ├── context/
│ │ ├── features/
│ │ │ ├── feature_1_mcp_server.md
│ │ │ ├── feature_2_mcp_client.md
│ │ │ └── feature_3_streamlit_gui.md
│ │ ├── outputs/
│ │ │ ├── client_get_date_input.json
│ │ │ ├── client_get_weather_input.json
│ │ │ ├── client_invalid_tool.json
│ │ │ ├── client_roll_dice_input.json
│ │ │ ├── get_date_example.json
│ │ │ ├── get_weather_example.json
│ │ │ ├── roll_dice_example.json
│ │ │ ├── streamlit_error_handling.json
│ │ │ ├── streamlit_get_date_interaction.json
│ │ │ ├── streamlit_get_weather_interaction.json
│ │ │ └── streamlit_roll_dice_interaction.json
│ │ ├── PRPs/
│ │ │ ├── feature_1_mcp_server.md
│ │ │ ├── feature_2_mcp_client.md
│ │ │ └── feature_3_streamlit_gui.md
│ │ └── templates/
│ │ ├── feature_base.md
│ │ └── prp_base.md
│ ├── src/
│ │ ├── __init__.py
│ │ ├── main.py
│ │ ├── py.typed
│ │ ├── gui/
│ │ │ ├── __init__.py
│ │ │ ├── app.py
│ │ │ ├── components/
│ │ │ │ ├── __init__.py
│ │ │ │ ├── connection.py
│ │ │ │ ├── history.py
│ │ │ │ └── tool_forms.py
│ │ │ ├── models/
│ │ │ │ ├── __init__.py
│ │ │ │ └── gui_models.py
│ │ │ └── utils/
│ │ │ ├── __init__.py
│ │ │ ├── async_helper.py
│ │ │ ├── formatting.py
│ │ │ ├── mcp_wrapper.py
│ │ │ └── validation.py
│ │ ├── mcp_client/
│ │ │ ├── __init__.py
│ │ │ ├── cli.py
│ │ │ ├── client.py
│ │ │ ├── transport.py
│ │ │ └── models/
│ │ │ ├── __init__.py
│ │ │ └── responses.py
│ │ └── mcp_server/
│ │ ├── __init__.py
│ │ ├── server.py
│ │ ├── models/
│ │ │ ├── __init__.py
│ │ │ └── requests.py
│ │ └── tools/
│ │ ├── __init__.py
│ │ ├── base.py
│ │ ├── date_time.py
│ │ ├── dice.py
│ │ └── weather.py
│ └── tests/
│ ├── __init__.py
│ ├── test_cli.py
│ ├── test_gui.py
│ ├── test_mcp_client.py
│ ├── test_mcp_server.py
│ ├── fixtures/
│ │ ├── __init__.py
│ │ └── mcp_messages.py
│ └── test_tools/
│ ├── __init__.py
│ ├── test_date_time.py
│ ├── test_dice.py
│ └── test_weather.py
├── src/
│ ├── __init__.py
│ ├── main.py
│ └── py.typed
├── .claude/
│ ├── settings.local.json
│ └── commands/
│ ├── execute-prp.md
│ └── generate-prp.md
├── .devcontainer/
│ └── setup_python_claude/
│ └── devcontainer.json
└── .github/
├── dependabot.yaml
├── scripts/
│ ├── create_pr.sh
│ └── delete_branch_pr_tag.sh
└── workflows/
├── bump-my-version.yaml
├── codeql.yaml
├── generate-deploy-mkdocs-ghpages.yaml
├── links-fail-fast.yaml
├── pytest.yaml
├── ruff.yaml
├── summarize-jobs-reusable.yaml
└── write-llms-txt.yaml
================================================
FILE: README.md
================================================
# Context Engineering Template
This project aims to implement a template for context engineering with coding agents. As suggested by several resources, including [context-engineering-intro](https://github.com/coleam00/context-engineering-intro), [The rise of "context engineering"](https://blog.langchain.com/the-rise-of-context-engineering/), [Context Engineering](https://blog.langchain.com/context-engineering-for-agents/) and somewhat [He Built 40 Startups Using Just Prompts — Here’s His System](https://youtu.be/CIAu6WeckQ0).
[](LICENSE)

[](https://github.com/qte77/context-engineering-template/actions/workflows/codeql.yaml)
[](https://www.codefactor.io/repository/github/qte77/context-engineering-template)
[](https://github.com/qte77/context-engineering-template/actions/workflows/ruff.yaml)
[](https://github.com/qte77/context-engineering-template/actions/workflows/pytest.yaml)
[](https://github.com/qte77/context-engineering-template/actions/workflows/links-fail-fast.yaml)
[](https://github.com/qte77/context-engineering-template/actions/workflows/generate-deploy-mkdocs-ghpages.yaml)
**DevEx** [](https://vscode.dev/github/qte77/context-engineering-template)
[](https://github.com/codespaces/new?repo=qte77/context-engineering-template&devcontainer_path=.devcontainer/setup_python_claude/devcontainer.json)
[](https://talktogithub.com/qte77/context-engineering-template)
[](https://github.com/qte77/context-engineering-template)
[](https://gittodoc.com/qte77/context-engineering-template)
## Status
(DRAFT) (WIP) ----> Not fully implemented yet
For version history have a look at the [CHANGELOG](CHANGELOG.md).
## Purpose
Let the Coding Agent do the heavy lifting. Build code base from top to bottom: Define Business Requirements (BRD) and afterwards features to be implemented. The goal could be to to implement some kind of guided top-down BDD: behavior > tests > implementation.
## Features
- Runs tests, linting and type checks: only
Show Sequence Diagram
## Setup
1. `make setup_python_claude`
2. If .env to be used: `make export_env_file`
## Usage
1. Update [Agents.md](AGENTS.md) to your needs.
2. Describe desired feature in `/context/features/feature_XXX.md`, like shown in [feature_base.md](/context/templates/feature_base.md).
3. Place optional examples into [/context/examples](/context/examples).
4. Let the Product Requirements Prompt (PRP) be generated:
- In Claude Code CLI: `/generate-prp feature_XXX.md`
- or: `make prp_gen_claude "ARGS=feature_XXX.md"`
5. Let the feature be implemented based on the PRP:
- In Claude Code CLI: `/execute-prp feature_XXX.md`
- or: `make prp_exe_claude "ARGS=feature_XXX.md"`
### Configuration
- General system behavior: `AGENTS.md`, redirected from `CLAUDE.md`
- Claude settings: `.claude/settings.local.json`
- CLaude commands: `.claude/commands`
- Feature template: `context/templates/feature_base.md`
- PRP template: `context/templates/prp_base.md`
### Environment
[.env.example](.env.example) contains examples for usage of API keys and variables.
```text
ANTHROPIC_API_KEY="sk-abc-xyz"
GEMINI_API_KEY="xyz"
GITHUB_API_KEY="ghp_xyz"
...
```
## TODO
- Implement business process as discussed in [He Built 40 Startups Using Just Prompts — Here’s His System](https://youtu.be/CIAu6WeckQ0)
- Refine `AGENTS.md` to let the agent not do bulk but incremental changes, also implement tests first, then code and iterate until functional (red > green > blue).
================================================
FILE: AGENTS.md
================================================
# Agent instructions for `Agents-eval` repository
As proposed by [agentsmd.net](https://agentsmd.net/) and used by [wandb weave AGENTS.md](https://github.com/wandb/weave/blob/master/AGENTS.md).
## Core Rules & AI Behavior
* When you learn something new about the codebase or introduce a new concept, **update this file (`AGENTS.md`)** to reflect the new knowledge. This is YOUR FILE! It should grow and evolve with you.
* If something doesn't make sense architecturally, from a developer experience standpoint, or product-wise, please add it to the **`Requests to Humans`** section below.
* Always follow the established coding patterns, conventions, and architectural decisions documented here and in the `docs/` directory.
* **Never assume missing context.** Ask questions if you are uncertain about requirements or implementation details.
* **Never hallucinate libraries or functions.** Only use known, verified Python packages listed in `pyproject.toml`.
* **Always confirm file paths and module names** exist before referencing them in code or tests.
* **Never delete or overwrite existing code** unless explicitly instructed to or as part of a documented refactoring task.
## Architecture Overview
This is a multi-agent evaluation system for assessing agentic AI systems. The project uses **PydanticAI** as the core framework for agent orchestration and is designed for evaluation purposes, not for production agent deployment.
### Data Flow
1. User input → Manager Agent
2. Manager delegates to Researcher Agent (with DuckDuckGo search)
3. Researcher results → Analyst Agent for validation
4. Validated data → Synthesizer Agent for report generation
5. Results evaluated using configurable metrics
### Key Dependencies
* **PydanticAI**: Agent framework and orchestration
* **uv**: Fast Python dependency management
* **Streamlit**: GUI framework
* **Ruff**: Code formatting and linting
* **MyPy**: Static type checking
## Codebase Structure & Modularity
### Main Components
* `src/app/`: The core application logic. This is where most of your work will be.
* `main.py`: The main entry point for the CLI application.
* `agents/agent_system.py`: Defines the multi-agent system, their interactions, and orchestration. **This is the central logic for agent behavior.**
* `config/data_models.py`: Contains all **Pydantic** models that define the data contracts. This is a critical file for understanding data flow.
* `config/config_chat.json`: Holds provider settings and system prompts for agents.
* `config/config_eval.json`: Defines evaluation metrics and their weights.
* `evals/metrics.py`: Implements the evaluation metrics.
* `src/gui/`: Contains the source code for the Streamlit GUI.
* `docs/`: Contains project documentation, including the Product Requirements Document (`PRD.md`) and the C4 architecture model.
* `tests/`: Contains all tests for the project, written using **pytest**.
### Code Organization Rules
* **Never create a file longer than 500 lines of code.** If a file approaches this limit, refactor by splitting it into smaller, more focused modules or helper files.
* Organize code into clearly separated modules grouped by feature.
* Use clear, consistent, and absolute imports within packages.
## Development Commands & Environment
### Environment Setup
The project requirements are stated in `pyproject.toml`. Your development environment should be set up automatically using the provided `Makefile`, which configures the virtual environment.
* `make setup_dev`: Install all dev dependencies.
* `make setup_dev_claude`: Setup dev environment with Claude Code CLI.
* `make setup_dev_ollama`: Setup dev environment with Ollama local LLM.
### Running the Application
* `make run_cli`: Run the CLI application.
* `make run_cli ARGS="--help"`: Run CLI with specific arguments.
* `make run_gui`: Run the Streamlit GUI.
### Testing and Code Quality
* `make test_all`: Run all tests with pytest.
* `make coverage_all`: Run tests and generate a coverage report.
* `make ruff`: Format code and fix linting issues with Ruff.
* `make type_check`: Run mypy static type checking on `src/app/`.
## Testing & Reliability
* **Always create Pytest unit tests** for new features (functions, classes, etc.).
* Tests must live in the `tests/` folder, mirroring the `src/app` structure.
* After updating any logic, check whether existing unit tests need to be updated. If so, do it.
* For each new feature, include at least:
* 1 test for the expected use case (happy path).
* 1 test for a known edge case.
* 1 test for an expected failure case (e.g., invalid input).
* **To run a specific test file or function, use `uv run pytest` directly:**
* `uv run pytest tests/test_specific_file.py`
* `uv run pytest tests/test_specific_file.py::test_function`
## Style, Patterns & Documentation
### Coding Style
* **Use Pydantic** models in `src/app/config/data_models.py` for all data validation and data contracts. **Always use or update these models** when modifying data flows.
* Use the predefined error message functions from `src/app/utils/error_messages.py` for consistency.
* When writing complex logic, **add an inline `# Reason:` comment** explaining the *why*, not just the *what*.
* Comment non-obvious code to ensure it is understandable to a mid-level developer.
### Documentation
* Write **docstrings for every function, class, and method** using the Google style format. This is critical as the documentation site is built automatically from docstrings.
```python
def example_function(param1: int) -> str:
"""A brief summary of the function.
Args:
param1 (int): A description of the first parameter.
Returns:
str: A description of the return value.
"""
return "example"
```
* Update this `AGENTS.md` file when introducing new patterns or concepts.
* Document significant architectural decisions in `docs/ADR.md`.
* Document all significant changes, features, and bug fixes in `docs/CHANGELOG.md`.
## Code Review & PR Guidelines
### PR Requirements
* **Title Format**: Commit messages and PR titles must follow the **Conventional Commits** specification, as outlined in the `.gitmessage` template.
* Provide detailed PR summaries including the purpose of the changes and the testing performed.
### Pre-commit Checklist
1. Run the linter and formatter: `make ruff`.
2. Ensure all tests pass: `make test_all`.
3. Ensure static type checks pass: `make type_check`.
4. Update documentation as described below.
## Requests to Humans
This section contains a list of questions, clarifications, or tasks that AI agents wish to have humans complete or elaborate on.
* [ ] The `agent_system.py` module has a `NotImplementedError` for streaming with Pydantic model outputs. Please clarify the intended approach for streaming structured data.
* [ ] The `llm_model_funs.py` module has `NotImplementedError` for the Gemini and HuggingFace providers. Please provide the correct implementation or remove them if they are not supported.
* [ ] The `agent_system.py` module contains a `FIXME` note regarding the use of a try-catch context manager. Please review and implement the intended error handling.
* [ ] Add TypeScript testing guidelines (if a TypeScript frontend is planned for the future).
================================================
FILE: CHANGELOG.md
================================================
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Guiding Principles
- Changelogs are for humans, not machines.
- There should be an entry for every single version.
- The same types of changes should be grouped.
- Versions and sections should be linkable.
- The latest version comes first.
- The release date of each version is displayed.
- Mention whether you follow Semantic Versioning.
## Types of changes
- `Added` for new features.
- `Changed` for changes in existing functionality.
- `Deprecated` for soon-to-be removed features.
- `Removed` for now removed features.
- `Fixed` for any bug fixes.
- `Security` in case of vulnerabilities.
## [Unreleased]
### Added
- Initial template containing templates for PRP
================================================
FILE: CLAUDE.md
================================================
@AGENTS.md
================================================
FILE: LICENSE
================================================
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc.
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
Copyright (C)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
Copyright (C)
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
.
================================================
FILE: Makefile
================================================
# This Makefile automates the build, test, and clean processes for the project.
# It provides a convenient way to run common tasks using the 'make' command.
# Run `make help` to see all available recipes.
.SILENT:
.ONESHELL:
.PHONY: all setup_python_claude setup_dev setup_prod setup_claude_code prp_gen_claude prp_exe_claude ruff test_all check_types coverage_all output_unset_app_env_sh run_example_gui run_example_server run_example_client help
.DEFAULT_GOAL := help
ENV_FILE := .env
SRC_PATH := src
APP_PATH := $(SRC_PATH)
EXAMPLES_PATH := examples/mcp-server-client
FEAT_DEF_PATH := /context/features
PRP_DEF_PATH := /context/PRPs
PRP_CLAUDE_GEN_CMD := generate-prp
PRP_CLAUDE_EXE_CMD := execute-prp
# MARK: setup
# construct the full paths and execute Claude Code commands
# TODO switch folder by function called ()
# TODO Claude Code non-interactive headless mode tee to CLI
define CLAUDE_PRP_RUNNER
echo "Starting Claude Code PRP runner ..."
dest_file=$(firstword $(strip $(1)))
dest_cmd=$(firstword $(strip $(2)))
if [ -z "$${dest_file}" ]; then
echo "Error: ARGS for PRP filename is empty. Please provide a PRP filename."
exit 1
fi
case "$${dest_cmd}" in
start)
dest_cmd=$(PRP_CLAUDE_GEN_CMD)
dest_path=$(FEAT_DEF_PATH);;
stop)
dest_cmd=$(PRP_CLAUDE_EXE_CMD)
dest_path=$(PRP_DEF_PATH);;
*)
echo "Unknown command: $${dest_cmd}. Exiting ..."
exit 1;;
esac
dest_cmd="/project:$${dest_cmd} $${dest_path}/$${dest_file}"
echo "Executing command '$${dest_cmd}' ..."
claude -p "$${dest_cmd}" 2>&1
claude -p "/cost" 2>&1
endef
setup_python_claude: # Set up environment and install Claude Code CLI
$(MAKE) -s setup_dev
$(MAKE) -s export_env_file
$(MAKE) -s setup_claude_code
setup_dev: ## Install uv and deps, Download and start Ollama
echo "Setting up dev environment ..."
pip install uv -q
uv sync --all-groups
setup_prod: ## Install uv and deps, Download and start Ollama
echo "Setting up prod environment ..."
pip install uv -q
uv sync --frozen
setup_claude_code: ## Setup Claude Code CLI, node.js and npm have to be present
echo "Setting up claude code ..."
npm install -g @anthropic-ai/claude-code
claude config set --global preferredNotifChannel terminal_bell
echo "npm version: $$(npm --version)"
claude --version
export_env_file: # Read ENV_FILE and export k=v to env
while IFS='=' read -r key value || [ -n "$${key}" ]; do
case "$${key}" in
''|\#*) continue ;;
esac
value=$$(echo "$${value}" | sed -e 's/^"//' -e 's/"$$//')
export "$${key}=$${value}"
done < .env
output_unset_env_sh: ## Unset app environment variables
uf="./unset_env.sh"
echo "Outputing '$${uf}' ..."
printenv | awk -F= '/_API_KEY=/ {print "unset " $$1}' > $$uf
# MARK: context engineering
prp_gen_claude: ## generates the PRP from the file passed in "ARGS=file"
$(call CLAUDE_PRP_RUNNER, $(ARGS), "generate")
prp_exe_claude: ## executes the PRP from the file passed in "ARGS=file"
$(call CLAUDE_PRP_RUNNER, $(ARGS), "execute")
# MARK: code quality
ruff: ## Lint: Format and check with ruff
uv run ruff format
uv run ruff check --fix
test_all: ## Run all tests
uv run pytest
coverage_all: ## Get test coverage
uv run coverage run -m pytest || true
uv run coverage report -m
check_types: ## Check for static typing errors
uv run mypy $(APP_PATH)
# MARK: run
run_example_gui: ## Launch MCP server-client example GUI
unset VIRTUAL_ENV && $(MAKE) -C $(EXAMPLES_PATH) run_gui
run_example_server: ## Run MCP server-client example server
unset VIRTUAL_ENV && $(MAKE) -C $(EXAMPLES_PATH) run_server
run_example_client: ## Run MCP server-client example client
unset VIRTUAL_ENV && $(MAKE) -C $(EXAMPLES_PATH) run_client ARGS="$(ARGS)"
# MARK: help
# TODO add stackoverflow source
help: ## Displays this message with available recipes
echo "Usage: make [recipe]"
echo "Recipes:"
awk '/^[a-zA-Z0-9_-]+:.*?##/ {
helpMessage = match($$0, /## (.*)/)
if (helpMessage) {
recipe = $$1
sub(/:/, "", recipe)
printf " \033[36m%-20s\033[0m %s\n", recipe, substr($$0, RSTART + 3, RLENGTH)
}
}' $(MAKEFILE_LIST)
================================================
FILE: mkdocs.yaml
================================================
---
# https://github.com/james-willett/mkdocs-material-youtube-tutorial
# https://mkdocstrings.github.io/recipes/
# site info set in workflow
site_name: ''
site_description: ''
repo_url: ''
edit_uri: edit/main
theme:
name: material
language: en
features:
- content.code.annotation
- content.code.copy
- content.tabs.link
- navigation.footer
- navigation.sections
- navigation.tabs
- navigation.top
- toc.integrate
- search.suggest
- search.highlight
palette:
- media: "(prefers-color-scheme: light)"
scheme: default
toggle:
# icon: material/brightness-7
icon: material/toggle-switch-off-outline
name: "Toggle Dark Mode"
- media: "(prefers-color-scheme: dark)"
scheme: slate
toggle:
# icon: material/brightness-4
icon: material/toggle-switch
name: "Toggle Light Mode"
nav:
- Home: index.md
- Code: docstrings.md
- Change Log: CHANGELOG.md
- License: LICENSE
- llms.txt: llms.txt
plugins:
- search:
lang: en
- autorefs
- mkdocstrings:
handlers:
python:
paths: [src]
options:
show_root_heading: true
show_root_full_path: true
show_object_full_path: false
show_root_members_full_path: false
show_category_heading: true
show_submodules: true
markdown_extensions:
- attr_list
- pymdownx.magiclink
- pymdownx.tabbed
- pymdownx.highlight:
anchor_linenums: true
- pymdownx.superfences
- pymdownx.snippets:
check_paths: true
- pymdownx.tasklist:
custom_checkbox: true
- sane_lists
- smarty
- toc:
permalink: true
validation:
links:
not_found: warn
anchors: warn
# builds only if validation succeeds while
# threating warnings as errors
# also checks for broken links
# strict: true
...
================================================
FILE: pyproject.toml
================================================
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
version = "0.0.2"
name = "context-engineering-template"
description = "Assess the effectiveness of agentic AI systems across various use cases focusing on agnostic metrics that measure core agentic capabilities."
authors = [
{name = "qte77", email = "qte@77.gh"}
]
readme = "README.md"
requires-python = "==3.13.*"
license = "bsd-3-clause"
dependencies = []
[project.urls]
Documentation = "https://qte77.github.io/context-engineering-template/"
[dependency-groups]
dev = []
test = []
docs = [
"griffe>=1.5.1",
"mkdocs>=1.6.1",
"mkdocs-awesome-pages-plugin>=2.9.3",
"mkdocs-gen-files>=0.5.0",
"mkdocs-literate-nav>=0.6.1",
"mkdocs-material>=9.5.44",
"mkdocs-section-index>=0.3.8",
"mkdocstrings[python]>=0.27.0",
]
[tool.uv]
package = true
exclude-newer = "2025-07-06T00:00:00Z"
[tool.hatch.build.targets.wheel]
only-include = ["/README.md"]
[tool.hatch.build.targets.sdist]
include = ["/README.md", "/Makefile", "/tests"]
[tool.ruff]
target-version = "py313"
src = ["src", "tests"]
[tool.ruff.format]
docstring-code-format = true
[tool.ruff.lint]
# ignore = ["E203"] # Whitespace before ':'
unfixable = ["B"]
select = [
# pycodestyle
"E",
# Pyflakes
"F",
# pyupgrade
"UP",
# isort
"I",
]
[tool.ruff.lint.isort]
known-first-party = ["src", "tests"]
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.mypy]
python_version = "3.13"
strict = true
disallow_untyped_defs = true
disallow_any_generics = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_return_any = true
warn_unreachable = true
show_error_codes = true
namespace_packages = true
explicit_package_bases = true
mypy_path = "src"
[tool.pytest.ini_options]
addopts = "--strict-markers"
# "function", "class", "module", "package", "session"
asyncio_default_fixture_loop_scope = "function"
pythonpath = ["src"]
testpaths = ["tests/"]
[tool.coverage]
[tool.coverage.run]
include = [
"tests/**/*.py",
]
# omit = []
# branch = true
[tool.coverage.report]
show_missing = true
exclude_lines = [
# 'pragma: no cover',
'raise AssertionError',
'raise NotImplementedError',
]
omit = [
'env/*',
'venv/*',
'.venv/*',
'*/virtualenv/*',
'*/virtualenvs/*',
'*/tests/*',
]
[tool.bumpversion]
current_version = "0.0.2"
parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)"
serialize = ["{major}.{minor}.{patch}"]
commit = true
tag = true
allow_dirty = false
ignore_missing_version = false
sign_tags = false
tag_name = "v{new_version}"
tag_message = "Bump version: {current_version} → {new_version}"
message = "Bump version: {current_version} → {new_version}"
commit_args = ""
[[tool.bumpversion.files]]
filename = "pyproject.toml"
search = 'version = "{current_version}"'
replace = 'version = "{new_version}"'
[[tool.bumpversion.files]]
filename = "src/__init__.py"
search = '__version__ = "{current_version}"'
replace = '__version__ = "{new_version}"'
[[tool.bumpversion.files]]
filename = "README.md"
search = "version-{current_version}-58f4c2"
replace = "version-{new_version}-58f4c2"
[[tool.bumpversion.files]]
filename = "CHANGELOG.md"
search = """
## [Unreleased]
"""
replace = """
## [Unreleased]
## [{new_version}] - {now:%Y-%m-%d}
"""
================================================
FILE: uv.lock
================================================
version = 1
revision = 2
requires-python = "==3.13.*"
[options]
exclude-newer = "2025-07-06T00:00:00Z"
[[package]]
name = "babel"
version = "2.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" },
]
[[package]]
name = "backrefs"
version = "5.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/eb/a7/312f673df6a79003279e1f55619abbe7daebbb87c17c976ddc0345c04c7b/backrefs-5.9.tar.gz", hash = "sha256:808548cb708d66b82ee231f962cb36faaf4f2baab032f2fbb783e9c2fdddaa59", size = 5765857, upload-time = "2025-06-22T19:34:13.97Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/19/4d/798dc1f30468134906575156c089c492cf79b5a5fd373f07fe26c4d046bf/backrefs-5.9-py310-none-any.whl", hash = "sha256:db8e8ba0e9de81fcd635f440deab5ae5f2591b54ac1ebe0550a2ca063488cd9f", size = 380267, upload-time = "2025-06-22T19:34:05.252Z" },
{ url = "https://files.pythonhosted.org/packages/55/07/f0b3375bf0d06014e9787797e6b7cc02b38ac9ff9726ccfe834d94e9991e/backrefs-5.9-py311-none-any.whl", hash = "sha256:6907635edebbe9b2dc3de3a2befff44d74f30a4562adbb8b36f21252ea19c5cf", size = 392072, upload-time = "2025-06-22T19:34:06.743Z" },
{ url = "https://files.pythonhosted.org/packages/9d/12/4f345407259dd60a0997107758ba3f221cf89a9b5a0f8ed5b961aef97253/backrefs-5.9-py312-none-any.whl", hash = "sha256:7fdf9771f63e6028d7fee7e0c497c81abda597ea45d6b8f89e8ad76994f5befa", size = 397947, upload-time = "2025-06-22T19:34:08.172Z" },
{ url = "https://files.pythonhosted.org/packages/10/bf/fa31834dc27a7f05e5290eae47c82690edc3a7b37d58f7fb35a1bdbf355b/backrefs-5.9-py313-none-any.whl", hash = "sha256:cc37b19fa219e93ff825ed1fed8879e47b4d89aa7a1884860e2db64ccd7c676b", size = 399843, upload-time = "2025-06-22T19:34:09.68Z" },
{ url = "https://files.pythonhosted.org/packages/41/ff/392bff89415399a979be4a65357a41d92729ae8580a66073d8ec8d810f98/backrefs-5.9-py39-none-any.whl", hash = "sha256:f48ee18f6252b8f5777a22a00a09a85de0ca931658f1dd96d4406a34f3748c60", size = 380265, upload-time = "2025-06-22T19:34:12.405Z" },
]
[[package]]
name = "bracex"
version = "2.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/63/9a/fec38644694abfaaeca2798b58e276a8e61de49e2e37494ace423395febc/bracex-2.6.tar.gz", hash = "sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7", size = 26642, upload-time = "2025-06-22T19:12:31.254Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/2a/9186535ce58db529927f6cf5990a849aa9e052eea3e2cfefe20b9e1802da/bracex-2.6-py3-none-any.whl", hash = "sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952", size = 11508, upload-time = "2025-06-22T19:12:29.781Z" },
]
[[package]]
name = "certifi"
version = "2025.6.15"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/73/f7/f14b46d4bcd21092d7d3ccef689615220d8a08fb25e564b65d20738e672e/certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b", size = 158753, upload-time = "2025-06-15T02:45:51.329Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/ae/320161bd181fc06471eed047ecce67b693fd7515b16d495d8932db763426/certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057", size = 157650, upload-time = "2025-06-15T02:45:49.977Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload-time = "2025-05-02T08:32:56.363Z" },
{ url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload-time = "2025-05-02T08:32:58.551Z" },
{ url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload-time = "2025-05-02T08:33:00.342Z" },
{ url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload-time = "2025-05-02T08:33:02.081Z" },
{ url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload-time = "2025-05-02T08:33:04.063Z" },
{ url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload-time = "2025-05-02T08:33:06.418Z" },
{ url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload-time = "2025-05-02T08:33:08.183Z" },
{ url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload-time = "2025-05-02T08:33:09.986Z" },
{ url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload-time = "2025-05-02T08:33:11.814Z" },
{ url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload-time = "2025-05-02T08:33:13.707Z" },
{ url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload-time = "2025-05-02T08:33:15.458Z" },
{ url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" },
{ url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" },
{ url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" },
]
[[package]]
name = "click"
version = "8.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "context-engineering-template"
version = "0.0.2"
source = { editable = "." }
[package.dev-dependencies]
docs = [
{ name = "griffe" },
{ name = "mkdocs" },
{ name = "mkdocs-awesome-pages-plugin" },
{ name = "mkdocs-gen-files" },
{ name = "mkdocs-literate-nav" },
{ name = "mkdocs-material" },
{ name = "mkdocs-section-index" },
{ name = "mkdocstrings", extra = ["python"] },
]
[package.metadata]
[package.metadata.requires-dev]
dev = []
docs = [
{ name = "griffe", specifier = ">=1.5.1" },
{ name = "mkdocs", specifier = ">=1.6.1" },
{ name = "mkdocs-awesome-pages-plugin", specifier = ">=2.9.3" },
{ name = "mkdocs-gen-files", specifier = ">=0.5.0" },
{ name = "mkdocs-literate-nav", specifier = ">=0.6.1" },
{ name = "mkdocs-material", specifier = ">=9.5.44" },
{ name = "mkdocs-section-index", specifier = ">=0.3.8" },
{ name = "mkdocstrings", extras = ["python"], specifier = ">=0.27.0" },
]
test = []
[[package]]
name = "ghp-import"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dateutil" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" },
]
[[package]]
name = "griffe"
version = "1.7.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/3e/5aa9a61f7c3c47b0b52a1d930302992229d191bf4bc76447b324b731510a/griffe-1.7.3.tar.gz", hash = "sha256:52ee893c6a3a968b639ace8015bec9d36594961e156e23315c8e8e51401fa50b", size = 395137, upload-time = "2025-04-23T11:29:09.147Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/c6/5c20af38c2a57c15d87f7f38bee77d63c1d2a3689f74fefaf35915dd12b2/griffe-1.7.3-py3-none-any.whl", hash = "sha256:c6b3ee30c2f0f17f30bcdef5068d6ab7a2a4f1b8bf1a3e74b56fffd21e1c5f75", size = 129303, upload-time = "2025-04-23T11:29:07.145Z" },
]
[[package]]
name = "idna"
version = "3.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "markdown"
version = "3.8.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/c2/4ab49206c17f75cb08d6311171f2d65798988db4360c4d1485bd0eedd67c/markdown-3.8.2.tar.gz", hash = "sha256:247b9a70dd12e27f67431ce62523e675b866d254f900c4fe75ce3dda62237c45", size = 362071, upload-time = "2025-06-19T17:12:44.483Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/96/2b/34cc11786bc00d0f04d0f5fdc3a2b1ae0b6239eef72d3d345805f9ad92a1/markdown-3.8.2-py3-none-any.whl", hash = "sha256:5c83764dbd4e00bdd94d85a19b8d55ccca20fe35b2e678a1422b380324dd5f24", size = 106827, upload-time = "2025-06-19T17:12:42.994Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" },
{ url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" },
{ url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" },
{ url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" },
{ url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" },
{ url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" },
{ url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" },
{ url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" },
{ url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" },
{ url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" },
{ url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" },
{ url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" },
{ url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" },
{ url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" },
{ url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" },
{ url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" },
{ url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" },
{ url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" },
{ url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" },
{ url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" },
]
[[package]]
name = "mergedeep"
version = "1.3.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" },
]
[[package]]
name = "mkdocs"
version = "1.6.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "ghp-import" },
{ name = "jinja2" },
{ name = "markdown" },
{ name = "markupsafe" },
{ name = "mergedeep" },
{ name = "mkdocs-get-deps" },
{ name = "packaging" },
{ name = "pathspec" },
{ name = "pyyaml" },
{ name = "pyyaml-env-tag" },
{ name = "watchdog" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" },
]
[[package]]
name = "mkdocs-autorefs"
version = "1.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown" },
{ name = "markupsafe" },
{ name = "mkdocs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/47/0c/c9826f35b99c67fa3a7cddfa094c1a6c43fafde558c309c6e4403e5b37dc/mkdocs_autorefs-1.4.2.tar.gz", hash = "sha256:e2ebe1abd2b67d597ed19378c0fff84d73d1dbce411fce7a7cc6f161888b6749", size = 54961, upload-time = "2025-05-20T13:09:09.886Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/87/dc/fc063b78f4b769d1956319351704e23ebeba1e9e1d6a41b4b602325fd7e4/mkdocs_autorefs-1.4.2-py3-none-any.whl", hash = "sha256:83d6d777b66ec3c372a1aad4ae0cf77c243ba5bcda5bf0c6b8a2c5e7a3d89f13", size = 24969, upload-time = "2025-05-20T13:09:08.237Z" },
]
[[package]]
name = "mkdocs-awesome-pages-plugin"
version = "2.10.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mkdocs" },
{ name = "natsort" },
{ name = "wcmatch" },
]
sdist = { url = "https://files.pythonhosted.org/packages/92/e8/6ae9c18d8174a5d74ce4ade7a7f4c350955063968bc41ff1e5833cff4a2b/mkdocs_awesome_pages_plugin-2.10.1.tar.gz", hash = "sha256:cda2cb88c937ada81a4785225f20ef77ce532762f4500120b67a1433c1cdbb2f", size = 16303, upload-time = "2024-12-22T21:13:49.19Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/73/61/19fc1e9c579dbfd4e8a402748f1d63cab7aabe8f8d91eb0235e45b32d040/mkdocs_awesome_pages_plugin-2.10.1-py3-none-any.whl", hash = "sha256:c6939dbea37383fc3cf8c0a4e892144ec3d2f8a585e16fdc966b34e7c97042a7", size = 15118, upload-time = "2024-12-22T21:13:46.945Z" },
]
[[package]]
name = "mkdocs-gen-files"
version = "0.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mkdocs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/48/85/2d634462fd59136197d3126ca431ffb666f412e3db38fd5ce3a60566303e/mkdocs_gen_files-0.5.0.tar.gz", hash = "sha256:4c7cf256b5d67062a788f6b1d035e157fc1a9498c2399be9af5257d4ff4d19bc", size = 7539, upload-time = "2023-04-27T19:48:04.894Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/0f/1e55b3fd490ad2cecb6e7b31892d27cb9fc4218ec1dab780440ba8579e74/mkdocs_gen_files-0.5.0-py3-none-any.whl", hash = "sha256:7ac060096f3f40bd19039e7277dd3050be9a453c8ac578645844d4d91d7978ea", size = 8380, upload-time = "2023-04-27T19:48:07.059Z" },
]
[[package]]
name = "mkdocs-get-deps"
version = "0.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mergedeep" },
{ name = "platformdirs" },
{ name = "pyyaml" },
]
sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239, upload-time = "2023-11-20T17:51:09.981Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" },
]
[[package]]
name = "mkdocs-literate-nav"
version = "0.6.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mkdocs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/5f/99aa379b305cd1c2084d42db3d26f6de0ea9bf2cc1d10ed17f61aff35b9a/mkdocs_literate_nav-0.6.2.tar.gz", hash = "sha256:760e1708aa4be86af81a2b56e82c739d5a8388a0eab1517ecfd8e5aa40810a75", size = 17419, upload-time = "2025-03-18T21:53:09.711Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/84/b5b14d2745e4dd1a90115186284e9ee1b4d0863104011ab46abb7355a1c3/mkdocs_literate_nav-0.6.2-py3-none-any.whl", hash = "sha256:0a6489a26ec7598477b56fa112056a5e3a6c15729f0214bea8a4dbc55bd5f630", size = 13261, upload-time = "2025-03-18T21:53:08.1Z" },
]
[[package]]
name = "mkdocs-material"
version = "9.6.15"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "babel" },
{ name = "backrefs" },
{ name = "colorama" },
{ name = "jinja2" },
{ name = "markdown" },
{ name = "mkdocs" },
{ name = "mkdocs-material-extensions" },
{ name = "paginate" },
{ name = "pygments" },
{ name = "pymdown-extensions" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/95/c1/f804ba2db2ddc2183e900befe7dad64339a34fa935034e1ab405289d0a97/mkdocs_material-9.6.15.tar.gz", hash = "sha256:64adf8fa8dba1a17905b6aee1894a5aafd966d4aeb44a11088519b0f5ca4f1b5", size = 3951836, upload-time = "2025-07-01T10:14:15.671Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/30/dda19f0495a9096b64b6b3c07c4bfcff1c76ee0fc521086d53593f18b4c0/mkdocs_material-9.6.15-py3-none-any.whl", hash = "sha256:ac969c94d4fe5eb7c924b6d2f43d7db41159ea91553d18a9afc4780c34f2717a", size = 8716840, upload-time = "2025-07-01T10:14:13.18Z" },
]
[[package]]
name = "mkdocs-material-extensions"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" },
]
[[package]]
name = "mkdocs-section-index"
version = "0.3.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mkdocs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/93/40/4aa9d3cfa2ac6528b91048847a35f005b97ec293204c02b179762a85b7f2/mkdocs_section_index-0.3.10.tar.gz", hash = "sha256:a82afbda633c82c5568f0e3b008176b9b365bf4bd8b6f919d6eff09ee146b9f8", size = 14446, upload-time = "2025-04-05T20:56:45.387Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/01/53/76c109e6f822a6d19befb0450c87330b9a6ce52353de6a9dda7892060a1f/mkdocs_section_index-0.3.10-py3-none-any.whl", hash = "sha256:bc27c0d0dc497c0ebaee1fc72839362aed77be7318b5ec0c30628f65918e4776", size = 8796, upload-time = "2025-04-05T20:56:43.975Z" },
]
[[package]]
name = "mkdocstrings"
version = "0.29.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jinja2" },
{ name = "markdown" },
{ name = "markupsafe" },
{ name = "mkdocs" },
{ name = "mkdocs-autorefs" },
{ name = "pymdown-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/41/e8/d22922664a627a0d3d7ff4a6ca95800f5dde54f411982591b4621a76225d/mkdocstrings-0.29.1.tar.gz", hash = "sha256:8722f8f8c5cd75da56671e0a0c1bbed1df9946c0cef74794d6141b34011abd42", size = 1212686, upload-time = "2025-03-31T08:33:11.997Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/14/22533a578bf8b187e05d67e2c1721ce10e3f526610eebaf7a149d557ea7a/mkdocstrings-0.29.1-py3-none-any.whl", hash = "sha256:37a9736134934eea89cbd055a513d40a020d87dfcae9e3052c2a6b8cd4af09b6", size = 1631075, upload-time = "2025-03-31T08:33:09.661Z" },
]
[package.optional-dependencies]
python = [
{ name = "mkdocstrings-python" },
]
[[package]]
name = "mkdocstrings-python"
version = "1.16.12"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "griffe" },
{ name = "mkdocs-autorefs" },
{ name = "mkdocstrings" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bf/ed/b886f8c714fd7cccc39b79646b627dbea84cd95c46be43459ef46852caf0/mkdocstrings_python-1.16.12.tar.gz", hash = "sha256:9b9eaa066e0024342d433e332a41095c4e429937024945fea511afe58f63175d", size = 206065, upload-time = "2025-06-03T12:52:49.276Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/dd/a24ee3de56954bfafb6ede7cd63c2413bb842cc48eb45e41c43a05a33074/mkdocstrings_python-1.16.12-py3-none-any.whl", hash = "sha256:22ded3a63b3d823d57457a70ff9860d5a4de9e8b1e482876fc9baabaf6f5f374", size = 124287, upload-time = "2025-06-03T12:52:47.819Z" },
]
[[package]]
name = "natsort"
version = "8.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e2/a9/a0c57aee75f77794adaf35322f8b6404cbd0f89ad45c87197a937764b7d0/natsort-8.4.0.tar.gz", hash = "sha256:45312c4a0e5507593da193dedd04abb1469253b601ecaf63445ad80f0a1ea581", size = 76575, upload-time = "2023-06-20T04:17:19.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/82/7a9d0550484a62c6da82858ee9419f3dd1ccc9aa1c26a1e43da3ecd20b0d/natsort-8.4.0-py3-none-any.whl", hash = "sha256:4732914fb471f56b5cce04d7bae6f164a592c7712e1c85f9ef585e197299521c", size = 38268, upload-time = "2023-06-20T04:17:17.522Z" },
]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "paginate"
version = "0.5.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" },
]
[[package]]
name = "pathspec"
version = "0.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" },
]
[[package]]
name = "platformdirs"
version = "4.3.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pymdown-extensions"
version = "10.16"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown" },
{ name = "pyyaml" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1a/0a/c06b542ac108bfc73200677309cd9188a3a01b127a63f20cadc18d873d88/pymdown_extensions-10.16.tar.gz", hash = "sha256:71dac4fca63fabeffd3eb9038b756161a33ec6e8d230853d3cecf562155ab3de", size = 853197, upload-time = "2025-06-21T17:56:36.974Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/d4/10bb14004d3c792811e05e21b5e5dcae805aacb739bd12a0540967b99592/pymdown_extensions-10.16-py3-none-any.whl", hash = "sha256:f5dd064a4db588cb2d95229fc4ee63a1b16cc8b4d0e6145c0899ed8723da1df2", size = 266143, upload-time = "2025-06-21T17:56:35.356Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" },
{ url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" },
{ url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" },
{ url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" },
{ url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" },
{ url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" },
{ url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" },
{ url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" },
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" },
]
[[package]]
name = "pyyaml-env-tag"
version = "1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyyaml" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" },
]
[[package]]
name = "requests"
version = "2.32.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258, upload-time = "2025-06-09T16:43:07.34Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload-time = "2025-06-09T16:43:05.728Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "urllib3"
version = "2.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
]
[[package]]
name = "watchdog"
version = "6.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" },
{ url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" },
{ url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" },
{ url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" },
{ url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" },
{ url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" },
{ url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" },
{ url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" },
{ url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" },
{ url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" },
{ url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" },
{ url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" },
{ url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
]
[[package]]
name = "wcmatch"
version = "10.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "bracex" },
]
sdist = { url = "https://files.pythonhosted.org/packages/79/3e/c0bdc27cf06f4e47680bd5803a07cb3dfd17de84cde92dd217dcb9e05253/wcmatch-10.1.tar.gz", hash = "sha256:f11f94208c8c8484a16f4f48638a85d771d9513f4ab3f37595978801cb9465af", size = 117421, upload-time = "2025-06-22T19:14:02.49Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/d8/0d1d2e9d3fabcf5d6840362adcf05f8cf3cd06a73358140c3a97189238ae/wcmatch-10.1-py3-none-any.whl", hash = "sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a", size = 39854, upload-time = "2025-06-22T19:14:00.978Z" },
]
================================================
FILE: .env.example
================================================
ANTHROPIC_API_KEY="sk-abc-xyz"
GEMINI_API_KEY="xyz"
GITHUB_API_KEY="ghp_xyz"
GROK_API_KEY="xai-xyz"
HUGGINGFACE_API_KEY="hf_xyz"
OPENROUTER_API_KEY="sk-or-v1-xyz"
PERPLEXITY_API_KEY=""
RESTACK_API_KEY="xyz"
TOGETHER_API_KEY="xyz"
================================================
FILE: .gitmessage
================================================
#<--- 72 characters --------------------------------------------------->
#
# Conventional Commits, semantic commit messages for humans and machines
# https://www.conventionalcommits.org/en/v1.0.0/
# Lint your conventional commits
# https://github.com/conventional-changelog/commitlint/tree/master/%40 \
# commitlint/config-conventional
# Common types can be (based on Angular convention)
# build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test
# https://github.com/conventional-changelog/commitlint/tree/master/%40
# Footer
# https://git-scm.com/docs/git-interpret-trailers
#
#<--- pattern --------------------------------------------------------->
#
# [(Scope)][!]: \
#
# short description: [()]:
#
# ! after scope in header indicates breaking change
#
# [optional body]
#
# - with bullets points
#
# [optional footer(s)]
#
# [BREAKING CHANGE:, Refs:, Resolves:, Addresses:, Reviewed by:]
#
#<--- usage ----------------------------------------------------------->
#
# Set locally (in the repository)
# `git config commit.template .gitmessage`
#
# Set globally
# `git config --global commit.template .gitmessage`
#
#<--- 72 characters --------------------------------------------------->
================================================
FILE: assets/images/example_execute_feature_mcp_client_created.PNG
================================================
[Non-text file]
================================================
FILE: assets/images/example_execute_feature_mcp_server_created.PNG
================================================
[Non-text file]
================================================
FILE: assets/images/example_execute_feature_mcp_server_PRP_update_pyproject.PNG
================================================
[Non-text file]
================================================
FILE: assets/images/example_execute_feature_mcp_server_PRP_update_pyproject_diff.PNG
================================================
[Non-text file]
================================================
FILE: assets/images/example_generate_feature_mcp_server_PRP.PNG
================================================
[Non-text file]
================================================
FILE: context/templates/feature_base.md
================================================
# Feature description for: [ Initial template for new features ]
## FEATURE
[Insert your feature here]
## EXAMPLES
[Provide and explain examples that you have in the `/context/examples` folder]
## DOCUMENTATION
[List out any documentation (web pages, sources for an MCP server like Crawl4AI RAG, etc.) that will need to be referenced during development]
## OTHER CONSIDERATIONS
[Any other considerations or specific requirements - great place to include gotchas that you see AI coding assistants miss with your projects a lot]
================================================
FILE: context/templates/prp_base.md
================================================
# "Base PRP Template v2 - Context-Rich with Validation Loops"
## Purpose
Product Requirements Prompt (PRP) Template optimized for AI agents to implement features with sufficient context and self-validation capabilities to achieve working code through iterative refinement.
## Core Principles
1. **Context is King**: Include ALL necessary documentation, examples, and caveats
2. **Validation Loops**: Provide executable tests/lints the AI can run and fix
3. **Information Dense**: Use keywords and patterns from the codebase
4. **Progressive Success**: Start simple, validate, then enhance
5. **Global rules**: Be sure to follow all rules in CLAUDE.md
---
## Goal
[What needs to be built - be specific about the end state and desires]
## Why
- [Business value and user impact]
- [Integration with existing features]
- [Problems this solves and for whom]
## What
[User-visible behavior and technical requirements]
### Success Criteria
- [ ] [Specific measurable outcomes]
## All Needed Context
### Documentation & References (list all context needed to implement the feature)
```yaml
# MUST READ - Include these in your context window
- url: [Official API docs URL]
why: [Specific sections/methods you'll need]
- file: [path/to/example.py]
why: [Pattern to follow, gotchas to avoid]
- doc: [Library documentation URL]
section: [Specific section about common pitfalls]
critical: [Key insight that prevents common errors]
- docfile: [PRPs/ai_docs/file.md]
why: [docs that the user has pasted in to the project]
```
### Current Codebase tree (run `tree` in the root of the project) to get an overview of the codebase
```bash
```
### Desired Codebase tree with files to be added and responsibility of file
```bash
```
### Known Gotchas of our codebase & Library Quirks
```python
# CRITICAL: [Library name] requires [specific setup]
# Example: FastAPI requires async functions for endpoints
# Example: This ORM doesn't support batch inserts over 1000 records
# Example: We use pydantic v2 and
```
## Implementation Blueprint
### Data models and structure
Create the core data models, we ensure type safety and consistency.
```python
Examples:
- orm models
- pydantic models
- pydantic schemas
- pydantic validators
```
### list of tasks to be completed to fullfill the PRP in the order they should be completed
```yaml
Task 1:
MODIFY src/existing_module.py:
- FIND pattern: "class OldImplementation"
- INJECT after line containing "def __init__"
- PRESERVE existing method signatures
CREATE src/new_feature.py:
- MIRROR pattern from: src/similar_feature.py
- MODIFY class name and core logic
- KEEP error handling pattern identical
...(...)
Task N:
...
```
### Per task pseudocode as needed added to each task
```python
# Task 1
# Pseudocode with CRITICAL details dont write entire code
async def new_feature(param: str) -> Result:
# PATTERN: Always validate input first (see src/validators.py)
validated = validate_input(param) # raises ValidationError
# GOTCHA: This library requires connection pooling
async with get_connection() as conn: # see src/db/pool.py
# PATTERN: Use existing retry decorator
@retry(attempts=3, backoff=exponential)
async def _inner():
# CRITICAL: API returns 429 if >10 req/sec
await rate_limiter.acquire()
return await external_api.call(validated)
result = await _inner()
# PATTERN: Standardized response format
return format_response(result) # see src/utils/responses.py
```
### Integration Points
```yaml
DATABASE:
- migration: "Add column 'feature_enabled' to users table"
- index: "CREATE INDEX idx_feature_lookup ON users(feature_id)"
CONFIG:
- add to: config/settings.py
- pattern: "FEATURE_TIMEOUT = int(os.getenv('FEATURE_TIMEOUT', '30'))"
ROUTES:
- add to: src/api/routes.py
- pattern: "router.include_router(feature_router, prefix='/feature')"
```
## Validation Loop
### Level 1: Syntax & Style
```bash
# Run these FIRST - fix any errors before proceeding
ruff check src/new_feature.py --fix # Auto-fix what's possible
mypy src/new_feature.py # Type checking
# Expected: No errors. If errors, READ the error and fix.
```
### Level 2: Unit Tests each new feature/file/function use existing test patterns
```python
# CREATE test_new_feature.py with these test cases:
def test_happy_path():
"""Basic functionality works"""
result = new_feature("valid_input")
assert result.status == "success"
def test_validation_error():
"""Invalid input raises ValidationError"""
with pytest.raises(ValidationError):
new_feature("")
def test_external_api_timeout():
"""Handles timeouts gracefully"""
with mock.patch('external_api.call', side_effect=TimeoutError):
result = new_feature("valid")
assert result.status == "error"
assert "timeout" in result.message
```
```bash
# Run and iterate until passing:
uv run pytest test_new_feature.py -v
# If failing: Read error, understand root cause, fix code, re-run (never mock to pass)
```
### Level 3: Integration Test
```bash
# Start the service
uv run python -m src.main --dev
# Test the endpoint
curl -X POST http://localhost:8000/feature \
-H "Content-Type: application/json" \
-d '{"param": "test_value"}'
# Expected: {"status": "success", "data": {...}}
# If error: Check logs at logs/app.log for stack trace
```
## Final validation Checklist
- [ ] All tests pass: `uv run pytest tests/ -v`
- [ ] No linting errors: `uv run ruff check src/`
- [ ] No type errors: `uv run mypy src/`
- [ ] Manual test successful: [specific curl/command]
- [ ] Error cases handled gracefully
- [ ] Logs are informative but not verbose
- [ ] Documentation updated if needed
---
## Anti-Patterns to Avoid
- ❌ Don't create new patterns when existing ones work
- ❌ Don't skip validation because "it should work"
- ❌ Don't ignore failing tests - fix them
- ❌ Don't use sync functions in async context
- ❌ Don't hardcode values that should be config
- ❌ Don't catch all exceptions - be specific
================================================
FILE: docs/llms.txt
================================================
├── .claude
├── commands
│ ├── execute-prp.md
│ └── generate-prp.md
└── settings.local.json
├── .devcontainer
└── setup_python_claude
│ └── devcontainer.json
├── .env.example
├── .github
├── dependabot.yaml
├── scripts
│ ├── create_pr.sh
│ └── delete_branch_pr_tag.sh
└── workflows
│ ├── bump-my-version.yaml
│ ├── codeql.yaml
│ ├── generate-deploy-mkdocs-ghpages.yaml
│ ├── links-fail-fast.yaml
│ ├── pytest.yaml
│ ├── ruff.yaml
│ ├── summarize-jobs-reusable.yaml
│ └── write-llms-txt.yaml
├── .gitignore
├── .gitmessage
├── .vscode
├── extensions.json
└── settings.json
├── AGENTS.md
├── CHANGELOG.md
├── CLAUDE.md
├── LICENSE
├── Makefile
├── README.md
├── assets
└── images
│ ├── example_execute_feature_mcp_server_PRP_update_pyproject.PNG
│ ├── example_execute_feature_mcp_server_PRP_update_pyproject_diff.PNG
│ ├── example_execute_feature_mcp_server_created.PNG
│ ├── example_generate_feature_mcp_server_PRP.PNG
│ ├── sequence_diagram.png
│ └── sequence_diagram.svg
├── context
└── templates
│ ├── feature_base.md
│ └── prp_base.md
├── docs
├── architecture
│ └── sequence_diagram.mermaid
└── llms.txt
├── examples
└── mcp-server-client
│ ├── Makefile
│ ├── context
│ ├── PRPs
│ │ ├── feature_1_mcp_server.md
│ │ ├── feature_2_mcp_client.md
│ │ └── feature_3_streamlit_gui.md
│ ├── features
│ │ ├── feature_1_mcp_server.md
│ │ ├── feature_2_mcp_client.md
│ │ └── feature_3_streamlit_gui.md
│ ├── outputs
│ │ ├── client_get_date_input.json
│ │ ├── client_get_weather_input.json
│ │ ├── client_invalid_tool.json
│ │ ├── client_roll_dice_input.json
│ │ ├── get_date_example.json
│ │ ├── get_weather_example.json
│ │ ├── roll_dice_example.json
│ │ ├── streamlit_error_handling.json
│ │ ├── streamlit_get_date_interaction.json
│ │ ├── streamlit_get_weather_interaction.json
│ │ └── streamlit_roll_dice_interaction.json
│ └── templates
│ │ ├── feature_base.md
│ │ └── prp_base.md
│ ├── pyproject.toml
│ ├── src
│ ├── __init__.py
│ ├── gui
│ │ ├── __init__.py
│ │ ├── app.py
│ │ ├── components
│ │ │ ├── __init__.py
│ │ │ ├── connection.py
│ │ │ ├── history.py
│ │ │ └── tool_forms.py
│ │ ├── models
│ │ │ ├── __init__.py
│ │ │ └── gui_models.py
│ │ └── utils
│ │ │ ├── __init__.py
│ │ │ ├── async_helper.py
│ │ │ ├── formatting.py
│ │ │ ├── mcp_wrapper.py
│ │ │ └── validation.py
│ ├── main.py
│ ├── mcp_client
│ │ ├── __init__.py
│ │ ├── cli.py
│ │ ├── client.py
│ │ ├── models
│ │ │ ├── __init__.py
│ │ │ └── responses.py
│ │ └── transport.py
│ ├── mcp_server
│ │ ├── __init__.py
│ │ ├── models
│ │ │ ├── __init__.py
│ │ │ └── requests.py
│ │ ├── server.py
│ │ └── tools
│ │ │ ├── __init__.py
│ │ │ ├── base.py
│ │ │ ├── date_time.py
│ │ │ ├── dice.py
│ │ │ └── weather.py
│ └── py.typed
│ ├── tests
│ ├── __init__.py
│ ├── fixtures
│ │ ├── __init__.py
│ │ └── mcp_messages.py
│ ├── test_cli.py
│ ├── test_gui.py
│ ├── test_mcp_client.py
│ ├── test_mcp_server.py
│ └── test_tools
│ │ ├── __init__.py
│ │ ├── test_date_time.py
│ │ ├── test_dice.py
│ │ └── test_weather.py
│ └── uv.lock
├── mkdocs.yaml
├── pyproject.toml
├── src
├── __init__.py
├── main.py
└── py.typed
└── uv.lock
/.claude/commands/execute-prp.md:
--------------------------------------------------------------------------------
1 | # Execute Product Requirements Prompt (PRP)
2 |
3 | Implement a feature using using the PRP file.
4 |
5 | ## PRP File: $ARGUMENTS
6 |
7 | ## Execution Process
8 |
9 | 1. **Load PRP**
10 | - Read the specified PRP file
11 | - Understand all context and requirements
12 | - Follow all instructions in the PRP and extend the research if needed
13 | - Ensure you have all needed context to implement the PRP fully
14 | - Do more web searches and codebase exploration as needed
15 |
16 | 2. **ULTRATHINK**
17 | - Think hard before you execute the plan. Create a comprehensive plan addressing all requirements.
18 | - Break down complex tasks into smaller, manageable steps using your todos tools.
19 | - Use the TodoWrite tool to create and track your implementation plan.
20 | - Identify implementation patterns from existing code to follow.
21 |
22 | 3. **Execute the plan**
23 | - Execute the PRP
24 | - Implement all the code
25 |
26 | 4. **Validate**
27 | - Run each validation command
28 | - Fix any failures
29 | - Re-run until all pass
30 |
31 | 5. **Complete**
32 | - Ensure all checklist items done
33 | - Run final validation suite
34 | - Report completion status
35 | - Read the PRP again to ensure you have implemented everything
36 |
37 | 6. **Reference the PRP**
38 | - You can always reference the PRP again if needed
39 |
40 | Note: If validation fails, use error patterns in PRP to fix and retry.
41 |
--------------------------------------------------------------------------------
/.claude/commands/generate-prp.md:
--------------------------------------------------------------------------------
1 | # Create Product Requirements Prompt (PRP)
2 |
3 | ## Feature file: $ARGUMENTS
4 |
5 | Generate a complete PRP (Product Requirements Prompt) for general feature implementation with thorough research. Ensure context is passed to the AI agent to enable self-validation and iterative refinement. Read the feature file first to understand what needs to be created, how the examples provided help, and any other considerations.
6 |
7 | The AI agent only gets the context you are appending to the PRP and training data. Assume the AI agent has access to the codebase and the same knowledge cutoff as you, so its important that your research findings are included or referenced in the PRP. The Agent has Websearch capabilities, so pass urls to documentation and examples.
8 |
9 | - Use `/context/PRPs` as `$base_path`
10 | - Extract only the filename from `$ARGUMENTS` into `$file_name`
11 |
12 | ## Research Process
13 |
14 | 1. **Codebase Analysis**
15 | - Search for similar features/patterns in the codebase
16 | - Identify files to reference in PRP
17 | - Note existing conventions to follow
18 | - Check test patterns for validation approach
19 |
20 | 2. **External Research**
21 | - Search for similar features/patterns online
22 | - Library documentation (include specific URLs)
23 | - Implementation examples (GitHub/StackOverflow/blogs)
24 | - Best practices and common pitfalls
25 |
26 | 3. **User Clarification** (if needed)
27 | - Specific patterns to mirror and where to find them?
28 | - Integration requirements and where to find them?
29 |
30 | ## PRP Generation
31 |
32 | - Use `${base_path}/templates/prp_base.md` in the base folder as template
33 |
34 | ### Critical Context to Include and pass to the AI agent as part of the PRP
35 |
36 | - **Documentation**: URLs with specific sections
37 | - **Code Examples**: Real snippets from codebase
38 | - **Gotchas**: Library quirks, version issues
39 | - **Patterns**: Existing approaches to follow
40 |
41 | ### Implementation Blueprint
42 |
43 | - Start with pseudocode showing approach
44 | - Reference real files for patterns
45 | - Include error handling strategy
46 | - list tasks to be completed to fullfill the PRP in the order they should be completed
47 |
48 | ### Validation Gates (Must be Executable) eg for python
49 |
50 | ```bash
51 | # Syntax/Style
52 | make ruff
53 | make check_types
54 |
55 | # Unit Tests
56 | make coverage_all
57 | ```
58 |
59 | ***CRITICAL AFTER YOU ARE DONE RESEARCHING AND EXPLORING THE CODEBASE BEFORE YOU START WRITING THE PRP***
60 |
61 | ***ULTRATHINK ABOUT THE PRP AND PLAN YOUR APPROACH THEN START WRITING THE PRP***
62 |
63 | ## Output
64 |
65 | - Save the result to `${base_path}/${file_name}`
66 |
67 | ## Quality Checklist
68 |
69 | - [ ] All necessary context included
70 | - [ ] Validation gates are executable by AI
71 | - [ ] References existing patterns
72 | - [ ] Clear implementation path
73 | - [ ] Error handling documented
74 |
75 | Score the PRP on a scale of 1-10 (confidence level to succeed in one-pass implementation using claude codes)
76 |
77 | Remember: The goal is one-pass implementation success through comprehensive context.
78 |
--------------------------------------------------------------------------------
/.claude/settings.local.json:
--------------------------------------------------------------------------------
1 | {
2 | "permissions": {
3 | "allow": [
4 | "Bash(cat:*)",
5 | "Bash(find:*)",
6 | "Bash(git:diff*)",
7 | "Bash(git:log*)",
8 | "Bash(git:status*)",
9 | "Bash(grep:*)",
10 | "Bash(ls:*)",
11 | "Bash(mkdir:*)",
12 | "Bash(source:*)",
13 | "Bash(touch:*)",
14 | "Bash(tree:*)",
15 | "Bash(uv:run*)",
16 | "Edit(AGENTS.md)",
17 | "Edit(docs/**/*.md)",
18 | "Edit(src/**/*.py)",
19 | "Edit(src/**/*.json)",
20 | "Edit(tests/**/*.py)",
21 | "WebFetch(domain:docs.anthropic.com)"
22 | ],
23 | "deny": [
24 | "Bash(mv:*)",
25 | "Bash(rm:*)",
26 | "Edit(CLAUDE.md"
27 | ]
28 | }
29 | }
--------------------------------------------------------------------------------
/.devcontainer/setup_python_claude/devcontainer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "setup_python_claude",
3 | "image": "mcr.microsoft.com/vscode/devcontainers/python:3.13",
4 | "features": {
5 | "ghcr.io/devcontainers/features/node:1": {}
6 | },
7 | "customizations": {
8 | "vscode": {
9 | "extensions": [
10 | "anthropic.claude-code"
11 | ]
12 | }
13 | },
14 | "postCreateCommand": "make setup_python_claude"
15 | }
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | ANTHROPIC_API_KEY="sk-abc-xyz"
2 | GEMINI_API_KEY="xyz"
3 | GITHUB_API_KEY="ghp_xyz"
4 | GROK_API_KEY="xai-xyz"
5 | HUGGINGFACE_API_KEY="hf_xyz"
6 | OPENROUTER_API_KEY="sk-or-v1-xyz"
7 | PERPLEXITY_API_KEY=""
8 | RESTACK_API_KEY="xyz"
9 | TOGETHER_API_KEY="xyz"
--------------------------------------------------------------------------------
/.github/dependabot.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
3 | version: 2
4 | updates:
5 | - package-ecosystem: "pip"
6 | directory: "/"
7 | schedule:
8 | interval: "weekly"
9 | ...
10 |
--------------------------------------------------------------------------------
/.github/scripts/create_pr.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # 1 base ref, 2 target ref, 3 title suffix
3 | # 4 current version, 5 bumped
4 |
5 | pr_title="PR $2 $3"
6 | pr_body="PR automatically created from \`$1\` to bump from \`$4\` to \`$5\` on \`$2\`. Tag \`v$5\` will be created and has to be deleted manually if PR gets closed without merge."
7 |
8 | gh pr create \
9 | --base $1 \
10 | --head $2 \
11 | --title "${pr_title}" \
12 | --body "${pr_body}"
13 | # --label "bump"
14 |
--------------------------------------------------------------------------------
/.github/scripts/delete_branch_pr_tag.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # 1 repo, 2 target ref, 3 current version
3 |
4 | tag_to_delete="v$3"
5 | branch_del_api_call="repos/$1/git/refs/heads/$2"
6 | del_msg="'$2' force deletion attempted."
7 | close_msg="Closing PR '$2' to rollback after failure"
8 |
9 | echo "Tag $tag_to_delete for $del_msg"
10 | git tag -d "$tag_to_delete"
11 | echo "PR for $del_msg"
12 | gh pr close "$2" --comment "$close_msg"
13 | echo "Branch $del_msg"
14 | gh api "$branch_del_api_call" -X DELETE && \
15 | echo "Branch without error return deleted."
--------------------------------------------------------------------------------
/.github/workflows/bump-my-version.yaml:
--------------------------------------------------------------------------------
1 | name: bump-my-version
2 |
3 | on:
4 | # pull_request:
5 | # types: [closed]
6 | # branches: [main]
7 | workflow_dispatch:
8 | inputs:
9 | bump_type:
10 | description: '[major|minor|patch]'
11 | required: true
12 | default: 'patch'
13 | type: choice
14 | options:
15 | - 'major'
16 | - 'minor'
17 | - 'patch'
18 |
19 | env:
20 | BRANCH_NEW: "bump-${{ github.run_number }}-${{ github.ref_name }}"
21 | SKIP_PR_HINT: "[skip ci bump]"
22 | SCRIPT_PATH: ".github/scripts"
23 |
24 | jobs:
25 | bump_my_version:
26 | # TODO bug? currently resulting in: Unrecognized named-value: 'env'.
27 | # https://stackoverflow.com/questions/61238849/github-actions-if-contains-function-not-working-with-env-variable/61240761
28 | # if: !contains(
29 | # github.event.pull_request.title,
30 | # ${{ env.SKIP_PR_HINT }}
31 | # )
32 | # TODO check for PR closed by bot to avoid PR creation loop
33 | # github.actor != 'github-actions'
34 | if: >
35 | github.event_name == 'workflow_dispatch' ||
36 | ( github.event.pull_request.merged == true &&
37 | github.event.pull_request.closed_by != 'github-actions' )
38 | runs-on: ubuntu-latest
39 | outputs:
40 | branch_new: ${{ steps.create_branch.outputs.branch_new }}
41 | summary_data: ${{ steps.set_summary.outputs.summary_data }}
42 | permissions:
43 | actions: read
44 | checks: write
45 | contents: write
46 | pull-requests: write
47 | steps:
48 |
49 | - name: Checkout repo
50 | uses: actions/checkout@v4
51 | with:
52 | fetch-depth: 1
53 |
54 | - name: Set git cfg and create branch
55 | id: create_branch
56 | run: |
57 | git config user.email "github-actions@users.noreply.github.com"
58 | git config user.name "github-actions[bot]"
59 | git checkout -b "${{ env.BRANCH_NEW }}"
60 | echo "branch_new=${{ env.BRANCH_NEW }}" >> $GITHUB_OUTPUT
61 |
62 | - name: Bump version
63 | id: bump
64 | uses: callowayproject/bump-my-version@0.29.0
65 | env:
66 | BUMPVERSION_TAG: "true"
67 | with:
68 | args: ${{ inputs.bump_type }}
69 | branch: ${{ env.BRANCH_NEW }}
70 |
71 | - name: "Create PR '${{ env.BRANCH_NEW }}'"
72 | if: steps.bump.outputs.bumped == 'true'
73 | env:
74 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
75 | run: |
76 | src="${{ env.SCRIPT_PATH }}/create_pr.sh"
77 | chmod +x "$src"
78 | $src "${{ github.ref_name }}" "${{ env.BRANCH_NEW }}" "${{ env.SKIP_PR_HINT }}" "${{ steps.bump.outputs.previous-version }}" "${{ steps.bump.outputs.current-version }}"
79 |
80 | - name: Delete branch, PR and tag in case of failure or cancel
81 | if: failure() || cancelled()
82 | env:
83 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
84 | run: |
85 | src="${{ env.SCRIPT_PATH }}/delete_branch_pr_tag.sh"
86 | chmod +x "$src"
87 | $src "${{ github.repository }}" "${{ env.BRANCH_NEW }}" "${{ steps.bump.outputs.current-version }}"
88 |
89 | - name: Set summary data
90 | id: set_summary
91 | if: ${{ always() }}
92 | run: echo "summary_data=${GITHUB_STEP_SUMMARY}" >> $GITHUB_OUTPUT
93 |
94 | generate_summary:
95 | name: Generate Summary Report
96 | if: ${{ always() }}
97 | needs: bump_my_version
98 | uses: ./.github/workflows/summarize-jobs-reusable.yaml
99 | with:
100 | branch_to_summarize: ${{ needs.bump_my_version.outputs.branch_new }}
101 | summary_data: ${{ needs.bump_my_version.outputs.summary_data }}
102 |
--------------------------------------------------------------------------------
/.github/workflows/codeql.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | # https://github.blog/changelog/2023-01-18-code-scanning-codeql-action-v1-is-now-deprecated/
3 | name: "CodeQL"
4 |
5 | on:
6 | push:
7 | pull_request:
8 | types: [closed]
9 | branches: [ main ]
10 | schedule:
11 | - cron: '27 11 * * 0'
12 | workflow_dispatch:
13 |
14 | jobs:
15 | analyze:
16 | name: Analyze
17 | runs-on: ubuntu-latest
18 | permissions:
19 | actions: read
20 | contents: read
21 | security-events: write
22 |
23 | steps:
24 | - name: Checkout repository
25 | uses: actions/checkout@v4
26 |
27 | - name: Initialize CodeQL
28 | uses: github/codeql-action/init@v3
29 | with:
30 | languages: python
31 |
32 | - name: Autobuild
33 | uses: github/codeql-action/autobuild@v3
34 | # if autobuild fails
35 | #- run: |
36 | # make bootstrap
37 | # make release
38 |
39 | - name: Perform CodeQL Analysis
40 | uses: github/codeql-action/analyze@v3
41 | #- name: sarif
42 | # uses: github/codeql-action/upload-sarif@v2
43 | ...
44 |
--------------------------------------------------------------------------------
/.github/workflows/generate-deploy-mkdocs-ghpages.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | name: Deploy Docs
3 |
4 | on:
5 | pull_request:
6 | types: [closed]
7 | branches: [main]
8 | workflow_dispatch:
9 |
10 | env:
11 | DOCSTRINGS_FILE: "docstrings.md"
12 | DOC_DIR: "docs"
13 | SRC_DIR: "src"
14 | SITE_DIR: "site"
15 | IMG_DIR: "assets/images"
16 |
17 | jobs:
18 | build-and-deploy:
19 | runs-on: ubuntu-latest
20 | permissions:
21 | contents: read
22 | pages: write
23 | id-token: write
24 | environment:
25 | name: github-pages
26 | steps:
27 |
28 | - name: Checkout the repository
29 | uses: actions/checkout@v4.0.0
30 | with:
31 | ref:
32 | ${{
33 | github.event.pull_request.merged == true &&
34 | 'main' ||
35 | github.ref_name
36 | }}
37 | fetch-depth: 0
38 |
39 | - uses: actions/configure-pages@v5.0.0
40 |
41 | # caching instead of actions/cache@v4.0.0
42 | # https://docs.astral.sh/uv/guides/integration/github/#caching
43 | - name: Install uv with cache dependency glob
44 | uses: astral-sh/setup-uv@v5.0.0
45 | with:
46 | enable-cache: true
47 | cache-dependency-glob: "uv.lock"
48 |
49 | # setup python from pyproject.toml using uv
50 | # instead of using actions/setup-python@v5.0.0
51 | # https://docs.astral.sh/uv/guides/integration/github/#setting-up-python
52 | - name: "Set up Python"
53 | run: uv python install
54 |
55 | - name: Install only doc deps
56 | run: uv sync --only-group docs # --frozen
57 |
58 | - name: Get repo info and stream into mkdocs.yaml
59 | id: repo_info
60 | run: |
61 | REPO_INFO=$(curl -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
62 | -H "Accept: application/vnd.github.v3+json" \
63 | https://api.github.com/repos/${{ github.repository }})
64 | REPO_URL="${{ github.server_url }}/${{ github.repository }}"
65 | REPO_URL=$(echo ${REPO_URL} | sed 's|/|\\/|g')
66 | SITE_NAME=$(sed '1!d' README.md | sed '0,/# /{s/# //}')
67 | SITE_DESC=$(echo $REPO_INFO | jq -r .description)
68 | sed -i "s//${REPO_URL}/g" mkdocs.yaml
69 | sed -i "s//${SITE_NAME}/g" mkdocs.yaml
70 | sed -i "s//${SITE_DESC}/g" mkdocs.yaml
71 |
72 | - name: Copy text files to be included
73 | run: |
74 | CFG_PATH="src/app/config"
75 | mkdir -p "${DOC_DIR}/${CFG_PATH}"
76 | cp README.md "${DOC_DIR}/index.md"
77 | cp CHANGELOG.md LICENSE "${DOC_DIR}"
78 | # Auxiliary files
79 | cp .env.example "${DOC_DIR}"
80 |
81 | - name: Generate code docstrings concat file
82 | run: |
83 | PREFIX="::: "
84 | find "${SRC_DIR}" -type f -name "*.py" \
85 | -type f -not -name "__*__*" -printf "%P\n" | \
86 | sed 's/\//./g' | sed 's/\.py$//' | \
87 | sed "s/^/${PREFIX}/" | sort > \
88 | "${DOC_DIR}/${DOCSTRINGS_FILE}"
89 |
90 | - name: Build documentation
91 | run: uv run --locked --only-group docs mkdocs build
92 |
93 | - name: Copy image files to be included
94 | run: |
95 | # copy images, mkdocs does not by default
96 | # mkdocs also overwrites pre-made directories
97 | dir="${{ env.SITE_DIR }}/${{ env.IMG_DIR }}"
98 | if [ -d "${{ env.IMG_DIR }}" ]; then
99 | mkdir -p "${dir}"
100 | cp "${{ env.IMG_DIR }}"/* "${dir}"
101 | fi
102 |
103 | # - name: Push to gh-pages
104 | # run: uv run mkdocs gh-deploy --force
105 |
106 | - name: Upload artifact
107 | uses: actions/upload-pages-artifact@v3.0.0
108 | with:
109 | path: "${{ env.SITE_DIR }}"
110 |
111 | - name: Deploy to GitHub Pages
112 | id: deployment
113 | uses: actions/deploy-pages@v4.0.0
114 | ...
115 |
--------------------------------------------------------------------------------
/.github/workflows/links-fail-fast.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | # https://github.com/lycheeverse/lychee-action
3 | # https://github.com/marketplace/actions/lychee-broken-link-checker
4 | name: "Link Checker"
5 |
6 | on:
7 | workflow_dispatch:
8 | push:
9 | branches-ignore: [main]
10 | pull_request:
11 | types: [closed]
12 | branches: [main]
13 | schedule:
14 | - cron: "00 00 * * 0"
15 |
16 | jobs:
17 | linkChecker:
18 | runs-on: ubuntu-latest
19 | permissions:
20 | issues: write
21 |
22 | steps:
23 | - uses: actions/checkout@v4
24 |
25 | - name: Link Checker
26 | id: lychee
27 | uses: lycheeverse/lychee-action@v2
28 |
29 | - name: Create Issue From File
30 | if: steps.lychee.outputs.exit_code != 0
31 | uses: peter-evans/create-issue-from-file@v5
32 | with:
33 | title: lychee Link Checker Report
34 | content-filepath: ./lychee/out.md
35 | labels: report, automated issue
36 | ...
37 |
--------------------------------------------------------------------------------
/.github/workflows/pytest.yaml:
--------------------------------------------------------------------------------
1 | name: pytest
2 |
3 | on:
4 | workflow_dispatch:
5 |
6 | jobs:
7 | test:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - name: Checkout repository
11 | uses: actions/checkout@v4
12 |
13 | - name: Set up Python
14 | uses: actions/setup-python@v4
15 | with:
16 | python-version: '3.12'
17 |
18 | - name: Install dependencies
19 | run: |
20 | python -m pip install --upgrade pip
21 | pip install pytest
22 |
23 | - name: Run tests
24 | run: pytest
25 |
--------------------------------------------------------------------------------
/.github/workflows/ruff.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | # https://github.com/astral-sh/ruff-action
3 | # https://github.com/astral-sh/ruff
4 | name: ruff
5 | on:
6 | push:
7 | pull_request:
8 | types: [closed]
9 | branches: [main]
10 | schedule:
11 | - cron: "0 0 * * 0"
12 | workflow_dispatch:
13 | jobs:
14 | ruff:
15 | runs-on: ubuntu-latest
16 | steps:
17 | - uses: actions/checkout@v4
18 | - uses: astral-sh/ruff-action@v3
19 | ...
20 |
--------------------------------------------------------------------------------
/.github/workflows/summarize-jobs-reusable.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | # https://ecanarys.com/supercharging-github-actions-with-job-summaries-and-pull-request-comments/
3 | # FIXME currently bug in gha summaries ? $GITHUB_STEP_SUMMARY files are empty
4 | # https://github.com/orgs/community/discussions/110283
5 | # https://github.com/orgs/community/discussions/67991
6 | # Possible workaround
7 | # echo ${{ fromJSON(step).name }}" >> $GITHUB_STEP_SUMMARY
8 | # echo ${{ fromJSON(step).outcome }}" >> $GITHUB_STEP_SUMMARY
9 | # echo ${{ fromJSON(step).conclusion }}"
10 |
11 | name: Summarize workflow jobs
12 |
13 | on:
14 | workflow_call:
15 | outputs:
16 | summary:
17 | description: "Outputs summaries of jobs in a workflow"
18 | value: ${{ jobs.generate_summary.outputs.summary }}
19 | inputs:
20 | branch_to_summarize:
21 | required: false
22 | default: 'main'
23 | type: string
24 | summary_data:
25 | required: false
26 | type: string
27 |
28 | jobs:
29 | generate_summary:
30 | name: Generate Summary
31 | runs-on: ubuntu-latest
32 | permissions:
33 | contents: read
34 | actions: read
35 | checks: read
36 | pull-requests: none
37 | outputs:
38 | summary: ${{ steps.add_changed_files.outputs.summary }}
39 | steps:
40 |
41 | - name: Add general information
42 | id: general_info
43 | run: |
44 | echo "# Job Summaries" >> $GITHUB_STEP_SUMMARY
45 | echo "Job: `${{ github.job }}`" >> $GITHUB_STEP_SUMMARY
46 | echo "Date: $(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_STEP_SUMMARY
47 |
48 | - name: Add step states
49 | id: step_states
50 | run: |
51 | echo "### Steps:" >> $GITHUB_STEP_SUMMARY
52 | # loop summary_data if valid json
53 | if jq -e . >/dev/null 2>&1 <<< "${{ inputs.summary_data }}"; then
54 | jq -r '
55 | .steps[]
56 | | select(.conclusion != null)
57 | | "- **\(.name)**: \(
58 | if .conclusion == "success" then ":white_check_mark:"
59 | elif .conclusion == "failure" then ":x:"
60 | else ":warning:" end
61 | )"
62 | ' <<< "${{ inputs.summary_data }}" >> $GITHUB_STEP_SUMMARY
63 | else
64 | echo "Invalid JSON in summary data." >> $GITHUB_STEP_SUMMARY
65 | fi
66 |
67 | - name: Checkout repo
68 | uses: actions/checkout@v4
69 | with:
70 | ref: "${{ inputs.branch_to_summarize }}"
71 | fetch-depth: 0
72 |
73 | - name: Add changed files since last push
74 | id: add_changed_files
75 | run: |
76 | # Get the tags
77 | # Use disabled lines to get last two commits
78 | # current=$(git show -s --format=%ci HEAD)
79 | # previous=$(git show -s --format=%ci HEAD~1)
80 | # git diff --name-only HEAD^ HEAD >> $GITHUB_STEP_SUMMARY
81 | version_tag_regex="^v[0-9]+\.[0-9]+\.[0-9]+$" # v0.0.0
82 | tags=$(git tag --sort=-version:refname | \
83 | grep -E "${version_tag_regex}" || echo "")
84 |
85 | # Get latest and previous tags
86 | latest_tag=$(echo "${tags}" | head -n 1)
87 | previous_tag=$(echo "${tags}" | head -n 2 | tail -n 1)
88 |
89 | echo "tags: latest '${latest_tag}', previous '${previous_tag}'"
90 |
91 | # Write to summary
92 | error_msg="No files to output. Tag not found:"
93 | echo ${{ steps.step_states.outputs.summary }} >> $GITHUB_STEP_SUMMARY
94 | echo "## Changed files on '${{ inputs.branch_to_summarize }}'" >> $GITHUB_STEP_SUMMARY
95 |
96 | if [ -z "${latest_tag}" ]; then
97 | echo "${error_msg} latest" >> $GITHUB_STEP_SUMMARY
98 | elif [ -z "${previous_tag}" ]; then
99 | echo "${error_msg} previous" >> $GITHUB_STEP_SUMMARY
100 | elif [ "${latest_tag}" == "${previous_tag}" ]; then
101 | echo "Latest and previous tags are the same: '${latest_tag}'" >> $GITHUB_STEP_SUMMARY
102 | else
103 | # Get commit dates and hashes
104 | latest_date=$(git log -1 --format=%ci $latest_tag)
105 | previous_date=$(git log -1 --format=%ci $previous_tag)
106 | current_hash=$(git rev-parse --short $latest_tag)
107 | previous_hash=$(git rev-parse --short $previous_tag)
108 |
109 | # Append summary to the job summary
110 | echo "Latest Tag Commit: '${latest_tag}' (${current_hash}) ${latest_date}" >> $GITHUB_STEP_SUMMARY
111 | echo "Previous Tag Commit: '${previous_tag}' (${previous_hash}) ${previous_date}" >> $GITHUB_STEP_SUMMARY
112 | echo "Files changed:" >> $GITHUB_STEP_SUMMARY
113 | echo '```' >> $GITHUB_STEP_SUMMARY
114 | git diff --name-only $previous_tag..$latest_tag >> $GITHUB_STEP_SUMMARY
115 | echo '```' >> $GITHUB_STEP_SUMMARY
116 | fi
117 |
118 | - name: Output error message in case of failure or cancel
119 | if: failure() || cancelled()
120 | run: |
121 | if [ "${{ job.status }}" == "cancelled" ]; then
122 | out_msg="## Workflow was cancelled"
123 | else
124 | out_msg="## Error in previous step"
125 | fi
126 | echo $out_msg >> $GITHUB_STEP_SUMMARY
127 | ...
--------------------------------------------------------------------------------
/.github/workflows/write-llms-txt.yaml:
--------------------------------------------------------------------------------
1 | # TODO use local installation of repo to text
2 | # https://github.com/itsitgroup/repo2txt
3 | name: Write repo llms.txt
4 |
5 | on:
6 | push:
7 | branches: [main]
8 | workflow_dispatch:
9 | inputs:
10 | LLMS_TXT_PATH:
11 | description: 'Path to the directory to save llsm.txt'
12 | required: true
13 | default: 'docs'
14 | type: string
15 | LLMS_TXT_NAME:
16 | description: 'Path to the directory to save llsm.txt'
17 | required: true
18 | default: 'llms.txt'
19 | type: string
20 | CONVERTER_URL:
21 | description: '[uithub|gittodoc]' # |repo2txt
22 | required: true
23 | default: 'uithub.com'
24 | type: choice
25 | options:
26 | - 'uithub.com'
27 | - 'gittodoc.com'
28 | # - 'repo2txt.com'
29 |
30 | jobs:
31 | generate-file:
32 | runs-on: ubuntu-latest
33 |
34 | steps:
35 | - name: Checkout repo
36 | uses: actions/checkout@v4
37 |
38 | - name: Construct and create llms.txt path
39 | id: construct_and_create_llms_txt_path
40 | run: |
41 | LLMS_TXT_PATH="${{ inputs.LLMS_TXT_PATH }}"
42 | LLMS_TXT_PATH="${LLMS_TXT_PATH:-docs}"
43 | LLMS_TXT_NAME="${{ inputs.LLMS_TXT_NAME }}"
44 | LLMS_TXT_NAME="${LLMS_TXT_NAME:-llms.txt}"
45 | echo "LLMS_TXT_FULL=${LLMS_TXT_PATH}/${LLMS_TXT_NAME}" >> $GITHUB_OUTPUT
46 | mkdir -p "${LLMS_TXT_PATH}"
47 |
48 | - name: Fetch TXT from URL
49 | run: |
50 | LLMS_TXT_FULL=${{ steps.construct_and_create_llms_txt_path.outputs.LLMS_TXT_FULL }}
51 | URL="https://${{ inputs.CONVERTER_URL }}/${{ github.repository }}"
52 | echo "Fetching content from: ${URL}"
53 | echo "Saving content to: ${LLMS_TXT_FULL}"
54 | curl -s "${URL}" > "${LLMS_TXT_FULL}"
55 |
56 | - name: Commit and push file
57 | run: |
58 | LLMS_TXT_FULL=${{ steps.construct_and_create_llms_txt_path.outputs.LLMS_TXT_FULL }}
59 | commit_msg="feat(docs): Add/Update ${LLMS_TXT_FULL}, a flattened repo as single text file, inspired by [llmstxt.org](https://llmstxt.org/)."
60 | git config user.name "github-actions"
61 | git config user.email "github-actions@github.com"
62 | git add "${LLMS_TXT_FULL}"
63 | git commit -m "${commit_msg}"
64 | git push
65 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Python bytecode
2 | __pycache__/
3 | *.py[cod]
4 |
5 | # environment
6 | .venv/
7 | *.env
8 |
9 | # Distribution / packaging
10 | build/
11 | dist/
12 | *.egg-info/
13 |
14 | # Testing
15 | .pytest_cache/
16 | .coverage
17 |
18 | # Logs
19 | *.log
20 | /logs
21 |
22 | # OS generated files
23 | .DS_Store
24 | Thumbs.db
25 |
26 | # IDE specific files (adjust as needed)
27 | # .vscode/
28 | # .idea/
29 |
30 | # mkdocs
31 | reference/
32 | site/
33 |
34 | # linting
35 | .ruff_cache
36 |
37 | # type checking
38 | .mypy_cache/
39 |
--------------------------------------------------------------------------------
/.gitmessage:
--------------------------------------------------------------------------------
1 | #<--- 72 characters --------------------------------------------------->
2 | #
3 | # Conventional Commits, semantic commit messages for humans and machines
4 | # https://www.conventionalcommits.org/en/v1.0.0/
5 | # Lint your conventional commits
6 | # https://github.com/conventional-changelog/commitlint/tree/master/%40 \
7 | # commitlint/config-conventional
8 | # Common types can be (based on Angular convention)
9 | # build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test
10 | # https://github.com/conventional-changelog/commitlint/tree/master/%40
11 | # Footer
12 | # https://git-scm.com/docs/git-interpret-trailers
13 | #
14 | #<--- pattern --------------------------------------------------------->
15 | #
16 | # [(Scope)][!]: \
17 | #
18 | # short description: [()]:
19 | #
20 | # ! after scope in header indicates breaking change
21 | #
22 | # [optional body]
23 | #
24 | # - with bullets points
25 | #
26 | # [optional footer(s)]
27 | #
28 | # [BREAKING CHANGE:, Refs:, Resolves:, Addresses:, Reviewed by:]
29 | #
30 | #<--- usage ----------------------------------------------------------->
31 | #
32 | # Set locally (in the repository)
33 | # `git config commit.template .gitmessage`
34 | #
35 | # Set globally
36 | # `git config --global commit.template .gitmessage`
37 | #
38 | #<--- 72 characters --------------------------------------------------->
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "charliermarsh.ruff",
4 | "davidanson.vscode-markdownlint",
5 | "donjayamanne.githistory",
6 | "editorconfig.editorconfig",
7 | "gruntfuggly.todo-tree",
8 | "mhutchie.git-graph",
9 | "PKief.material-icon-theme",
10 | "redhat.vscode-yaml",
11 | "tamasfe.even-better-toml",
12 | "yzhang.markdown-all-in-one",
13 |
14 | "github.copilot",
15 | "github.copilot-chat",
16 | "github.vscode-github-actions",
17 | "ms-azuretools.vscode-docker",
18 | "ms-python.debugpy",
19 | "ms-python.python",
20 | "ms-python.vscode-pylance",
21 | "ms-vscode.makefile-tools",
22 | ]
23 | }
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.lineNumbers": "on",
3 | "editor.wordWrap": "on",
4 | "explorer.confirmDelete": true,
5 | "files.autoSave": "onFocusChange",
6 | "git.autofetch": true,
7 | "git.enableSmartCommit": true,
8 | "makefile.configureOnOpen": false,
9 | "markdownlint.config": {
10 | "MD024": false,
11 | "MD033": false
12 | },
13 | "python.analysis.extraPaths": ["./venv/lib/python3.13/site-packages"],
14 | "python.defaultInterpreterPath": "./.venv/bin/python",
15 | "python.analysis.typeCheckingMode": "strict",
16 | "python.analysis.diagnosticSeverityOverrides": {
17 | "reportMissingTypeStubs": "none",
18 | "reportUnknownMemberType": "none",
19 | "reportUnknownVariableType": "none"
20 | },
21 | "redhat.telemetry.enabled": false
22 | }
--------------------------------------------------------------------------------
/AGENTS.md:
--------------------------------------------------------------------------------
1 | # Agent instructions for `Agents-eval` repository
2 |
3 | As proposed by [agentsmd.net](https://agentsmd.net/) and used by [wandb weave AGENTS.md](https://github.com/wandb/weave/blob/master/AGENTS.md).
4 |
5 | ## Core Rules & AI Behavior
6 |
7 | * When you learn something new about the codebase or introduce a new concept, **update this file (`AGENTS.md`)** to reflect the new knowledge. This is YOUR FILE! It should grow and evolve with you.
8 | * If something doesn't make sense architecturally, from a developer experience standpoint, or product-wise, please add it to the **`Requests to Humans`** section below.
9 | * Always follow the established coding patterns, conventions, and architectural decisions documented here and in the `docs/` directory.
10 | * **Never assume missing context.** Ask questions if you are uncertain about requirements or implementation details.
11 | * **Never hallucinate libraries or functions.** Only use known, verified Python packages listed in `pyproject.toml`.
12 | * **Always confirm file paths and module names** exist before referencing them in code or tests.
13 | * **Never delete or overwrite existing code** unless explicitly instructed to or as part of a documented refactoring task.
14 |
15 | ## Architecture Overview
16 |
17 | This is a multi-agent evaluation system for assessing agentic AI systems. The project uses **PydanticAI** as the core framework for agent orchestration and is designed for evaluation purposes, not for production agent deployment.
18 |
19 | ### Data Flow
20 |
21 | 1. User input → Manager Agent
22 | 2. Manager delegates to Researcher Agent (with DuckDuckGo search)
23 | 3. Researcher results → Analyst Agent for validation
24 | 4. Validated data → Synthesizer Agent for report generation
25 | 5. Results evaluated using configurable metrics
26 |
27 | ### Key Dependencies
28 |
29 | * **PydanticAI**: Agent framework and orchestration
30 | * **uv**: Fast Python dependency management
31 | * **Streamlit**: GUI framework
32 | * **Ruff**: Code formatting and linting
33 | * **MyPy**: Static type checking
34 |
35 | ## Codebase Structure & Modularity
36 |
37 | ### Main Components
38 |
39 | * `src/app/`: The core application logic. This is where most of your work will be.
40 | * `main.py`: The main entry point for the CLI application.
41 | * `agents/agent_system.py`: Defines the multi-agent system, their interactions, and orchestration. **This is the central logic for agent behavior.**
42 | * `config/data_models.py`: Contains all **Pydantic** models that define the data contracts. This is a critical file for understanding data flow.
43 | * `config/config_chat.json`: Holds provider settings and system prompts for agents.
44 | * `config/config_eval.json`: Defines evaluation metrics and their weights.
45 | * `evals/metrics.py`: Implements the evaluation metrics.
46 | * `src/gui/`: Contains the source code for the Streamlit GUI.
47 | * `docs/`: Contains project documentation, including the Product Requirements Document (`PRD.md`) and the C4 architecture model.
48 | * `tests/`: Contains all tests for the project, written using **pytest**.
49 |
50 | ### Code Organization Rules
51 |
52 | * **Never create a file longer than 500 lines of code.** If a file approaches this limit, refactor by splitting it into smaller, more focused modules or helper files.
53 | * Organize code into clearly separated modules grouped by feature.
54 | * Use clear, consistent, and absolute imports within packages.
55 |
56 | ## Development Commands & Environment
57 |
58 | ### Environment Setup
59 |
60 | The project requirements are stated in `pyproject.toml`. Your development environment should be set up automatically using the provided `Makefile`, which configures the virtual environment.
61 |
62 | * `make setup_dev`: Install all dev dependencies.
63 | * `make setup_dev_claude`: Setup dev environment with Claude Code CLI.
64 | * `make setup_dev_ollama`: Setup dev environment with Ollama local LLM.
65 |
66 | ### Running the Application
67 |
68 | * `make run_cli`: Run the CLI application.
69 | * `make run_cli ARGS="--help"`: Run CLI with specific arguments.
70 | * `make run_gui`: Run the Streamlit GUI.
71 |
72 | ### Testing and Code Quality
73 |
74 | * `make test_all`: Run all tests with pytest.
75 | * `make coverage_all`: Run tests and generate a coverage report.
76 | * `make ruff`: Format code and fix linting issues with Ruff.
77 | * `make type_check`: Run mypy static type checking on `src/app/`.
78 |
79 | ## Testing & Reliability
80 |
81 | * **Always create Pytest unit tests** for new features (functions, classes, etc.).
82 | * Tests must live in the `tests/` folder, mirroring the `src/app` structure.
83 | * After updating any logic, check whether existing unit tests need to be updated. If so, do it.
84 | * For each new feature, include at least:
85 | * 1 test for the expected use case (happy path).
86 | * 1 test for a known edge case.
87 | * 1 test for an expected failure case (e.g., invalid input).
88 | * **To run a specific test file or function, use `uv run pytest` directly:**
89 | * `uv run pytest tests/test_specific_file.py`
90 | * `uv run pytest tests/test_specific_file.py::test_function`
91 |
92 | ## Style, Patterns & Documentation
93 |
94 | ### Coding Style
95 |
96 | * **Use Pydantic** models in `src/app/config/data_models.py` for all data validation and data contracts. **Always use or update these models** when modifying data flows.
97 | * Use the predefined error message functions from `src/app/utils/error_messages.py` for consistency.
98 | * When writing complex logic, **add an inline `# Reason:` comment** explaining the *why*, not just the *what*.
99 | * Comment non-obvious code to ensure it is understandable to a mid-level developer.
100 |
101 | ### Documentation
102 |
103 | * Write **docstrings for every function, class, and method** using the Google style format. This is critical as the documentation site is built automatically from docstrings.
104 |
105 | ```python
106 | def example_function(param1: int) -> str:
107 | """A brief summary of the function.
108 |
109 | Args:
110 | param1 (int): A description of the first parameter.
111 |
112 | Returns:
113 | str: A description of the return value.
114 | """
115 | return "example"
116 | ```
117 |
118 | * Update this `AGENTS.md` file when introducing new patterns or concepts.
119 | * Document significant architectural decisions in `docs/ADR.md`.
120 | * Document all significant changes, features, and bug fixes in `docs/CHANGELOG.md`.
121 |
122 | ## Code Review & PR Guidelines
123 |
124 | ### PR Requirements
125 |
126 | * **Title Format**: Commit messages and PR titles must follow the **Conventional Commits** specification, as outlined in the `.gitmessage` template.
127 | * Provide detailed PR summaries including the purpose of the changes and the testing performed.
128 |
129 | ### Pre-commit Checklist
130 |
131 | 1. Run the linter and formatter: `make ruff`.
132 | 2. Ensure all tests pass: `make test_all`.
133 | 3. Ensure static type checks pass: `make type_check`.
134 | 4. Update documentation as described below.
135 |
136 | ## Requests to Humans
137 |
138 | This section contains a list of questions, clarifications, or tasks that AI agents wish to have humans complete or elaborate on.
139 |
140 | * [ ] The `agent_system.py` module has a `NotImplementedError` for streaming with Pydantic model outputs. Please clarify the intended approach for streaming structured data.
141 | * [ ] The `llm_model_funs.py` module has `NotImplementedError` for the Gemini and HuggingFace providers. Please provide the correct implementation or remove them if they are not supported.
142 | * [ ] The `agent_system.py` module contains a `FIXME` note regarding the use of a try-catch context manager. Please review and implement the intended error handling.
143 | * [ ] Add TypeScript testing guidelines (if a TypeScript frontend is planned for the future).
144 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 |
8 | ## Guiding Principles
9 |
10 | - Changelogs are for humans, not machines.
11 | - There should be an entry for every single version.
12 | - The same types of changes should be grouped.
13 | - Versions and sections should be linkable.
14 | - The latest version comes first.
15 | - The release date of each version is displayed.
16 | - Mention whether you follow Semantic Versioning.
17 |
18 | ## Types of changes
19 |
20 | - `Added` for new features.
21 | - `Changed` for changes in existing functionality.
22 | - `Deprecated` for soon-to-be removed features.
23 | - `Removed` for now removed features.
24 | - `Fixed` for any bug fixes.
25 | - `Security` in case of vulnerabilities.
26 |
27 | ## [Unreleased]
28 |
29 | ## [0.0.2] - 2025-07-08
30 |
31 | ## [0.0.1] - 2025-07-07
32 |
33 | ### Added
34 |
35 | - Initial template containing templates for PRP
36 |
--------------------------------------------------------------------------------
/CLAUDE.md:
--------------------------------------------------------------------------------
1 | @AGENTS.md
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | # This Makefile automates the build, test, and clean processes for the project.
2 | # It provides a convenient way to run common tasks using the 'make' command.
3 | # Run `make help` to see all available recipes.
4 |
5 | .SILENT:
6 | .ONESHELL:
7 | .PHONY: all setup_python_claude setup_dev setup_prod setup_claude_code prp_gen_claude prp_exe_claude ruff test_all check_types coverage_all output_unset_app_env_sh run_example_gui run_example_server run_example_client help
8 | .DEFAULT_GOAL := help
9 |
10 |
11 | ENV_FILE := .env
12 | SRC_PATH := src
13 | APP_PATH := $(SRC_PATH)
14 | EXAMPLES_PATH := examples/mcp-server-client
15 | FEAT_DEF_PATH := /context/features
16 | PRP_DEF_PATH := /context/PRPs
17 | PRP_CLAUDE_GEN_CMD := generate-prp
18 | PRP_CLAUDE_EXE_CMD := execute-prp
19 |
20 |
21 | # MARK: setup
22 |
23 |
24 | # construct the full paths and execute Claude Code commands
25 | # TODO switch folder by function called ()
26 | # TODO Claude Code non-interactive headless mode tee to CLI
27 | define CLAUDE_PRP_RUNNER
28 | echo "Starting Claude Code PRP runner ..."
29 | dest_file=$(firstword $(strip $(1)))
30 | dest_cmd=$(firstword $(strip $(2)))
31 | if [ -z "$${dest_file}" ]; then
32 | echo "Error: ARGS for PRP filename is empty. Please provide a PRP filename."
33 | exit 1
34 | fi
35 | case "$${dest_cmd}" in
36 | start)
37 | dest_cmd=$(PRP_CLAUDE_GEN_CMD)
38 | dest_path=$(FEAT_DEF_PATH);;
39 | stop)
40 | dest_cmd=$(PRP_CLAUDE_EXE_CMD)
41 | dest_path=$(PRP_DEF_PATH);;
42 | *)
43 | echo "Unknown command: $${dest_cmd}. Exiting ..."
44 | exit 1;;
45 | esac
46 | dest_cmd="/project:$${dest_cmd} $${dest_path}/$${dest_file}"
47 | echo "Executing command '$${dest_cmd}' ..."
48 | claude -p "$${dest_cmd}" 2>&1
49 | claude -p "/cost" 2>&1
50 | endef
51 |
52 |
53 | setup_python_claude: # Set up environment and install Claude Code CLI
54 | $(MAKE) -s setup_dev
55 | $(MAKE) -s export_env_file
56 | $(MAKE) -s setup_claude_code
57 |
58 |
59 | setup_dev: ## Install uv and deps, Download and start Ollama
60 | echo "Setting up dev environment ..."
61 | pip install uv -q
62 | uv sync --all-groups
63 |
64 |
65 | setup_prod: ## Install uv and deps, Download and start Ollama
66 | echo "Setting up prod environment ..."
67 | pip install uv -q
68 | uv sync --frozen
69 |
70 |
71 | setup_claude_code: ## Setup Claude Code CLI, node.js and npm have to be present
72 | echo "Setting up claude code ..."
73 | npm install -g @anthropic-ai/claude-code
74 | claude config set --global preferredNotifChannel terminal_bell
75 | echo "npm version: $$(npm --version)"
76 | claude --version
77 |
78 |
79 | export_env_file: # Read ENV_FILE and export k=v to env
80 | while IFS='=' read -r key value || [ -n "$${key}" ]; do
81 | case "$${key}" in
82 | ''|\#*) continue ;;
83 | esac
84 | value=$$(echo "$${value}" | sed -e 's/^"//' -e 's/"$$//')
85 | export "$${key}=$${value}"
86 | done < .env
87 |
88 |
89 | output_unset_env_sh: ## Unset app environment variables
90 | uf="./unset_env.sh"
91 | echo "Outputing '$${uf}' ..."
92 | printenv | awk -F= '/_API_KEY=/ {print "unset " $$1}' > $$uf
93 |
94 |
95 | # MARK: context engineering
96 |
97 |
98 | prp_gen_claude: ## generates the PRP from the file passed in "ARGS=file"
99 | $(call CLAUDE_PRP_RUNNER, $(ARGS), "generate")
100 |
101 |
102 | prp_exe_claude: ## executes the PRP from the file passed in "ARGS=file"
103 | $(call CLAUDE_PRP_RUNNER, $(ARGS), "execute")
104 |
105 |
106 | # MARK: code quality
107 |
108 |
109 | ruff: ## Lint: Format and check with ruff
110 | uv run ruff format
111 | uv run ruff check --fix
112 |
113 |
114 | test_all: ## Run all tests
115 | uv run pytest
116 |
117 |
118 | coverage_all: ## Get test coverage
119 | uv run coverage run -m pytest || true
120 | uv run coverage report -m
121 |
122 |
123 | check_types: ## Check for static typing errors
124 | uv run mypy $(APP_PATH)
125 |
126 |
127 | # MARK: run
128 |
129 |
130 | run_example_gui: ## Launch MCP server-client example GUI
131 | $(MAKE) -C $(EXAMPLES_PATH) run_gui
132 |
133 | run_example_server: ## Run MCP server-client example server
134 | $(MAKE) -C $(EXAMPLES_PATH) run_server
135 |
136 | run_example_client: ## Run MCP server-client example client
137 | $(MAKE) -C $(EXAMPLES_PATH) run_client ARGS="$(ARGS)"
138 |
139 |
140 | # MARK: help
141 |
142 |
143 | # TODO add stackoverflow source
144 | help: ## Displays this message with available recipes
145 | echo "Usage: make [recipe]"
146 | echo "Recipes:"
147 | awk '/^[a-zA-Z0-9_-]+:.*?##/ {
148 | helpMessage = match($$0, /## (.*)/)
149 | if (helpMessage) {
150 | recipe = $$1
151 | sub(/:/, "", recipe)
152 | printf " \033[36m%-20s\033[0m %s\n", recipe, substr($$0, RSTART + 3, RLENGTH)
153 | }
154 | }' $(MAKEFILE_LIST)
155 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Context Engineering Template
2 |
3 | This project aims to implement a template for context engineering with coding agents. As suggested by several resources, including [context-engineering-intro](https://github.com/coleam00/context-engineering-intro), [The rise of "context engineering"](https://blog.langchain.com/the-rise-of-context-engineering/), [Context Engineering](https://blog.langchain.com/context-engineering-for-agents/) and somewhat [He Built 40 Startups Using Just Prompts — Here’s His System](https://youtu.be/CIAu6WeckQ0).
4 |
5 | [](LICENSE)
6 | 
7 | [](https://github.com/qte77/context-engineering-template/actions/workflows/codeql.yaml)
8 | [](https://www.codefactor.io/repository/github/qte77/context-engineering-template)
9 | [](https://github.com/qte77/context-engineering-template/actions/workflows/ruff.yaml)
10 | [](https://github.com/qte77/context-engineering-template/actions/workflows/pytest.yaml)
11 | [](https://github.com/qte77/context-engineering-template/actions/workflows/links-fail-fast.yaml)
12 | [](https://github.com/qte77/context-engineering-template/actions/workflows/generate-deploy-mkdocs-ghpages.yaml)
13 |
14 | **DevEx** [](https://vscode.dev/github/qte77/context-engineering-template)
15 | [](https://github.com/codespaces/new?repo=qte77/context-engineering-template&devcontainer_path=.devcontainer/setup_python_claude/devcontainer.json)
16 | [](https://talktogithub.com/qte77/context-engineering-template)
17 | [](https://github.com/qte77/context-engineering-template)
18 | [](https://gittodoc.com/qte77/context-engineering-template)
19 |
20 | ## Status
21 |
22 | (DRAFT) (WIP) ----> Not fully implemented yet
23 |
24 | For version history have a look at the [CHANGELOG](CHANGELOG.md).
25 |
26 | ## Purpose
27 |
28 | Let the Coding Agent do the heavy lifting. Build code base from top to bottom: Define Business Requirements (BRD) and afterwards features to be implemented. The goal could be to to implement some kind of guided top-down BDD: behavior > tests > implementation.
29 |
30 | ## Features
31 |
32 | - Runs tests, linting and type checks: only
33 |
34 |
35 | Show Sequence Diagram
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | ## Setup
45 |
46 | 1. `make setup_python_claude`
47 | 2. If .env to be used: `make export_env_file`
48 |
49 | ## Usage
50 |
51 | 1. Update [Agents.md](AGENTS.md) to your needs.
52 | 2. Describe desired feature in `/context/features/feature_XXX.md`, like shown in [feature_base.md](/context/templates/feature_base.md).
53 | 3. Place optional examples into [/context/examples](/context/examples).
54 | 4. Let the Product Requirements Prompt (PRP) be generated:
55 | - In Claude Code CLI: `/generate-prp feature_XXX.md`
56 | - or: `make prp_gen_claude "ARGS=feature_XXX.md"`
57 | 5. Let the feature be implemented based on the PRP:
58 | - In Claude Code CLI: `/execute-prp feature_XXX.md`
59 | - or: `make prp_exe_claude "ARGS=feature_XXX.md"`
60 |
61 | ### Configuration
62 |
63 | - General system behavior: `AGENTS.md`, redirected from `CLAUDE.md`
64 | - Claude settings: `.claude/settings.local.json`
65 | - CLaude commands: `.claude/commands`
66 | - Feature template: `context/templates/feature_base.md`
67 | - PRP template: `context/templates/prp_base.md`
68 |
69 | ### Environment
70 |
71 | [.env.example](.env.example) contains examples for usage of API keys and variables.
72 |
73 | ```text
74 | ANTHROPIC_API_KEY="sk-abc-xyz"
75 | GEMINI_API_KEY="xyz"
76 | GITHUB_API_KEY="ghp_xyz"
77 | ...
78 | ```
79 |
80 | ## TODO
81 |
82 | - Implement business process as discussed in [He Built 40 Startups Using Just Prompts — Here’s His System](https://youtu.be/CIAu6WeckQ0)
83 | - Refine `AGENTS.md` to let the agent not do bulk but incremental changes, also implement tests first, then code and iterate until functional (red > green > blue).
84 |
--------------------------------------------------------------------------------
/assets/images/example_execute_feature_mcp_server_PRP_update_pyproject.PNG:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qte77/context-engineering-template/e35a269c47a5c9a91c90609d25c9b1a611f1858f/assets/images/example_execute_feature_mcp_server_PRP_update_pyproject.PNG
--------------------------------------------------------------------------------
/assets/images/example_execute_feature_mcp_server_PRP_update_pyproject_diff.PNG:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qte77/context-engineering-template/e35a269c47a5c9a91c90609d25c9b1a611f1858f/assets/images/example_execute_feature_mcp_server_PRP_update_pyproject_diff.PNG
--------------------------------------------------------------------------------
/assets/images/example_execute_feature_mcp_server_created.PNG:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qte77/context-engineering-template/e35a269c47a5c9a91c90609d25c9b1a611f1858f/assets/images/example_execute_feature_mcp_server_created.PNG
--------------------------------------------------------------------------------
/assets/images/example_generate_feature_mcp_server_PRP.PNG:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qte77/context-engineering-template/e35a269c47a5c9a91c90609d25c9b1a611f1858f/assets/images/example_generate_feature_mcp_server_PRP.PNG
--------------------------------------------------------------------------------
/assets/images/sequence_diagram.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qte77/context-engineering-template/e35a269c47a5c9a91c90609d25c9b1a611f1858f/assets/images/sequence_diagram.png
--------------------------------------------------------------------------------
/context/templates/feature_base.md:
--------------------------------------------------------------------------------
1 | # Feature description for: [ Initial template for new features ]
2 |
3 | ## FEATURE
4 |
5 | [Insert your feature here]
6 |
7 | ## EXAMPLES
8 |
9 | [Provide and explain examples that you have in the `/context/examples` folder]
10 |
11 | ## DOCUMENTATION
12 |
13 | [List out any documentation (web pages, sources for an MCP server like Crawl4AI RAG, etc.) that will need to be referenced during development]
14 |
15 | ## OTHER CONSIDERATIONS
16 |
17 | [Any other considerations or specific requirements - great place to include gotchas that you see AI coding assistants miss with your projects a lot]
18 |
--------------------------------------------------------------------------------
/context/templates/prp_base.md:
--------------------------------------------------------------------------------
1 | # "Base PRP Template v2 - Context-Rich with Validation Loops"
2 |
3 | ## Purpose
4 |
5 | Product Requirements Prompt (PRP) Template optimized for AI agents to implement features with sufficient context and self-validation capabilities to achieve working code through iterative refinement.
6 |
7 | ## Core Principles
8 |
9 | 1. **Context is King**: Include ALL necessary documentation, examples, and caveats
10 | 2. **Validation Loops**: Provide executable tests/lints the AI can run and fix
11 | 3. **Information Dense**: Use keywords and patterns from the codebase
12 | 4. **Progressive Success**: Start simple, validate, then enhance
13 | 5. **Global rules**: Be sure to follow all rules in CLAUDE.md
14 |
15 | ---
16 |
17 | ## Goal
18 |
19 | [What needs to be built - be specific about the end state and desires]
20 |
21 | ## Why
22 |
23 | - [Business value and user impact]
24 | - [Integration with existing features]
25 | - [Problems this solves and for whom]
26 |
27 | ## What
28 |
29 | [User-visible behavior and technical requirements]
30 |
31 | ### Success Criteria
32 |
33 | - [ ] [Specific measurable outcomes]
34 |
35 | ## All Needed Context
36 |
37 | ### Documentation & References (list all context needed to implement the feature)
38 |
39 | ```yaml
40 | # MUST READ - Include these in your context window
41 | - url: [Official API docs URL]
42 | why: [Specific sections/methods you'll need]
43 |
44 | - file: [path/to/example.py]
45 | why: [Pattern to follow, gotchas to avoid]
46 |
47 | - doc: [Library documentation URL]
48 | section: [Specific section about common pitfalls]
49 | critical: [Key insight that prevents common errors]
50 |
51 | - docfile: [PRPs/ai_docs/file.md]
52 | why: [docs that the user has pasted in to the project]
53 | ```
54 |
55 | ### Current Codebase tree (run `tree` in the root of the project) to get an overview of the codebase
56 |
57 | ```bash
58 |
59 | ```
60 |
61 | ### Desired Codebase tree with files to be added and responsibility of file
62 |
63 | ```bash
64 |
65 | ```
66 |
67 | ### Known Gotchas of our codebase & Library Quirks
68 |
69 | ```python
70 | # CRITICAL: [Library name] requires [specific setup]
71 | # Example: FastAPI requires async functions for endpoints
72 | # Example: This ORM doesn't support batch inserts over 1000 records
73 | # Example: We use pydantic v2 and
74 | ```
75 |
76 | ## Implementation Blueprint
77 |
78 | ### Data models and structure
79 |
80 | Create the core data models, we ensure type safety and consistency.
81 |
82 | ```python
83 | Examples:
84 | - orm models
85 | - pydantic models
86 | - pydantic schemas
87 | - pydantic validators
88 | ```
89 |
90 | ### list of tasks to be completed to fullfill the PRP in the order they should be completed
91 |
92 | ```yaml
93 | Task 1:
94 | MODIFY src/existing_module.py:
95 | - FIND pattern: "class OldImplementation"
96 | - INJECT after line containing "def __init__"
97 | - PRESERVE existing method signatures
98 |
99 | CREATE src/new_feature.py:
100 | - MIRROR pattern from: src/similar_feature.py
101 | - MODIFY class name and core logic
102 | - KEEP error handling pattern identical
103 |
104 | ...(...)
105 |
106 | Task N:
107 | ...
108 |
109 | ```
110 |
111 | ### Per task pseudocode as needed added to each task
112 |
113 | ```python
114 |
115 | # Task 1
116 | # Pseudocode with CRITICAL details dont write entire code
117 | async def new_feature(param: str) -> Result:
118 | # PATTERN: Always validate input first (see src/validators.py)
119 | validated = validate_input(param) # raises ValidationError
120 |
121 | # GOTCHA: This library requires connection pooling
122 | async with get_connection() as conn: # see src/db/pool.py
123 | # PATTERN: Use existing retry decorator
124 | @retry(attempts=3, backoff=exponential)
125 | async def _inner():
126 | # CRITICAL: API returns 429 if >10 req/sec
127 | await rate_limiter.acquire()
128 | return await external_api.call(validated)
129 |
130 | result = await _inner()
131 |
132 | # PATTERN: Standardized response format
133 | return format_response(result) # see src/utils/responses.py
134 | ```
135 |
136 | ### Integration Points
137 |
138 | ```yaml
139 | DATABASE:
140 | - migration: "Add column 'feature_enabled' to users table"
141 | - index: "CREATE INDEX idx_feature_lookup ON users(feature_id)"
142 |
143 | CONFIG:
144 | - add to: config/settings.py
145 | - pattern: "FEATURE_TIMEOUT = int(os.getenv('FEATURE_TIMEOUT', '30'))"
146 |
147 | ROUTES:
148 | - add to: src/api/routes.py
149 | - pattern: "router.include_router(feature_router, prefix='/feature')"
150 | ```
151 |
152 | ## Validation Loop
153 |
154 | ### Level 1: Syntax & Style
155 |
156 | ```bash
157 | # Run these FIRST - fix any errors before proceeding
158 | ruff check src/new_feature.py --fix # Auto-fix what's possible
159 | mypy src/new_feature.py # Type checking
160 |
161 | # Expected: No errors. If errors, READ the error and fix.
162 | ```
163 |
164 | ### Level 2: Unit Tests each new feature/file/function use existing test patterns
165 |
166 | ```python
167 | # CREATE test_new_feature.py with these test cases:
168 | def test_happy_path():
169 | """Basic functionality works"""
170 | result = new_feature("valid_input")
171 | assert result.status == "success"
172 |
173 | def test_validation_error():
174 | """Invalid input raises ValidationError"""
175 | with pytest.raises(ValidationError):
176 | new_feature("")
177 |
178 | def test_external_api_timeout():
179 | """Handles timeouts gracefully"""
180 | with mock.patch('external_api.call', side_effect=TimeoutError):
181 | result = new_feature("valid")
182 | assert result.status == "error"
183 | assert "timeout" in result.message
184 | ```
185 |
186 | ```bash
187 | # Run and iterate until passing:
188 | uv run pytest test_new_feature.py -v
189 | # If failing: Read error, understand root cause, fix code, re-run (never mock to pass)
190 | ```
191 |
192 | ### Level 3: Integration Test
193 |
194 | ```bash
195 | # Start the service
196 | uv run python -m src.main --dev
197 |
198 | # Test the endpoint
199 | curl -X POST http://localhost:8000/feature \
200 | -H "Content-Type: application/json" \
201 | -d '{"param": "test_value"}'
202 |
203 | # Expected: {"status": "success", "data": {...}}
204 | # If error: Check logs at logs/app.log for stack trace
205 | ```
206 |
207 | ## Final validation Checklist
208 |
209 | - [ ] All tests pass: `uv run pytest tests/ -v`
210 | - [ ] No linting errors: `uv run ruff check src/`
211 | - [ ] No type errors: `uv run mypy src/`
212 | - [ ] Manual test successful: [specific curl/command]
213 | - [ ] Error cases handled gracefully
214 | - [ ] Logs are informative but not verbose
215 | - [ ] Documentation updated if needed
216 |
217 | ---
218 |
219 | ## Anti-Patterns to Avoid
220 |
221 | - ❌ Don't create new patterns when existing ones work
222 | - ❌ Don't skip validation because "it should work"
223 | - ❌ Don't ignore failing tests - fix them
224 | - ❌ Don't use sync functions in async context
225 | - ❌ Don't hardcode values that should be config
226 | - ❌ Don't catch all exceptions - be specific
227 |
--------------------------------------------------------------------------------
/docs/architecture/sequence_diagram.mermaid:
--------------------------------------------------------------------------------
1 | sequenceDiagram
2 | participant User
3 | participant feature_DESC_XXX.md
4 | participant ClaudeCode
5 | participant generate-prp.md
6 | participant feature_PRP_XXX.md
7 | participant execute-prp.md
8 | participant AI Coding Agent
9 |
10 | User->>/context/features/feature_DESC_XXX.md: Describes feature
11 | User->>ClaudeCode: Runs /generate-prp.md /context/features/feature_DESC_XXX.md
12 | ClaudeCode->>generate-prp.md: Executes command
13 | generate-prp.md->>AI Coding Agent: Reads /context/features/feature_DESC_XXX.md, codebase, and web
14 | AI Coding Agent-->>generate-prp.md: Researches codebase, docs, and examples
15 | generate-prp.md->>/context/features/feature_PRP_XXX.md: Generates PRP
16 | ClaudeCode->>User: PRP saved in /context/PRPs/feature_PRP_XXX.md
17 | User->>ClaudeCode: Runs /execute-prp /context/PRPs/feature_PRP_XXX.md
18 | ClaudeCode->>execute-prp.md: Executes command
19 | execute-prp.md->>AI Coding Agent: Reads /context/PRPs/feature_PRP_XXX.md and CLAUDE.md
20 | AI Coding Agent->>AI Coding Agent: Creates implementation plan, executes, validates, and iterates
21 | AI Coding Agent-->>User: Implements feature
22 |
--------------------------------------------------------------------------------
/docs/llms.txt:
--------------------------------------------------------------------------------
1 | 404 - Failed to fetch repository. If the repo is private, be sure to provide an Authorization header. Status:404
--------------------------------------------------------------------------------
/examples/mcp-server-client/Makefile:
--------------------------------------------------------------------------------
1 | # MCP Server-Client Example Makefile
2 | # This Makefile provides commands for the MCP server-client example
3 |
4 | .SILENT:
5 | .ONESHELL:
6 | .PHONY: setup_dev setup_prod ruff test_all check_types coverage_all run_gui run_server run_client help
7 | .DEFAULT_GOAL := help
8 |
9 | SRC_PATH := src
10 | APP_PATH := $(SRC_PATH)
11 |
12 | # MARK: setup
13 |
14 | setup_dev: ## Install uv and development dependencies
15 | echo "Setting up dev environment ..."
16 | pip install uv -q
17 | uv sync --all-groups
18 |
19 | setup_prod: ## Install uv and production dependencies
20 | echo "Setting up prod environment ..."
21 | pip install uv -q
22 | uv sync --frozen
23 |
24 | # MARK: code quality
25 |
26 | ruff: ## Format and lint code with ruff
27 | uv run ruff format
28 | uv run ruff check --fix
29 |
30 | test_all: ## Run all tests
31 | uv run pytest
32 |
33 | coverage_all: ## Get test coverage
34 | uv run coverage run -m pytest || true
35 | uv run coverage report -m
36 |
37 | check_types: ## Check for static typing errors
38 | uv run mypy $(APP_PATH)
39 |
40 | # MARK: run
41 |
42 | run_gui: ## Launch Streamlit GUI
43 | uv run python -m src.main gui
44 |
45 | run_server: ## Run MCP server
46 | uv run python -m src.main server
47 |
48 | run_client: ## Run MCP client (requires ARGS)
49 | uv run python -m src.main client $(ARGS)
50 |
51 | # MARK: help
52 |
53 | help: ## Display available commands
54 | echo "Usage: make [command]"
55 | echo "Commands:"
56 | awk '/^[a-zA-Z0-9_-]+:.*?##/ {
57 | helpMessage = match($$0, /## (.*)/)
58 | if (helpMessage) {
59 | recipe = $$1
60 | sub(/:/, "", recipe)
61 | printf " \033[36m%-20s\033[0m %s\n", recipe, substr($$0, RSTART + 3, RLENGTH)
62 | }
63 | }' $(MAKEFILE_LIST)
--------------------------------------------------------------------------------
/examples/mcp-server-client/context/features/feature_1_mcp_server.md:
--------------------------------------------------------------------------------
1 | # Feature description for: MCP Server with tools
2 |
3 | ## FEATURE
4 |
5 | Implement an **MCP (Message Control Protocol) Server** in Python that exposes three callable tools via structured messages. The server should receive well-formed MCP messages and dispatch tool invocations accordingly. The three tools to be exposed are:
6 |
7 | 1. **Roll Dice**: Accepts a format like `2d6` or `1d20` and returns the rolled values and total.
8 | 2. **Get Weather**: Accepts a city name or coordinates and returns the current weather conditions using a public weather API.
9 | 3. **Get Date**: Returns the current date and time in ISO 8601 format or based on a requested timezone.
10 |
11 | The server should be modular, testable, and extensible for future tools. Logging, error handling, and message validation should be considered first-class concerns.
12 |
13 | ## EXAMPLES
14 |
15 | Located in `/context/examples`:
16 |
17 | * `roll_dice_example.json`: Demonstrates sending `{"tool": "roll_dice", "args": {"notation": "3d6"}}` and receiving `{"result": {"values": [4,2,6], "total": 12}}`.
18 | * `get_weather_example.json`: Sends `{"tool": "get_weather", "args": {"location": "San Francisco"}}` and expects weather data such as temperature, condition, and wind speed.
19 | * `get_date_example.json`: Sends `{"tool": "get_date", "args": {"timezone": "UTC"}}` and receives `{"result": "2025-07-06T16:22:00Z"}`.
20 |
21 | These examples cover correct usage and malformed inputs to validate tool response and error handling.
22 |
23 | ## DOCUMENTATION
24 |
25 | * [Open-Meteo API](https://open-meteo.com/en/docs): For retrieving weather information.
26 | * [Python `datetime` module](https://docs.python.org/3/library/datetime.html): For implementing date and time tool.
27 | * [random module (Python)](https://docs.python.org/3/library/random.html): For rolling dice.
28 | * \[MCP Protocol Overview (proprietary/internal if applicable)] or general protocol documentation, if using a specific spec.
29 |
30 | Additional context from [context-engineering-intro](https://github.com/qte77/context-engineering-template) will inform message structure and processing strategy.
31 |
32 | ## OTHER CONSIDERATIONS
33 |
34 | * **Tool routing logic** should be clearly separated to allow clean expansion.
35 | * **Input validation** is critical: especially for `roll_dice`, invalid formats (e.g., `3x5`, `0d6`, `d10`) must return informative errors.
36 | * **Weather API failures or rate limits** should be gracefully handled with fallback messages.
37 | * **Timezone parsing** for `get_date` should use `pytz` or `zoneinfo`, and clearly inform users when timezones are unsupported.
38 | * **Security note**: Weather and date APIs should not expose sensitive request metadata or leak internal server details in errors.
39 | * AI coding assistants often:
40 |
41 | * Miss edge case handling (e.g., zero dice, negative sides)
42 | * Forget to structure results consistently across tools
43 | * Fail to modularize tool logic, making future expansion difficult
44 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/context/features/feature_2_mcp_client.md:
--------------------------------------------------------------------------------
1 | # Feature description for: MCP Client for Tool Invocation
2 |
3 | ## FEATURE
4 |
5 | Implement a **Python-based MCP Client** capable of sending structured requests to an MCP Server and handling the corresponding responses. The client should:
6 |
7 | * Connect to the MCP server over a socket, HTTP, or another configured protocol.
8 | * Serialize requests into the expected MCP message format (e.g., JSON or line-based protocol).
9 | * Provide a command-line interface (CLI) and/or programmatic interface for interacting with the following tools:
10 |
11 | 1. **Roll Dice** (`roll_dice`) – accepts dice notation like `2d6`, `1d20`.
12 | 2. **Get Weather** (`get_weather`) – accepts a location name or coordinates.
13 | 3. **Get Date** (`get_date`) – optionally accepts a timezone.
14 |
15 | The client should also handle connection errors, invalid tool responses, and retry logic gracefully.
16 |
17 | ## EXAMPLES
18 |
19 | Located in `/context/examples`:
20 |
21 | * `client_roll_dice_input.json`: `{ "tool": "roll_dice", "args": { "notation": "2d6" } }`
22 | * `client_get_weather_input.json`: `{ "tool": "get_weather", "args": { "location": "Berlin" } }`
23 | * `client_get_date_input.json`: `{ "tool": "get_date", "args": { "timezone": "UTC" } }`
24 | * `client_invalid_tool.json`: `{ "tool": "fly_to_mars", "args": {} }` → Should trigger a meaningful error from the server
25 |
26 | These example requests and expected responses can be used for local testing and automated integration checks.
27 |
28 | ## DOCUMENTATION
29 |
30 | * [Python `socket` module](https://docs.python.org/3/library/socket.html) or [requests](https://docs.python.org/3/library/urllib.request.html) depending on transport.
31 | * [JSON module](https://docs.python.org/3/library/json.html) for message formatting.
32 | * [argparse](https://docs.python.org/3/library/argparse.html) for implementing a simple CLI wrapper.
33 | * Reference the MCP Server protocol spec or internal documentation (e.g. *MCP Protocol Overview* if proprietary).
34 | * [context-engineering-template](https://github.com/qte/context-engineering-template) for usage conventions.
35 |
36 | ## OTHER CONSIDERATIONS
37 |
38 | * Client must validate outgoing messages before sending to avoid malformed requests.
39 | * Handle connection errors, timeouts, and retries in a user-friendly manner.
40 | * The response handler should check for required fields (`result`, `error`, etc.) to avoid crashes on malformed server responses.
41 | * Consider pluggability of tools so future expansions can be supported with minimal refactoring.
42 | * AI assistants often:
43 |
44 | * Miss error handling around partial or no server responses.
45 | * Forget to properly close socket connections or handle timeouts.
46 | * Write overly rigid request builders, making CLI usage frustrating.
47 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/context/features/feature_3_streamlit_gui.md:
--------------------------------------------------------------------------------
1 | # Feature description for: Streamlit GUI for MCP Server-Client Interaction Showcase
2 |
3 | ## FEATURE
4 |
5 | Develop a **Streamlit-based graphical user interface (GUI)** to demonstrate and interactively showcase the communication and integration between the MCP Server and MCP Client. The GUI should allow users to:
6 |
7 | * Select and invoke any of the three available tools (`roll_dice`, `get_weather`, `get_date`) via intuitive form inputs.
8 | * Enter tool-specific parameters such as dice notation, location, or timezone.
9 | * Display real-time request payloads sent by the client and the corresponding responses received from the server.
10 | * Handle and display error messages gracefully.
11 | * Log interaction history for the current session, allowing users to review previous commands and results.
12 | * Provide clear visual feedback about the status of the connection and request execution.
13 |
14 | This GUI acts as both a testing ground and demonstration interface, useful for users unfamiliar with command-line tools or raw protocol messages.
15 |
16 | ## EXAMPLES
17 |
18 | Located in `/context/examples`:
19 |
20 | * `streamlit_roll_dice_interaction.json`: Example input/output pairs demonstrating a dice roll session in the GUI.
21 | * `streamlit_get_weather_interaction.json`: Demonstrates user inputs for location and the displayed weather response.
22 | * `streamlit_get_date_interaction.json`: Shows date/time requests with optional timezone selection.
23 | * `streamlit_error_handling.json`: Examples of how the GUI displays server-side validation errors or connection issues.
24 |
25 | These examples serve as test cases for GUI input validation and response rendering.
26 |
27 | ## DOCUMENTATION
28 |
29 | * [Streamlit Documentation](https://docs.streamlit.io/) for building interactive Python apps.
30 | * \[MCP Server and Client Protocol Specs] (internal/proprietary or from context-engineering-intro).
31 | * Python libraries for HTTP or socket communication used by the client.
32 | * UI/UX design best practices for interactive demos.
33 | * [context-engineering-intro](https://github.com/coleam00/context-engineering-intro) for project conventions.
34 |
35 | ## OTHER CONSIDERATIONS
36 |
37 | * Ensure asynchronous or non-blocking communication so the UI remains responsive during server interactions.
38 | * Validate inputs in the GUI before sending to the client to minimize server errors.
39 | * Provide helpful tooltips or inline help to explain tool parameters to users unfamiliar with dice notation or timezone formats.
40 | * Consider session state management in Streamlit to maintain history and status.
41 | * AI coding assistants often overlook proper error propagation to the UI and user-friendly messaging.
42 | * Security considerations: if exposing any sensitive endpoints or API keys, avoid hardcoding secrets in the GUI code.
43 | * Design with extensibility in mind to add new tools or more complex workflows easily.
44 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/context/outputs/client_get_date_input.json:
--------------------------------------------------------------------------------
1 | {
2 | "description": "Simple client input format for get_date tool",
3 | "cli_command": "python -m src.main client --server src/mcp_server/server.py get_date --timezone UTC",
4 | "expected_input": {
5 | "tool": "get_date",
6 | "arguments": {
7 | "timezone": "UTC"
8 | }
9 | },
10 | "expected_output_format": {
11 | "success": true,
12 | "tool_name": "get_date",
13 | "result": {
14 | "content": [
15 | {
16 | "type": "text",
17 | "text": "🕐 **Current Date & Time**\n📅 Date: **2025-07-07** (Monday)\n⏰ Time: **14:30:25**\n🌍 Timezone: **UTC**\n📋 ISO 8601: `2025-07-07T14:30:25+00:00`\n🔢 Unix Timestamp: `1720360225`"
18 | }
19 | ]
20 | }
21 | },
22 | "examples": [
23 | {
24 | "timezone": "America/New_York",
25 | "description": "Get Eastern Time"
26 | },
27 | {
28 | "timezone": "Europe/London",
29 | "description": "Get London time"
30 | },
31 | {
32 | "timezone": "Asia/Tokyo",
33 | "description": "Get Tokyo time"
34 | },
35 | {
36 | "timezone": "pst",
37 | "description": "Get Pacific Time using alias"
38 | }
39 | ]
40 | }
41 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/context/outputs/client_get_weather_input.json:
--------------------------------------------------------------------------------
1 | {
2 | "description": "Simple client input format for get_weather tool",
3 | "cli_command": "python -m src.main client --server src/mcp_server/server.py get_weather --location 'San Francisco'",
4 | "expected_input": {
5 | "tool": "get_weather",
6 | "arguments": {
7 | "location": "San Francisco"
8 | }
9 | },
10 | "expected_output_format": {
11 | "success": true,
12 | "tool_name": "get_weather",
13 | "result": {
14 | "content": [
15 | {
16 | "type": "text",
17 | "text": "🌤️ **Weather for San Francisco**\n🌡️ Temperature: **18.5°C**\n☁️ Condition: **Partly cloudy**\n💨 Wind Speed: **12.3 km/h**\n💧 Humidity: **65%**\n🕐 Updated: 2025-07-07 14:30 UTC"
18 | }
19 | ]
20 | }
21 | },
22 | "examples": [
23 | {
24 | "location": "London",
25 | "description": "Get weather for London"
26 | },
27 | {
28 | "location": "New York",
29 | "description": "Get weather for New York"
30 | },
31 | {
32 | "location": "37.7749,-122.4194",
33 | "description": "Get weather using coordinates"
34 | }
35 | ]
36 | }
37 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/context/outputs/client_invalid_tool.json:
--------------------------------------------------------------------------------
1 | {
2 | "description": "Error handling example for invalid tool",
3 | "cli_command": "python -m src.main client --server src/mcp_server/server.py invalid_tool --arg value",
4 | "expected_input": {
5 | "tool": "invalid_tool",
6 | "arguments": {
7 | "arg": "value"
8 | }
9 | },
10 | "expected_output_format": {
11 | "success": false,
12 | "tool_name": "invalid_tool",
13 | "error": "Tool 'invalid_tool' not available. Available tools: ['roll_dice', 'get_weather', 'get_date']",
14 | "arguments": {
15 | "arg": "value"
16 | }
17 | },
18 | "error_scenarios": [
19 | {
20 | "scenario": "Tool not available",
21 | "tool": "nonexistent_tool",
22 | "expected_error": "Tool 'nonexistent_tool' not available"
23 | },
24 | {
25 | "scenario": "Server not running",
26 | "server_path": "./nonexistent_server.py",
27 | "expected_error": "Server script not found"
28 | },
29 | {
30 | "scenario": "Invalid server path",
31 | "server_path": "/dev/null",
32 | "expected_error": "Failed to connect to server"
33 | },
34 | {
35 | "scenario": "Connection timeout",
36 | "timeout": 1,
37 | "expected_error": "Connection timeout"
38 | }
39 | ]
40 | }
41 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/context/outputs/client_roll_dice_input.json:
--------------------------------------------------------------------------------
1 | {
2 | "description": "Simple client input format for roll_dice tool",
3 | "cli_command": "python -m src.main client --server src/mcp_server/server.py roll_dice --notation 2d6",
4 | "expected_input": {
5 | "tool": "roll_dice",
6 | "arguments": {
7 | "notation": "2d6"
8 | }
9 | },
10 | "expected_output_format": {
11 | "success": true,
12 | "tool_name": "roll_dice",
13 | "result": {
14 | "content": [
15 | {
16 | "type": "text",
17 | "text": "🎲 Rolled 2d6: [3, 5] = **8**"
18 | }
19 | ]
20 | }
21 | },
22 | "examples": [
23 | {
24 | "notation": "1d20",
25 | "description": "Roll a 20-sided die"
26 | },
27 | {
28 | "notation": "3d6",
29 | "description": "Roll three 6-sided dice"
30 | },
31 | {
32 | "notation": "2d10",
33 | "description": "Roll two 10-sided dice"
34 | }
35 | ]
36 | }
37 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/context/outputs/get_date_example.json:
--------------------------------------------------------------------------------
1 | {
2 | "description": "Example request and response for the get_date tool",
3 | "request": {
4 | "jsonrpc": "2.0",
5 | "method": "tools/call",
6 | "params": {
7 | "name": "get_date",
8 | "arguments": {
9 | "timezone": "UTC"
10 | }
11 | },
12 | "id": 3
13 | },
14 | "response": {
15 | "jsonrpc": "2.0",
16 | "id": 3,
17 | "result": {
18 | "content": [
19 | {
20 | "type": "text",
21 | "text": "🕐 **Current Date & Time**\n📅 Date: **2025-07-07** (Monday)\n⏰ Time: **14:30:25**\n🌍 Timezone: **UTC**\n📋 ISO 8601: `2025-07-07T14:30:25+00:00`\n🔢 Unix Timestamp: `1720360225`"
22 | }
23 | ],
24 | "isError": false
25 | }
26 | },
27 | "examples": {
28 | "valid_requests": [
29 | {
30 | "timezone": "UTC",
31 | "description": "Coordinated Universal Time"
32 | },
33 | {
34 | "timezone": "America/New_York",
35 | "description": "Eastern Time"
36 | },
37 | {
38 | "timezone": "America/Los_Angeles",
39 | "description": "Pacific Time"
40 | },
41 | {
42 | "timezone": "Europe/London",
43 | "description": "British Time"
44 | },
45 | {
46 | "timezone": "Asia/Tokyo",
47 | "description": "Japan Standard Time"
48 | },
49 | {
50 | "timezone": "est",
51 | "description": "Eastern Time alias"
52 | },
53 | {
54 | "timezone": "pst",
55 | "description": "Pacific Time alias"
56 | }
57 | ],
58 | "invalid_requests": [
59 | {
60 | "timezone": "Invalid/Timezone",
61 | "error": "Invalid timezone: 'Invalid/Timezone'. Common timezones: UTC, America/New_York, America/Los_Angeles, America/Chicago, Europe/London, Europe/Paris, Asia/Tokyo, Australia/Sydney. Aliases: utc, gmt, est, pst, cst, mst, edt, pdt, cdt, mdt, bst, cet, jst, aest. Use IANA timezone names (e.g., 'America/New_York') or aliases."
62 | },
63 | {
64 | "timezone": "",
65 | "error": "Timezone cannot be empty"
66 | }
67 | ]
68 | },
69 | "supported_timezones": {
70 | "aliases": {
71 | "utc": "UTC",
72 | "gmt": "UTC",
73 | "est": "America/New_York",
74 | "pst": "America/Los_Angeles",
75 | "cst": "America/Chicago",
76 | "mst": "America/Denver",
77 | "edt": "America/New_York",
78 | "pdt": "America/Los_Angeles",
79 | "cdt": "America/Chicago",
80 | "mdt": "America/Denver",
81 | "bst": "Europe/London",
82 | "cet": "Europe/Paris",
83 | "jst": "Asia/Tokyo",
84 | "aest": "Australia/Sydney"
85 | },
86 | "common_iana_zones": [
87 | "UTC",
88 | "America/New_York",
89 | "America/Los_Angeles",
90 | "America/Chicago",
91 | "America/Denver",
92 | "Europe/London",
93 | "Europe/Paris",
94 | "Europe/Berlin",
95 | "Asia/Tokyo",
96 | "Asia/Shanghai",
97 | "Australia/Sydney"
98 | ]
99 | }
100 | }
--------------------------------------------------------------------------------
/examples/mcp-server-client/context/outputs/get_weather_example.json:
--------------------------------------------------------------------------------
1 | {
2 | "description": "Example request and response for the get_weather tool",
3 | "request": {
4 | "jsonrpc": "2.0",
5 | "method": "tools/call",
6 | "params": {
7 | "name": "get_weather",
8 | "arguments": {
9 | "location": "San Francisco"
10 | }
11 | },
12 | "id": 2
13 | },
14 | "response": {
15 | "jsonrpc": "2.0",
16 | "id": 2,
17 | "result": {
18 | "content": [
19 | {
20 | "type": "text",
21 | "text": "🌤️ **Weather for San Francisco**\n🌡️ Temperature: **18.5°C**\n☁️ Condition: **Partly cloudy**\n💨 Wind Speed: **12.3 km/h**\n💧 Humidity: **65%**\n🕐 Updated: 2025-07-07 14:30 UTC"
22 | }
23 | ],
24 | "isError": false
25 | }
26 | },
27 | "examples": {
28 | "valid_requests": [
29 | {
30 | "location": "London",
31 | "description": "Weather for London city"
32 | },
33 | {
34 | "location": "New York",
35 | "description": "Weather for New York city"
36 | },
37 | {
38 | "location": "37.7749,-122.4194",
39 | "description": "Weather using coordinates (San Francisco)"
40 | },
41 | {
42 | "location": "Tokyo",
43 | "description": "Weather for Tokyo city"
44 | }
45 | ],
46 | "invalid_requests": [
47 | {
48 | "location": "Unknown City",
49 | "error": "Unknown location: 'Unknown City'. Please use coordinates (lat,lon) or one of: berlin, beijing, cairo, chicago, lagos, london, los angeles, madrid, miami, moscow, mumbai, new york, paris, rome, san francisco, seattle, sydney, tokyo, toronto, vancouver"
50 | },
51 | {
52 | "location": "",
53 | "error": "Location cannot be empty"
54 | },
55 | {
56 | "location": "999,999",
57 | "error": "Unknown location: '999,999'. Please use coordinates (lat,lon) or one of: [city list]"
58 | }
59 | ]
60 | },
61 | "supported_cities": [
62 | "San Francisco",
63 | "New York",
64 | "London",
65 | "Paris",
66 | "Tokyo",
67 | "Sydney",
68 | "Los Angeles",
69 | "Chicago",
70 | "Miami",
71 | "Seattle",
72 | "Vancouver",
73 | "Toronto",
74 | "Berlin",
75 | "Rome",
76 | "Madrid",
77 | "Moscow",
78 | "Beijing",
79 | "Mumbai",
80 | "Cairo",
81 | "Lagos"
82 | ]
83 | }
--------------------------------------------------------------------------------
/examples/mcp-server-client/context/outputs/roll_dice_example.json:
--------------------------------------------------------------------------------
1 | {
2 | "description": "Example request and response for the roll_dice tool",
3 | "request": {
4 | "jsonrpc": "2.0",
5 | "method": "tools/call",
6 | "params": {
7 | "name": "roll_dice",
8 | "arguments": {
9 | "notation": "3d6"
10 | }
11 | },
12 | "id": 1
13 | },
14 | "response": {
15 | "jsonrpc": "2.0",
16 | "id": 1,
17 | "result": {
18 | "content": [
19 | {
20 | "type": "text",
21 | "text": "🎲 Rolled 3d6: [4, 2, 6] = **12**"
22 | }
23 | ],
24 | "isError": false
25 | }
26 | },
27 | "examples": {
28 | "valid_requests": [
29 | {
30 | "notation": "1d20",
31 | "description": "Single twenty-sided die"
32 | },
33 | {
34 | "notation": "2d6",
35 | "description": "Two six-sided dice"
36 | },
37 | {
38 | "notation": "4d10",
39 | "description": "Four ten-sided dice"
40 | }
41 | ],
42 | "invalid_requests": [
43 | {
44 | "notation": "d6",
45 | "error": "Invalid dice notation: 'd6'. Expected format: 'XdY' (e.g., '2d6', '1d20')"
46 | },
47 | {
48 | "notation": "0d6",
49 | "error": "Dice count must be greater than 0"
50 | },
51 | {
52 | "notation": "101d6",
53 | "error": "Dice count must not exceed 100"
54 | },
55 | {
56 | "notation": "1d0",
57 | "error": "Number of sides must be greater than 0"
58 | },
59 | {
60 | "notation": "abc",
61 | "error": "Invalid dice notation: 'abc'. Expected format: 'XdY' (e.g., '2d6', '1d20')"
62 | }
63 | ]
64 | }
65 | }
--------------------------------------------------------------------------------
/examples/mcp-server-client/context/outputs/streamlit_error_handling.json:
--------------------------------------------------------------------------------
1 | {
2 | "interaction_type": "streamlit_gui",
3 | "tool_name": "roll_dice",
4 | "timestamp": "2024-01-15T10:36:00Z",
5 | "request": {
6 | "method": "tool_invocation",
7 | "parameters": {
8 | "tool": "roll_dice",
9 | "arguments": {
10 | "notation": "invalid_dice"
11 | }
12 | }
13 | },
14 | "response": {
15 | "success": false,
16 | "result": null,
17 | "error": "Invalid dice notation format: invalid_dice",
18 | "tool_name": "roll_dice",
19 | "arguments": {
20 | "notation": "invalid_dice"
21 | }
22 | },
23 | "execution_time": 0.05,
24 | "gui_state": {
25 | "connected": true,
26 | "server_path": "src/mcp_server/server.py",
27 | "available_tools": ["roll_dice", "get_weather", "get_date"]
28 | },
29 | "error_details": {
30 | "validation_error": "Dice notation must follow pattern: NdN (e.g., 2d6, 1d20)",
31 | "user_input": "invalid_dice",
32 | "suggested_corrections": ["2d6", "1d20", "3d10"]
33 | }
34 | }
--------------------------------------------------------------------------------
/examples/mcp-server-client/context/outputs/streamlit_get_date_interaction.json:
--------------------------------------------------------------------------------
1 | {
2 | "interaction_type": "streamlit_gui",
3 | "tool_name": "get_date",
4 | "timestamp": "2024-01-15T10:34:00Z",
5 | "request": {
6 | "method": "tool_invocation",
7 | "parameters": {
8 | "tool": "get_date",
9 | "arguments": {
10 | "timezone": "America/New_York"
11 | }
12 | }
13 | },
14 | "response": {
15 | "success": true,
16 | "result": {
17 | "datetime": "2024-01-15T05:34:15-05:00",
18 | "timezone": "America/New_York",
19 | "formatted": "Monday, January 15, 2024 at 5:34:15 AM EST",
20 | "unix_timestamp": 1705313655
21 | },
22 | "tool_name": "get_date",
23 | "arguments": {
24 | "timezone": "America/New_York"
25 | }
26 | },
27 | "execution_time": 0.12,
28 | "gui_state": {
29 | "connected": true,
30 | "server_path": "src/mcp_server/server.py",
31 | "available_tools": ["roll_dice", "get_weather", "get_date"]
32 | }
33 | }
--------------------------------------------------------------------------------
/examples/mcp-server-client/context/outputs/streamlit_get_weather_interaction.json:
--------------------------------------------------------------------------------
1 | {
2 | "interaction_type": "streamlit_gui",
3 | "tool_name": "get_weather",
4 | "timestamp": "2024-01-15T10:32:00Z",
5 | "request": {
6 | "method": "tool_invocation",
7 | "parameters": {
8 | "tool": "get_weather",
9 | "arguments": {
10 | "location": "San Francisco"
11 | }
12 | }
13 | },
14 | "response": {
15 | "success": true,
16 | "result": {
17 | "location": "San Francisco, CA",
18 | "temperature": "18°C",
19 | "condition": "Partly cloudy",
20 | "humidity": "65%",
21 | "wind": "12 mph NW"
22 | },
23 | "tool_name": "get_weather",
24 | "arguments": {
25 | "location": "San Francisco"
26 | }
27 | },
28 | "execution_time": 0.45,
29 | "gui_state": {
30 | "connected": true,
31 | "server_path": "src/mcp_server/server.py",
32 | "available_tools": ["roll_dice", "get_weather", "get_date"]
33 | }
34 | }
--------------------------------------------------------------------------------
/examples/mcp-server-client/context/outputs/streamlit_roll_dice_interaction.json:
--------------------------------------------------------------------------------
1 | {
2 | "interaction_type": "streamlit_gui",
3 | "tool_name": "roll_dice",
4 | "timestamp": "2024-01-15T10:30:00Z",
5 | "request": {
6 | "method": "tool_invocation",
7 | "parameters": {
8 | "tool": "roll_dice",
9 | "arguments": {
10 | "notation": "2d6"
11 | }
12 | }
13 | },
14 | "response": {
15 | "success": true,
16 | "result": {
17 | "values": [3, 5],
18 | "total": 8,
19 | "notation": "2d6"
20 | },
21 | "tool_name": "roll_dice",
22 | "arguments": {
23 | "notation": "2d6"
24 | }
25 | },
26 | "execution_time": 0.15,
27 | "gui_state": {
28 | "connected": true,
29 | "server_path": "src/mcp_server/server.py",
30 | "available_tools": ["roll_dice", "get_weather", "get_date"]
31 | }
32 | }
--------------------------------------------------------------------------------
/examples/mcp-server-client/context/templates/feature_base.md:
--------------------------------------------------------------------------------
1 | # Feature description for: [ Initial template for new features ]
2 |
3 | ## FEATURE
4 |
5 | [Insert your feature here]
6 |
7 | ## EXAMPLES
8 |
9 | [Provide and explain examples that you have in the `/context/examples` folder]
10 |
11 | ## DOCUMENTATION
12 |
13 | [List out any documentation (web pages, sources for an MCP server like Crawl4AI RAG, etc.) that will need to be referenced during development]
14 |
15 | ## OTHER CONSIDERATIONS
16 |
17 | [Any other considerations or specific requirements - great place to include gotchas that you see AI coding assistants miss with your projects a lot]
18 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/context/templates/prp_base.md:
--------------------------------------------------------------------------------
1 | # "Base PRP Template v2 - Context-Rich with Validation Loops"
2 |
3 | ## Purpose
4 |
5 | Product Requirements Prompt (PRP) Template optimized for AI agents to implement features with sufficient context and self-validation capabilities to achieve working code through iterative refinement.
6 |
7 | ## Core Principles
8 |
9 | 1. **Context is King**: Include ALL necessary documentation, examples, and caveats
10 | 2. **Validation Loops**: Provide executable tests/lints the AI can run and fix
11 | 3. **Information Dense**: Use keywords and patterns from the codebase
12 | 4. **Progressive Success**: Start simple, validate, then enhance
13 | 5. **Global rules**: Be sure to follow all rules in CLAUDE.md
14 |
15 | ---
16 |
17 | ## Goal
18 |
19 | [What needs to be built - be specific about the end state and desires]
20 |
21 | ## Why
22 |
23 | - [Business value and user impact]
24 | - [Integration with existing features]
25 | - [Problems this solves and for whom]
26 |
27 | ## What
28 |
29 | [User-visible behavior and technical requirements]
30 |
31 | ### Success Criteria
32 |
33 | - [ ] [Specific measurable outcomes]
34 |
35 | ## All Needed Context
36 |
37 | ### Documentation & References (list all context needed to implement the feature)
38 |
39 | ```yaml
40 | # MUST READ - Include these in your context window
41 | - url: [Official API docs URL]
42 | why: [Specific sections/methods you'll need]
43 |
44 | - file: [path/to/example.py]
45 | why: [Pattern to follow, gotchas to avoid]
46 |
47 | - doc: [Library documentation URL]
48 | section: [Specific section about common pitfalls]
49 | critical: [Key insight that prevents common errors]
50 |
51 | - docfile: [PRPs/ai_docs/file.md]
52 | why: [docs that the user has pasted in to the project]
53 | ```
54 |
55 | ### Current Codebase tree (run `tree` in the root of the project) to get an overview of the codebase
56 |
57 | ```bash
58 |
59 | ```
60 |
61 | ### Desired Codebase tree with files to be added and responsibility of file
62 |
63 | ```bash
64 |
65 | ```
66 |
67 | ### Known Gotchas of our codebase & Library Quirks
68 |
69 | ```python
70 | # CRITICAL: [Library name] requires [specific setup]
71 | # Example: FastAPI requires async functions for endpoints
72 | # Example: This ORM doesn't support batch inserts over 1000 records
73 | # Example: We use pydantic v2 and
74 | ```
75 |
76 | ## Implementation Blueprint
77 |
78 | ### Data models and structure
79 |
80 | Create the core data models, we ensure type safety and consistency.
81 |
82 | ```python
83 | Examples:
84 | - orm models
85 | - pydantic models
86 | - pydantic schemas
87 | - pydantic validators
88 | ```
89 |
90 | ### list of tasks to be completed to fullfill the PRP in the order they should be completed
91 |
92 | ```yaml
93 | Task 1:
94 | MODIFY src/existing_module.py:
95 | - FIND pattern: "class OldImplementation"
96 | - INJECT after line containing "def __init__"
97 | - PRESERVE existing method signatures
98 |
99 | CREATE src/new_feature.py:
100 | - MIRROR pattern from: src/similar_feature.py
101 | - MODIFY class name and core logic
102 | - KEEP error handling pattern identical
103 |
104 | ...(...)
105 |
106 | Task N:
107 | ...
108 |
109 | ```
110 |
111 | ### Per task pseudocode as needed added to each task
112 |
113 | ```python
114 |
115 | # Task 1
116 | # Pseudocode with CRITICAL details dont write entire code
117 | async def new_feature(param: str) -> Result:
118 | # PATTERN: Always validate input first (see src/validators.py)
119 | validated = validate_input(param) # raises ValidationError
120 |
121 | # GOTCHA: This library requires connection pooling
122 | async with get_connection() as conn: # see src/db/pool.py
123 | # PATTERN: Use existing retry decorator
124 | @retry(attempts=3, backoff=exponential)
125 | async def _inner():
126 | # CRITICAL: API returns 429 if >10 req/sec
127 | await rate_limiter.acquire()
128 | return await external_api.call(validated)
129 |
130 | result = await _inner()
131 |
132 | # PATTERN: Standardized response format
133 | return format_response(result) # see src/utils/responses.py
134 | ```
135 |
136 | ### Integration Points
137 |
138 | ```yaml
139 | DATABASE:
140 | - migration: "Add column 'feature_enabled' to users table"
141 | - index: "CREATE INDEX idx_feature_lookup ON users(feature_id)"
142 |
143 | CONFIG:
144 | - add to: config/settings.py
145 | - pattern: "FEATURE_TIMEOUT = int(os.getenv('FEATURE_TIMEOUT', '30'))"
146 |
147 | ROUTES:
148 | - add to: src/api/routes.py
149 | - pattern: "router.include_router(feature_router, prefix='/feature')"
150 | ```
151 |
152 | ## Validation Loop
153 |
154 | ### Level 1: Syntax & Style
155 |
156 | ```bash
157 | # Run these FIRST - fix any errors before proceeding
158 | ruff check src/new_feature.py --fix # Auto-fix what's possible
159 | mypy src/new_feature.py # Type checking
160 |
161 | # Expected: No errors. If errors, READ the error and fix.
162 | ```
163 |
164 | ### Level 2: Unit Tests each new feature/file/function use existing test patterns
165 |
166 | ```python
167 | # CREATE test_new_feature.py with these test cases:
168 | def test_happy_path():
169 | """Basic functionality works"""
170 | result = new_feature("valid_input")
171 | assert result.status == "success"
172 |
173 | def test_validation_error():
174 | """Invalid input raises ValidationError"""
175 | with pytest.raises(ValidationError):
176 | new_feature("")
177 |
178 | def test_external_api_timeout():
179 | """Handles timeouts gracefully"""
180 | with mock.patch('external_api.call', side_effect=TimeoutError):
181 | result = new_feature("valid")
182 | assert result.status == "error"
183 | assert "timeout" in result.message
184 | ```
185 |
186 | ```bash
187 | # Run and iterate until passing:
188 | uv run pytest test_new_feature.py -v
189 | # If failing: Read error, understand root cause, fix code, re-run (never mock to pass)
190 | ```
191 |
192 | ### Level 3: Integration Test
193 |
194 | ```bash
195 | # Start the service
196 | uv run python -m src.main --dev
197 |
198 | # Test the endpoint
199 | curl -X POST http://localhost:8000/feature \
200 | -H "Content-Type: application/json" \
201 | -d '{"param": "test_value"}'
202 |
203 | # Expected: {"status": "success", "data": {...}}
204 | # If error: Check logs at logs/app.log for stack trace
205 | ```
206 |
207 | ## Final validation Checklist
208 |
209 | - [ ] All tests pass: `uv run pytest tests/ -v`
210 | - [ ] No linting errors: `uv run ruff check src/`
211 | - [ ] No type errors: `uv run mypy src/`
212 | - [ ] Manual test successful: [specific curl/command]
213 | - [ ] Error cases handled gracefully
214 | - [ ] Logs are informative but not verbose
215 | - [ ] Documentation updated if needed
216 |
217 | ---
218 |
219 | ## Anti-Patterns to Avoid
220 |
221 | - ❌ Don't create new patterns when existing ones work
222 | - ❌ Don't skip validation because "it should work"
223 | - ❌ Don't ignore failing tests - fix them
224 | - ❌ Don't use sync functions in async context
225 | - ❌ Don't hardcode values that should be config
226 | - ❌ Don't catch all exceptions - be specific
227 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["hatchling"]
3 | build-backend = "hatchling.build"
4 |
5 | [project]
6 | version = "0.0.1"
7 | name = "context-engineering-template"
8 | description = "Assess the effectiveness of agentic AI systems across various use cases focusing on agnostic metrics that measure core agentic capabilities."
9 | authors = [
10 | {name = "qte77", email = "qte@77.gh"}
11 | ]
12 | readme = "README.md"
13 | requires-python = "==3.13.*"
14 | license = "bsd-3-clause"
15 | dependencies = [
16 | "mcp[cli]>=1.10.0",
17 | "httpx>=0.25.0",
18 | "pydantic>=2.0.0",
19 | "streamlit>=1.28.0",
20 | ]
21 |
22 | # [project.urls]
23 | # Documentation = ""
24 |
25 | [dependency-groups]
26 | dev = [
27 | "mypy>=1.16.0",
28 | "ruff>=0.11.12",
29 | ]
30 | test = [
31 | "pytest>=7.0.0",
32 | "pytest-asyncio>=0.21.0",
33 | "pytest-httpx>=0.28.0",
34 | ]
35 | docs = [
36 | "griffe>=1.5.1",
37 | "mkdocs>=1.6.1",
38 | "mkdocs-awesome-pages-plugin>=2.9.3",
39 | "mkdocs-gen-files>=0.5.0",
40 | "mkdocs-literate-nav>=0.6.1",
41 | "mkdocs-material>=9.5.44",
42 | "mkdocs-section-index>=0.3.8",
43 | "mkdocstrings[python]>=0.27.0",
44 | ]
45 |
46 | [tool.uv]
47 | package = true
48 | exclude-newer = "2025-07-06T00:00:00Z"
49 |
50 | [tool.hatch.build.targets.wheel]
51 | only-include = ["/README.md"]
52 |
53 | [tool.hatch.build.targets.sdist]
54 | include = ["/README.md", "/Makefile", "/tests"]
55 |
56 | [tool.ruff]
57 | target-version = "py313"
58 | src = ["src", "tests"]
59 |
60 | [tool.ruff.format]
61 | docstring-code-format = true
62 |
63 | [tool.ruff.lint]
64 | # ignore = ["E203"] # Whitespace before ':'
65 | unfixable = ["B"]
66 | select = [
67 | # pycodestyle
68 | "E",
69 | # Pyflakes
70 | "F",
71 | # pyupgrade
72 | "UP",
73 | # isort
74 | "I",
75 | ]
76 |
77 | [tool.ruff.lint.isort]
78 | known-first-party = ["src", "tests"]
79 |
80 | [tool.ruff.lint.pydocstyle]
81 | convention = "google"
82 |
83 | [tool.mypy]
84 | python_version = "3.13"
85 | strict = true
86 | disallow_untyped_defs = true
87 | disallow_any_generics = true
88 | warn_redundant_casts = true
89 | warn_unused_ignores = true
90 | warn_return_any = true
91 | warn_unreachable = true
92 | show_error_codes = true
93 | namespace_packages = true
94 | explicit_package_bases = true
95 | mypy_path = "src"
96 |
97 | [tool.pytest.ini_options]
98 | addopts = "--strict-markers"
99 | # "function", "class", "module", "package", "session"
100 | asyncio_default_fixture_loop_scope = "function"
101 | pythonpath = ["src"]
102 | testpaths = ["tests/"]
103 |
104 | [tool.coverage]
105 | [tool.coverage.run]
106 | include = [
107 | "tests/**/*.py",
108 | ]
109 | # omit = []
110 | # branch = true
111 |
112 | [tool.coverage.report]
113 | show_missing = true
114 | exclude_lines = [
115 | # 'pragma: no cover',
116 | 'raise AssertionError',
117 | 'raise NotImplementedError',
118 | ]
119 | omit = [
120 | 'env/*',
121 | 'venv/*',
122 | '.venv/*',
123 | '*/virtualenv/*',
124 | '*/virtualenvs/*',
125 | '*/tests/*',
126 | ]
127 |
128 | [tool.bumpversion]
129 | current_version = "0.0.1"
130 | parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)"
131 | serialize = ["{major}.{minor}.{patch}"]
132 | commit = true
133 | tag = true
134 | allow_dirty = false
135 | ignore_missing_version = false
136 | sign_tags = false
137 | tag_name = "v{new_version}"
138 | tag_message = "Bump version: {current_version} → {new_version}"
139 | message = "Bump version: {current_version} → {new_version}"
140 | commit_args = ""
141 |
142 | [[tool.bumpversion.files]]
143 | filename = "pyproject.toml"
144 | search = 'version = "{current_version}"'
145 | replace = 'version = "{new_version}"'
146 |
147 | [[tool.bumpversion.files]]
148 | filename = "src/__init__.py"
149 | search = '__version__ = "{current_version}"'
150 | replace = '__version__ = "{new_version}"'
151 |
152 | [[tool.bumpversion.files]]
153 | filename = "README.md"
154 | search = "version-{current_version}-58f4c2"
155 | replace = "version-{new_version}-58f4c2"
156 |
157 | [[tool.bumpversion.files]]
158 | filename = "CHANGELOG.md"
159 | search = """
160 | ## [Unreleased]
161 | """
162 | replace = """
163 | ## [Unreleased]
164 |
165 | ## [{new_version}] - {now:%Y-%m-%d}
166 | """
--------------------------------------------------------------------------------
/examples/mcp-server-client/src/__init__.py:
--------------------------------------------------------------------------------
1 | """Defines the application version."""
2 |
3 | __version__ = "0.0.1"
4 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/src/gui/__init__.py:
--------------------------------------------------------------------------------
1 | """GUI module for MCP client interface."""
2 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/src/gui/app.py:
--------------------------------------------------------------------------------
1 | """Main Streamlit application for MCP Tool Showcase."""
2 |
3 | import sys
4 | from pathlib import Path
5 |
6 | import streamlit as st
7 |
8 | from src.gui.components.connection import ConnectionManager
9 | from src.gui.components.history import HistoryManager
10 | from src.gui.components.tool_forms import ToolForms
11 | from src.gui.models.gui_models import GUISession
12 |
13 | # Add project root to Python path
14 | project_root = Path(__file__).parent.parent.parent
15 | sys.path.insert(0, str(project_root))
16 |
17 |
18 | def main() -> None:
19 | """Main Streamlit application entry point."""
20 | st.set_page_config(
21 | page_title="MCP Tool Showcase",
22 | page_icon="🛠️",
23 | layout="wide",
24 | initial_sidebar_state="expanded",
25 | )
26 |
27 | # Initialize session state
28 | if "gui_session" not in st.session_state:
29 | st.session_state.gui_session = GUISession()
30 |
31 | if "mcp_client" not in st.session_state:
32 | st.session_state.mcp_client = None
33 |
34 | # Main layout with sidebar
35 | with st.sidebar:
36 | st.title("🛠️ MCP Tool Showcase")
37 | st.markdown("---")
38 |
39 | # Connection management
40 | connection_manager = ConnectionManager()
41 | connection_manager.render()
42 |
43 | # Main content area
44 | col1, col2 = st.columns([1, 1])
45 |
46 | with col1:
47 | st.header("Tool Invocation")
48 | if st.session_state.gui_session.connected:
49 | tool_forms = ToolForms()
50 | tool_forms.render()
51 | else:
52 | st.info("Please connect to the MCP server first")
53 |
54 | with col2:
55 | st.header("Request/Response")
56 | if st.session_state.gui_session.interaction_history:
57 | latest_interaction = st.session_state.gui_session.interaction_history[-1]
58 | st.subheader("Latest Request")
59 | st.json(latest_interaction.request_payload)
60 | st.subheader("Latest Response")
61 | st.json(latest_interaction.response_payload)
62 | else:
63 | st.info("No interactions yet")
64 |
65 | # History section
66 | st.header("Interaction History")
67 | history_manager = HistoryManager()
68 | history_manager.render()
69 |
70 |
71 | if __name__ == "__main__":
72 | main()
73 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/src/gui/components/__init__.py:
--------------------------------------------------------------------------------
1 | """GUI components for reusable interface elements."""
2 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/src/gui/components/connection.py:
--------------------------------------------------------------------------------
1 | """Connection management component for MCP server."""
2 |
3 | import logging
4 |
5 | import streamlit as st
6 |
7 | from src.gui.utils.mcp_wrapper import MCPConnectionManager
8 |
9 | logger = logging.getLogger(__name__)
10 |
11 |
12 | class ConnectionManager:
13 | """Manages MCP server connection in GUI."""
14 |
15 | def render(self) -> None:
16 | """Render connection management interface."""
17 | st.subheader("Connection Status")
18 |
19 | # Display current status
20 | if st.session_state.gui_session.connected:
21 | st.success("✅ Connected to MCP Server")
22 | st.write(f"Server: {st.session_state.gui_session.server_path}")
23 | tools = ", ".join(st.session_state.gui_session.available_tools)
24 | st.write(f"Available Tools: {tools}")
25 |
26 | # Health check button
27 | if st.button("Health Check"):
28 | self._perform_health_check()
29 |
30 | # Disconnect button
31 | if st.button("Disconnect"):
32 | self._disconnect()
33 | else:
34 | st.error("❌ Not Connected")
35 |
36 | # Connection form
37 | server_path = st.text_input(
38 | "Server Path",
39 | value=st.session_state.gui_session.server_path,
40 | help="Path to MCP server script",
41 | )
42 |
43 | if st.button("Connect"):
44 | self._connect(server_path)
45 |
46 | def _connect(self, server_path: str) -> None:
47 | """Connect to MCP server."""
48 | try:
49 | with st.spinner("Connecting to server..."):
50 | # Create connection manager if it doesn't exist
51 | if "mcp_connection_manager" not in st.session_state:
52 | st.session_state.mcp_connection_manager = MCPConnectionManager()
53 |
54 | manager = st.session_state.mcp_connection_manager
55 |
56 | # Connect to server
57 | success = manager.connect(server_path)
58 |
59 | if success:
60 | st.session_state.gui_session.connected = True
61 | st.session_state.gui_session.server_path = server_path
62 | st.session_state.gui_session.available_tools = (
63 | manager.available_tools
64 | )
65 |
66 | st.success("Connected successfully!")
67 | st.rerun()
68 | else:
69 | st.error("Failed to connect to server")
70 |
71 | except Exception as e:
72 | logger.error(f"Connection failed: {e}")
73 | st.error(f"Connection failed: {str(e)}")
74 |
75 | def _disconnect(self) -> None:
76 | """Disconnect from MCP server."""
77 | if "mcp_connection_manager" in st.session_state:
78 | try:
79 | manager = st.session_state.mcp_connection_manager
80 | manager.disconnect()
81 |
82 | st.session_state.gui_session.connected = False
83 | st.session_state.gui_session.available_tools = []
84 | st.success("Disconnected successfully!")
85 | st.rerun()
86 | except Exception as e:
87 | logger.error(f"Disconnect failed: {e}")
88 | st.error(f"Disconnect failed: {str(e)}")
89 |
90 | def _perform_health_check(self) -> None:
91 | """Perform health check on connection."""
92 | if "mcp_connection_manager" in st.session_state:
93 | try:
94 | manager = st.session_state.mcp_connection_manager
95 | health = manager.health_check()
96 |
97 | if health:
98 | st.success("Health check passed!")
99 | else:
100 | st.warning("Health check failed - connection may be unhealthy")
101 | except Exception as e:
102 | logger.error(f"Health check error: {e}")
103 | st.error(f"Health check error: {str(e)}")
104 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/src/gui/components/history.py:
--------------------------------------------------------------------------------
1 | """History management component for interaction tracking."""
2 |
3 | import streamlit as st
4 |
5 | from src.gui.models.gui_models import GUIInteraction
6 |
7 |
8 | class HistoryManager:
9 | """Manages session history display and interaction."""
10 |
11 | def render(self) -> None:
12 | """Render interaction history interface."""
13 | history = st.session_state.gui_session.interaction_history
14 |
15 | if not history:
16 | st.info(
17 | "No interactions yet. Connect to a server and use tools to see history."
18 | )
19 | return
20 |
21 | # History controls
22 | col1, col2 = st.columns([1, 1])
23 |
24 | with col1:
25 | st.write(f"**Total Interactions:** {len(history)}")
26 |
27 | with col2:
28 | if st.button("Clear History"):
29 | st.session_state.gui_session.interaction_history = []
30 | st.rerun()
31 |
32 | # Display history
33 | for i, interaction in enumerate(reversed(history)):
34 | self._render_interaction(i, interaction)
35 |
36 | def _render_interaction(self, index: int, interaction: GUIInteraction) -> None:
37 | """Render a single interaction."""
38 | # Create expander with status indicator
39 | status_icon = "✅" if interaction.success else "❌"
40 | timestamp = interaction.timestamp.strftime("%H:%M:%S")
41 |
42 | with st.expander(
43 | f"{status_icon} {interaction.tool_name} - {timestamp}",
44 | expanded=index == 0, # Expand latest interaction
45 | ):
46 | # Basic info
47 | col1, col2 = st.columns([1, 1])
48 |
49 | with col1:
50 | st.write(f"**Tool:** {interaction.tool_name}")
51 | st.write(
52 | f"**Status:** {'Success' if interaction.success else 'Failed'}"
53 | )
54 | if interaction.execution_time:
55 | st.write(f"**Execution Time:** {interaction.execution_time:.2f}s")
56 |
57 | with col2:
58 | timestamp_str = interaction.timestamp.strftime("%Y-%m-%d %H:%M:%S")
59 | st.write(f"**Timestamp:** {timestamp_str}")
60 | if interaction.error_message:
61 | st.write(f"**Error:** {interaction.error_message}")
62 |
63 | # Request/Response payloads
64 | req_col, resp_col = st.columns([1, 1])
65 |
66 | with req_col:
67 | st.subheader("Request")
68 | st.json(interaction.request_payload)
69 |
70 | with resp_col:
71 | st.subheader("Response")
72 | st.json(interaction.response_payload)
73 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/src/gui/components/tool_forms.py:
--------------------------------------------------------------------------------
1 | """Tool-specific form components for MCP tools."""
2 |
3 | import logging
4 | import re
5 | import time
6 |
7 | import streamlit as st
8 |
9 | from src.gui.models.gui_models import GUIInteraction
10 |
11 | logger = logging.getLogger(__name__)
12 |
13 |
14 | class ToolForms:
15 | """Manages tool-specific form interfaces."""
16 |
17 | def render(self) -> None:
18 | """Render tool selection and forms."""
19 | available_tools = st.session_state.gui_session.available_tools
20 |
21 | if not available_tools:
22 | st.warning("No tools available")
23 | return
24 |
25 | # Tool selection
26 | selected_tool = st.selectbox(
27 | "Select Tool", options=available_tools, help="Choose a tool to invoke"
28 | )
29 |
30 | # Tool-specific forms
31 | if selected_tool == "roll_dice":
32 | self._render_dice_form()
33 | elif selected_tool == "get_weather":
34 | self._render_weather_form()
35 | elif selected_tool == "get_date":
36 | self._render_date_form()
37 |
38 | def _render_dice_form(self) -> None:
39 | """Render dice rolling form."""
40 | with st.form("dice_form"):
41 | st.subheader("🎲 Roll Dice")
42 |
43 | notation = st.text_input(
44 | "Dice Notation",
45 | value="2d6",
46 | help="Enter dice notation (e.g., 2d6, 1d20, 3d10)",
47 | )
48 |
49 | # Help text with examples
50 | st.caption("Examples: 1d20 (single 20-sided die), 3d6 (three 6-sided dice)")
51 |
52 | submitted = st.form_submit_button("Roll Dice")
53 |
54 | if submitted:
55 | if self._validate_dice_notation(notation):
56 | self._execute_tool("roll_dice", {"notation": notation})
57 | else:
58 | st.error("Invalid dice notation. Use format like '2d6' or '1d20'")
59 |
60 | def _render_weather_form(self) -> None:
61 | """Render weather lookup form."""
62 | with st.form("weather_form"):
63 | st.subheader("🌤️ Get Weather")
64 |
65 | location = st.text_input(
66 | "Location",
67 | value="San Francisco",
68 | help="Enter city name or coordinates (lat,lon)",
69 | )
70 |
71 | # Common location examples
72 | st.caption("Examples: London, New York, 37.7749,-122.4194")
73 |
74 | submitted = st.form_submit_button("Get Weather")
75 |
76 | if submitted:
77 | if location.strip():
78 | self._execute_tool("get_weather", {"location": location})
79 | else:
80 | st.error("Please enter a location")
81 |
82 | def _render_date_form(self) -> None:
83 | """Render date/time lookup form."""
84 | with st.form("date_form"):
85 | st.subheader("🕐 Get Date & Time")
86 |
87 | timezone = st.selectbox(
88 | "Timezone",
89 | options=[
90 | "UTC",
91 | "America/New_York",
92 | "America/Los_Angeles",
93 | "Europe/London",
94 | "Asia/Tokyo",
95 | "Australia/Sydney",
96 | ],
97 | help="Select timezone or enter custom IANA timezone",
98 | )
99 |
100 | custom_timezone = st.text_input(
101 | "Custom Timezone (optional)",
102 | placeholder="e.g., America/Chicago",
103 | help="Enter custom IANA timezone identifier",
104 | )
105 |
106 | submitted = st.form_submit_button("Get Date & Time")
107 |
108 | if submitted:
109 | tz = custom_timezone.strip() if custom_timezone.strip() else timezone
110 | self._execute_tool("get_date", {"timezone": tz})
111 |
112 | def _execute_tool(self, tool_name: str, arguments: dict[str, str]) -> None:
113 | """Execute tool and update GUI state."""
114 | if "mcp_connection_manager" not in st.session_state:
115 | st.error("Not connected to server")
116 | return
117 |
118 | try:
119 | with st.spinner(f"Executing {tool_name}..."):
120 | # Time execution for performance metrics
121 | start_time = time.time()
122 |
123 | # Use connection manager
124 | manager = st.session_state.mcp_connection_manager
125 | result = manager.invoke_tool(tool_name, arguments)
126 |
127 | execution_time = time.time() - start_time
128 |
129 | # Create interaction record
130 | interaction = GUIInteraction(
131 | tool_name=tool_name,
132 | arguments=arguments,
133 | request_payload={"tool": tool_name, "arguments": arguments},
134 | response_payload=result,
135 | success=result.get("success", False),
136 | error_message=result.get("error")
137 | if not result.get("success", False)
138 | else None,
139 | execution_time=execution_time,
140 | )
141 |
142 | # Update session state
143 | st.session_state.gui_session.interaction_history.append(interaction)
144 |
145 | # Display result
146 | if result.get("success", False):
147 | st.success(f"✅ {tool_name} executed successfully!")
148 | st.json(result)
149 | else:
150 | st.error(
151 | f"❌ {tool_name} failed: {result.get('error', 'Unknown error')}"
152 | )
153 |
154 | st.rerun()
155 |
156 | except Exception as e:
157 | logger.error(f"Tool execution error: {e}")
158 | st.error(f"Execution error: {str(e)}")
159 |
160 | def _validate_dice_notation(self, notation: str) -> bool:
161 | """Validate dice notation format."""
162 | pattern = r"^(\d+)d(\d+)$"
163 | return bool(re.match(pattern, notation.strip().lower()))
164 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/src/gui/models/__init__.py:
--------------------------------------------------------------------------------
1 | """GUI models for state management and data validation."""
2 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/src/gui/models/gui_models.py:
--------------------------------------------------------------------------------
1 | """GUI-specific models for state management and type safety."""
2 |
3 | from datetime import datetime
4 | from typing import Any
5 |
6 | from pydantic import BaseModel, Field
7 |
8 |
9 | class GUIInteraction(BaseModel):
10 | """Single tool interaction record for GUI history."""
11 |
12 | timestamp: datetime = Field(default_factory=datetime.now)
13 | tool_name: str
14 | arguments: dict[str, Any]
15 | request_payload: dict[str, Any]
16 | response_payload: dict[str, Any]
17 | success: bool
18 | error_message: str | None = None
19 | execution_time: float | None = None
20 |
21 |
22 | class GUISession(BaseModel):
23 | """GUI session state management."""
24 |
25 | connected: bool = False
26 | server_path: str = "src/mcp_server/server.py"
27 | available_tools: list[str] = Field(default_factory=list)
28 | interaction_history: list[GUIInteraction] = Field(default_factory=list)
29 | current_tool: str | None = None
30 |
31 |
32 | class ConnectionStatus(BaseModel):
33 | """Connection status information."""
34 |
35 | connected: bool = False
36 | server_path: str
37 | last_health_check: datetime | None = None
38 | available_tools: list[str] = Field(default_factory=list)
39 | error_message: str | None = None
40 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/src/gui/utils/__init__.py:
--------------------------------------------------------------------------------
1 | """GUI utilities for formatting and validation."""
2 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/src/gui/utils/async_helper.py:
--------------------------------------------------------------------------------
1 | """Async helper utilities for Streamlit GUI."""
2 |
3 | import asyncio
4 | import threading
5 | from collections.abc import Awaitable
6 | from typing import TypeVar
7 |
8 | T = TypeVar("T")
9 |
10 |
11 | def run_async(coro: Awaitable[T]) -> T:
12 | """Run an async function in Streamlit context.
13 |
14 | This handles the case where Streamlit might already have an event loop running.
15 |
16 | Args:
17 | coro: The coroutine to run
18 |
19 | Returns:
20 | The result of the coroutine
21 | """
22 | try:
23 | # Try to get the current event loop
24 | # loop = asyncio.get_running_loop()
25 |
26 | # If we're already in an event loop, we need to run in a new thread
27 | # with its own event loop
28 | result = None
29 | exception = None
30 |
31 | def run_in_new_loop():
32 | nonlocal result, exception
33 | try:
34 | new_loop = asyncio.new_event_loop()
35 | asyncio.set_event_loop(new_loop)
36 | try:
37 | result = new_loop.run_until_complete(coro)
38 | finally:
39 | new_loop.close()
40 | except Exception as e:
41 | exception = e
42 |
43 | thread = threading.Thread(target=run_in_new_loop)
44 | thread.start()
45 | thread.join()
46 |
47 | if exception:
48 | raise exception
49 | return result
50 |
51 | except RuntimeError:
52 | # No event loop running, can use asyncio.run directly
53 | return asyncio.run(coro)
54 |
55 |
56 | class AsyncContextManager:
57 | """Helper to manage async context across multiple calls."""
58 |
59 | def __init__(self):
60 | self._client = None
61 | self._loop = None
62 | self._thread = None
63 |
64 | def run_async(self, coro: Awaitable[T]) -> T:
65 | """Run async function maintaining context."""
66 | return run_async(coro)
67 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/src/gui/utils/formatting.py:
--------------------------------------------------------------------------------
1 | """Formatting utilities for GUI display."""
2 |
3 | import json
4 | from typing import Any
5 |
6 |
7 | def format_json_for_display(data: Any, indent: int = 2) -> str:
8 | """Format JSON data for display with proper indentation.
9 |
10 | Args:
11 | data: Data to format as JSON
12 | indent: Number of spaces for indentation
13 |
14 | Returns:
15 | Formatted JSON string
16 | """
17 | try:
18 | return json.dumps(data, indent=indent, ensure_ascii=False)
19 | except (TypeError, ValueError) as e:
20 | return f"Error formatting JSON: {str(e)}"
21 |
22 |
23 | def format_error_message(error: str) -> str:
24 | """Format error messages for user-friendly display.
25 |
26 | Args:
27 | error: Raw error message
28 |
29 | Returns:
30 | Formatted error message
31 | """
32 | # Remove common Python error prefixes
33 | error = error.replace("Exception: ", "")
34 | error = error.replace("Error: ", "")
35 |
36 | # Capitalize first letter
37 | if error:
38 | error = error[0].upper() + error[1:]
39 |
40 | return error
41 |
42 |
43 | def format_execution_time(seconds: float) -> str:
44 | """Format execution time for display.
45 |
46 | Args:
47 | seconds: Execution time in seconds
48 |
49 | Returns:
50 | Formatted time string
51 | """
52 | if seconds < 1:
53 | return f"{seconds * 1000:.0f}ms"
54 | elif seconds < 60:
55 | return f"{seconds:.2f}s"
56 | else:
57 | minutes = int(seconds // 60)
58 | remaining_seconds = seconds % 60
59 | return f"{minutes}m {remaining_seconds:.1f}s"
60 |
61 |
62 | def truncate_text(text: str, max_length: int = 100) -> str:
63 | """Truncate text with ellipsis if too long.
64 |
65 | Args:
66 | text: Text to truncate
67 | max_length: Maximum length before truncation
68 |
69 | Returns:
70 | Truncated text with ellipsis if needed
71 | """
72 | if len(text) <= max_length:
73 | return text
74 | return text[: max_length - 3] + "..."
75 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/src/gui/utils/mcp_wrapper.py:
--------------------------------------------------------------------------------
1 | """MCP client wrapper for Streamlit GUI with persistent connection."""
2 |
3 | import asyncio
4 | import logging
5 | import threading
6 | import time
7 | from queue import Empty, Queue
8 | from typing import Any
9 |
10 | from src.mcp_client.client import MCPClient
11 |
12 | logger = logging.getLogger(__name__)
13 |
14 |
15 | class MCPConnectionManager:
16 | """Manages a persistent MCP connection in a background thread."""
17 |
18 | def __init__(self):
19 | self._client = None
20 | self._thread = None
21 | self._loop = None
22 | self._connected = False
23 | self._available_tools = []
24 | self._request_queue = Queue()
25 | self._response_queue = Queue()
26 | self._shutdown_event = threading.Event()
27 |
28 | def connect(self, server_path: str) -> bool:
29 | """Connect to MCP server in background thread."""
30 | if self._connected:
31 | self.disconnect()
32 |
33 | self._shutdown_event.clear()
34 | self._thread = threading.Thread(
35 | target=self._run_connection, args=(server_path,)
36 | )
37 | self._thread.daemon = True
38 | self._thread.start()
39 |
40 | # Wait for connection to establish (with timeout)
41 | start_time = time.time()
42 | while not self._connected and time.time() - start_time < 10:
43 | time.sleep(0.1)
44 |
45 | return self._connected
46 |
47 | def disconnect(self) -> None:
48 | """Disconnect from MCP server."""
49 | if self._thread and self._thread.is_alive():
50 | self._shutdown_event.set()
51 | # Send disconnect command
52 | self._request_queue.put({"action": "disconnect"})
53 | self._thread.join(timeout=5)
54 |
55 | self._connected = False
56 | self._available_tools = []
57 |
58 | def invoke_tool(self, tool_name: str, arguments: dict[str, Any]) -> dict[str, Any]:
59 | """Invoke a tool and return the result."""
60 | if not self._connected:
61 | return {
62 | "success": False,
63 | "error": "Not connected to server",
64 | "tool_name": tool_name,
65 | "arguments": arguments,
66 | }
67 |
68 | # Send request
69 | request_id = f"{tool_name}_{time.time()}"
70 | self._request_queue.put(
71 | {
72 | "action": "invoke_tool",
73 | "id": request_id,
74 | "tool_name": tool_name,
75 | "arguments": arguments,
76 | }
77 | )
78 |
79 | # Wait for response (with timeout)
80 | start_time = time.time()
81 | while time.time() - start_time < 30: # 30 second timeout
82 | try:
83 | response = self._response_queue.get(timeout=1)
84 | if response.get("id") == request_id:
85 | return response.get(
86 | "result",
87 | {
88 | "success": False,
89 | "error": "No result in response",
90 | "tool_name": tool_name,
91 | "arguments": arguments,
92 | },
93 | )
94 | except Empty:
95 | continue
96 |
97 | return {
98 | "success": False,
99 | "error": "Request timeout",
100 | "tool_name": tool_name,
101 | "arguments": arguments,
102 | }
103 |
104 | def health_check(self) -> bool:
105 | """Check if connection is healthy."""
106 | if not self._connected:
107 | return False
108 |
109 | # Send health check request
110 | request_id = f"health_{time.time()}"
111 | self._request_queue.put({"action": "health_check", "id": request_id})
112 |
113 | # Wait for response
114 | start_time = time.time()
115 | while time.time() - start_time < 5: # 5 second timeout
116 | try:
117 | response = self._response_queue.get(timeout=1)
118 | if response.get("id") == request_id:
119 | return response.get("result", False)
120 | except Empty:
121 | continue
122 |
123 | return False
124 |
125 | @property
126 | def connected(self) -> bool:
127 | """Check if connected."""
128 | return self._connected
129 |
130 | @property
131 | def available_tools(self) -> list[str]:
132 | """Get available tools."""
133 | return self._available_tools.copy()
134 |
135 | def _run_connection(self, server_path: str) -> None:
136 | """Run the connection in background thread with its own event loop."""
137 | try:
138 | # Create new event loop for this thread
139 | self._loop = asyncio.new_event_loop()
140 | asyncio.set_event_loop(self._loop)
141 |
142 | # Run the connection handler
143 | self._loop.run_until_complete(self._connection_handler(server_path))
144 |
145 | except Exception as e:
146 | logger.error(f"Connection thread error: {e}")
147 | finally:
148 | if self._loop:
149 | self._loop.close()
150 | self._connected = False
151 |
152 | async def _connection_handler(self, server_path: str) -> None:
153 | """Handle the MCP connection and requests."""
154 | try:
155 | # Create and connect client
156 | self._client = MCPClient(server_path)
157 | await self._client.connect()
158 |
159 | self._connected = True
160 | self._available_tools = self._client.available_tools.copy()
161 | logger.info(
162 | f"Connected to MCP server. Available tools: {self._available_tools}"
163 | )
164 |
165 | # Process requests until shutdown
166 | while not self._shutdown_event.is_set():
167 | try:
168 | # Check for requests (non-blocking)
169 | request = self._request_queue.get(timeout=0.1)
170 |
171 | if request["action"] == "disconnect":
172 | break
173 | elif request["action"] == "invoke_tool":
174 | await self._handle_invoke_tool(request)
175 | elif request["action"] == "health_check":
176 | await self._handle_health_check(request)
177 |
178 | except Empty:
179 | # No request, continue
180 | continue
181 | except Exception as e:
182 | logger.error(f"Error processing request: {e}")
183 |
184 | except Exception as e:
185 | logger.error(f"Connection handler error: {e}")
186 | self._response_queue.put(
187 | {
188 | "id": "connection_error",
189 | "result": {"success": False, "error": str(e)},
190 | }
191 | )
192 | finally:
193 | # Clean up
194 | if self._client:
195 | try:
196 | await self._client.disconnect()
197 | except Exception as e:
198 | logger.error(f"Error disconnecting client: {e}")
199 | self._connected = False
200 |
201 | async def _handle_invoke_tool(self, request: dict[str, Any]) -> None:
202 | """Handle tool invocation request."""
203 | try:
204 | result = await self._client.invoke_tool(
205 | request["tool_name"], request["arguments"]
206 | )
207 |
208 | self._response_queue.put(
209 | {
210 | "id": request["id"],
211 | "result": result.model_dump()
212 | if hasattr(result, "model_dump")
213 | else {
214 | "success": True,
215 | "result": result,
216 | "tool_name": request["tool_name"],
217 | "arguments": request["arguments"],
218 | },
219 | }
220 | )
221 |
222 | except Exception as e:
223 | logger.error(f"Tool invocation error: {e}")
224 | self._response_queue.put(
225 | {
226 | "id": request["id"],
227 | "result": {
228 | "success": False,
229 | "error": str(e),
230 | "tool_name": request["tool_name"],
231 | "arguments": request["arguments"],
232 | },
233 | }
234 | )
235 |
236 | async def _handle_health_check(self, request: dict[str, Any]) -> None:
237 | """Handle health check request."""
238 | try:
239 | health = await self._client.health_check()
240 | self._response_queue.put({"id": request["id"], "result": health})
241 | except Exception as e:
242 | logger.error(f"Health check error: {e}")
243 | self._response_queue.put({"id": request["id"], "result": False})
244 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/src/gui/utils/validation.py:
--------------------------------------------------------------------------------
1 | """Validation utilities for GUI input."""
2 |
3 | import re
4 |
5 |
6 | def validate_dice_notation(notation: str) -> bool:
7 | """Validate dice notation format.
8 |
9 | Args:
10 | notation: Dice notation string (e.g., "2d6", "1d20")
11 |
12 | Returns:
13 | True if valid, False otherwise
14 | """
15 | if not notation or not isinstance(notation, str):
16 | return False
17 |
18 | # Pattern: number + 'd' + number
19 | pattern = r"^(\d+)d(\d+)$"
20 | match = re.match(pattern, notation.strip().lower())
21 |
22 | if not match:
23 | return False
24 |
25 | # Check reasonable limits
26 | num_dice = int(match.group(1))
27 | num_sides = int(match.group(2))
28 |
29 | # Validate ranges
30 | if num_dice < 1 or num_dice > 100:
31 | return False
32 | if num_sides < 2 or num_sides > 1000:
33 | return False
34 |
35 | return True
36 |
37 |
38 | def validate_location(location: str) -> bool:
39 | """Validate location input for weather lookup.
40 |
41 | Args:
42 | location: Location string (city name or coordinates)
43 |
44 | Returns:
45 | True if valid, False otherwise
46 | """
47 | if not location or not isinstance(location, str):
48 | return False
49 |
50 | location = location.strip()
51 |
52 | # Check for coordinates format (lat,lon)
53 | coord_pattern = r"^-?\d+\.?\d*,-?\d+\.?\d*$"
54 | if re.match(coord_pattern, location):
55 | return True
56 |
57 | # Check for city name (at least 2 characters, letters and spaces)
58 | city_pattern = r"^[a-zA-Z\s]{2,}$"
59 | if re.match(city_pattern, location):
60 | return True
61 |
62 | return False
63 |
64 |
65 | def validate_timezone(timezone: str) -> bool:
66 | """Validate timezone identifier.
67 |
68 | Args:
69 | timezone: Timezone string (IANA format)
70 |
71 | Returns:
72 | True if valid, False otherwise
73 | """
74 | if not timezone or not isinstance(timezone, str):
75 | return False
76 |
77 | timezone = timezone.strip()
78 |
79 | # Common timezone patterns
80 | common_timezones = [
81 | "UTC",
82 | "GMT",
83 | "EST",
84 | "PST",
85 | "CST",
86 | "MST",
87 | "America/New_York",
88 | "America/Los_Angeles",
89 | "America/Chicago",
90 | "Europe/London",
91 | "Europe/Paris",
92 | "Asia/Tokyo",
93 | "Australia/Sydney",
94 | ]
95 |
96 | if timezone in common_timezones:
97 | return True
98 |
99 | # IANA timezone format: Area/City or Area/Region/City
100 | iana_pattern = r"^[A-Za-z_]+/[A-Za-z_]+(?:/[A-Za-z_]+)?$"
101 | return bool(re.match(iana_pattern, timezone))
102 |
103 |
104 | def validate_server_path(path: str) -> str | None:
105 | """Validate server path and return error message if invalid.
106 |
107 | Args:
108 | path: Server path string
109 |
110 | Returns:
111 | Error message if invalid, None if valid
112 | """
113 | if not path or not isinstance(path, str):
114 | return "Server path is required"
115 |
116 | path = path.strip()
117 |
118 | if not path:
119 | return "Server path cannot be empty"
120 |
121 | if not path.endswith(".py"):
122 | return "Server path must be a Python file (.py)"
123 |
124 | # Basic path validation
125 | if ".." in path or path.startswith("/"):
126 | return "Server path should be relative to project root"
127 |
128 | return None
129 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/src/main.py:
--------------------------------------------------------------------------------
1 | """Main entry point for the MCP server and client applications."""
2 |
3 | import argparse
4 | import logging
5 | import sys
6 | from asyncio import run
7 |
8 | from src.mcp_client.cli import MCPClientCLI
9 | from src.mcp_server import run_server
10 |
11 |
12 | def setup_logging(level: str = "INFO") -> None:
13 | """Setup logging configuration."""
14 | logging.basicConfig(
15 | level=getattr(logging, level.upper()),
16 | format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
17 | datefmt="%Y-%m-%d %H:%M:%S",
18 | )
19 |
20 |
21 | async def run_client_async(args) -> int:
22 | """Run the MCP client in async mode."""
23 | # Create client CLI and run it
24 | cli = MCPClientCLI()
25 |
26 | # Build client arguments from parsed args
27 | client_args = [
28 | "--server",
29 | args.server,
30 | "--log-level",
31 | args.log_level,
32 | "--timeout",
33 | str(args.timeout),
34 | ]
35 |
36 | if args.tool:
37 | client_args.append(args.tool)
38 |
39 | # Add tool-specific arguments
40 | if args.tool == "roll_dice":
41 | client_args.extend(["--notation", args.notation])
42 | elif args.tool == "get_weather":
43 | client_args.extend(["--location", args.location])
44 | elif args.tool == "get_date":
45 | client_args.extend(["--timezone", args.timezone])
46 |
47 | # Run the client
48 | return await cli.run(client_args)
49 |
50 |
51 | def main() -> None:
52 | """Main entry point for MCP applications."""
53 | parser = argparse.ArgumentParser(
54 | description="MCP Server and Client with dice, weather, and date/time tools",
55 | formatter_class=argparse.RawDescriptionHelpFormatter,
56 | epilog="""
57 | Examples:
58 | # Run MCP server
59 | %(prog)s server
60 |
61 | # Run MCP client
62 | %(prog)s client --server ./server.py roll_dice --notation 2d6
63 | %(prog)s client --server ./server.py get_weather --location "San Francisco"
64 | %(prog)s client --server ./server.py get_date --timezone UTC
65 |
66 | # Launch Streamlit GUI
67 | %(prog)s gui
68 | %(prog)s gui --port 8502
69 | """,
70 | )
71 |
72 | # Add subcommands
73 | subparsers = parser.add_subparsers(
74 | dest="mode", help="Choose server or client mode", metavar="MODE"
75 | )
76 |
77 | # Server subcommand
78 | server_parser = subparsers.add_parser("server", help="Run MCP server")
79 | server_parser.add_argument(
80 | "--log-level",
81 | choices=["DEBUG", "INFO", "WARNING", "ERROR"],
82 | default="INFO",
83 | help="Set the logging level",
84 | )
85 | server_parser.add_argument(
86 | "--version",
87 | action="version",
88 | version="MCP Server v1.0.0",
89 | )
90 |
91 | # Client subcommand
92 | client_parser = subparsers.add_parser("client", help="Run MCP client")
93 |
94 | # GUI subcommand
95 | gui_parser = subparsers.add_parser("gui", help="Launch Streamlit GUI")
96 | gui_parser.add_argument(
97 | "--log-level",
98 | choices=["DEBUG", "INFO", "WARNING", "ERROR"],
99 | default="INFO",
100 | help="Set the logging level",
101 | )
102 | gui_parser.add_argument(
103 | "--port",
104 | type=int,
105 | default=8501,
106 | help="Port for Streamlit server (default: 8501)",
107 | )
108 | client_parser.add_argument(
109 | "--server", required=True, help="Path to MCP server script"
110 | )
111 | client_parser.add_argument(
112 | "--log-level",
113 | choices=["DEBUG", "INFO", "WARNING", "ERROR"],
114 | default="INFO",
115 | help="Set logging level (default: INFO)",
116 | )
117 | client_parser.add_argument(
118 | "--timeout",
119 | type=int,
120 | default=30,
121 | help="Connection timeout in seconds (default: 30)",
122 | )
123 |
124 | # Tool subcommands for client
125 | tool_subparsers = client_parser.add_subparsers(
126 | dest="tool", help="Available tools", metavar="TOOL"
127 | )
128 |
129 | # Roll dice tool
130 | dice_parser = tool_subparsers.add_parser(
131 | "roll_dice", help="Roll dice using standard notation"
132 | )
133 | dice_parser.add_argument(
134 | "--notation", required=True, help="Dice notation (e.g., 2d6, 1d20, 3d10)"
135 | )
136 |
137 | # Weather tool
138 | weather_parser = tool_subparsers.add_parser(
139 | "get_weather", help="Get current weather conditions"
140 | )
141 | weather_parser.add_argument(
142 | "--location", required=True, help="Location name or coordinates (lat,lon)"
143 | )
144 |
145 | # Date/time tool
146 | date_parser = tool_subparsers.add_parser(
147 | "get_date", help="Get current date and time"
148 | )
149 | date_parser.add_argument(
150 | "--timezone", default="UTC", help="Timezone identifier (default: UTC)"
151 | )
152 |
153 | args = parser.parse_args()
154 |
155 | # Check if mode is specified
156 | if not args.mode:
157 | parser.print_help()
158 | sys.exit(1)
159 |
160 | # Setup logging
161 | log_level = getattr(args, "log_level", "INFO")
162 | setup_logging(log_level)
163 |
164 | logger = logging.getLogger(__name__)
165 |
166 | try:
167 | if args.mode == "server":
168 | logger.info("Starting MCP Server application")
169 | # Run the MCP server (this will block until server shuts down)
170 | run_server()
171 | elif args.mode == "client":
172 | logger.info("Starting MCP Client application")
173 | # Run client in async mode
174 | exit_code = run(run_client_async(args))
175 | sys.exit(exit_code)
176 | elif args.mode == "gui":
177 | logger.info("Starting Streamlit GUI application")
178 | # Launch Streamlit GUI
179 | import os
180 | import subprocess
181 |
182 | # Set environment variables for Streamlit
183 | env = os.environ.copy()
184 | env["STREAMLIT_SERVER_PORT"] = str(args.port)
185 | env["STREAMLIT_LOGGER_LEVEL"] = args.log_level
186 |
187 | # Run Streamlit
188 | cmd = [
189 | "streamlit",
190 | "run",
191 | "src/gui/app.py",
192 | "--server.port",
193 | str(args.port),
194 | "--logger.level",
195 | args.log_level.lower(),
196 | ]
197 |
198 | subprocess.run(cmd, env=env)
199 | else:
200 | parser.print_help()
201 | sys.exit(1)
202 |
203 | except KeyboardInterrupt:
204 | logger.info("Application interrupted by user")
205 | sys.exit(0)
206 | except Exception as e:
207 | logger.error(f"Application error: {e}")
208 | sys.exit(1)
209 |
210 |
211 | if __name__ == "__main__":
212 | main()
213 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/src/mcp_client/__init__.py:
--------------------------------------------------------------------------------
1 | """MCP Client implementation for tool invocation."""
2 |
3 | from .cli import MCPClientCLI
4 | from .client import MCPClient
5 |
6 | __all__ = ["MCPClient", "MCPClientCLI"]
7 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/src/mcp_client/cli.py:
--------------------------------------------------------------------------------
1 | """CLI interface for MCP client tool invocation."""
2 |
3 | import argparse
4 | import asyncio
5 | import json
6 | import logging
7 | import sys
8 | from typing import Any
9 |
10 | from .client import MCPClient
11 | from .models.responses import ClientToolResult
12 |
13 | # Configure logging
14 | logger = logging.getLogger(__name__)
15 |
16 |
17 | class MCPClientCLI:
18 | """CLI interface for MCP client tool invocation."""
19 |
20 | def __init__(self) -> None:
21 | """Initialize CLI interface."""
22 | self.parser = self._create_parser()
23 | self.client: MCPClient | None = None
24 |
25 | def _create_parser(self) -> argparse.ArgumentParser:
26 | """Create argument parser with subcommands.
27 |
28 | Returns:
29 | Configured argument parser
30 | """
31 | parser = argparse.ArgumentParser(
32 | description="MCP Client for tool invocation",
33 | formatter_class=argparse.RawDescriptionHelpFormatter,
34 | epilog="""Examples:
35 | %(prog)s --server ./server.py roll_dice --notation 2d6
36 | %(prog)s --server ./server.py get_weather --location "San Francisco"
37 | %(prog)s --server ./server.py get_date --timezone UTC
38 | """,
39 | )
40 |
41 | # Global arguments
42 | parser.add_argument("--server", required=True, help="Path to MCP server script")
43 |
44 | parser.add_argument(
45 | "--log-level",
46 | choices=["DEBUG", "INFO", "WARNING", "ERROR"],
47 | default="INFO",
48 | help="Set logging level (default: INFO)",
49 | )
50 |
51 | parser.add_argument(
52 | "--timeout",
53 | type=int,
54 | default=30,
55 | help="Connection timeout in seconds (default: 30)",
56 | )
57 |
58 | # Subcommands for tools
59 | subparsers = parser.add_subparsers(
60 | dest="tool", help="Available tools", metavar="TOOL"
61 | )
62 |
63 | # Roll dice tool
64 | dice_parser = subparsers.add_parser(
65 | "roll_dice", help="Roll dice using standard notation"
66 | )
67 | dice_parser.add_argument(
68 | "--notation", required=True, help="Dice notation (e.g., 2d6, 1d20, 3d10)"
69 | )
70 |
71 | # Weather tool
72 | weather_parser = subparsers.add_parser(
73 | "get_weather", help="Get current weather conditions"
74 | )
75 | weather_parser.add_argument(
76 | "--location", required=True, help="Location name or coordinates (lat,lon)"
77 | )
78 |
79 | # Date/time tool
80 | date_parser = subparsers.add_parser(
81 | "get_date", help="Get current date and time"
82 | )
83 | date_parser.add_argument(
84 | "--timezone", default="UTC", help="Timezone identifier (default: UTC)"
85 | )
86 |
87 | return parser
88 |
89 | def _setup_logging(self, level: str) -> None:
90 | """Setup logging configuration.
91 |
92 | Args:
93 | level: Logging level string
94 | """
95 | logging.basicConfig(
96 | level=getattr(logging, level.upper()),
97 | format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
98 | datefmt="%Y-%m-%d %H:%M:%S",
99 | )
100 |
101 | def _build_tool_arguments(self, args: argparse.Namespace) -> dict[str, Any]:
102 | """Build tool arguments from parsed CLI arguments.
103 |
104 | Args:
105 | args: Parsed command line arguments
106 |
107 | Returns:
108 | Dictionary of tool arguments
109 | """
110 | if args.tool == "roll_dice":
111 | return {"notation": args.notation}
112 | elif args.tool == "get_weather":
113 | return {"location": args.location}
114 | elif args.tool == "get_date":
115 | return {"timezone": args.timezone}
116 | else:
117 | return {}
118 |
119 | def _display_success(self, result: ClientToolResult) -> None:
120 | """Display successful tool result.
121 |
122 | Args:
123 | result: Successful tool result
124 | """
125 | print(f"✅ {result.tool_name} executed successfully:")
126 | print()
127 |
128 | # Handle MCP response format
129 | if result.result and hasattr(result.result, "content"):
130 | content = result.result.content
131 | if isinstance(content, list):
132 | for item in content:
133 | if isinstance(item, dict) and "text" in item:
134 | print(item["text"])
135 | else:
136 | print(json.dumps(item, indent=2))
137 | else:
138 | print(json.dumps(content, indent=2))
139 | else:
140 | print(json.dumps(result.result, indent=2))
141 |
142 | def _display_error(self, result: ClientToolResult) -> None:
143 | """Display tool execution error.
144 |
145 | Args:
146 | result: Failed tool result
147 | """
148 | print(f"❌ {result.tool_name} failed:")
149 | print(f"Error: {result.error}")
150 | print()
151 | print(f"Tool: {result.tool_name}")
152 | print(f"Arguments: {json.dumps(result.arguments, indent=2)}")
153 |
154 | def _display_connection_error(self, error: Exception) -> None:
155 | """Display connection error.
156 |
157 | Args:
158 | error: Connection error
159 | """
160 | print("❌ Connection failed:")
161 | print(f"Error: {error}")
162 | print()
163 | print("Troubleshooting:")
164 | print("1. Check that the server script exists")
165 | print("2. Verify the server script is executable")
166 | print("3. Ensure required dependencies are installed")
167 | print("4. Check that the server starts correctly")
168 |
169 | async def run(self, args: list[str] | None = None) -> int:
170 | """Run CLI with provided arguments.
171 |
172 | Args:
173 | args: Command line arguments (uses sys.argv if None)
174 |
175 | Returns:
176 | Exit code (0 for success, 1 for error)
177 | """
178 | try:
179 | # Parse arguments
180 | parsed_args = self.parser.parse_args(args)
181 |
182 | # Setup logging
183 | self._setup_logging(parsed_args.log_level)
184 |
185 | # Check if tool specified
186 | if not parsed_args.tool:
187 | self.parser.print_help()
188 | return 1
189 |
190 | # Create client
191 | self.client = MCPClient(parsed_args.server)
192 |
193 | # Connect to server with timeout
194 | try:
195 | await asyncio.wait_for(
196 | self.client.connect(), timeout=parsed_args.timeout
197 | )
198 | except TimeoutError:
199 | print(f"❌ Connection timeout after {parsed_args.timeout} seconds")
200 | return 1
201 | except Exception as e:
202 | self._display_connection_error(e)
203 | return 1
204 |
205 | # Build tool arguments
206 | tool_args = self._build_tool_arguments(parsed_args)
207 |
208 | # Invoke tool
209 | result = await self.client.invoke_tool(parsed_args.tool, tool_args)
210 |
211 | # Display result
212 | if result.success:
213 | self._display_success(result)
214 | return 0
215 | else:
216 | self._display_error(result)
217 | return 1
218 |
219 | except KeyboardInterrupt:
220 | print("\n❌ Interrupted by user")
221 | return 130
222 | except Exception as e:
223 | logger.error(f"Unexpected error: {e}")
224 | print(f"❌ Unexpected error: {e}")
225 | return 1
226 | finally:
227 | # Cleanup
228 | if self.client:
229 | await self.client.disconnect()
230 |
231 |
232 | async def main() -> int:
233 | """Main CLI entry point.
234 |
235 | Returns:
236 | Exit code
237 | """
238 | cli = MCPClientCLI()
239 | return await cli.run()
240 |
241 |
242 | if __name__ == "__main__":
243 | sys.exit(asyncio.run(main()))
244 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/src/mcp_client/client.py:
--------------------------------------------------------------------------------
1 | """Main MCP client class for tool invocation."""
2 |
3 | import logging
4 | from typing import Any
5 |
6 | from .models.responses import ClientToolResult
7 | from .transport import MCPTransport
8 |
9 | # Configure logging
10 | logger = logging.getLogger(__name__)
11 |
12 |
13 | class MCPClient:
14 | """MCP client for connecting to servers and invoking tools."""
15 |
16 | def __init__(self, server_path: str):
17 | """Initialize MCP client.
18 |
19 | Args:
20 | server_path: Path to the MCP server script
21 | """
22 | self.server_path = server_path
23 | self.transport = MCPTransport(server_path)
24 | self._connected = False
25 |
26 | @property
27 | def connected(self) -> bool:
28 | """Check if client is connected to server."""
29 | return self._connected and self.transport.connected
30 |
31 | @property
32 | def available_tools(self) -> list[str]:
33 | """Get list of available tools."""
34 | return self.transport.available_tools
35 |
36 | async def connect(self) -> None:
37 | """Connect to MCP server.
38 |
39 | Raises:
40 | FileNotFoundError: If server script doesn't exist
41 | ConnectionError: If connection fails
42 | ValueError: If server script type is not supported
43 | """
44 | logger.info(f"Connecting to MCP server: {self.server_path}")
45 |
46 | try:
47 | await self.transport.connect()
48 | self._connected = True
49 | logger.info(
50 | f"Connected successfully. Available tools: {self.available_tools}"
51 | )
52 | except Exception as e:
53 | logger.error(f"Failed to connect: {e}")
54 | raise
55 |
56 | async def disconnect(self) -> None:
57 | """Disconnect from MCP server."""
58 | logger.info("Disconnecting from MCP server")
59 | await self.transport.disconnect()
60 | self._connected = False
61 |
62 | async def invoke_tool(
63 | self, tool_name: str, arguments: dict[str, Any]
64 | ) -> ClientToolResult:
65 | """Invoke a tool on the connected server.
66 |
67 | Args:
68 | tool_name: Name of the tool to invoke
69 | arguments: Arguments to pass to the tool
70 |
71 | Returns:
72 | ClientToolResult with success status and result or error
73 | """
74 | logger.info(f"Invoking tool: {tool_name} with arguments: {arguments}")
75 |
76 | # Check if connected
77 | if not self.connected:
78 | error_msg = "Not connected to server"
79 | logger.error(error_msg)
80 | return ClientToolResult(
81 | success=False,
82 | result=None,
83 | error=error_msg,
84 | tool_name=tool_name,
85 | arguments=arguments,
86 | )
87 |
88 | # Check if tool is available
89 | if tool_name not in self.available_tools:
90 | error_msg = (
91 | f"Tool '{tool_name}' not available. "
92 | f"Available tools: {self.available_tools}"
93 | )
94 | logger.error(error_msg)
95 | return ClientToolResult(
96 | success=False,
97 | result=None,
98 | error=error_msg,
99 | tool_name=tool_name,
100 | arguments=arguments,
101 | )
102 |
103 | try:
104 | # Call the tool through transport
105 | result = await self.transport.call_tool(tool_name, arguments)
106 |
107 | # Process the result
108 | logger.info(f"Tool '{tool_name}' executed successfully")
109 | return ClientToolResult(
110 | success=True,
111 | result=result,
112 | error=None,
113 | tool_name=tool_name,
114 | arguments=arguments,
115 | )
116 |
117 | except Exception as e:
118 | error_msg = f"Tool execution failed: {str(e)}"
119 | logger.error(error_msg)
120 | return ClientToolResult(
121 | success=False,
122 | result=None,
123 | error=error_msg,
124 | tool_name=tool_name,
125 | arguments=arguments,
126 | )
127 |
128 | async def health_check(self) -> bool:
129 | """Check if connection is healthy.
130 |
131 | Returns:
132 | True if connection is healthy, False otherwise
133 | """
134 | if not self.connected:
135 | return False
136 |
137 | return await self.transport.health_check()
138 |
139 | async def __aenter__(self) -> "MCPClient":
140 | """Async context manager entry."""
141 | await self.connect()
142 | return self
143 |
144 | async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
145 | """Async context manager exit."""
146 | await self.disconnect()
147 |
148 |
149 | class MCPClientError(Exception):
150 | """Base exception for MCP client errors."""
151 |
152 | pass
153 |
154 |
155 | class MCPConnectionError(MCPClientError):
156 | """Raised when connection to MCP server fails."""
157 |
158 | pass
159 |
160 |
161 | class MCPToolError(MCPClientError):
162 | """Raised when tool execution fails."""
163 |
164 | pass
165 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/src/mcp_client/models/__init__.py:
--------------------------------------------------------------------------------
1 | """MCP Client models for type safety and validation."""
2 |
3 | from .responses import ClientToolResult, MCPToolResponse
4 |
5 | __all__ = ["ClientToolResult", "MCPToolResponse"]
6 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/src/mcp_client/models/responses.py:
--------------------------------------------------------------------------------
1 | """Client-specific response models for MCP tool invocation."""
2 |
3 | from typing import Any
4 |
5 | from pydantic import BaseModel, Field
6 |
7 |
8 | class MCPToolResponse(BaseModel):
9 | """Response from MCP tool execution."""
10 |
11 | content: list[dict[str, Any]] = Field(..., description="Response content")
12 | isError: bool = Field(False, description="Whether response indicates error")
13 |
14 |
15 | class ClientToolResult(BaseModel):
16 | """Processed tool result for client consumption."""
17 |
18 | success: bool = Field(..., description="Whether tool execution was successful")
19 | result: Any | None = Field(None, description="Tool execution result")
20 | error: str | None = Field(None, description="Error message if execution failed")
21 | tool_name: str = Field(..., description="Name of the tool that was executed")
22 | arguments: dict[str, Any] = Field(..., description="Arguments passed to the tool")
23 |
24 |
25 | class ClientSession(BaseModel):
26 | """Client session information."""
27 |
28 | server_path: str = Field(..., description="Path to the MCP server script")
29 | connected: bool = Field(False, description="Whether client is connected to server")
30 | available_tools: list[str] = Field(
31 | default_factory=list, description="List of available tools"
32 | )
33 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/src/mcp_client/transport.py:
--------------------------------------------------------------------------------
1 | """Transport layer for MCP client connections."""
2 |
3 | import os
4 | from contextlib import AsyncExitStack
5 | from typing import Any
6 |
7 | from mcp import ClientSession, StdioServerParameters
8 | from mcp.client.stdio import stdio_client
9 |
10 |
11 | class MCPTransport:
12 | """Handles MCP server connections via stdio transport."""
13 |
14 | def __init__(self, server_path: str):
15 | """Initialize transport with server path.
16 |
17 | Args:
18 | server_path: Path to the MCP server script
19 | """
20 | self.server_path = server_path
21 | self.session: ClientSession | None = None
22 | self.exit_stack: AsyncExitStack | None = None
23 | self.connected = False
24 | self.available_tools: list[str] = []
25 |
26 | async def connect(self) -> None:
27 | """Connect to MCP server via stdio transport.
28 |
29 | Raises:
30 | FileNotFoundError: If server script doesn't exist
31 | ConnectionError: If connection fails
32 | ValueError: If server script type is not supported
33 | """
34 | # Validate server script exists
35 | if not os.path.exists(self.server_path):
36 | raise FileNotFoundError(f"Server script not found: {self.server_path}")
37 |
38 | # Determine server type and command
39 | if self.server_path.endswith(".py"):
40 | # Use uv run python for proper environment
41 | command = "uv"
42 | args = ["run", "python", self.server_path]
43 | elif self.server_path.endswith(".js"):
44 | command = "node"
45 | args = [self.server_path]
46 | else:
47 | raise ValueError(f"Unsupported server script type: {self.server_path}")
48 |
49 | # Create server parameters
50 | server_params = StdioServerParameters(command=command, args=args, env=None)
51 |
52 | try:
53 | # Setup resource management
54 | self.exit_stack = AsyncExitStack()
55 |
56 | # Connect to server
57 | read, write = await self.exit_stack.enter_async_context(
58 | stdio_client(server_params)
59 | )
60 |
61 | # Create session
62 | self.session = await self.exit_stack.enter_async_context(
63 | ClientSession(read, write)
64 | )
65 |
66 | # Initialize connection
67 | await self.session.initialize()
68 |
69 | # Discover available tools
70 | tools_response = await self.session.list_tools()
71 | self.available_tools = [tool.name for tool in tools_response.tools]
72 |
73 | self.connected = True
74 |
75 | except Exception as e:
76 | # Clean up on connection failure
77 | if self.exit_stack:
78 | await self.exit_stack.aclose()
79 | self.exit_stack = None
80 | raise ConnectionError(f"Failed to connect to server: {e}")
81 |
82 | async def disconnect(self) -> None:
83 | """Disconnect from MCP server."""
84 | if self.exit_stack:
85 | await self.exit_stack.aclose()
86 | self.exit_stack = None
87 |
88 | self.session = None
89 | self.connected = False
90 | self.available_tools = []
91 |
92 | async def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any:
93 | """Call a tool on the connected server.
94 |
95 | Args:
96 | tool_name: Name of the tool to call
97 | arguments: Arguments to pass to the tool
98 |
99 | Returns:
100 | Tool response content
101 |
102 | Raises:
103 | RuntimeError: If not connected to server
104 | ValueError: If tool is not available
105 | """
106 | if not self.connected or not self.session:
107 | raise RuntimeError("Not connected to server")
108 |
109 | if tool_name not in self.available_tools:
110 | raise ValueError(
111 | f"Tool '{tool_name}' not available. "
112 | f"Available tools: {self.available_tools}"
113 | )
114 |
115 | # Call the tool
116 | result = await self.session.call_tool(tool_name, arguments)
117 | return result
118 |
119 | async def health_check(self) -> bool:
120 | """Check if connection is healthy.
121 |
122 | Returns:
123 | True if connection is healthy, False otherwise
124 | """
125 | if not self.connected or not self.session:
126 | return False
127 |
128 | try:
129 | # Try to list tools as a health check
130 | await self.session.list_tools()
131 | return True
132 | except Exception:
133 | return False
134 |
135 | async def __aenter__(self) -> "MCPTransport":
136 | """Async context manager entry."""
137 | await self.connect()
138 | return self
139 |
140 | async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
141 | """Async context manager exit."""
142 | await self.disconnect()
143 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/src/mcp_server/__init__.py:
--------------------------------------------------------------------------------
1 | """MCP server package."""
2 |
3 | from .server import mcp, run_server
4 |
5 | __all__ = ["mcp", "run_server"]
6 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/src/mcp_server/models/__init__.py:
--------------------------------------------------------------------------------
1 | """MCP server data models."""
2 |
3 | from .requests import (
4 | DateTimeRequest,
5 | DateTimeResponse,
6 | DiceRollRequest,
7 | DiceRollResponse,
8 | MCPError,
9 | MCPRequest,
10 | MCPResponse,
11 | ToolCallRequest,
12 | ToolCallResponse,
13 | WeatherRequest,
14 | WeatherResponse,
15 | )
16 |
17 | __all__ = [
18 | "DateTimeRequest",
19 | "DateTimeResponse",
20 | "DiceRollRequest",
21 | "DiceRollResponse",
22 | "MCPError",
23 | "MCPRequest",
24 | "MCPResponse",
25 | "ToolCallRequest",
26 | "ToolCallResponse",
27 | "WeatherRequest",
28 | "WeatherResponse",
29 | ]
30 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/src/mcp_server/models/requests.py:
--------------------------------------------------------------------------------
1 | """Pydantic models for MCP server request/response validation."""
2 |
3 | import re
4 | from typing import Any
5 |
6 | from pydantic import BaseModel, Field, field_validator
7 |
8 |
9 | class MCPRequest(BaseModel):
10 | """Base MCP request structure following JSON-RPC 2.0 format."""
11 |
12 | jsonrpc: str = "2.0"
13 | method: str
14 | params: dict | None = None
15 | id: str | int | None = None
16 |
17 |
18 | class MCPResponse(BaseModel):
19 | """Base MCP response structure following JSON-RPC 2.0 format."""
20 |
21 | jsonrpc: str = "2.0"
22 | id: str | int | None = None
23 | result: Any | None = None
24 | error: dict | None = None
25 |
26 |
27 | class MCPError(BaseModel):
28 | """MCP error structure."""
29 |
30 | code: int
31 | message: str
32 | data: dict | None = None
33 |
34 |
35 | class DiceRollRequest(BaseModel):
36 | """Dice roll tool request with notation validation."""
37 |
38 | notation: str = Field(..., description="Dice notation like '2d6' or '1d20'")
39 |
40 | @field_validator("notation")
41 | @classmethod
42 | def validate_notation(cls, v: str) -> str:
43 | """Validate dice notation format."""
44 | if not isinstance(v, str):
45 | raise ValueError("Notation must be a string")
46 |
47 | # Remove spaces and convert to lowercase
48 | notation = v.strip().lower()
49 |
50 | # Validate format using regex
51 | pattern = re.compile(r"^(\d+)d(\d+)$")
52 | match = pattern.match(notation)
53 |
54 | if not match:
55 | raise ValueError(
56 | f"Invalid dice notation: '{v}'. "
57 | f"Expected format: 'XdY' (e.g., '2d6', '1d20')"
58 | )
59 |
60 | dice_count = int(match.group(1))
61 | sides = int(match.group(2))
62 |
63 | # Validate reasonable limits
64 | if dice_count <= 0:
65 | raise ValueError("Dice count must be greater than 0")
66 | if dice_count > 100:
67 | raise ValueError("Dice count must not exceed 100")
68 |
69 | if sides <= 0:
70 | raise ValueError("Number of sides must be greater than 0")
71 | if sides > 1000:
72 | raise ValueError("Number of sides must not exceed 1000")
73 |
74 | return notation
75 |
76 |
77 | class DiceRollResponse(BaseModel):
78 | """Dice roll tool response."""
79 |
80 | values: list[int] = Field(..., description="Individual dice roll results")
81 | total: int = Field(..., description="Sum of all dice rolls")
82 | notation: str = Field(..., description="Original dice notation")
83 |
84 |
85 | class WeatherRequest(BaseModel):
86 | """Weather tool request with location validation."""
87 |
88 | location: str = Field(..., description="City name or coordinates (lat,lon)")
89 |
90 | @field_validator("location")
91 | @classmethod
92 | def validate_location(cls, v: str) -> str:
93 | """Validate location format."""
94 | if not isinstance(v, str):
95 | raise ValueError("Location must be a string")
96 |
97 | location = v.strip()
98 | if not location:
99 | raise ValueError("Location cannot be empty")
100 |
101 | return location
102 |
103 |
104 | class WeatherResponse(BaseModel):
105 | """Weather tool response."""
106 |
107 | location: str = Field(..., description="Requested location")
108 | temperature: float = Field(..., description="Temperature in Celsius")
109 | condition: str = Field(..., description="Weather condition description")
110 | wind_speed: float = Field(..., description="Wind speed in km/h")
111 | humidity: float | None = Field(None, description="Humidity percentage")
112 | timestamp: str | None = Field(None, description="Data timestamp")
113 |
114 |
115 | class DateTimeRequest(BaseModel):
116 | """Date/time tool request with timezone validation."""
117 |
118 | timezone: str = Field(
119 | "UTC", description="Timezone identifier (e.g., 'UTC', 'America/New_York')"
120 | )
121 |
122 | @field_validator("timezone")
123 | @classmethod
124 | def validate_timezone(cls, v: str) -> str:
125 | """Validate timezone format."""
126 | if not isinstance(v, str):
127 | raise ValueError("Timezone must be a string")
128 |
129 | timezone = v.strip()
130 | if not timezone:
131 | raise ValueError("Timezone cannot be empty")
132 |
133 | return timezone
134 |
135 |
136 | class DateTimeResponse(BaseModel):
137 | """Date/time tool response."""
138 |
139 | datetime: str = Field(..., description="ISO 8601 formatted date/time")
140 | timezone: str = Field(..., description="Timezone identifier")
141 | timestamp: float = Field(..., description="Unix timestamp")
142 |
143 |
144 | class ToolCallRequest(BaseModel):
145 | """Generic tool call request."""
146 |
147 | name: str = Field(..., description="Tool name")
148 | arguments: dict = Field(..., description="Tool arguments")
149 |
150 |
151 | class ToolCallResponse(BaseModel):
152 | """Generic tool call response."""
153 |
154 | content: list[dict] = Field(..., description="Tool response content")
155 | isError: bool = Field(False, description="Whether this is an error response")
156 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/src/mcp_server/server.py:
--------------------------------------------------------------------------------
1 | """MCP server implementation with dice, weather, and date/time tools."""
2 |
3 | import logging
4 | import sys
5 | from pathlib import Path
6 | from typing import Any
7 |
8 | from mcp.server.fastmcp import FastMCP
9 |
10 | from src.mcp_server.tools.date_time import DateTimeTool
11 | from src.mcp_server.tools.dice import DiceRollTool
12 | from src.mcp_server.tools.weather import WeatherTool
13 |
14 | # Add project root to Python path
15 | project_root = Path(__file__).parent.parent.parent
16 | sys.path.insert(0, str(project_root))
17 |
18 | # Configure logging
19 | logging.basicConfig(level=logging.INFO)
20 | logger = logging.getLogger(__name__)
21 |
22 | # Create MCP server instance
23 | mcp = FastMCP("dice-weather-datetime-server")
24 |
25 | # Initialize tool instances
26 | dice_tool = DiceRollTool()
27 | weather_tool = WeatherTool()
28 | datetime_tool = DateTimeTool()
29 |
30 |
31 | @mcp.tool()
32 | async def roll_dice(notation: str) -> dict[str, Any]:
33 | """Roll dice using standard notation like '2d6' or '1d20'.
34 |
35 | Args:
36 | notation: Dice notation (e.g., "2d6", "1d20", "3d10")
37 |
38 | Returns:
39 | Dict containing dice roll results, total, and formatted display
40 | """
41 | logger.info(f"Tool call: roll_dice(notation='{notation}')")
42 | return await dice_tool.safe_execute(notation=notation)
43 |
44 |
45 | @mcp.tool()
46 | async def get_weather(location: str) -> dict[str, Any]:
47 | """Get current weather conditions for a location.
48 |
49 | Args:
50 | location: City name or coordinates (lat,lon)
51 |
52 | Returns:
53 | Dict containing weather data including temperature, condition, and wind speed
54 | """
55 | logger.info(f"Tool call: get_weather(location='{location}')")
56 | return await weather_tool.safe_execute(location=location)
57 |
58 |
59 | @mcp.tool()
60 | async def get_date(timezone: str = "UTC") -> dict[str, Any]:
61 | """Get current date and time for a specific timezone.
62 |
63 | Args:
64 | timezone: Timezone identifier (e.g., "UTC", "America/New_York") or alias
65 |
66 | Returns:
67 | Dict containing current date/time in ISO 8601 format with timezone info
68 | """
69 | logger.info(f"Tool call: get_date(timezone='{timezone}')")
70 | return await datetime_tool.safe_execute(timezone=timezone)
71 |
72 |
73 | @mcp.resource("mcp://tools/help")
74 | async def get_help() -> str:
75 | """Get help information about available tools."""
76 | help_text = """
77 | 🎲 **MCP Server - Available Tools**
78 |
79 | **roll_dice** - Roll dice using standard notation
80 | - Usage: roll_dice(notation="2d6")
81 | - Examples: "1d20", "3d6", "2d10"
82 | - Returns individual values and total
83 |
84 | **get_weather** - Get current weather conditions
85 | - Usage: get_weather(location="San Francisco")
86 | - Supports city names or coordinates (lat,lon)
87 | - Returns temperature, condition, wind speed
88 |
89 | **get_date** - Get current date and time
90 | - Usage: get_date(timezone="UTC")
91 | - Supports IANA timezones and common aliases
92 | - Returns ISO 8601 formatted datetime
93 |
94 | **Examples:**
95 | - roll_dice("2d6") → Roll two six-sided dice
96 | - get_weather("London") → Weather for London
97 | - get_date("America/New_York") → NYC current time
98 | """
99 | return help_text
100 |
101 |
102 | async def cleanup_server():
103 | """Cleanup server resources."""
104 | logger.info("Cleaning up server resources...")
105 | try:
106 | await weather_tool.cleanup()
107 | logger.info("Server cleanup completed")
108 | except Exception as e:
109 | logger.error(f"Error during cleanup: {e}")
110 |
111 |
112 | # Server lifecycle management
113 | async def startup():
114 | """Server startup handler."""
115 | logger.info("MCP Server starting up...")
116 | logger.info("Tools available: roll_dice, get_weather, get_date")
117 |
118 |
119 | async def shutdown():
120 | """Server shutdown handler."""
121 | logger.info("MCP Server shutting down...")
122 | await cleanup_server()
123 |
124 |
125 | def run_server():
126 | """Run the MCP server."""
127 | try:
128 | logger.info("Starting MCP server...")
129 | mcp.run()
130 | except KeyboardInterrupt:
131 | logger.info("Server interrupted by user")
132 | except Exception as e:
133 | logger.error(f"Server error: {e}")
134 | raise
135 |
136 |
137 | if __name__ == "__main__":
138 | run_server()
139 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/src/mcp_server/tools/__init__.py:
--------------------------------------------------------------------------------
1 | """MCP server tools package."""
2 |
3 | from .base import (
4 | AsyncHttpMixin,
5 | BaseTool,
6 | ExternalServiceError,
7 | ToolError,
8 | ValidationToolError,
9 | )
10 |
11 | __all__ = [
12 | "AsyncHttpMixin",
13 | "BaseTool",
14 | "ExternalServiceError",
15 | "ToolError",
16 | "ValidationToolError",
17 | ]
18 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/src/mcp_server/tools/base.py:
--------------------------------------------------------------------------------
1 | """Base tool interface and common patterns for MCP server tools."""
2 |
3 | import logging
4 | from abc import ABC, abstractmethod
5 | from typing import Any
6 |
7 | import httpx
8 | from pydantic import BaseModel, ValidationError
9 |
10 | logger = logging.getLogger(__name__)
11 |
12 |
13 | class ToolError(Exception):
14 | """Base exception for tool-related errors."""
15 |
16 | def __init__(
17 | self, message: str, code: int = -1, data: dict[str, Any] | None = None
18 | ):
19 | super().__init__(message)
20 | self.message = message
21 | self.code = code
22 | self.data = data or {}
23 |
24 |
25 | class ValidationToolError(ToolError):
26 | """Exception for input validation errors."""
27 |
28 | def __init__(self, message: str, validation_errors: list | None = None):
29 | super().__init__(message, code=-32602) # Invalid params error code
30 | self.validation_errors = validation_errors or []
31 |
32 |
33 | class ExternalServiceError(ToolError):
34 | """Exception for external service errors."""
35 |
36 | def __init__(self, message: str, service_name: str, status_code: int | None = None):
37 | super().__init__(message, code=-32603) # Internal error code
38 | self.service_name = service_name
39 | self.status_code = status_code
40 |
41 |
42 | class BaseTool(ABC):
43 | """Abstract base class for all MCP server tools."""
44 |
45 | def __init__(self, name: str, description: str):
46 | self.name = name
47 | self.description = description
48 | self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
49 |
50 | @abstractmethod
51 | async def execute(self, **kwargs: Any) -> Any:
52 | """Execute the tool with the given arguments."""
53 | pass
54 |
55 | def validate_input(
56 | self, input_data: dict[str, Any], model_class: type[BaseModel]
57 | ) -> BaseModel:
58 | """Validate input data against a Pydantic model."""
59 | try:
60 | return model_class(**input_data)
61 | except ValidationError as e:
62 | error_messages = []
63 | for error in e.errors():
64 | field = " -> ".join(str(x) for x in error["loc"])
65 | message = error["msg"]
66 | error_messages.append(f"{field}: {message}")
67 |
68 | raise ValidationToolError(
69 | f"Invalid input for {self.name}: {'; '.join(error_messages)}",
70 | validation_errors=e.errors(),
71 | )
72 |
73 | def create_success_response(self, data: Any) -> dict[str, Any]:
74 | """Create a successful tool response."""
75 | return {
76 | "content": [
77 | {
78 | "type": "text",
79 | "text": str(data) if not isinstance(data, dict | list) else data,
80 | }
81 | ],
82 | "isError": False,
83 | }
84 |
85 | def create_error_response(self, error: Exception) -> dict[str, Any]:
86 | """Create an error tool response."""
87 | if isinstance(error, ToolError):
88 | error_message = error.message
89 | else:
90 | error_message = f"An unexpected error occurred: {str(error)}"
91 |
92 | # Log the full error for debugging
93 | self.logger.error(f"Tool {self.name} error: {error}", exc_info=True)
94 |
95 | return {
96 | "content": [
97 | {
98 | "type": "text",
99 | "text": error_message,
100 | }
101 | ],
102 | "isError": True,
103 | }
104 |
105 | async def safe_execute(self, **kwargs) -> dict[str, Any]:
106 | """Execute the tool with error handling."""
107 | try:
108 | result = await self.execute(**kwargs)
109 | return self.create_success_response(result)
110 | except Exception as e:
111 | return self.create_error_response(e)
112 |
113 |
114 | class AsyncHttpMixin:
115 | """Mixin for tools that need HTTP client capabilities."""
116 |
117 | def __init__(self, *args, **kwargs):
118 | super().__init__(*args, **kwargs)
119 | self._http_client = None
120 |
121 | @property
122 | def http_client(self):
123 | """Get or create HTTP client."""
124 | if self._http_client is None:
125 | import httpx
126 |
127 | self._http_client = httpx.AsyncClient(
128 | timeout=30.0,
129 | headers={"User-Agent": "MCP-Server/1.0"},
130 | )
131 | return self._http_client
132 |
133 | async def cleanup(self):
134 | """Cleanup HTTP client resources."""
135 | if self._http_client:
136 | await self._http_client.aclose()
137 | self._http_client = None
138 |
139 | async def make_request(
140 | self, method: str, url: str, timeout: float = 10.0, **kwargs
141 | ) -> dict[str, Any]:
142 | """Make an HTTP request with error handling."""
143 | try:
144 | response = await self.http_client.request(
145 | method=method, url=url, timeout=timeout, **kwargs
146 | )
147 | response.raise_for_status()
148 | return response.json()
149 | except httpx.TimeoutException:
150 | raise ExternalServiceError(
151 | f"Request to {url} timed out after {timeout} seconds",
152 | service_name="HTTP",
153 | )
154 | except httpx.HTTPStatusError as e:
155 | raise ExternalServiceError(
156 | f"HTTP error {e.response.status_code}: {e.response.text}",
157 | service_name="HTTP",
158 | status_code=e.response.status_code,
159 | )
160 | except Exception as e:
161 | raise ExternalServiceError(
162 | f"Failed to make request to {url}: {str(e)}",
163 | service_name="HTTP",
164 | )
165 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/src/mcp_server/tools/date_time.py:
--------------------------------------------------------------------------------
1 | """Date and time tool for MCP server with timezone support."""
2 |
3 | import zoneinfo
4 | from datetime import UTC, datetime
5 | from typing import Any
6 |
7 | from ..models import DateTimeRequest, DateTimeResponse
8 | from .base import BaseTool, ToolError
9 |
10 |
11 | class DateTimeTool(BaseTool):
12 | """Tool for getting current date and time in various timezones."""
13 |
14 | def __init__(self):
15 | super().__init__(
16 | name="get_date",
17 | description="Get current date and time in ISO 8601 format for any timezone",
18 | )
19 |
20 | # Common timezone aliases for user convenience
21 | self.timezone_aliases = {
22 | "utc": "UTC",
23 | "gmt": "UTC",
24 | "est": "America/New_York",
25 | "pst": "America/Los_Angeles",
26 | "cst": "America/Chicago",
27 | "mst": "America/Denver",
28 | "edt": "America/New_York",
29 | "pdt": "America/Los_Angeles",
30 | "cdt": "America/Chicago",
31 | "mdt": "America/Denver",
32 | "bst": "Europe/London",
33 | "cet": "Europe/Paris",
34 | "jst": "Asia/Tokyo",
35 | "aest": "Australia/Sydney",
36 | }
37 |
38 | def parse_timezone(self, timezone_str: str) -> zoneinfo.ZoneInfo | type[UTC]:
39 | """Parse timezone string to ZoneInfo object."""
40 | # Normalize timezone string
41 | tz_lower = timezone_str.lower().strip()
42 |
43 | # Handle UTC as a special case
44 | if tz_lower in ("utc", "gmt"):
45 | return UTC
46 |
47 | # Check aliases
48 | if tz_lower in self.timezone_aliases:
49 | timezone_str = self.timezone_aliases[tz_lower]
50 |
51 | # Try to create ZoneInfo object
52 | try:
53 | if timezone_str.upper() == "UTC":
54 | return UTC
55 | else:
56 | return zoneinfo.ZoneInfo(timezone_str)
57 | except zoneinfo.ZoneInfoNotFoundError:
58 | # Provide helpful error message with suggestions
59 | common_timezones = [
60 | "UTC",
61 | "America/New_York",
62 | "America/Los_Angeles",
63 | "America/Chicago",
64 | "Europe/London",
65 | "Europe/Paris",
66 | "Asia/Tokyo",
67 | "Australia/Sydney",
68 | ]
69 | suggestions = ", ".join(common_timezones)
70 | aliases = ", ".join(self.timezone_aliases.keys())
71 |
72 | raise ToolError(
73 | f"Invalid timezone: '{timezone_str}'. "
74 | f"Common timezones: {suggestions}. "
75 | f"Aliases: {aliases}. "
76 | f"Use IANA timezone names (e.g., 'America/New_York') or aliases."
77 | )
78 |
79 | async def execute(self, **kwargs: Any) -> DateTimeResponse:
80 | """Get current date and time for the specified timezone."""
81 | timezone = kwargs.get("timezone", "UTC")
82 |
83 | # Validate input
84 | request = self.validate_input({"timezone": timezone}, DateTimeRequest)
85 |
86 | # Parse timezone
87 | tz = self.parse_timezone(request.timezone)
88 |
89 | self.logger.info(f"Getting current time for timezone: {timezone}")
90 |
91 | # Get current time in the specified timezone
92 | if isinstance(tz, zoneinfo.ZoneInfo):
93 | current_time = datetime.now(tz)
94 | tz_name = str(tz)
95 | else: # UTC
96 | current_time = datetime.now(tz)
97 | tz_name = "UTC"
98 |
99 | # Format as ISO 8601
100 | iso_datetime = current_time.isoformat()
101 |
102 | # Get Unix timestamp
103 | timestamp = current_time.timestamp()
104 |
105 | self.logger.info(f"Current time in {tz_name}: {iso_datetime}")
106 |
107 | return DateTimeResponse(
108 | datetime=iso_datetime,
109 | timezone=tz_name,
110 | timestamp=timestamp,
111 | )
112 |
113 | def format_result(self, response: DateTimeResponse) -> str:
114 | """Format date/time data for display."""
115 | # Parse the ISO datetime to extract components
116 | try:
117 | dt = datetime.fromisoformat(response.datetime)
118 | date_part = dt.strftime("%Y-%m-%d")
119 | time_part = dt.strftime("%H:%M:%S")
120 | weekday = dt.strftime("%A")
121 |
122 | result = "🕐 **Current Date & Time**\n"
123 | result += f"📅 Date: **{date_part}** ({weekday})\n"
124 | result += f"⏰ Time: **{time_part}**\n"
125 | result += f"🌍 Timezone: **{response.timezone}**\n"
126 | result += f"📋 ISO 8601: `{response.datetime}`\n"
127 | result += f"🔢 Unix Timestamp: `{int(response.timestamp)}`"
128 |
129 | return result
130 |
131 | except ValueError:
132 | # Fallback if datetime parsing fails
133 | return (
134 | f"🕐 **Current Date & Time**\n"
135 | f"📋 ISO 8601: `{response.datetime}`\n"
136 | f"🌍 Timezone: **{response.timezone}**\n"
137 | f"🔢 Unix Timestamp: `{int(response.timestamp)}`"
138 | )
139 |
140 | async def safe_execute(self, **kwargs) -> dict[str, Any]:
141 | """Execute date/time lookup with formatted output."""
142 | try:
143 | result = await self.execute(**kwargs)
144 | formatted_result = self.format_result(result)
145 |
146 | return {
147 | "content": [
148 | {
149 | "type": "text",
150 | "text": formatted_result,
151 | }
152 | ],
153 | "isError": False,
154 | }
155 | except Exception as e:
156 | return self.create_error_response(e)
157 |
158 | def get_available_timezones(self) -> list[str]:
159 | """Get a list of common available timezones."""
160 | common_zones = [
161 | "UTC",
162 | "America/New_York",
163 | "America/Los_Angeles",
164 | "America/Chicago",
165 | "America/Denver",
166 | "America/Phoenix",
167 | "America/Anchorage",
168 | "America/Honolulu",
169 | "America/Toronto",
170 | "America/Vancouver",
171 | "Europe/London",
172 | "Europe/Paris",
173 | "Europe/Berlin",
174 | "Europe/Rome",
175 | "Europe/Madrid",
176 | "Europe/Amsterdam",
177 | "Europe/Stockholm",
178 | "Europe/Moscow",
179 | "Asia/Tokyo",
180 | "Asia/Shanghai",
181 | "Asia/Kolkata",
182 | "Asia/Dubai",
183 | "Asia/Singapore",
184 | "Australia/Sydney",
185 | "Australia/Melbourne",
186 | "Pacific/Auckland",
187 | ]
188 | return sorted(common_zones + list(self.timezone_aliases.values()))
189 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/src/mcp_server/tools/dice.py:
--------------------------------------------------------------------------------
1 | """Dice rolling tool for MCP server."""
2 |
3 | import random
4 | import re
5 | from typing import Any
6 |
7 | from ..models import DiceRollRequest, DiceRollResponse
8 | from .base import BaseTool, ToolError
9 |
10 |
11 | class DiceRollTool(BaseTool):
12 | """Tool for rolling dice using standard notation."""
13 |
14 | def __init__(self):
15 | super().__init__(
16 | name="roll_dice",
17 | description="Roll dice using standard notation like '2d6' or '1d20'",
18 | )
19 | self.notation_pattern = re.compile(r"^(\d+)d(\d+)$")
20 |
21 | async def execute(self, **kwargs: Any) -> DiceRollResponse:
22 | """Execute dice roll with the given notation."""
23 | notation = kwargs.get("notation")
24 | if not notation:
25 | raise ToolError("Missing required parameter: notation")
26 |
27 | # Validate input using Pydantic model
28 | request = self.validate_input({"notation": notation}, DiceRollRequest)
29 |
30 | # Parse the notation
31 | match = self.notation_pattern.match(request.notation)
32 | if not match:
33 | raise ToolError(f"Invalid dice notation: {notation}")
34 |
35 | dice_count = int(match.group(1))
36 | sides = int(match.group(2))
37 |
38 | self.logger.info(f"Rolling {dice_count}d{sides}")
39 |
40 | # Generate random values for each die
41 | values = []
42 | for _ in range(dice_count):
43 | roll = random.randint(1, sides)
44 | values.append(roll)
45 |
46 | total = sum(values)
47 |
48 | self.logger.info(f"Dice roll result: {values} (total: {total})")
49 |
50 | # Return structured response
51 | return DiceRollResponse(
52 | values=values,
53 | total=total,
54 | notation=str(notation), # Return original notation as provided
55 | )
56 |
57 | def format_result(self, response: DiceRollResponse) -> str:
58 | """Format dice roll result for display."""
59 | if len(response.values) == 1:
60 | return f"🎲 Rolled {response.notation}: **{response.values[0]}**"
61 | else:
62 | values_str = ", ".join(map(str, response.values))
63 | return (
64 | f"🎲 Rolled {response.notation}: [{values_str}] = **{response.total}**"
65 | )
66 |
67 | async def safe_execute(self, **kwargs) -> dict[str, Any]:
68 | """Execute dice roll with formatted output."""
69 | try:
70 | result = await self.execute(**kwargs)
71 | formatted_result = self.format_result(result)
72 |
73 | return {
74 | "content": [
75 | {
76 | "type": "text",
77 | "text": formatted_result,
78 | }
79 | ],
80 | "isError": False,
81 | }
82 | except Exception as e:
83 | return self.create_error_response(e)
84 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/src/mcp_server/tools/weather.py:
--------------------------------------------------------------------------------
1 | """Weather tool for MCP server using Open-Meteo API."""
2 |
3 | from datetime import datetime
4 | from typing import Any
5 |
6 | from ..models import WeatherRequest, WeatherResponse
7 | from .base import AsyncHttpMixin, BaseTool, ExternalServiceError, ToolError
8 |
9 |
10 | class WeatherTool(BaseTool, AsyncHttpMixin):
11 | """Tool for getting current weather data."""
12 |
13 | def __init__(self):
14 | super().__init__(
15 | name="get_weather",
16 | description="Get current weather conditions for a location",
17 | )
18 | self.api_base = "https://api.open-meteo.com/v1"
19 |
20 | # Basic city to coordinates mapping
21 | # In production, this would use a proper geocoding service
22 | self.city_coords = {
23 | "san francisco": (37.7749, -122.4194),
24 | "new york": (40.7128, -74.0060),
25 | "london": (51.5074, -0.1278),
26 | "paris": (48.8566, 2.3522),
27 | "tokyo": (35.6762, 139.6503),
28 | "sydney": (-33.8688, 151.2093),
29 | "los angeles": (34.0522, -118.2437),
30 | "chicago": (41.8781, -87.6298),
31 | "miami": (25.7617, -80.1918),
32 | "seattle": (47.6062, -122.3321),
33 | "vancouver": (49.2827, -123.1207),
34 | "toronto": (43.6532, -79.3832),
35 | "berlin": (52.5200, 13.4050),
36 | "rome": (41.9028, 12.4964),
37 | "madrid": (40.4168, -3.7038),
38 | "moscow": (55.7558, 37.6176),
39 | "beijing": (39.9042, 116.4074),
40 | "mumbai": (19.0760, 72.8777),
41 | "cairo": (30.0444, 31.2357),
42 | "lagos": (6.5244, 3.3792),
43 | }
44 |
45 | # Weather code to description mapping (subset of WMO codes)
46 | self.weather_codes = {
47 | 0: "Clear sky",
48 | 1: "Mainly clear",
49 | 2: "Partly cloudy",
50 | 3: "Overcast",
51 | 45: "Fog",
52 | 48: "Depositing rime fog",
53 | 51: "Light drizzle",
54 | 53: "Moderate drizzle",
55 | 55: "Dense drizzle",
56 | 61: "Slight rain",
57 | 63: "Moderate rain",
58 | 65: "Heavy rain",
59 | 71: "Slight snow",
60 | 73: "Moderate snow",
61 | 75: "Heavy snow",
62 | 77: "Snow grains",
63 | 80: "Slight rain showers",
64 | 81: "Moderate rain showers",
65 | 82: "Violent rain showers",
66 | 85: "Slight snow showers",
67 | 86: "Heavy snow showers",
68 | 95: "Thunderstorm",
69 | 96: "Thunderstorm with slight hail",
70 | 99: "Thunderstorm with heavy hail",
71 | }
72 |
73 | def parse_location(self, location: str) -> tuple[float, float]:
74 | """Parse location string to get coordinates."""
75 | location_lower = location.lower().strip()
76 |
77 | # Check if it's in our city mapping
78 | if location_lower in self.city_coords:
79 | return self.city_coords[location_lower]
80 |
81 | # Try to parse as "lat,lon" coordinates
82 | try:
83 | parts = location.split(",")
84 | if len(parts) == 2:
85 | lat = float(parts[0].strip())
86 | lon = float(parts[1].strip())
87 |
88 | # Basic validation for reasonable coordinate ranges
89 | if -90 <= lat <= 90 and -180 <= lon <= 180:
90 | return lat, lon
91 | except ValueError:
92 | pass
93 |
94 | # If we can't parse the location, raise an error
95 | available_cities = ", ".join(sorted(self.city_coords.keys()))
96 | raise ToolError(
97 | f"Unknown location: '{location}'. "
98 | f"Please use coordinates (lat,lon) or one of: {available_cities}"
99 | )
100 |
101 | def weather_code_to_text(self, code: int) -> str:
102 | """Convert weather code to readable description."""
103 | return self.weather_codes.get(code, f"Unknown weather condition (code: {code})")
104 |
105 | async def execute(self, **kwargs: Any) -> WeatherResponse:
106 | """Get weather data for the specified location."""
107 | location = kwargs.get("location")
108 | if not location:
109 | raise ToolError("Missing required parameter: location")
110 |
111 | # Validate input
112 | request = self.validate_input({"location": location}, WeatherRequest)
113 |
114 | # Parse location to coordinates
115 | lat, lon = self.parse_location(request.location)
116 |
117 | self.logger.info(f"Getting weather for {location} ({lat}, {lon})")
118 |
119 | # Make API request to Open-Meteo
120 | try:
121 | data = await self.make_request(
122 | method="GET",
123 | url=f"{self.api_base}/forecast",
124 | params={
125 | "latitude": lat,
126 | "longitude": lon,
127 | "current": (
128 | "temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m"
129 | ),
130 | "timezone": "auto",
131 | },
132 | timeout=15.0,
133 | )
134 |
135 | # Extract current weather data
136 | current = data.get("current", {})
137 | if not current:
138 | raise ExternalServiceError(
139 | "No current weather data available",
140 | service_name="Open-Meteo",
141 | )
142 |
143 | temperature = current.get("temperature_2m")
144 | humidity = current.get("relative_humidity_2m")
145 | weather_code = current.get("weather_code")
146 | wind_speed = current.get("wind_speed_10m")
147 | timestamp = current.get("time")
148 |
149 | if temperature is None or weather_code is None or wind_speed is None:
150 | raise ExternalServiceError(
151 | "Incomplete weather data received",
152 | service_name="Open-Meteo",
153 | )
154 |
155 | condition = self.weather_code_to_text(weather_code)
156 |
157 | self.logger.info(
158 | f"Weather data retrieved: {temperature}°C, {condition}, "
159 | f"{wind_speed} km/h wind"
160 | )
161 |
162 | return WeatherResponse(
163 | location=str(location),
164 | temperature=temperature,
165 | condition=condition,
166 | wind_speed=wind_speed,
167 | humidity=humidity,
168 | timestamp=timestamp,
169 | )
170 |
171 | except ExternalServiceError:
172 | raise
173 | except Exception as e:
174 | raise ExternalServiceError(
175 | f"Failed to retrieve weather data: {str(e)}",
176 | service_name="Open-Meteo",
177 | )
178 |
179 | def format_result(self, response: WeatherResponse) -> str:
180 | """Format weather data for display."""
181 | result = f"🌤️ **Weather for {response.location}**\n"
182 | result += f"🌡️ Temperature: **{response.temperature}°C**\n"
183 | result += f"☁️ Condition: **{response.condition}**\n"
184 | result += f"💨 Wind Speed: **{response.wind_speed} km/h**"
185 |
186 | if response.humidity is not None:
187 | result += f"\n💧 Humidity: **{response.humidity}%**"
188 |
189 | if response.timestamp:
190 | try:
191 | # Parse and format timestamp
192 | dt = datetime.fromisoformat(response.timestamp.replace("Z", "+00:00"))
193 | result += f"\n🕐 Updated: {dt.strftime('%Y-%m-%d %H:%M UTC')}"
194 | except ValueError:
195 | pass
196 |
197 | return result
198 |
199 | async def safe_execute(self, **kwargs) -> dict[str, Any]:
200 | """Execute weather lookup with formatted output."""
201 | try:
202 | result = await self.execute(**kwargs)
203 | formatted_result = self.format_result(result)
204 |
205 | return {
206 | "content": [
207 | {
208 | "type": "text",
209 | "text": formatted_result,
210 | }
211 | ],
212 | "isError": False,
213 | }
214 | except Exception as e:
215 | return self.create_error_response(e)
216 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/src/py.typed:
--------------------------------------------------------------------------------
1 | # PEP 561 – Distributing and Packaging Type Information
2 | # https://peps.python.org/pep-0561/
--------------------------------------------------------------------------------
/examples/mcp-server-client/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qte77/context-engineering-template/e35a269c47a5c9a91c90609d25c9b1a611f1858f/examples/mcp-server-client/tests/__init__.py
--------------------------------------------------------------------------------
/examples/mcp-server-client/tests/fixtures/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qte77/context-engineering-template/e35a269c47a5c9a91c90609d25c9b1a611f1858f/examples/mcp-server-client/tests/fixtures/__init__.py
--------------------------------------------------------------------------------
/examples/mcp-server-client/tests/fixtures/mcp_messages.py:
--------------------------------------------------------------------------------
1 | """Test fixtures for MCP messages and responses."""
2 |
3 | from typing import Any
4 |
5 |
6 | class MCPMessageFixtures:
7 | """Collection of MCP message fixtures for testing."""
8 |
9 | @staticmethod
10 | def tool_call_request(
11 | tool_name: str, arguments: dict[str, Any], request_id: int = 1
12 | ) -> dict[str, Any]:
13 | """Create a standard MCP tool call request."""
14 | return {
15 | "jsonrpc": "2.0",
16 | "method": "tools/call",
17 | "params": {
18 | "name": tool_name,
19 | "arguments": arguments,
20 | },
21 | "id": request_id,
22 | }
23 |
24 | @staticmethod
25 | def success_response(content: Any, request_id: int = 1) -> dict[str, Any]:
26 | """Create a successful MCP response."""
27 | return {
28 | "jsonrpc": "2.0",
29 | "id": request_id,
30 | "result": {
31 | "content": [
32 | {
33 | "type": "text",
34 | "text": content,
35 | }
36 | ],
37 | "isError": False,
38 | },
39 | }
40 |
41 | @staticmethod
42 | def error_response(error_message: str, request_id: int = 1) -> dict[str, Any]:
43 | """Create an error MCP response."""
44 | return {
45 | "jsonrpc": "2.0",
46 | "id": request_id,
47 | "result": {
48 | "content": [
49 | {
50 | "type": "text",
51 | "text": error_message,
52 | }
53 | ],
54 | "isError": True,
55 | },
56 | }
57 |
58 |
59 | class WeatherAPIFixtures:
60 | """Collection of weather API response fixtures."""
61 |
62 | @staticmethod
63 | def current_weather_response(
64 | temperature: float = 20.0,
65 | weather_code: int = 0,
66 | wind_speed: float = 10.0,
67 | humidity: float = 65.0,
68 | ) -> dict[str, Any]:
69 | """Create a mock Open-Meteo API response."""
70 | return {
71 | "current": {
72 | "time": "2025-07-07T14:30:00Z",
73 | "temperature_2m": temperature,
74 | "relative_humidity_2m": humidity,
75 | "weather_code": weather_code,
76 | "wind_speed_10m": wind_speed,
77 | },
78 | "current_units": {
79 | "time": "iso8601",
80 | "temperature_2m": "°C",
81 | "relative_humidity_2m": "%",
82 | "weather_code": "wmo code",
83 | "wind_speed_10m": "km/h",
84 | },
85 | }
86 |
87 | @staticmethod
88 | def api_error_response(status_code: int = 500) -> dict[str, Any]:
89 | """Create a mock API error response."""
90 | return {
91 | "error": True,
92 | "reason": "Internal server error" if status_code == 500 else "Bad request",
93 | }
94 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/tests/test_mcp_server.py:
--------------------------------------------------------------------------------
1 | """Tests for the MCP server integration."""
2 |
3 | from unittest.mock import AsyncMock, patch
4 |
5 | import pytest
6 |
7 | from src.mcp_server.server import cleanup_server, datetime_tool, dice_tool, weather_tool
8 | from tests.fixtures.mcp_messages import WeatherAPIFixtures
9 |
10 |
11 | class TestMCPServerIntegration:
12 | """Test suite for MCP server integration."""
13 |
14 | @pytest.mark.asyncio
15 | async def test_dice_tool_integration(self):
16 | """Test dice tool integration through MCP server."""
17 | # Import the MCP tool function
18 | from src.mcp_server.server import roll_dice
19 |
20 | result = await roll_dice(notation="2d6")
21 |
22 | assert "content" in result
23 | assert "isError" in result
24 | assert result["isError"] is False
25 | assert "🎲" in result["content"][0]["text"]
26 | assert "2d6" in result["content"][0]["text"]
27 |
28 | @pytest.mark.asyncio
29 | async def test_weather_tool_integration(self):
30 | """Test weather tool integration through MCP server."""
31 | from src.mcp_server.server import get_weather
32 |
33 | mock_response = WeatherAPIFixtures.current_weather_response()
34 |
35 | with patch.object(weather_tool, "make_request", return_value=mock_response):
36 | result = await get_weather(location="San Francisco")
37 |
38 | assert "content" in result
39 | assert "isError" in result
40 | assert result["isError"] is False
41 | assert "🌤️" in result["content"][0]["text"]
42 | assert "San Francisco" in result["content"][0]["text"]
43 |
44 | @pytest.mark.asyncio
45 | async def test_datetime_tool_integration(self):
46 | """Test date/time tool integration through MCP server."""
47 | from src.mcp_server.server import get_date
48 |
49 | result = await get_date(timezone="UTC")
50 |
51 | assert "content" in result
52 | assert "isError" in result
53 | assert result["isError"] is False
54 | assert "🕐" in result["content"][0]["text"]
55 | assert "UTC" in result["content"][0]["text"]
56 |
57 | @pytest.mark.asyncio
58 | async def test_dice_tool_error_handling(self):
59 | """Test error handling in dice tool integration."""
60 | from src.mcp_server.server import roll_dice
61 |
62 | result = await roll_dice(notation="invalid")
63 |
64 | assert "content" in result
65 | assert "isError" in result
66 | assert result["isError"] is True
67 | assert "Invalid input" in result["content"][0]["text"]
68 |
69 | @pytest.mark.asyncio
70 | async def test_weather_tool_error_handling(self):
71 | """Test error handling in weather tool integration."""
72 | from src.mcp_server.server import get_weather
73 |
74 | result = await get_weather(location="Unknown City")
75 |
76 | assert "content" in result
77 | assert "isError" in result
78 | assert result["isError"] is True
79 | assert "Unknown location" in result["content"][0]["text"]
80 |
81 | @pytest.mark.asyncio
82 | async def test_datetime_tool_error_handling(self):
83 | """Test error handling in date/time tool integration."""
84 | from src.mcp_server.server import get_date
85 |
86 | result = await get_date(timezone="Invalid/Timezone")
87 |
88 | assert "content" in result
89 | assert "isError" in result
90 | assert result["isError"] is True
91 | assert "Invalid timezone" in result["content"][0]["text"]
92 |
93 | @pytest.mark.asyncio
94 | async def test_help_resource(self):
95 | """Test help resource is available."""
96 | from src.mcp_server.server import get_help
97 |
98 | help_text = await get_help()
99 |
100 | assert isinstance(help_text, str)
101 | assert "roll_dice" in help_text
102 | assert "get_weather" in help_text
103 | assert "get_date" in help_text
104 | assert "🎲" in help_text
105 |
106 | @pytest.mark.asyncio
107 | async def test_cleanup_server(self):
108 | """Test server cleanup functionality."""
109 | # Mock the weather tool's cleanup method
110 | weather_tool.cleanup = AsyncMock()
111 |
112 | await cleanup_server()
113 |
114 | weather_tool.cleanup.assert_called_once()
115 |
116 | @pytest.mark.asyncio
117 | async def test_cleanup_server_with_error(self):
118 | """Test server cleanup handles errors gracefully."""
119 | # Mock the weather tool's cleanup method to raise an error
120 | weather_tool.cleanup = AsyncMock(side_effect=Exception("Cleanup error"))
121 |
122 | # Should not raise an exception
123 | await cleanup_server()
124 |
125 | weather_tool.cleanup.assert_called_once()
126 |
127 | def test_tool_instances_exist(self):
128 | """Test that tool instances are properly created."""
129 | assert dice_tool is not None
130 | assert weather_tool is not None
131 | assert datetime_tool is not None
132 |
133 | assert dice_tool.name == "roll_dice"
134 | assert weather_tool.name == "get_weather"
135 | assert datetime_tool.name == "get_date"
136 |
137 | @pytest.mark.asyncio
138 | async def test_mcp_tool_docstrings(self):
139 | """Test that MCP tool functions have proper docstrings."""
140 | from src.mcp_server.server import get_date, get_weather, roll_dice
141 |
142 | assert roll_dice.__doc__ is not None
143 | assert "dice" in roll_dice.__doc__.lower()
144 | assert "notation" in roll_dice.__doc__.lower()
145 |
146 | assert get_weather.__doc__ is not None
147 | assert "weather" in get_weather.__doc__.lower()
148 | assert "location" in get_weather.__doc__.lower()
149 |
150 | assert get_date.__doc__ is not None
151 | assert "date" in get_date.__doc__.lower()
152 | assert "timezone" in get_date.__doc__.lower()
153 |
154 | @pytest.mark.asyncio
155 | async def test_all_tools_return_proper_format(self):
156 | """Test that all tools return the expected MCP response format."""
157 | from src.mcp_server.server import get_date, roll_dice
158 |
159 | # Test dice tool
160 | dice_result = await roll_dice(notation="1d6")
161 | assert "content" in dice_result
162 | assert "isError" in dice_result
163 | assert isinstance(dice_result["content"], list)
164 | assert len(dice_result["content"]) == 1
165 | assert "type" in dice_result["content"][0]
166 | assert "text" in dice_result["content"][0]
167 |
168 | # Test date tool
169 | date_result = await get_date(timezone="UTC")
170 | assert "content" in date_result
171 | assert "isError" in date_result
172 | assert isinstance(date_result["content"], list)
173 | assert len(date_result["content"]) == 1
174 | assert "type" in date_result["content"][0]
175 | assert "text" in date_result["content"][0]
176 |
177 | @pytest.mark.asyncio
178 | async def test_weather_tool_with_coordinates(self):
179 | """Test weather tool with coordinate input."""
180 | from src.mcp_server.server import get_weather
181 |
182 | mock_response = WeatherAPIFixtures.current_weather_response()
183 |
184 | with patch.object(weather_tool, "make_request", return_value=mock_response):
185 | result = await get_weather(location="37.7749,-122.4194")
186 |
187 | assert "content" in result
188 | assert "isError" in result
189 | assert result["isError"] is False
190 | assert "37.7749,-122.4194" in result["content"][0]["text"]
191 |
--------------------------------------------------------------------------------
/examples/mcp-server-client/tests/test_tools/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qte77/context-engineering-template/e35a269c47a5c9a91c90609d25c9b1a611f1858f/examples/mcp-server-client/tests/test_tools/__init__.py
--------------------------------------------------------------------------------
/examples/mcp-server-client/tests/test_tools/test_dice.py:
--------------------------------------------------------------------------------
1 | """Tests for the dice rolling tool."""
2 |
3 | import pytest
4 |
5 | from src.mcp_server.tools.base import ValidationToolError
6 | from src.mcp_server.tools.dice import DiceRollTool
7 |
8 |
9 | class TestDiceRollTool:
10 | """Test suite for DiceRollTool."""
11 |
12 | @pytest.fixture
13 | def dice_tool(self):
14 | """Create a DiceRollTool instance for testing."""
15 | return DiceRollTool()
16 |
17 | @pytest.mark.asyncio
18 | async def test_roll_dice_valid_notation(self, dice_tool):
19 | """Test valid dice notation works correctly."""
20 | result = await dice_tool.execute(notation="2d6")
21 |
22 | assert isinstance(result.values, list)
23 | assert len(result.values) == 2
24 | assert all(1 <= v <= 6 for v in result.values)
25 | assert result.total == sum(result.values)
26 | assert result.notation == "2d6"
27 |
28 | @pytest.mark.asyncio
29 | async def test_roll_dice_single_die(self, dice_tool):
30 | """Test rolling a single die."""
31 | result = await dice_tool.execute(notation="1d20")
32 |
33 | assert len(result.values) == 1
34 | assert 1 <= result.values[0] <= 20
35 | assert result.total == result.values[0]
36 | assert result.notation == "1d20"
37 |
38 | @pytest.mark.asyncio
39 | async def test_roll_dice_multiple_dice(self, dice_tool):
40 | """Test rolling multiple dice."""
41 | result = await dice_tool.execute(notation="4d10")
42 |
43 | assert len(result.values) == 4
44 | assert all(1 <= v <= 10 for v in result.values)
45 | assert result.total == sum(result.values)
46 | assert result.notation == "4d10"
47 |
48 | @pytest.mark.asyncio
49 | async def test_roll_dice_invalid_notation_format(self, dice_tool):
50 | """Test invalid dice notation raises ValidationError."""
51 | with pytest.raises(ValidationToolError) as exc_info:
52 | await dice_tool.execute(notation="d6")
53 |
54 | assert "Invalid dice notation" in str(exc_info.value)
55 |
56 | @pytest.mark.asyncio
57 | async def test_roll_dice_invalid_notation_no_d(self, dice_tool):
58 | """Test notation without 'd' raises ValidationError."""
59 | with pytest.raises(ValidationToolError) as exc_info:
60 | await dice_tool.execute(notation="2x6")
61 |
62 | assert "Invalid dice notation" in str(exc_info.value)
63 |
64 | @pytest.mark.asyncio
65 | async def test_roll_dice_zero_dice_count(self, dice_tool):
66 | """Test zero dice count raises ValidationError."""
67 | with pytest.raises(ValidationToolError) as exc_info:
68 | await dice_tool.execute(notation="0d6")
69 |
70 | assert "Dice count must be greater than 0" in str(exc_info.value)
71 |
72 | @pytest.mark.asyncio
73 | async def test_roll_dice_zero_sides(self, dice_tool):
74 | """Test zero sides raises ValidationError."""
75 | with pytest.raises(ValidationToolError) as exc_info:
76 | await dice_tool.execute(notation="1d0")
77 |
78 | assert "Number of sides must be greater than 0" in str(exc_info.value)
79 |
80 | @pytest.mark.asyncio
81 | async def test_roll_dice_too_many_dice(self, dice_tool):
82 | """Test too many dice raises ValidationError."""
83 | with pytest.raises(ValidationToolError) as exc_info:
84 | await dice_tool.execute(notation="101d6")
85 |
86 | assert "Dice count must not exceed 100" in str(exc_info.value)
87 |
88 | @pytest.mark.asyncio
89 | async def test_roll_dice_too_many_sides(self, dice_tool):
90 | """Test too many sides raises ValidationError."""
91 | with pytest.raises(ValidationToolError) as exc_info:
92 | await dice_tool.execute(notation="1d1001")
93 |
94 | assert "Number of sides must not exceed 1000" in str(exc_info.value)
95 |
96 | @pytest.mark.asyncio
97 | async def test_roll_dice_random_text(self, dice_tool):
98 | """Test random text raises ValidationError."""
99 | with pytest.raises(ValidationToolError) as exc_info:
100 | await dice_tool.execute(notation="abc")
101 |
102 | assert "Invalid dice notation" in str(exc_info.value)
103 |
104 | def test_format_result_single_die(self, dice_tool):
105 | """Test formatting result for single die."""
106 | from src.mcp_server.models import DiceRollResponse
107 |
108 | response = DiceRollResponse(values=[15], total=15, notation="1d20")
109 | formatted = dice_tool.format_result(response)
110 |
111 | assert "🎲" in formatted
112 | assert "1d20" in formatted
113 | assert "**15**" in formatted
114 |
115 | def test_format_result_multiple_dice(self, dice_tool):
116 | """Test formatting result for multiple dice."""
117 | from src.mcp_server.models import DiceRollResponse
118 |
119 | response = DiceRollResponse(values=[4, 2, 6], total=12, notation="3d6")
120 | formatted = dice_tool.format_result(response)
121 |
122 | assert "🎲" in formatted
123 | assert "3d6" in formatted
124 | assert "[4, 2, 6]" in formatted
125 | assert "**12**" in formatted
126 |
127 | @pytest.mark.asyncio
128 | async def test_safe_execute_success(self, dice_tool):
129 | """Test safe_execute returns proper success format."""
130 | result = await dice_tool.safe_execute(notation="2d6")
131 |
132 | assert "content" in result
133 | assert "isError" in result
134 | assert result["isError"] is False
135 | assert len(result["content"]) == 1
136 | assert result["content"][0]["type"] == "text"
137 | assert "🎲" in result["content"][0]["text"]
138 |
139 | @pytest.mark.asyncio
140 | async def test_safe_execute_error(self, dice_tool):
141 | """Test safe_execute returns proper error format."""
142 | result = await dice_tool.safe_execute(notation="invalid")
143 |
144 | assert "content" in result
145 | assert "isError" in result
146 | assert result["isError"] is True
147 | assert len(result["content"]) == 1
148 | assert result["content"][0]["type"] == "text"
149 | assert "Invalid input" in result["content"][0]["text"]
150 |
151 | @pytest.mark.asyncio
152 | async def test_roll_dice_case_insensitive(self, dice_tool):
153 | """Test dice notation is case insensitive."""
154 | result1 = await dice_tool.execute(notation="2D6")
155 | result2 = await dice_tool.execute(notation="2d6")
156 |
157 | # Both should work and produce valid results
158 | assert len(result1.values) == 2
159 | assert len(result2.values) == 2
160 | assert all(1 <= v <= 6 for v in result1.values)
161 | assert all(1 <= v <= 6 for v in result2.values)
162 |
--------------------------------------------------------------------------------
/mkdocs.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | # https://github.com/james-willett/mkdocs-material-youtube-tutorial
3 | # https://mkdocstrings.github.io/recipes/
4 | # site info set in workflow
5 | site_name: ''
6 | site_description: ''
7 | repo_url: ''
8 | edit_uri: edit/main
9 | theme:
10 | name: material
11 | language: en
12 | features:
13 | - content.code.annotation
14 | - content.code.copy
15 | - content.tabs.link
16 | - navigation.footer
17 | - navigation.sections
18 | - navigation.tabs
19 | - navigation.top
20 | - toc.integrate
21 | - search.suggest
22 | - search.highlight
23 | palette:
24 | - media: "(prefers-color-scheme: light)"
25 | scheme: default
26 | toggle:
27 | # icon: material/brightness-7
28 | icon: material/toggle-switch-off-outline
29 | name: "Toggle Dark Mode"
30 | - media: "(prefers-color-scheme: dark)"
31 | scheme: slate
32 | toggle:
33 | # icon: material/brightness-4
34 | icon: material/toggle-switch
35 | name: "Toggle Light Mode"
36 | nav:
37 | - Home: index.md
38 | - Code: docstrings.md
39 | - Change Log: CHANGELOG.md
40 | - License: LICENSE
41 | - llms.txt: llms.txt
42 | plugins:
43 | - search:
44 | lang: en
45 | - autorefs
46 | - mkdocstrings:
47 | handlers:
48 | python:
49 | paths: [src]
50 | options:
51 | show_root_heading: true
52 | show_root_full_path: true
53 | show_object_full_path: false
54 | show_root_members_full_path: false
55 | show_category_heading: true
56 | show_submodules: true
57 | markdown_extensions:
58 | - attr_list
59 | - pymdownx.magiclink
60 | - pymdownx.tabbed
61 | - pymdownx.highlight:
62 | anchor_linenums: true
63 | - pymdownx.superfences
64 | - pymdownx.snippets:
65 | check_paths: true
66 | - pymdownx.tasklist:
67 | custom_checkbox: true
68 | - sane_lists
69 | - smarty
70 | - toc:
71 | permalink: true
72 | validation:
73 | links:
74 | not_found: warn
75 | anchors: warn
76 | # builds only if validation succeeds while
77 | # threating warnings as errors
78 | # also checks for broken links
79 | # strict: true
80 | ...
81 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["hatchling"]
3 | build-backend = "hatchling.build"
4 |
5 | [project]
6 | version = "0.0.2"
7 | name = "context-engineering-template"
8 | description = "Assess the effectiveness of agentic AI systems across various use cases focusing on agnostic metrics that measure core agentic capabilities."
9 | authors = [
10 | {name = "qte77", email = "qte@77.gh"}
11 | ]
12 | readme = "README.md"
13 | requires-python = "==3.13.*"
14 | license = "bsd-3-clause"
15 | dependencies = []
16 |
17 | [project.urls]
18 | Documentation = "https://qte77.github.io/context-engineering-template/"
19 |
20 | [dependency-groups]
21 | dev = []
22 | test = []
23 | docs = [
24 | "griffe>=1.5.1",
25 | "mkdocs>=1.6.1",
26 | "mkdocs-awesome-pages-plugin>=2.9.3",
27 | "mkdocs-gen-files>=0.5.0",
28 | "mkdocs-literate-nav>=0.6.1",
29 | "mkdocs-material>=9.5.44",
30 | "mkdocs-section-index>=0.3.8",
31 | "mkdocstrings[python]>=0.27.0",
32 | ]
33 |
34 | [tool.uv]
35 | package = true
36 | exclude-newer = "2025-07-06T00:00:00Z"
37 |
38 | [tool.hatch.build.targets.wheel]
39 | only-include = ["/README.md"]
40 |
41 | [tool.hatch.build.targets.sdist]
42 | include = ["/README.md", "/Makefile", "/tests"]
43 |
44 | [tool.ruff]
45 | target-version = "py313"
46 | src = ["src", "tests"]
47 |
48 | [tool.ruff.format]
49 | docstring-code-format = true
50 |
51 | [tool.ruff.lint]
52 | # ignore = ["E203"] # Whitespace before ':'
53 | unfixable = ["B"]
54 | select = [
55 | # pycodestyle
56 | "E",
57 | # Pyflakes
58 | "F",
59 | # pyupgrade
60 | "UP",
61 | # isort
62 | "I",
63 | ]
64 |
65 | [tool.ruff.lint.isort]
66 | known-first-party = ["src", "tests"]
67 |
68 | [tool.ruff.lint.pydocstyle]
69 | convention = "google"
70 |
71 | [tool.mypy]
72 | python_version = "3.13"
73 | strict = true
74 | disallow_untyped_defs = true
75 | disallow_any_generics = true
76 | warn_redundant_casts = true
77 | warn_unused_ignores = true
78 | warn_return_any = true
79 | warn_unreachable = true
80 | show_error_codes = true
81 | namespace_packages = true
82 | explicit_package_bases = true
83 | mypy_path = "src"
84 |
85 | [tool.pytest.ini_options]
86 | addopts = "--strict-markers"
87 | # "function", "class", "module", "package", "session"
88 | asyncio_default_fixture_loop_scope = "function"
89 | pythonpath = ["src"]
90 | testpaths = ["tests/"]
91 |
92 | [tool.coverage]
93 | [tool.coverage.run]
94 | include = [
95 | "tests/**/*.py",
96 | ]
97 | # omit = []
98 | # branch = true
99 |
100 | [tool.coverage.report]
101 | show_missing = true
102 | exclude_lines = [
103 | # 'pragma: no cover',
104 | 'raise AssertionError',
105 | 'raise NotImplementedError',
106 | ]
107 | omit = [
108 | 'env/*',
109 | 'venv/*',
110 | '.venv/*',
111 | '*/virtualenv/*',
112 | '*/virtualenvs/*',
113 | '*/tests/*',
114 | ]
115 |
116 | [tool.bumpversion]
117 | current_version = "0.0.2"
118 | parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)"
119 | serialize = ["{major}.{minor}.{patch}"]
120 | commit = true
121 | tag = true
122 | allow_dirty = false
123 | ignore_missing_version = false
124 | sign_tags = false
125 | tag_name = "v{new_version}"
126 | tag_message = "Bump version: {current_version} → {new_version}"
127 | message = "Bump version: {current_version} → {new_version}"
128 | commit_args = ""
129 |
130 | [[tool.bumpversion.files]]
131 | filename = "pyproject.toml"
132 | search = 'version = "{current_version}"'
133 | replace = 'version = "{new_version}"'
134 |
135 | [[tool.bumpversion.files]]
136 | filename = "src/__init__.py"
137 | search = '__version__ = "{current_version}"'
138 | replace = '__version__ = "{new_version}"'
139 |
140 | [[tool.bumpversion.files]]
141 | filename = "README.md"
142 | search = "version-{current_version}-58f4c2"
143 | replace = "version-{new_version}-58f4c2"
144 |
145 | [[tool.bumpversion.files]]
146 | filename = "CHANGELOG.md"
147 | search = """
148 | ## [Unreleased]
149 | """
150 | replace = """
151 | ## [Unreleased]
152 |
153 | ## [{new_version}] - {now:%Y-%m-%d}
154 | """
--------------------------------------------------------------------------------
/src/__init__.py:
--------------------------------------------------------------------------------
1 | """Defines the application version."""
2 |
3 | __version__ = "0.0.2"
4 |
--------------------------------------------------------------------------------
/src/main.py:
--------------------------------------------------------------------------------
1 | """Contains the entrypoint to the app."""
2 |
3 | pass
4 |
--------------------------------------------------------------------------------
/src/py.typed:
--------------------------------------------------------------------------------
1 | # PEP 561 – Distributing and Packaging Type Information
2 | # https://peps.python.org/pep-0561/
--------------------------------------------------------------------------------
================================================
FILE: docs/architecture/sequence_diagram.mermaid
================================================
sequenceDiagram
participant User
participant feature_DESC_XXX.md
participant ClaudeCode
participant generate-prp.md
participant feature_PRP_XXX.md
participant execute-prp.md
participant AI Coding Agent
User->>/context/features/feature_DESC_XXX.md: Describes feature
User->>ClaudeCode: Runs /generate-prp.md /context/features/feature_DESC_XXX.md
ClaudeCode->>generate-prp.md: Executes command
generate-prp.md->>AI Coding Agent: Reads /context/features/feature_DESC_XXX.md, codebase, and web
AI Coding Agent-->>generate-prp.md: Researches codebase, docs, and examples
generate-prp.md->>/context/features/feature_PRP_XXX.md: Generates PRP
ClaudeCode->>User: PRP saved in /context/PRPs/feature_PRP_XXX.md
User->>ClaudeCode: Runs /execute-prp /context/PRPs/feature_PRP_XXX.md
ClaudeCode->>execute-prp.md: Executes command
execute-prp.md->>AI Coding Agent: Reads /context/PRPs/feature_PRP_XXX.md and CLAUDE.md
AI Coding Agent->>AI Coding Agent: Creates implementation plan, executes, validates, and iterates
AI Coding Agent-->>User: Implements feature
================================================
FILE: examples/mcp-server-client/README.md
================================================
# MCP Server Client Example
This is an example implementation of an MCP (Message Control Protocol) server and client with a Streamlit GUI.
## Features
- MCP server with tools for date/time, dice rolling, and weather
- MCP client for interacting with the server
- Streamlit GUI for user interaction
## Usage
Run the GUI:
```bash
make run_gui
```
Run the CLI:
```bash
make run_cli
```
## Screenshots
Connect MCP
MCP connected
================================================
FILE: examples/mcp-server-client/Makefile
================================================
# MCP Server-Client Example Makefile
# This Makefile provides commands for the MCP server-client example
.SILENT:
.ONESHELL:
.PHONY: setup_dev setup_prod ruff test_all check_types coverage_all run_gui run_server run_client help
.DEFAULT_GOAL := help
SRC_PATH := src
APP_PATH := $(SRC_PATH)
# MARK: setup
setup_dev: ## Install uv and development dependencies
echo "Setting up dev environment ..."
pip install uv -q
uv sync --all-groups
setup_prod: ## Install uv and production dependencies
echo "Setting up prod environment ..."
pip install uv -q
uv sync --frozen
# MARK: code quality
ruff: ## Format and lint code with ruff
uv run ruff format
uv run ruff check --fix
test_all: ## Run all tests
uv run pytest
coverage_all: ## Get test coverage
uv run coverage run -m pytest || true
uv run coverage report -m
check_types: ## Check for static typing errors
uv run mypy $(APP_PATH)
# MARK: run
run_gui: ## Launch Streamlit GUI
uv run python -m src.main gui $(ARGS)
run_server: ## Run MCP server
uv run python -m src.main server
run_client: ## Run MCP client (requires ARGS)
uv run python -m src.main client $(ARGS)
# MARK: help
help: ## Display available commands
echo "Usage: make [command]"
echo "Commands:"
awk '/^[a-zA-Z0-9_-]+:.*?##/ {
helpMessage = match($$0, /## (.*)/)
if (helpMessage) {
recipe = $$1
sub(/:/, "", recipe)
printf " \033[36m%-20s\033[0m %s\n", recipe, substr($$0, RSTART + 3, RLENGTH)
}
}' $(MAKEFILE_LIST)
================================================
FILE: examples/mcp-server-client/pyproject.toml
================================================
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
version = "0.0.1"
name = "context-engineering-template"
description = "Assess the effectiveness of agentic AI systems across various use cases focusing on agnostic metrics that measure core agentic capabilities."
authors = [
{name = "qte77", email = "qte@77.gh"}
]
readme = "README.md"
requires-python = "==3.13.*"
license = "bsd-3-clause"
dependencies = [
"mcp[cli]>=1.10.0",
"httpx>=0.25.0",
"pydantic>=2.0.0",
"streamlit>=1.28.0",
]
# [project.urls]
# Documentation = ""
[dependency-groups]
dev = [
"mypy>=1.16.0",
"ruff>=0.11.12",
]
test = [
"pytest>=7.0.0",
"pytest-asyncio>=0.21.0",
"pytest-httpx>=0.28.0",
]
docs = [
"griffe>=1.5.1",
"mkdocs>=1.6.1",
"mkdocs-awesome-pages-plugin>=2.9.3",
"mkdocs-gen-files>=0.5.0",
"mkdocs-literate-nav>=0.6.1",
"mkdocs-material>=9.5.44",
"mkdocs-section-index>=0.3.8",
"mkdocstrings[python]>=0.27.0",
]
[tool.uv]
package = true
exclude-newer = "2025-07-06T00:00:00Z"
[tool.hatch.build.targets.wheel]
only-include = ["/README.md"]
[tool.hatch.build.targets.sdist]
include = ["/README.md", "/Makefile", "/tests"]
[tool.ruff]
target-version = "py313"
src = ["src", "tests"]
[tool.ruff.format]
docstring-code-format = true
[tool.ruff.lint]
# ignore = ["E203"] # Whitespace before ':'
unfixable = ["B"]
select = [
# pycodestyle
"E",
# Pyflakes
"F",
# pyupgrade
"UP",
# isort
"I",
]
[tool.ruff.lint.isort]
known-first-party = ["src", "tests"]
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.mypy]
python_version = "3.13"
strict = true
disallow_untyped_defs = true
disallow_any_generics = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_return_any = true
warn_unreachable = true
show_error_codes = true
namespace_packages = true
explicit_package_bases = true
mypy_path = "src"
[tool.pytest.ini_options]
addopts = "--strict-markers"
# "function", "class", "module", "package", "session"
asyncio_default_fixture_loop_scope = "function"
pythonpath = ["src"]
testpaths = ["tests/"]
[tool.coverage]
[tool.coverage.run]
include = [
"tests/**/*.py",
]
# omit = []
# branch = true
[tool.coverage.report]
show_missing = true
exclude_lines = [
# 'pragma: no cover',
'raise AssertionError',
'raise NotImplementedError',
]
omit = [
'env/*',
'venv/*',
'.venv/*',
'*/virtualenv/*',
'*/virtualenvs/*',
'*/tests/*',
]
[tool.bumpversion]
current_version = "0.0.1"
parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)"
serialize = ["{major}.{minor}.{patch}"]
commit = true
tag = true
allow_dirty = false
ignore_missing_version = false
sign_tags = false
tag_name = "v{new_version}"
tag_message = "Bump version: {current_version} → {new_version}"
message = "Bump version: {current_version} → {new_version}"
commit_args = ""
[[tool.bumpversion.files]]
filename = "pyproject.toml"
search = 'version = "{current_version}"'
replace = 'version = "{new_version}"'
[[tool.bumpversion.files]]
filename = "src/__init__.py"
search = '__version__ = "{current_version}"'
replace = '__version__ = "{new_version}"'
[[tool.bumpversion.files]]
filename = "README.md"
search = "version-{current_version}-58f4c2"
replace = "version-{new_version}-58f4c2"
[[tool.bumpversion.files]]
filename = "CHANGELOG.md"
search = """
## [Unreleased]
"""
replace = """
## [Unreleased]
## [{new_version}] - {now:%Y-%m-%d}
"""
================================================
FILE: examples/mcp-server-client/uv.lock
================================================
version = 1
revision = 2
requires-python = "==3.13.*"
[options]
exclude-newer = "2025-07-06T00:00:00Z"
[[package]]
name = "altair"
version = "5.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jinja2" },
{ name = "jsonschema" },
{ name = "narwhals" },
{ name = "packaging" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/16/b1/f2969c7bdb8ad8bbdda031687defdce2c19afba2aa2c8e1d2a17f78376d8/altair-5.5.0.tar.gz", hash = "sha256:d960ebe6178c56de3855a68c47b516be38640b73fb3b5111c2a9ca90546dd73d", size = 705305, upload-time = "2024-11-23T23:39:58.542Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/aa/f3/0b6ced594e51cc95d8c1fc1640d3623770d01e4969d29c0bd09945fafefa/altair-5.5.0-py3-none-any.whl", hash = "sha256:91a310b926508d560fe0148d02a194f38b824122641ef528113d029fcd129f8c", size = 731200, upload-time = "2024-11-23T23:39:56.4Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "sniffio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" },
]
[[package]]
name = "attrs"
version = "25.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" },
]
[[package]]
name = "babel"
version = "2.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" },
]
[[package]]
name = "backrefs"
version = "5.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/eb/a7/312f673df6a79003279e1f55619abbe7daebbb87c17c976ddc0345c04c7b/backrefs-5.9.tar.gz", hash = "sha256:808548cb708d66b82ee231f962cb36faaf4f2baab032f2fbb783e9c2fdddaa59", size = 5765857, upload-time = "2025-06-22T19:34:13.97Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/19/4d/798dc1f30468134906575156c089c492cf79b5a5fd373f07fe26c4d046bf/backrefs-5.9-py310-none-any.whl", hash = "sha256:db8e8ba0e9de81fcd635f440deab5ae5f2591b54ac1ebe0550a2ca063488cd9f", size = 380267, upload-time = "2025-06-22T19:34:05.252Z" },
{ url = "https://files.pythonhosted.org/packages/55/07/f0b3375bf0d06014e9787797e6b7cc02b38ac9ff9726ccfe834d94e9991e/backrefs-5.9-py311-none-any.whl", hash = "sha256:6907635edebbe9b2dc3de3a2befff44d74f30a4562adbb8b36f21252ea19c5cf", size = 392072, upload-time = "2025-06-22T19:34:06.743Z" },
{ url = "https://files.pythonhosted.org/packages/9d/12/4f345407259dd60a0997107758ba3f221cf89a9b5a0f8ed5b961aef97253/backrefs-5.9-py312-none-any.whl", hash = "sha256:7fdf9771f63e6028d7fee7e0c497c81abda597ea45d6b8f89e8ad76994f5befa", size = 397947, upload-time = "2025-06-22T19:34:08.172Z" },
{ url = "https://files.pythonhosted.org/packages/10/bf/fa31834dc27a7f05e5290eae47c82690edc3a7b37d58f7fb35a1bdbf355b/backrefs-5.9-py313-none-any.whl", hash = "sha256:cc37b19fa219e93ff825ed1fed8879e47b4d89aa7a1884860e2db64ccd7c676b", size = 399843, upload-time = "2025-06-22T19:34:09.68Z" },
{ url = "https://files.pythonhosted.org/packages/41/ff/392bff89415399a979be4a65357a41d92729ae8580a66073d8ec8d810f98/backrefs-5.9-py39-none-any.whl", hash = "sha256:f48ee18f6252b8f5777a22a00a09a85de0ca931658f1dd96d4406a34f3748c60", size = 380265, upload-time = "2025-06-22T19:34:12.405Z" },
]
[[package]]
name = "blinker"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
]
[[package]]
name = "bracex"
version = "2.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/63/9a/fec38644694abfaaeca2798b58e276a8e61de49e2e37494ace423395febc/bracex-2.6.tar.gz", hash = "sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7", size = 26642, upload-time = "2025-06-22T19:12:31.254Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/2a/9186535ce58db529927f6cf5990a849aa9e052eea3e2cfefe20b9e1802da/bracex-2.6-py3-none-any.whl", hash = "sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952", size = 11508, upload-time = "2025-06-22T19:12:29.781Z" },
]
[[package]]
name = "cachetools"
version = "6.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8a/89/817ad5d0411f136c484d535952aef74af9b25e0d99e90cdffbe121e6d628/cachetools-6.1.0.tar.gz", hash = "sha256:b4c4f404392848db3ce7aac34950d17be4d864da4b8b66911008e430bc544587", size = 30714, upload-time = "2025-06-16T18:51:03.07Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/f0/2ef431fe4141f5e334759d73e81120492b23b2824336883a91ac04ba710b/cachetools-6.1.0-py3-none-any.whl", hash = "sha256:1c7bb3cf9193deaf3508b7c5f2a79986c13ea38965c5adcff1f84519cf39163e", size = 11189, upload-time = "2025-06-16T18:51:01.514Z" },
]
[[package]]
name = "certifi"
version = "2025.6.15"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/73/f7/f14b46d4bcd21092d7d3ccef689615220d8a08fb25e564b65d20738e672e/certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b", size = 158753, upload-time = "2025-06-15T02:45:51.329Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/ae/320161bd181fc06471eed047ecce67b693fd7515b16d495d8932db763426/certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057", size = 157650, upload-time = "2025-06-15T02:45:49.977Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload-time = "2025-05-02T08:32:56.363Z" },
{ url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload-time = "2025-05-02T08:32:58.551Z" },
{ url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload-time = "2025-05-02T08:33:00.342Z" },
{ url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload-time = "2025-05-02T08:33:02.081Z" },
{ url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload-time = "2025-05-02T08:33:04.063Z" },
{ url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload-time = "2025-05-02T08:33:06.418Z" },
{ url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload-time = "2025-05-02T08:33:08.183Z" },
{ url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload-time = "2025-05-02T08:33:09.986Z" },
{ url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload-time = "2025-05-02T08:33:11.814Z" },
{ url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload-time = "2025-05-02T08:33:13.707Z" },
{ url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload-time = "2025-05-02T08:33:15.458Z" },
{ url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" },
{ url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" },
{ url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" },
]
[[package]]
name = "click"
version = "8.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "context-engineering-template"
version = "0.0.1"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
{ name = "mcp", extra = ["cli"] },
{ name = "pydantic" },
{ name = "streamlit" },
]
[package.dev-dependencies]
dev = [
{ name = "mypy" },
{ name = "ruff" },
]
docs = [
{ name = "griffe" },
{ name = "mkdocs" },
{ name = "mkdocs-awesome-pages-plugin" },
{ name = "mkdocs-gen-files" },
{ name = "mkdocs-literate-nav" },
{ name = "mkdocs-material" },
{ name = "mkdocs-section-index" },
{ name = "mkdocstrings", extra = ["python"] },
]
test = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-httpx" },
]
[package.metadata]
requires-dist = [
{ name = "httpx", specifier = ">=0.25.0" },
{ name = "mcp", extras = ["cli"], specifier = ">=1.10.0" },
{ name = "pydantic", specifier = ">=2.0.0" },
{ name = "streamlit", specifier = ">=1.28.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "mypy", specifier = ">=1.16.0" },
{ name = "ruff", specifier = ">=0.11.12" },
]
docs = [
{ name = "griffe", specifier = ">=1.5.1" },
{ name = "mkdocs", specifier = ">=1.6.1" },
{ name = "mkdocs-awesome-pages-plugin", specifier = ">=2.9.3" },
{ name = "mkdocs-gen-files", specifier = ">=0.5.0" },
{ name = "mkdocs-literate-nav", specifier = ">=0.6.1" },
{ name = "mkdocs-material", specifier = ">=9.5.44" },
{ name = "mkdocs-section-index", specifier = ">=0.3.8" },
{ name = "mkdocstrings", extras = ["python"], specifier = ">=0.27.0" },
]
test = [
{ name = "pytest", specifier = ">=7.0.0" },
{ name = "pytest-asyncio", specifier = ">=0.21.0" },
{ name = "pytest-httpx", specifier = ">=0.28.0" },
]
[[package]]
name = "ghp-import"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dateutil" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" },
]
[[package]]
name = "gitdb"
version = "4.0.12"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "smmap" },
]
sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" },
]
[[package]]
name = "gitpython"
version = "3.1.44"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "gitdb" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/89/37df0b71473153574a5cdef8f242de422a0f5d26d7a9e231e6f169b4ad14/gitpython-3.1.44.tar.gz", hash = "sha256:c87e30b26253bf5418b01b0660f818967f3c503193838337fe5e573331249269", size = 214196, upload-time = "2025-01-02T07:32:43.59Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/9a/4114a9057db2f1462d5c8f8390ab7383925fe1ac012eaa42402ad65c2963/GitPython-3.1.44-py3-none-any.whl", hash = "sha256:9e0e10cda9bed1ee64bc9a6de50e7e38a9c9943241cd7f585f6df3ed28011110", size = 207599, upload-time = "2025-01-02T07:32:40.731Z" },
]
[[package]]
name = "griffe"
version = "1.7.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/3e/5aa9a61f7c3c47b0b52a1d930302992229d191bf4bc76447b324b731510a/griffe-1.7.3.tar.gz", hash = "sha256:52ee893c6a3a968b639ace8015bec9d36594961e156e23315c8e8e51401fa50b", size = 395137, upload-time = "2025-04-23T11:29:09.147Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/c6/5c20af38c2a57c15d87f7f38bee77d63c1d2a3689f74fefaf35915dd12b2/griffe-1.7.3-py3-none-any.whl", hash = "sha256:c6b3ee30c2f0f17f30bcdef5068d6ab7a2a4f1b8bf1a3e74b56fffd21e1c5f75", size = 129303, upload-time = "2025-04-23T11:29:07.145Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
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.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6e/fa/66bd985dd0b7c109a3bcb89272ee0bfb7e2b4d06309ad7b38ff866734b2a/httpx_sse-0.4.1.tar.gz", hash = "sha256:8f44d34414bc7b21bf3602713005c5df4917884f76072479b21f68befa4ea26e", size = 12998, upload-time = "2025-06-24T13:21:05.71Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/25/0a/6269e3473b09aed2dab8aa1a600c70f31f00ae1349bee30658f7e358a159/httpx_sse-0.4.1-py3-none-any.whl", hash = "sha256:cba42174344c3a5b06f255ce65b350880f962d99ead85e776f23c6618a377a37", size = 8054, upload-time = "2025-06-24T13:21:04.772Z" },
]
[[package]]
name = "idna"
version = "3.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
]
[[package]]
name = "iniconfig"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "jsonschema"
version = "4.24.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "jsonschema-specifications" },
{ name = "referencing" },
{ name = "rpds-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bf/d3/1cf5326b923a53515d8f3a2cd442e6d7e94fcc444716e879ea70a0ce3177/jsonschema-4.24.0.tar.gz", hash = "sha256:0b4e8069eb12aedfa881333004bccaec24ecef5a8a6a4b6df142b2cc9599d196", size = 353480, upload-time = "2025-05-26T18:48:10.459Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/3d/023389198f69c722d039351050738d6755376c8fd343e91dc493ea485905/jsonschema-4.24.0-py3-none-any.whl", hash = "sha256:a462455f19f5faf404a7902952b6f0e3ce868f3ee09a359b05eca6673bd8412d", size = 88709, upload-time = "2025-05-26T18:48:08.417Z" },
]
[[package]]
name = "jsonschema-specifications"
version = "2025.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "referencing" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bf/ce/46fbd9c8119cfc3581ee5643ea49464d168028cfb5caff5fc0596d0cf914/jsonschema_specifications-2025.4.1.tar.gz", hash = "sha256:630159c9f4dbea161a6a2205c3011cc4f18ff381b189fff48bb39b9bf26ae608", size = 15513, upload-time = "2025-04-23T12:34:07.418Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/01/0e/b27cdbaccf30b890c40ed1da9fd4a3593a5cf94dae54fb34f8a4b74fcd3f/jsonschema_specifications-2025.4.1-py3-none-any.whl", hash = "sha256:4653bffbd6584f7de83a67e0d620ef16900b390ddc7939d56684d6c81e33f1af", size = 18437, upload-time = "2025-04-23T12:34:05.422Z" },
]
[[package]]
name = "markdown"
version = "3.8.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/c2/4ab49206c17f75cb08d6311171f2d65798988db4360c4d1485bd0eedd67c/markdown-3.8.2.tar.gz", hash = "sha256:247b9a70dd12e27f67431ce62523e675b866d254f900c4fe75ce3dda62237c45", size = 362071, upload-time = "2025-06-19T17:12:44.483Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/96/2b/34cc11786bc00d0f04d0f5fdc3a2b1ae0b6239eef72d3d345805f9ad92a1/markdown-3.8.2-py3-none-any.whl", hash = "sha256:5c83764dbd4e00bdd94d85a19b8d55ccca20fe35b2e678a1422b380324dd5f24", size = 106827, upload-time = "2025-06-19T17:12:42.994Z" },
]
[[package]]
name = "markdown-it-py"
version = "3.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" },
{ url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" },
{ url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" },
{ url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" },
{ url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" },
{ url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" },
{ url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" },
{ url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" },
{ url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" },
{ url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" },
{ url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" },
{ url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" },
{ url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" },
{ url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" },
{ url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" },
{ url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" },
{ url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" },
{ url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" },
{ url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" },
{ url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" },
]
[[package]]
name = "mcp"
version = "1.10.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "httpx" },
{ name = "httpx-sse" },
{ name = "jsonschema" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "python-multipart" },
{ name = "sse-starlette" },
{ name = "starlette" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7c/68/63045305f29ff680a9cd5be360c755270109e6b76f696ea6824547ddbc30/mcp-1.10.1.tar.gz", hash = "sha256:aaa0957d8307feeff180da2d9d359f2b801f35c0c67f1882136239055ef034c2", size = 392969, upload-time = "2025-06-27T12:03:08.982Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/3f/435a5b3d10ae242a9d6c2b33175551173c3c61fe637dc893be05c4ed0aaf/mcp-1.10.1-py3-none-any.whl", hash = "sha256:4d08301aefe906dce0fa482289db55ce1db831e3e67212e65b5e23ad8454b3c5", size = 150878, upload-time = "2025-06-27T12:03:07.328Z" },
]
[package.optional-dependencies]
cli = [
{ name = "python-dotenv" },
{ name = "typer" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "mergedeep"
version = "1.3.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" },
]
[[package]]
name = "mkdocs"
version = "1.6.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "ghp-import" },
{ name = "jinja2" },
{ name = "markdown" },
{ name = "markupsafe" },
{ name = "mergedeep" },
{ name = "mkdocs-get-deps" },
{ name = "packaging" },
{ name = "pathspec" },
{ name = "pyyaml" },
{ name = "pyyaml-env-tag" },
{ name = "watchdog" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" },
]
[[package]]
name = "mkdocs-autorefs"
version = "1.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown" },
{ name = "markupsafe" },
{ name = "mkdocs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/47/0c/c9826f35b99c67fa3a7cddfa094c1a6c43fafde558c309c6e4403e5b37dc/mkdocs_autorefs-1.4.2.tar.gz", hash = "sha256:e2ebe1abd2b67d597ed19378c0fff84d73d1dbce411fce7a7cc6f161888b6749", size = 54961, upload-time = "2025-05-20T13:09:09.886Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/87/dc/fc063b78f4b769d1956319351704e23ebeba1e9e1d6a41b4b602325fd7e4/mkdocs_autorefs-1.4.2-py3-none-any.whl", hash = "sha256:83d6d777b66ec3c372a1aad4ae0cf77c243ba5bcda5bf0c6b8a2c5e7a3d89f13", size = 24969, upload-time = "2025-05-20T13:09:08.237Z" },
]
[[package]]
name = "mkdocs-awesome-pages-plugin"
version = "2.10.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mkdocs" },
{ name = "natsort" },
{ name = "wcmatch" },
]
sdist = { url = "https://files.pythonhosted.org/packages/92/e8/6ae9c18d8174a5d74ce4ade7a7f4c350955063968bc41ff1e5833cff4a2b/mkdocs_awesome_pages_plugin-2.10.1.tar.gz", hash = "sha256:cda2cb88c937ada81a4785225f20ef77ce532762f4500120b67a1433c1cdbb2f", size = 16303, upload-time = "2024-12-22T21:13:49.19Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/73/61/19fc1e9c579dbfd4e8a402748f1d63cab7aabe8f8d91eb0235e45b32d040/mkdocs_awesome_pages_plugin-2.10.1-py3-none-any.whl", hash = "sha256:c6939dbea37383fc3cf8c0a4e892144ec3d2f8a585e16fdc966b34e7c97042a7", size = 15118, upload-time = "2024-12-22T21:13:46.945Z" },
]
[[package]]
name = "mkdocs-gen-files"
version = "0.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mkdocs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/48/85/2d634462fd59136197d3126ca431ffb666f412e3db38fd5ce3a60566303e/mkdocs_gen_files-0.5.0.tar.gz", hash = "sha256:4c7cf256b5d67062a788f6b1d035e157fc1a9498c2399be9af5257d4ff4d19bc", size = 7539, upload-time = "2023-04-27T19:48:04.894Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/0f/1e55b3fd490ad2cecb6e7b31892d27cb9fc4218ec1dab780440ba8579e74/mkdocs_gen_files-0.5.0-py3-none-any.whl", hash = "sha256:7ac060096f3f40bd19039e7277dd3050be9a453c8ac578645844d4d91d7978ea", size = 8380, upload-time = "2023-04-27T19:48:07.059Z" },
]
[[package]]
name = "mkdocs-get-deps"
version = "0.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mergedeep" },
{ name = "platformdirs" },
{ name = "pyyaml" },
]
sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239, upload-time = "2023-11-20T17:51:09.981Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" },
]
[[package]]
name = "mkdocs-literate-nav"
version = "0.6.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mkdocs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/5f/99aa379b305cd1c2084d42db3d26f6de0ea9bf2cc1d10ed17f61aff35b9a/mkdocs_literate_nav-0.6.2.tar.gz", hash = "sha256:760e1708aa4be86af81a2b56e82c739d5a8388a0eab1517ecfd8e5aa40810a75", size = 17419, upload-time = "2025-03-18T21:53:09.711Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/84/b5b14d2745e4dd1a90115186284e9ee1b4d0863104011ab46abb7355a1c3/mkdocs_literate_nav-0.6.2-py3-none-any.whl", hash = "sha256:0a6489a26ec7598477b56fa112056a5e3a6c15729f0214bea8a4dbc55bd5f630", size = 13261, upload-time = "2025-03-18T21:53:08.1Z" },
]
[[package]]
name = "mkdocs-material"
version = "9.6.15"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "babel" },
{ name = "backrefs" },
{ name = "colorama" },
{ name = "jinja2" },
{ name = "markdown" },
{ name = "mkdocs" },
{ name = "mkdocs-material-extensions" },
{ name = "paginate" },
{ name = "pygments" },
{ name = "pymdown-extensions" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/95/c1/f804ba2db2ddc2183e900befe7dad64339a34fa935034e1ab405289d0a97/mkdocs_material-9.6.15.tar.gz", hash = "sha256:64adf8fa8dba1a17905b6aee1894a5aafd966d4aeb44a11088519b0f5ca4f1b5", size = 3951836, upload-time = "2025-07-01T10:14:15.671Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/30/dda19f0495a9096b64b6b3c07c4bfcff1c76ee0fc521086d53593f18b4c0/mkdocs_material-9.6.15-py3-none-any.whl", hash = "sha256:ac969c94d4fe5eb7c924b6d2f43d7db41159ea91553d18a9afc4780c34f2717a", size = 8716840, upload-time = "2025-07-01T10:14:13.18Z" },
]
[[package]]
name = "mkdocs-material-extensions"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" },
]
[[package]]
name = "mkdocs-section-index"
version = "0.3.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mkdocs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/93/40/4aa9d3cfa2ac6528b91048847a35f005b97ec293204c02b179762a85b7f2/mkdocs_section_index-0.3.10.tar.gz", hash = "sha256:a82afbda633c82c5568f0e3b008176b9b365bf4bd8b6f919d6eff09ee146b9f8", size = 14446, upload-time = "2025-04-05T20:56:45.387Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/01/53/76c109e6f822a6d19befb0450c87330b9a6ce52353de6a9dda7892060a1f/mkdocs_section_index-0.3.10-py3-none-any.whl", hash = "sha256:bc27c0d0dc497c0ebaee1fc72839362aed77be7318b5ec0c30628f65918e4776", size = 8796, upload-time = "2025-04-05T20:56:43.975Z" },
]
[[package]]
name = "mkdocstrings"
version = "0.29.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jinja2" },
{ name = "markdown" },
{ name = "markupsafe" },
{ name = "mkdocs" },
{ name = "mkdocs-autorefs" },
{ name = "pymdown-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/41/e8/d22922664a627a0d3d7ff4a6ca95800f5dde54f411982591b4621a76225d/mkdocstrings-0.29.1.tar.gz", hash = "sha256:8722f8f8c5cd75da56671e0a0c1bbed1df9946c0cef74794d6141b34011abd42", size = 1212686, upload-time = "2025-03-31T08:33:11.997Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/14/22533a578bf8b187e05d67e2c1721ce10e3f526610eebaf7a149d557ea7a/mkdocstrings-0.29.1-py3-none-any.whl", hash = "sha256:37a9736134934eea89cbd055a513d40a020d87dfcae9e3052c2a6b8cd4af09b6", size = 1631075, upload-time = "2025-03-31T08:33:09.661Z" },
]
[package.optional-dependencies]
python = [
{ name = "mkdocstrings-python" },
]
[[package]]
name = "mkdocstrings-python"
version = "1.16.12"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "griffe" },
{ name = "mkdocs-autorefs" },
{ name = "mkdocstrings" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bf/ed/b886f8c714fd7cccc39b79646b627dbea84cd95c46be43459ef46852caf0/mkdocstrings_python-1.16.12.tar.gz", hash = "sha256:9b9eaa066e0024342d433e332a41095c4e429937024945fea511afe58f63175d", size = 206065, upload-time = "2025-06-03T12:52:49.276Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/dd/a24ee3de56954bfafb6ede7cd63c2413bb842cc48eb45e41c43a05a33074/mkdocstrings_python-1.16.12-py3-none-any.whl", hash = "sha256:22ded3a63b3d823d57457a70ff9860d5a4de9e8b1e482876fc9baabaf6f5f374", size = 124287, upload-time = "2025-06-03T12:52:47.819Z" },
]
[[package]]
name = "mypy"
version = "1.16.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/69/92c7fa98112e4d9eb075a239caa4ef4649ad7d441545ccffbd5e34607cbb/mypy-1.16.1.tar.gz", hash = "sha256:6bd00a0a2094841c5e47e7374bb42b83d64c527a502e3334e1173a0c24437bab", size = 3324747, upload-time = "2025-06-16T16:51:35.145Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/28/e3/96964af4a75a949e67df4b95318fe2b7427ac8189bbc3ef28f92a1c5bc56/mypy-1.16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ddc91eb318c8751c69ddb200a5937f1232ee8efb4e64e9f4bc475a33719de438", size = 11063480, upload-time = "2025-06-16T16:47:56.205Z" },
{ url = "https://files.pythonhosted.org/packages/f5/4d/cd1a42b8e5be278fab7010fb289d9307a63e07153f0ae1510a3d7b703193/mypy-1.16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:87ff2c13d58bdc4bbe7dc0dedfe622c0f04e2cb2a492269f3b418df2de05c536", size = 10090538, upload-time = "2025-06-16T16:46:43.92Z" },
{ url = "https://files.pythonhosted.org/packages/c9/4f/c3c6b4b66374b5f68bab07c8cabd63a049ff69796b844bc759a0ca99bb2a/mypy-1.16.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a7cfb0fe29fe5a9841b7c8ee6dffb52382c45acdf68f032145b75620acfbd6f", size = 11836839, upload-time = "2025-06-16T16:36:28.039Z" },
{ url = "https://files.pythonhosted.org/packages/b4/7e/81ca3b074021ad9775e5cb97ebe0089c0f13684b066a750b7dc208438403/mypy-1.16.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:051e1677689c9d9578b9c7f4d206d763f9bbd95723cd1416fad50db49d52f359", size = 12715634, upload-time = "2025-06-16T16:50:34.441Z" },
{ url = "https://files.pythonhosted.org/packages/e9/95/bdd40c8be346fa4c70edb4081d727a54d0a05382d84966869738cfa8a497/mypy-1.16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d5d2309511cc56c021b4b4e462907c2b12f669b2dbeb68300110ec27723971be", size = 12895584, upload-time = "2025-06-16T16:34:54.857Z" },
{ url = "https://files.pythonhosted.org/packages/5a/fd/d486a0827a1c597b3b48b1bdef47228a6e9ee8102ab8c28f944cb83b65dc/mypy-1.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:4f58ac32771341e38a853c5d0ec0dfe27e18e27da9cdb8bbc882d2249c71a3ee", size = 9573886, upload-time = "2025-06-16T16:36:43.589Z" },
{ url = "https://files.pythonhosted.org/packages/cf/d3/53e684e78e07c1a2bf7105715e5edd09ce951fc3f47cf9ed095ec1b7a037/mypy-1.16.1-py3-none-any.whl", hash = "sha256:5fc2ac4027d0ef28d6ba69a0343737a23c4d1b83672bf38d1fe237bdc0643b37", size = 2265923, upload-time = "2025-06-16T16:48:02.366Z" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "narwhals"
version = "1.45.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4c/9f/284886c5cea849b4ed1c55babcb48cb1084886139e8ac31e9849112ce6d0/narwhals-1.45.0.tar.gz", hash = "sha256:f9ecefb9d09cda6fefa8ead10dc37a79129b6c78b0ac7117d21b4d4486bdd0d1", size = 508812, upload-time = "2025-07-01T11:26:07.114Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/a4/337a229d184b23ee63e6b730ac1588d77067af77c550dbf69cf1d74c3298/narwhals-1.45.0-py3-none-any.whl", hash = "sha256:0585612aa7ec89f9d061e78410b6fb8772794389d1a29d5799572d6b81999497", size = 371633, upload-time = "2025-07-01T11:26:05.409Z" },
]
[[package]]
name = "natsort"
version = "8.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e2/a9/a0c57aee75f77794adaf35322f8b6404cbd0f89ad45c87197a937764b7d0/natsort-8.4.0.tar.gz", hash = "sha256:45312c4a0e5507593da193dedd04abb1469253b601ecaf63445ad80f0a1ea581", size = 76575, upload-time = "2023-06-20T04:17:19.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/82/7a9d0550484a62c6da82858ee9419f3dd1ccc9aa1c26a1e43da3ecd20b0d/natsort-8.4.0-py3-none-any.whl", hash = "sha256:4732914fb471f56b5cce04d7bae6f164a592c7712e1c85f9ef585e197299521c", size = 38268, upload-time = "2023-06-20T04:17:17.522Z" },
]
[[package]]
name = "numpy"
version = "2.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2e/19/d7c972dfe90a353dbd3efbbe1d14a5951de80c99c9dc1b93cd998d51dc0f/numpy-2.3.1.tar.gz", hash = "sha256:1ec9ae20a4226da374362cca3c62cd753faf2f951440b0e3b98e93c235441d2b", size = 20390372, upload-time = "2025-06-21T12:28:33.469Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/bd/35ad97006d8abff8631293f8ea6adf07b0108ce6fec68da3c3fcca1197f2/numpy-2.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25a1992b0a3fdcdaec9f552ef10d8103186f5397ab45e2d25f8ac51b1a6b97e8", size = 20889381, upload-time = "2025-06-21T12:19:04.103Z" },
{ url = "https://files.pythonhosted.org/packages/f1/4f/df5923874d8095b6062495b39729178eef4a922119cee32a12ee1bd4664c/numpy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7dea630156d39b02a63c18f508f85010230409db5b2927ba59c8ba4ab3e8272e", size = 14152726, upload-time = "2025-06-21T12:19:25.599Z" },
{ url = "https://files.pythonhosted.org/packages/8c/0f/a1f269b125806212a876f7efb049b06c6f8772cf0121139f97774cd95626/numpy-2.3.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:bada6058dd886061f10ea15f230ccf7dfff40572e99fef440a4a857c8728c9c0", size = 5105145, upload-time = "2025-06-21T12:19:34.782Z" },
{ url = "https://files.pythonhosted.org/packages/6d/63/a7f7fd5f375b0361682f6ffbf686787e82b7bbd561268e4f30afad2bb3c0/numpy-2.3.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:a894f3816eb17b29e4783e5873f92faf55b710c2519e5c351767c51f79d8526d", size = 6639409, upload-time = "2025-06-21T12:19:45.228Z" },
{ url = "https://files.pythonhosted.org/packages/bf/0d/1854a4121af895aab383f4aa233748f1df4671ef331d898e32426756a8a6/numpy-2.3.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:18703df6c4a4fee55fd3d6e5a253d01c5d33a295409b03fda0c86b3ca2ff41a1", size = 14257630, upload-time = "2025-06-21T12:20:06.544Z" },
{ url = "https://files.pythonhosted.org/packages/50/30/af1b277b443f2fb08acf1c55ce9d68ee540043f158630d62cef012750f9f/numpy-2.3.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5902660491bd7a48b2ec16c23ccb9124b8abfd9583c5fdfa123fe6b421e03de1", size = 16627546, upload-time = "2025-06-21T12:20:31.002Z" },
{ url = "https://files.pythonhosted.org/packages/6e/ec/3b68220c277e463095342d254c61be8144c31208db18d3fd8ef02712bcd6/numpy-2.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:36890eb9e9d2081137bd78d29050ba63b8dab95dff7912eadf1185e80074b2a0", size = 15562538, upload-time = "2025-06-21T12:20:54.322Z" },
{ url = "https://files.pythonhosted.org/packages/77/2b/4014f2bcc4404484021c74d4c5ee8eb3de7e3f7ac75f06672f8dcf85140a/numpy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a780033466159c2270531e2b8ac063704592a0bc62ec4a1b991c7c40705eb0e8", size = 18360327, upload-time = "2025-06-21T12:21:21.053Z" },
{ url = "https://files.pythonhosted.org/packages/40/8d/2ddd6c9b30fcf920837b8672f6c65590c7d92e43084c25fc65edc22e93ca/numpy-2.3.1-cp313-cp313-win32.whl", hash = "sha256:39bff12c076812595c3a306f22bfe49919c5513aa1e0e70fac756a0be7c2a2b8", size = 6312330, upload-time = "2025-06-21T12:25:07.447Z" },
{ url = "https://files.pythonhosted.org/packages/dd/c8/beaba449925988d415efccb45bf977ff8327a02f655090627318f6398c7b/numpy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d5ee6eec45f08ce507a6570e06f2f879b374a552087a4179ea7838edbcbfa42", size = 12731565, upload-time = "2025-06-21T12:25:26.444Z" },
{ url = "https://files.pythonhosted.org/packages/0b/c3/5c0c575d7ec78c1126998071f58facfc124006635da75b090805e642c62e/numpy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:0c4d9e0a8368db90f93bd192bfa771ace63137c3488d198ee21dfb8e7771916e", size = 10190262, upload-time = "2025-06-21T12:25:42.196Z" },
{ url = "https://files.pythonhosted.org/packages/ea/19/a029cd335cf72f79d2644dcfc22d90f09caa86265cbbde3b5702ccef6890/numpy-2.3.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:b0b5397374f32ec0649dd98c652a1798192042e715df918c20672c62fb52d4b8", size = 20987593, upload-time = "2025-06-21T12:21:51.664Z" },
{ url = "https://files.pythonhosted.org/packages/25/91/8ea8894406209107d9ce19b66314194675d31761fe2cb3c84fe2eeae2f37/numpy-2.3.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c5bdf2015ccfcee8253fb8be695516ac4457c743473a43290fd36eba6a1777eb", size = 14300523, upload-time = "2025-06-21T12:22:13.583Z" },
{ url = "https://files.pythonhosted.org/packages/a6/7f/06187b0066eefc9e7ce77d5f2ddb4e314a55220ad62dd0bfc9f2c44bac14/numpy-2.3.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d70f20df7f08b90a2062c1f07737dd340adccf2068d0f1b9b3d56e2038979fee", size = 5227993, upload-time = "2025-06-21T12:22:22.53Z" },
{ url = "https://files.pythonhosted.org/packages/e8/ec/a926c293c605fa75e9cfb09f1e4840098ed46d2edaa6e2152ee35dc01ed3/numpy-2.3.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:2fb86b7e58f9ac50e1e9dd1290154107e47d1eef23a0ae9145ded06ea606f992", size = 6736652, upload-time = "2025-06-21T12:22:33.629Z" },
{ url = "https://files.pythonhosted.org/packages/e3/62/d68e52fb6fde5586650d4c0ce0b05ff3a48ad4df4ffd1b8866479d1d671d/numpy-2.3.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:23ab05b2d241f76cb883ce8b9a93a680752fbfcbd51c50eff0b88b979e471d8c", size = 14331561, upload-time = "2025-06-21T12:22:55.056Z" },
{ url = "https://files.pythonhosted.org/packages/fc/ec/b74d3f2430960044bdad6900d9f5edc2dc0fb8bf5a0be0f65287bf2cbe27/numpy-2.3.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ce2ce9e5de4703a673e705183f64fd5da5bf36e7beddcb63a25ee2286e71ca48", size = 16693349, upload-time = "2025-06-21T12:23:20.53Z" },
{ url = "https://files.pythonhosted.org/packages/0d/15/def96774b9d7eb198ddadfcbd20281b20ebb510580419197e225f5c55c3e/numpy-2.3.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c4913079974eeb5c16ccfd2b1f09354b8fed7e0d6f2cab933104a09a6419b1ee", size = 15642053, upload-time = "2025-06-21T12:23:43.697Z" },
{ url = "https://files.pythonhosted.org/packages/2b/57/c3203974762a759540c6ae71d0ea2341c1fa41d84e4971a8e76d7141678a/numpy-2.3.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:010ce9b4f00d5c036053ca684c77441f2f2c934fd23bee058b4d6f196efd8280", size = 18434184, upload-time = "2025-06-21T12:24:10.708Z" },
{ url = "https://files.pythonhosted.org/packages/22/8a/ccdf201457ed8ac6245187850aff4ca56a79edbea4829f4e9f14d46fa9a5/numpy-2.3.1-cp313-cp313t-win32.whl", hash = "sha256:6269b9edfe32912584ec496d91b00b6d34282ca1d07eb10e82dfc780907d6c2e", size = 6440678, upload-time = "2025-06-21T12:24:21.596Z" },
{ url = "https://files.pythonhosted.org/packages/f1/7e/7f431d8bd8eb7e03d79294aed238b1b0b174b3148570d03a8a8a8f6a0da9/numpy-2.3.1-cp313-cp313t-win_amd64.whl", hash = "sha256:2a809637460e88a113e186e87f228d74ae2852a2e0c44de275263376f17b5bdc", size = 12870697, upload-time = "2025-06-21T12:24:40.644Z" },
{ url = "https://files.pythonhosted.org/packages/d4/ca/af82bf0fad4c3e573c6930ed743b5308492ff19917c7caaf2f9b6f9e2e98/numpy-2.3.1-cp313-cp313t-win_arm64.whl", hash = "sha256:eccb9a159db9aed60800187bc47a6d3451553f0e1b08b068d8b277ddfbb9b244", size = 10260376, upload-time = "2025-06-21T12:24:56.884Z" },
]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "paginate"
version = "0.5.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" },
]
[[package]]
name = "pandas"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "python-dateutil" },
{ name = "pytz" },
{ name = "tzdata" },
]
sdist = { url = "https://files.pythonhosted.org/packages/72/51/48f713c4c728d7c55ef7444ba5ea027c26998d96d1a40953b346438602fc/pandas-2.3.0.tar.gz", hash = "sha256:34600ab34ebf1131a7613a260a61dbe8b62c188ec0ea4c296da7c9a06b004133", size = 4484490, upload-time = "2025-06-05T03:27:54.133Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/57/5cb75a56a4842bbd0511c3d1c79186d8315b82dac802118322b2de1194fe/pandas-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c7e2fc25f89a49a11599ec1e76821322439d90820108309bf42130d2f36c983", size = 11518913, upload-time = "2025-06-05T03:27:02.757Z" },
{ url = "https://files.pythonhosted.org/packages/05/01/0c8785610e465e4948a01a059562176e4c8088aa257e2e074db868f86d4e/pandas-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c6da97aeb6a6d233fb6b17986234cc723b396b50a3c6804776351994f2a658fd", size = 10655249, upload-time = "2025-06-05T16:50:20.17Z" },
{ url = "https://files.pythonhosted.org/packages/e8/6a/47fd7517cd8abe72a58706aab2b99e9438360d36dcdb052cf917b7bf3bdc/pandas-2.3.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb32dc743b52467d488e7a7c8039b821da2826a9ba4f85b89ea95274f863280f", size = 11328359, upload-time = "2025-06-05T03:27:06.431Z" },
{ url = "https://files.pythonhosted.org/packages/2a/b3/463bfe819ed60fb7e7ddffb4ae2ee04b887b3444feee6c19437b8f834837/pandas-2.3.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:213cd63c43263dbb522c1f8a7c9d072e25900f6975596f883f4bebd77295d4f3", size = 12024789, upload-time = "2025-06-05T03:27:09.875Z" },
{ url = "https://files.pythonhosted.org/packages/04/0c/e0704ccdb0ac40aeb3434d1c641c43d05f75c92e67525df39575ace35468/pandas-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1d2b33e68d0ce64e26a4acc2e72d747292084f4e8db4c847c6f5f6cbe56ed6d8", size = 12480734, upload-time = "2025-06-06T00:00:22.246Z" },
{ url = "https://files.pythonhosted.org/packages/e9/df/815d6583967001153bb27f5cf075653d69d51ad887ebbf4cfe1173a1ac58/pandas-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:430a63bae10b5086995db1b02694996336e5a8ac9a96b4200572b413dfdfccb9", size = 13223381, upload-time = "2025-06-05T03:27:15.641Z" },
{ url = "https://files.pythonhosted.org/packages/79/88/ca5973ed07b7f484c493e941dbff990861ca55291ff7ac67c815ce347395/pandas-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:4930255e28ff5545e2ca404637bcc56f031893142773b3468dc021c6c32a1390", size = 10970135, upload-time = "2025-06-05T03:27:24.131Z" },
{ url = "https://files.pythonhosted.org/packages/24/fb/0994c14d1f7909ce83f0b1fb27958135513c4f3f2528bde216180aa73bfc/pandas-2.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f925f1ef673b4bd0271b1809b72b3270384f2b7d9d14a189b12b7fc02574d575", size = 12141356, upload-time = "2025-06-05T03:27:34.547Z" },
{ url = "https://files.pythonhosted.org/packages/9d/a2/9b903e5962134497ac4f8a96f862ee3081cb2506f69f8e4778ce3d9c9d82/pandas-2.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78ad363ddb873a631e92a3c063ade1ecfb34cae71e9a2be6ad100f875ac1042", size = 11474674, upload-time = "2025-06-05T03:27:39.448Z" },
{ url = "https://files.pythonhosted.org/packages/81/3a/3806d041bce032f8de44380f866059437fb79e36d6b22c82c187e65f765b/pandas-2.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:951805d146922aed8357e4cc5671b8b0b9be1027f0619cea132a9f3f65f2f09c", size = 11439876, upload-time = "2025-06-05T03:27:43.652Z" },
{ url = "https://files.pythonhosted.org/packages/15/aa/3fc3181d12b95da71f5c2537c3e3b3af6ab3a8c392ab41ebb766e0929bc6/pandas-2.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a881bc1309f3fce34696d07b00f13335c41f5f5a8770a33b09ebe23261cfc67", size = 11966182, upload-time = "2025-06-05T03:27:47.652Z" },
{ url = "https://files.pythonhosted.org/packages/37/e7/e12f2d9b0a2c4a2cc86e2aabff7ccfd24f03e597d770abfa2acd313ee46b/pandas-2.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e1991bbb96f4050b09b5f811253c4f3cf05ee89a589379aa36cd623f21a31d6f", size = 12547686, upload-time = "2025-06-06T00:00:26.142Z" },
{ url = "https://files.pythonhosted.org/packages/39/c2/646d2e93e0af70f4e5359d870a63584dacbc324b54d73e6b3267920ff117/pandas-2.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:bb3be958022198531eb7ec2008cfc78c5b1eed51af8600c6c5d9160d89d8d249", size = 13231847, upload-time = "2025-06-05T03:27:51.465Z" },
]
[[package]]
name = "pathspec"
version = "0.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" },
]
[[package]]
name = "pillow"
version = "11.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" },
{ url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" },
{ url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" },
{ url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" },
{ url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" },
{ url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" },
{ url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" },
{ url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" },
{ url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" },
{ url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" },
{ url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" },
{ url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" },
{ url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" },
{ url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" },
{ url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" },
{ url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" },
{ url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" },
{ url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" },
{ url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" },
{ url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" },
{ url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" },
{ url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" },
{ url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" },
{ url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" },
{ url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" },
]
[[package]]
name = "platformdirs"
version = "4.3.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "protobuf"
version = "6.31.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/52/f3/b9655a711b32c19720253f6f06326faf90580834e2e83f840472d752bc8b/protobuf-6.31.1.tar.gz", hash = "sha256:d8cac4c982f0b957a4dc73a80e2ea24fab08e679c0de9deb835f4a12d69aca9a", size = 441797, upload-time = "2025-05-28T19:25:54.947Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/6f/6ab8e4bf962fd5570d3deaa2d5c38f0a363f57b4501047b5ebeb83ab1125/protobuf-6.31.1-cp310-abi3-win32.whl", hash = "sha256:7fa17d5a29c2e04b7d90e5e32388b8bfd0e7107cd8e616feef7ed3fa6bdab5c9", size = 423603, upload-time = "2025-05-28T19:25:41.198Z" },
{ url = "https://files.pythonhosted.org/packages/44/3a/b15c4347dd4bf3a1b0ee882f384623e2063bb5cf9fa9d57990a4f7df2fb6/protobuf-6.31.1-cp310-abi3-win_amd64.whl", hash = "sha256:426f59d2964864a1a366254fa703b8632dcec0790d8862d30034d8245e1cd447", size = 435283, upload-time = "2025-05-28T19:25:44.275Z" },
{ url = "https://files.pythonhosted.org/packages/6a/c9/b9689a2a250264a84e66c46d8862ba788ee7a641cdca39bccf64f59284b7/protobuf-6.31.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:6f1227473dc43d44ed644425268eb7c2e488ae245d51c6866d19fe158e207402", size = 425604, upload-time = "2025-05-28T19:25:45.702Z" },
{ url = "https://files.pythonhosted.org/packages/76/a1/7a5a94032c83375e4fe7e7f56e3976ea6ac90c5e85fac8576409e25c39c3/protobuf-6.31.1-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:a40fc12b84c154884d7d4c4ebd675d5b3b5283e155f324049ae396b95ddebc39", size = 322115, upload-time = "2025-05-28T19:25:47.128Z" },
{ url = "https://files.pythonhosted.org/packages/fa/b1/b59d405d64d31999244643d88c45c8241c58f17cc887e73bcb90602327f8/protobuf-6.31.1-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:4ee898bf66f7a8b0bd21bce523814e6fbd8c6add948045ce958b73af7e8878c6", size = 321070, upload-time = "2025-05-28T19:25:50.036Z" },
{ url = "https://files.pythonhosted.org/packages/f7/af/ab3c51ab7507a7325e98ffe691d9495ee3d3aa5f589afad65ec920d39821/protobuf-6.31.1-py3-none-any.whl", hash = "sha256:720a6c7e6b77288b85063569baae8536671b39f15cc22037ec7045658d80489e", size = 168724, upload-time = "2025-05-28T19:25:53.926Z" },
]
[[package]]
name = "pyarrow"
version = "20.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/ee/a7810cb9f3d6e9238e61d312076a9859bf3668fd21c69744de9532383912/pyarrow-20.0.0.tar.gz", hash = "sha256:febc4a913592573c8d5805091a6c2b5064c8bd6e002131f01061797d91c783c1", size = 1125187, upload-time = "2025-04-27T12:34:23.264Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/aa/daa413b81446d20d4dad2944110dcf4cf4f4179ef7f685dd5a6d7570dc8e/pyarrow-20.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a15532e77b94c61efadde86d10957950392999503b3616b2ffcef7621a002893", size = 30798501, upload-time = "2025-04-27T12:30:48.351Z" },
{ url = "https://files.pythonhosted.org/packages/ff/75/2303d1caa410925de902d32ac215dc80a7ce7dd8dfe95358c165f2adf107/pyarrow-20.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:dd43f58037443af715f34f1322c782ec463a3c8a94a85fdb2d987ceb5658e061", size = 32277895, upload-time = "2025-04-27T12:30:55.238Z" },
{ url = "https://files.pythonhosted.org/packages/92/41/fe18c7c0b38b20811b73d1bdd54b1fccba0dab0e51d2048878042d84afa8/pyarrow-20.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa0d288143a8585806e3cc7c39566407aab646fb9ece164609dac1cfff45f6ae", size = 41327322, upload-time = "2025-04-27T12:31:05.587Z" },
{ url = "https://files.pythonhosted.org/packages/da/ab/7dbf3d11db67c72dbf36ae63dcbc9f30b866c153b3a22ef728523943eee6/pyarrow-20.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6953f0114f8d6f3d905d98e987d0924dabce59c3cda380bdfaa25a6201563b4", size = 42411441, upload-time = "2025-04-27T12:31:15.675Z" },
{ url = "https://files.pythonhosted.org/packages/90/c3/0c7da7b6dac863af75b64e2f827e4742161128c350bfe7955b426484e226/pyarrow-20.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:991f85b48a8a5e839b2128590ce07611fae48a904cae6cab1f089c5955b57eb5", size = 40677027, upload-time = "2025-04-27T12:31:24.631Z" },
{ url = "https://files.pythonhosted.org/packages/be/27/43a47fa0ff9053ab5203bb3faeec435d43c0d8bfa40179bfd076cdbd4e1c/pyarrow-20.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:97c8dc984ed09cb07d618d57d8d4b67a5100a30c3818c2fb0b04599f0da2de7b", size = 42281473, upload-time = "2025-04-27T12:31:31.311Z" },
{ url = "https://files.pythonhosted.org/packages/bc/0b/d56c63b078876da81bbb9ba695a596eabee9b085555ed12bf6eb3b7cab0e/pyarrow-20.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9b71daf534f4745818f96c214dbc1e6124d7daf059167330b610fc69b6f3d3e3", size = 42893897, upload-time = "2025-04-27T12:31:39.406Z" },
{ url = "https://files.pythonhosted.org/packages/92/ac/7d4bd020ba9145f354012838692d48300c1b8fe5634bfda886abcada67ed/pyarrow-20.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8b88758f9303fa5a83d6c90e176714b2fd3852e776fc2d7e42a22dd6c2fb368", size = 44543847, upload-time = "2025-04-27T12:31:45.997Z" },
{ url = "https://files.pythonhosted.org/packages/9d/07/290f4abf9ca702c5df7b47739c1b2c83588641ddfa2cc75e34a301d42e55/pyarrow-20.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:30b3051b7975801c1e1d387e17c588d8ab05ced9b1e14eec57915f79869b5031", size = 25653219, upload-time = "2025-04-27T12:31:54.11Z" },
{ url = "https://files.pythonhosted.org/packages/95/df/720bb17704b10bd69dde086e1400b8eefb8f58df3f8ac9cff6c425bf57f1/pyarrow-20.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:ca151afa4f9b7bc45bcc791eb9a89e90a9eb2772767d0b1e5389609c7d03db63", size = 30853957, upload-time = "2025-04-27T12:31:59.215Z" },
{ url = "https://files.pythonhosted.org/packages/d9/72/0d5f875efc31baef742ba55a00a25213a19ea64d7176e0fe001c5d8b6e9a/pyarrow-20.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:4680f01ecd86e0dd63e39eb5cd59ef9ff24a9d166db328679e36c108dc993d4c", size = 32247972, upload-time = "2025-04-27T12:32:05.369Z" },
{ url = "https://files.pythonhosted.org/packages/d5/bc/e48b4fa544d2eea72f7844180eb77f83f2030b84c8dad860f199f94307ed/pyarrow-20.0.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f4c8534e2ff059765647aa69b75d6543f9fef59e2cd4c6d18015192565d2b70", size = 41256434, upload-time = "2025-04-27T12:32:11.814Z" },
{ url = "https://files.pythonhosted.org/packages/c3/01/974043a29874aa2cf4f87fb07fd108828fc7362300265a2a64a94965e35b/pyarrow-20.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e1f8a47f4b4ae4c69c4d702cfbdfe4d41e18e5c7ef6f1bb1c50918c1e81c57b", size = 42353648, upload-time = "2025-04-27T12:32:20.766Z" },
{ url = "https://files.pythonhosted.org/packages/68/95/cc0d3634cde9ca69b0e51cbe830d8915ea32dda2157560dda27ff3b3337b/pyarrow-20.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:a1f60dc14658efaa927f8214734f6a01a806d7690be4b3232ba526836d216122", size = 40619853, upload-time = "2025-04-27T12:32:28.1Z" },
{ url = "https://files.pythonhosted.org/packages/29/c2/3ad40e07e96a3e74e7ed7cc8285aadfa84eb848a798c98ec0ad009eb6bcc/pyarrow-20.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:204a846dca751428991346976b914d6d2a82ae5b8316a6ed99789ebf976551e6", size = 42241743, upload-time = "2025-04-27T12:32:35.792Z" },
{ url = "https://files.pythonhosted.org/packages/eb/cb/65fa110b483339add6a9bc7b6373614166b14e20375d4daa73483755f830/pyarrow-20.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f3b117b922af5e4c6b9a9115825726cac7d8b1421c37c2b5e24fbacc8930612c", size = 42839441, upload-time = "2025-04-27T12:32:46.64Z" },
{ url = "https://files.pythonhosted.org/packages/98/7b/f30b1954589243207d7a0fbc9997401044bf9a033eec78f6cb50da3f304a/pyarrow-20.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e724a3fd23ae5b9c010e7be857f4405ed5e679db5c93e66204db1a69f733936a", size = 44503279, upload-time = "2025-04-27T12:32:56.503Z" },
{ url = "https://files.pythonhosted.org/packages/37/40/ad395740cd641869a13bcf60851296c89624662575621968dcfafabaa7f6/pyarrow-20.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:82f1ee5133bd8f49d31be1299dc07f585136679666b502540db854968576faf9", size = 25944982, upload-time = "2025-04-27T12:33:04.72Z" },
]
[[package]]
name = "pydantic"
version = "2.11.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" },
]
[[package]]
name = "pydantic-core"
version = "2.33.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" },
{ url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" },
{ url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" },
{ url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" },
{ url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" },
{ url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" },
{ url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" },
{ url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" },
{ url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" },
{ url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" },
{ url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" },
{ url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" },
{ url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" },
{ url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" },
{ url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" },
{ url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" },
{ url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.10.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583, upload-time = "2025-06-24T13:26:46.841Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" },
]
[[package]]
name = "pydeck"
version = "0.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jinja2" },
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a1/ca/40e14e196864a0f61a92abb14d09b3d3da98f94ccb03b49cf51688140dab/pydeck-0.9.1.tar.gz", hash = "sha256:f74475ae637951d63f2ee58326757f8d4f9cd9f2a457cf42950715003e2cb605", size = 3832240, upload-time = "2024-05-10T15:36:21.153Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ab/4c/b888e6cf58bd9db9c93f40d1c6be8283ff49d88919231afe93a6bcf61626/pydeck-0.9.1-py2.py3-none-any.whl", hash = "sha256:b3f75ba0d273fc917094fa61224f3f6076ca8752b93d46faf3bcfd9f9d59b038", size = 6900403, upload-time = "2024-05-10T15:36:17.36Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pymdown-extensions"
version = "10.16"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown" },
{ name = "pyyaml" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1a/0a/c06b542ac108bfc73200677309cd9188a3a01b127a63f20cadc18d873d88/pymdown_extensions-10.16.tar.gz", hash = "sha256:71dac4fca63fabeffd3eb9038b756161a33ec6e8d230853d3cecf562155ab3de", size = 853197, upload-time = "2025-06-21T17:56:36.974Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/d4/10bb14004d3c792811e05e21b5e5dcae805aacb739bd12a0540967b99592/pymdown_extensions-10.16-py3-none-any.whl", hash = "sha256:f5dd064a4db588cb2d95229fc4ee63a1b16cc8b4d0e6145c0899ed8723da1df2", size = 266143, upload-time = "2025-06-21T17:56:35.356Z" },
]
[[package]]
name = "pytest"
version = "8.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d0/d4/14f53324cb1a6381bef29d698987625d80052bb33932d8e7cbf9b337b17c/pytest_asyncio-1.0.0.tar.gz", hash = "sha256:d15463d13f4456e1ead2594520216b225a16f781e144f8fdf6c5bb4667c48b3f", size = 46960, upload-time = "2025-05-26T04:54:40.484Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/30/05/ce271016e351fddc8399e546f6e23761967ee09c8c568bbfbecb0c150171/pytest_asyncio-1.0.0-py3-none-any.whl", hash = "sha256:4f024da9f1ef945e680dc68610b52550e36590a67fd31bb3b4943979a1f90ef3", size = 15976, upload-time = "2025-05-26T04:54:39.035Z" },
]
[[package]]
name = "pytest-httpx"
version = "0.35.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1f/89/5b12b7b29e3d0af3a4b9c071ee92fa25a9017453731a38f08ba01c280f4c/pytest_httpx-0.35.0.tar.gz", hash = "sha256:d619ad5d2e67734abfbb224c3d9025d64795d4b8711116b1a13f72a251ae511f", size = 54146, upload-time = "2024-11-28T19:16:54.237Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/ed/026d467c1853dd83102411a78126b4842618e86c895f93528b0528c7a620/pytest_httpx-0.35.0-py3-none-any.whl", hash = "sha256:ee11a00ffcea94a5cbff47af2114d34c5b231c326902458deed73f9c459fd744", size = 19442, upload-time = "2024-11-28T19:16:52.787Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "python-dotenv"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" },
]
[[package]]
name = "python-multipart"
version = "0.0.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" },
]
[[package]]
name = "pytz"
version = "2025.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" },
{ url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" },
{ url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" },
{ url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" },
{ url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" },
{ url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" },
{ url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" },
{ url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" },
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" },
]
[[package]]
name = "pyyaml-env-tag"
version = "1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyyaml" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" },
]
[[package]]
name = "referencing"
version = "0.36.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "rpds-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" },
]
[[package]]
name = "requests"
version = "2.32.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258, upload-time = "2025-06-09T16:43:07.34Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload-time = "2025-06-09T16:43:05.728Z" },
]
[[package]]
name = "rich"
version = "14.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078, upload-time = "2025-03-30T14:15:14.23Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload-time = "2025-03-30T14:15:12.283Z" },
]
[[package]]
name = "rpds-py"
version = "0.26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a5/aa/4456d84bbb54adc6a916fb10c9b374f78ac840337644e4a5eda229c81275/rpds_py-0.26.0.tar.gz", hash = "sha256:20dae58a859b0906f0685642e591056f1e787f3a8b39c8e8749a45dc7d26bdb0", size = 27385, upload-time = "2025-07-01T15:57:13.958Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/67/bb62d0109493b12b1c6ab00de7a5566aa84c0e44217c2d94bee1bd370da9/rpds_py-0.26.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:696764a5be111b036256c0b18cd29783fab22154690fc698062fc1b0084b511d", size = 363917, upload-time = "2025-07-01T15:54:34.755Z" },
{ url = "https://files.pythonhosted.org/packages/4b/f3/34e6ae1925a5706c0f002a8d2d7f172373b855768149796af87bd65dcdb9/rpds_py-0.26.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1e6c15d2080a63aaed876e228efe4f814bc7889c63b1e112ad46fdc8b368b9e1", size = 350073, upload-time = "2025-07-01T15:54:36.292Z" },
{ url = "https://files.pythonhosted.org/packages/75/83/1953a9d4f4e4de7fd0533733e041c28135f3c21485faaef56a8aadbd96b5/rpds_py-0.26.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:390e3170babf42462739a93321e657444f0862c6d722a291accc46f9d21ed04e", size = 384214, upload-time = "2025-07-01T15:54:37.469Z" },
{ url = "https://files.pythonhosted.org/packages/48/0e/983ed1b792b3322ea1d065e67f4b230f3b96025f5ce3878cc40af09b7533/rpds_py-0.26.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7da84c2c74c0f5bc97d853d9e17bb83e2dcafcff0dc48286916001cc114379a1", size = 400113, upload-time = "2025-07-01T15:54:38.954Z" },
{ url = "https://files.pythonhosted.org/packages/69/7f/36c0925fff6f660a80be259c5b4f5e53a16851f946eb080351d057698528/rpds_py-0.26.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c5fe114a6dd480a510b6d3661d09d67d1622c4bf20660a474507aaee7eeeee9", size = 515189, upload-time = "2025-07-01T15:54:40.57Z" },
{ url = "https://files.pythonhosted.org/packages/13/45/cbf07fc03ba7a9b54662c9badb58294ecfb24f828b9732970bd1a431ed5c/rpds_py-0.26.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3100b3090269f3a7ea727b06a6080d4eb7439dca4c0e91a07c5d133bb1727ea7", size = 406998, upload-time = "2025-07-01T15:54:43.025Z" },
{ url = "https://files.pythonhosted.org/packages/6c/b0/8fa5e36e58657997873fd6a1cf621285ca822ca75b4b3434ead047daa307/rpds_py-0.26.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c03c9b0c64afd0320ae57de4c982801271c0c211aa2d37f3003ff5feb75bb04", size = 385903, upload-time = "2025-07-01T15:54:44.752Z" },
{ url = "https://files.pythonhosted.org/packages/4b/f7/b25437772f9f57d7a9fbd73ed86d0dcd76b4c7c6998348c070d90f23e315/rpds_py-0.26.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5963b72ccd199ade6ee493723d18a3f21ba7d5b957017607f815788cef50eaf1", size = 419785, upload-time = "2025-07-01T15:54:46.043Z" },
{ url = "https://files.pythonhosted.org/packages/a7/6b/63ffa55743dfcb4baf2e9e77a0b11f7f97ed96a54558fcb5717a4b2cd732/rpds_py-0.26.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9da4e873860ad5bab3291438525cae80169daecbfafe5657f7f5fb4d6b3f96b9", size = 561329, upload-time = "2025-07-01T15:54:47.64Z" },
{ url = "https://files.pythonhosted.org/packages/2f/07/1f4f5e2886c480a2346b1e6759c00278b8a69e697ae952d82ae2e6ee5db0/rpds_py-0.26.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5afaddaa8e8c7f1f7b4c5c725c0070b6eed0228f705b90a1732a48e84350f4e9", size = 590875, upload-time = "2025-07-01T15:54:48.9Z" },
{ url = "https://files.pythonhosted.org/packages/cc/bc/e6639f1b91c3a55f8c41b47d73e6307051b6e246254a827ede730624c0f8/rpds_py-0.26.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4916dc96489616a6f9667e7526af8fa693c0fdb4f3acb0e5d9f4400eb06a47ba", size = 556636, upload-time = "2025-07-01T15:54:50.619Z" },
{ url = "https://files.pythonhosted.org/packages/05/4c/b3917c45566f9f9a209d38d9b54a1833f2bb1032a3e04c66f75726f28876/rpds_py-0.26.0-cp313-cp313-win32.whl", hash = "sha256:2a343f91b17097c546b93f7999976fd6c9d5900617aa848c81d794e062ab302b", size = 222663, upload-time = "2025-07-01T15:54:52.023Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0b/0851bdd6025775aaa2365bb8de0697ee2558184c800bfef8d7aef5ccde58/rpds_py-0.26.0-cp313-cp313-win_amd64.whl", hash = "sha256:0a0b60701f2300c81b2ac88a5fb893ccfa408e1c4a555a77f908a2596eb875a5", size = 234428, upload-time = "2025-07-01T15:54:53.692Z" },
{ url = "https://files.pythonhosted.org/packages/ed/e8/a47c64ed53149c75fb581e14a237b7b7cd18217e969c30d474d335105622/rpds_py-0.26.0-cp313-cp313-win_arm64.whl", hash = "sha256:257d011919f133a4746958257f2c75238e3ff54255acd5e3e11f3ff41fd14256", size = 222571, upload-time = "2025-07-01T15:54:54.822Z" },
{ url = "https://files.pythonhosted.org/packages/89/bf/3d970ba2e2bcd17d2912cb42874107390f72873e38e79267224110de5e61/rpds_py-0.26.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:529c8156d7506fba5740e05da8795688f87119cce330c244519cf706a4a3d618", size = 360475, upload-time = "2025-07-01T15:54:56.228Z" },
{ url = "https://files.pythonhosted.org/packages/82/9f/283e7e2979fc4ec2d8ecee506d5a3675fce5ed9b4b7cb387ea5d37c2f18d/rpds_py-0.26.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f53ec51f9d24e9638a40cabb95078ade8c99251945dad8d57bf4aabe86ecee35", size = 346692, upload-time = "2025-07-01T15:54:58.561Z" },
{ url = "https://files.pythonhosted.org/packages/e3/03/7e50423c04d78daf391da3cc4330bdb97042fc192a58b186f2d5deb7befd/rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab504c4d654e4a29558eaa5bb8cea5fdc1703ea60a8099ffd9c758472cf913f", size = 379415, upload-time = "2025-07-01T15:54:59.751Z" },
{ url = "https://files.pythonhosted.org/packages/57/00/d11ee60d4d3b16808432417951c63df803afb0e0fc672b5e8d07e9edaaae/rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd0641abca296bc1a00183fe44f7fced8807ed49d501f188faa642d0e4975b83", size = 391783, upload-time = "2025-07-01T15:55:00.898Z" },
{ url = "https://files.pythonhosted.org/packages/08/b3/1069c394d9c0d6d23c5b522e1f6546b65793a22950f6e0210adcc6f97c3e/rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:69b312fecc1d017b5327afa81d4da1480f51c68810963a7336d92203dbb3d4f1", size = 512844, upload-time = "2025-07-01T15:55:02.201Z" },
{ url = "https://files.pythonhosted.org/packages/08/3b/c4fbf0926800ed70b2c245ceca99c49f066456755f5d6eb8863c2c51e6d0/rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c741107203954f6fc34d3066d213d0a0c40f7bb5aafd698fb39888af277c70d8", size = 402105, upload-time = "2025-07-01T15:55:03.698Z" },
{ url = "https://files.pythonhosted.org/packages/1c/b0/db69b52ca07413e568dae9dc674627a22297abb144c4d6022c6d78f1e5cc/rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc3e55a7db08dc9a6ed5fb7103019d2c1a38a349ac41901f9f66d7f95750942f", size = 383440, upload-time = "2025-07-01T15:55:05.398Z" },
{ url = "https://files.pythonhosted.org/packages/4c/e1/c65255ad5b63903e56b3bb3ff9dcc3f4f5c3badde5d08c741ee03903e951/rpds_py-0.26.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9e851920caab2dbcae311fd28f4313c6953993893eb5c1bb367ec69d9a39e7ed", size = 412759, upload-time = "2025-07-01T15:55:08.316Z" },
{ url = "https://files.pythonhosted.org/packages/e4/22/bb731077872377a93c6e93b8a9487d0406c70208985831034ccdeed39c8e/rpds_py-0.26.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dfbf280da5f876d0b00c81f26bedce274e72a678c28845453885a9b3c22ae632", size = 556032, upload-time = "2025-07-01T15:55:09.52Z" },
{ url = "https://files.pythonhosted.org/packages/e0/8b/393322ce7bac5c4530fb96fc79cc9ea2f83e968ff5f6e873f905c493e1c4/rpds_py-0.26.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:1cc81d14ddfa53d7f3906694d35d54d9d3f850ef8e4e99ee68bc0d1e5fed9a9c", size = 585416, upload-time = "2025-07-01T15:55:11.216Z" },
{ url = "https://files.pythonhosted.org/packages/49/ae/769dc372211835bf759319a7aae70525c6eb523e3371842c65b7ef41c9c6/rpds_py-0.26.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dca83c498b4650a91efcf7b88d669b170256bf8017a5db6f3e06c2bf031f57e0", size = 554049, upload-time = "2025-07-01T15:55:13.004Z" },
{ url = "https://files.pythonhosted.org/packages/6b/f9/4c43f9cc203d6ba44ce3146246cdc38619d92c7bd7bad4946a3491bd5b70/rpds_py-0.26.0-cp313-cp313t-win32.whl", hash = "sha256:4d11382bcaf12f80b51d790dee295c56a159633a8e81e6323b16e55d81ae37e9", size = 218428, upload-time = "2025-07-01T15:55:14.486Z" },
{ url = "https://files.pythonhosted.org/packages/7e/8b/9286b7e822036a4a977f2f1e851c7345c20528dbd56b687bb67ed68a8ede/rpds_py-0.26.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff110acded3c22c033e637dd8896e411c7d3a11289b2edf041f86663dbc791e9", size = 231524, upload-time = "2025-07-01T15:55:15.745Z" },
]
[[package]]
name = "ruff"
version = "0.12.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6c/3d/d9a195676f25d00dbfcf3cf95fdd4c685c497fcfa7e862a44ac5e4e96480/ruff-0.12.2.tar.gz", hash = "sha256:d7b4f55cd6f325cb7621244f19c873c565a08aff5a4ba9c69aa7355f3f7afd3e", size = 4432239, upload-time = "2025-07-03T16:40:19.566Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/74/b6/2098d0126d2d3318fd5bec3ad40d06c25d377d95749f7a0c5af17129b3b1/ruff-0.12.2-py3-none-linux_armv6l.whl", hash = "sha256:093ea2b221df1d2b8e7ad92fc6ffdca40a2cb10d8564477a987b44fd4008a7be", size = 10369761, upload-time = "2025-07-03T16:39:38.847Z" },
{ url = "https://files.pythonhosted.org/packages/b1/4b/5da0142033dbe155dc598cfb99262d8ee2449d76920ea92c4eeb9547c208/ruff-0.12.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:09e4cf27cc10f96b1708100fa851e0daf21767e9709e1649175355280e0d950e", size = 11155659, upload-time = "2025-07-03T16:39:42.294Z" },
{ url = "https://files.pythonhosted.org/packages/3e/21/967b82550a503d7c5c5c127d11c935344b35e8c521f52915fc858fb3e473/ruff-0.12.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8ae64755b22f4ff85e9c52d1f82644abd0b6b6b6deedceb74bd71f35c24044cc", size = 10537769, upload-time = "2025-07-03T16:39:44.75Z" },
{ url = "https://files.pythonhosted.org/packages/33/91/00cff7102e2ec71a4890fb7ba1803f2cdb122d82787c7d7cf8041fe8cbc1/ruff-0.12.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3eb3a6b2db4d6e2c77e682f0b988d4d61aff06860158fdb413118ca133d57922", size = 10717602, upload-time = "2025-07-03T16:39:47.652Z" },
{ url = "https://files.pythonhosted.org/packages/9b/eb/928814daec4e1ba9115858adcda44a637fb9010618721937491e4e2283b8/ruff-0.12.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:73448de992d05517170fc37169cbca857dfeaeaa8c2b9be494d7bcb0d36c8f4b", size = 10198772, upload-time = "2025-07-03T16:39:49.641Z" },
{ url = "https://files.pythonhosted.org/packages/50/fa/f15089bc20c40f4f72334f9145dde55ab2b680e51afb3b55422effbf2fb6/ruff-0.12.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b8b94317cbc2ae4a2771af641739f933934b03555e51515e6e021c64441532d", size = 11845173, upload-time = "2025-07-03T16:39:52.069Z" },
{ url = "https://files.pythonhosted.org/packages/43/9f/1f6f98f39f2b9302acc161a4a2187b1e3a97634fe918a8e731e591841cf4/ruff-0.12.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:45fc42c3bf1d30d2008023a0a9a0cfb06bf9835b147f11fe0679f21ae86d34b1", size = 12553002, upload-time = "2025-07-03T16:39:54.551Z" },
{ url = "https://files.pythonhosted.org/packages/d8/70/08991ac46e38ddd231c8f4fd05ef189b1b94be8883e8c0c146a025c20a19/ruff-0.12.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce48f675c394c37e958bf229fb5c1e843e20945a6d962cf3ea20b7a107dcd9f4", size = 12171330, upload-time = "2025-07-03T16:39:57.55Z" },
{ url = "https://files.pythonhosted.org/packages/88/a9/5a55266fec474acfd0a1c73285f19dd22461d95a538f29bba02edd07a5d9/ruff-0.12.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:793d8859445ea47591272021a81391350205a4af65a9392401f418a95dfb75c9", size = 11774717, upload-time = "2025-07-03T16:39:59.78Z" },
{ url = "https://files.pythonhosted.org/packages/87/e5/0c270e458fc73c46c0d0f7cf970bb14786e5fdb88c87b5e423a4bd65232b/ruff-0.12.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6932323db80484dda89153da3d8e58164d01d6da86857c79f1961934354992da", size = 11646659, upload-time = "2025-07-03T16:40:01.934Z" },
{ url = "https://files.pythonhosted.org/packages/b7/b6/45ab96070c9752af37f0be364d849ed70e9ccede07675b0ec4e3ef76b63b/ruff-0.12.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6aa7e623a3a11538108f61e859ebf016c4f14a7e6e4eba1980190cacb57714ce", size = 10604012, upload-time = "2025-07-03T16:40:04.363Z" },
{ url = "https://files.pythonhosted.org/packages/86/91/26a6e6a424eb147cc7627eebae095cfa0b4b337a7c1c413c447c9ebb72fd/ruff-0.12.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2a4a20aeed74671b2def096bdf2eac610c7d8ffcbf4fb0e627c06947a1d7078d", size = 10176799, upload-time = "2025-07-03T16:40:06.514Z" },
{ url = "https://files.pythonhosted.org/packages/f5/0c/9f344583465a61c8918a7cda604226e77b2c548daf8ef7c2bfccf2b37200/ruff-0.12.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:71a4c550195612f486c9d1f2b045a600aeba851b298c667807ae933478fcef04", size = 11241507, upload-time = "2025-07-03T16:40:08.708Z" },
{ url = "https://files.pythonhosted.org/packages/1c/b7/99c34ded8fb5f86c0280278fa89a0066c3760edc326e935ce0b1550d315d/ruff-0.12.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:4987b8f4ceadf597c927beee65a5eaf994c6e2b631df963f86d8ad1bdea99342", size = 11717609, upload-time = "2025-07-03T16:40:10.836Z" },
{ url = "https://files.pythonhosted.org/packages/51/de/8589fa724590faa057e5a6d171e7f2f6cffe3287406ef40e49c682c07d89/ruff-0.12.2-py3-none-win32.whl", hash = "sha256:369ffb69b70cd55b6c3fc453b9492d98aed98062db9fec828cdfd069555f5f1a", size = 10523823, upload-time = "2025-07-03T16:40:13.203Z" },
{ url = "https://files.pythonhosted.org/packages/94/47/8abf129102ae4c90cba0c2199a1a9b0fa896f6f806238d6f8c14448cc748/ruff-0.12.2-py3-none-win_amd64.whl", hash = "sha256:dca8a3b6d6dc9810ed8f328d406516bf4d660c00caeaef36eb831cf4871b0639", size = 11629831, upload-time = "2025-07-03T16:40:15.478Z" },
{ url = "https://files.pythonhosted.org/packages/e2/1f/72d2946e3cc7456bb837e88000eb3437e55f80db339c840c04015a11115d/ruff-0.12.2-py3-none-win_arm64.whl", hash = "sha256:48d6c6bfb4761df68bc05ae630e24f506755e702d4fb08f08460be778c7ccb12", size = 10735334, upload-time = "2025-07-03T16:40:17.677Z" },
]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "smmap"
version = "5.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", size = 22329, upload-time = "2025-01-02T07:14:40.909Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
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 = "2.3.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8c/f4/989bc70cb8091eda43a9034ef969b25145291f3601703b82766e5172dfed/sse_starlette-2.3.6.tar.gz", hash = "sha256:0382336f7d4ec30160cf9ca0518962905e1b69b72d6c1c995131e0a703b436e3", size = 18284, upload-time = "2025-05-30T13:34:12.914Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/05/78850ac6e79af5b9508f8841b0f26aa9fd329a1ba00bf65453c2d312bcc8/sse_starlette-2.3.6-py3-none-any.whl", hash = "sha256:d49a8285b182f6e2228e2609c350398b2ca2c36216c2675d875f81e93548f760", size = 10606, upload-time = "2025-05-30T13:34:11.703Z" },
]
[[package]]
name = "starlette"
version = "0.47.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0a/69/662169fdb92fb96ec3eaee218cf540a629d629c86d7993d9651226a6789b/starlette-0.47.1.tar.gz", hash = "sha256:aef012dd2b6be325ffa16698f9dc533614fb1cebd593a906b90dc1025529a79b", size = 2583072, upload-time = "2025-06-21T04:03:17.337Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/95/38ef0cd7fa11eaba6a99b3c4f5ac948d8bc6ff199aabd327a29cc000840c/starlette-0.47.1-py3-none-any.whl", hash = "sha256:5e11c9f5c7c3f24959edbf2dffdc01bba860228acf657129467d8a7468591527", size = 72747, upload-time = "2025-06-21T04:03:15.705Z" },
]
[[package]]
name = "streamlit"
version = "1.46.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "altair" },
{ name = "blinker" },
{ name = "cachetools" },
{ name = "click" },
{ name = "gitpython" },
{ name = "numpy" },
{ name = "packaging" },
{ name = "pandas" },
{ name = "pillow" },
{ name = "protobuf" },
{ name = "pyarrow" },
{ name = "pydeck" },
{ name = "requests" },
{ name = "tenacity" },
{ name = "toml" },
{ name = "tornado" },
{ name = "typing-extensions" },
{ name = "watchdog", marker = "sys_platform != 'darwin'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bd/7e/c7499c447b7021657fa5c851091072c1c7a26f1dda0369c21c0243fc35e7/streamlit-1.46.1.tar.gz", hash = "sha256:2cc4ad01cfeded9ad953a21829eb879b90aa77af4068c68397411c2d5c8862cf", size = 9651018, upload-time = "2025-06-26T16:03:05.66Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/3b/35400175788cdd6a43c90dce1e7f567eb6843a3ba0612508c0f19ee31f5f/streamlit-1.46.1-py3-none-any.whl", hash = "sha256:dffa373230965f87ccc156abaff848d7d731920cf14106f3b99b1ea18076f728", size = 10051346, upload-time = "2025-06-26T16:03:02.934Z" },
]
[[package]]
name = "tenacity"
version = "9.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" },
]
[[package]]
name = "toml"
version = "0.10.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" },
]
[[package]]
name = "tornado"
version = "6.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/51/89/c72771c81d25d53fe33e3dca61c233b665b2780f21820ba6fd2c6793c12b/tornado-6.5.1.tar.gz", hash = "sha256:84ceece391e8eb9b2b95578db65e920d2a61070260594819589609ba9bc6308c", size = 509934, upload-time = "2025-05-22T18:15:38.788Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/77/89/f4532dee6843c9e0ebc4e28d4be04c67f54f60813e4bf73d595fe7567452/tornado-6.5.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d50065ba7fd11d3bd41bcad0825227cc9a95154bad83239357094c36708001f7", size = 441948, upload-time = "2025-05-22T18:15:20.862Z" },
{ url = "https://files.pythonhosted.org/packages/15/9a/557406b62cffa395d18772e0cdcf03bed2fff03b374677348eef9f6a3792/tornado-6.5.1-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9e9ca370f717997cb85606d074b0e5b247282cf5e2e1611568b8821afe0342d6", size = 440112, upload-time = "2025-05-22T18:15:22.591Z" },
{ url = "https://files.pythonhosted.org/packages/55/82/7721b7319013a3cf881f4dffa4f60ceff07b31b394e459984e7a36dc99ec/tornado-6.5.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b77e9dfa7ed69754a54c89d82ef746398be82f749df69c4d3abe75c4d1ff4888", size = 443672, upload-time = "2025-05-22T18:15:24.027Z" },
{ url = "https://files.pythonhosted.org/packages/7d/42/d11c4376e7d101171b94e03cef0cbce43e823ed6567ceda571f54cf6e3ce/tornado-6.5.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:253b76040ee3bab8bcf7ba9feb136436a3787208717a1fb9f2c16b744fba7331", size = 443019, upload-time = "2025-05-22T18:15:25.735Z" },
{ url = "https://files.pythonhosted.org/packages/7d/f7/0c48ba992d875521ac761e6e04b0a1750f8150ae42ea26df1852d6a98942/tornado-6.5.1-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:308473f4cc5a76227157cdf904de33ac268af770b2c5f05ca6c1161d82fdd95e", size = 443252, upload-time = "2025-05-22T18:15:27.499Z" },
{ url = "https://files.pythonhosted.org/packages/89/46/d8d7413d11987e316df4ad42e16023cd62666a3c0dfa1518ffa30b8df06c/tornado-6.5.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:caec6314ce8a81cf69bd89909f4b633b9f523834dc1a352021775d45e51d9401", size = 443930, upload-time = "2025-05-22T18:15:29.299Z" },
{ url = "https://files.pythonhosted.org/packages/78/b2/f8049221c96a06df89bed68260e8ca94beca5ea532ffc63b1175ad31f9cc/tornado-6.5.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:13ce6e3396c24e2808774741331638ee6c2f50b114b97a55c5b442df65fd9692", size = 443351, upload-time = "2025-05-22T18:15:31.038Z" },
{ url = "https://files.pythonhosted.org/packages/76/ff/6a0079e65b326cc222a54720a748e04a4db246870c4da54ece4577bfa702/tornado-6.5.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5cae6145f4cdf5ab24744526cc0f55a17d76f02c98f4cff9daa08ae9a217448a", size = 443328, upload-time = "2025-05-22T18:15:32.426Z" },
{ url = "https://files.pythonhosted.org/packages/49/18/e3f902a1d21f14035b5bc6246a8c0f51e0eef562ace3a2cea403c1fb7021/tornado-6.5.1-cp39-abi3-win32.whl", hash = "sha256:e0a36e1bc684dca10b1aa75a31df8bdfed656831489bc1e6a6ebed05dc1ec365", size = 444396, upload-time = "2025-05-22T18:15:34.205Z" },
{ url = "https://files.pythonhosted.org/packages/7b/09/6526e32bf1049ee7de3bebba81572673b19a2a8541f795d887e92af1a8bc/tornado-6.5.1-cp39-abi3-win_amd64.whl", hash = "sha256:908e7d64567cecd4c2b458075589a775063453aeb1d2a1853eedb806922f568b", size = 444840, upload-time = "2025-05-22T18:15:36.1Z" },
{ url = "https://files.pythonhosted.org/packages/55/a7/535c44c7bea4578e48281d83c615219f3ab19e6abc67625ef637c73987be/tornado-6.5.1-cp39-abi3-win_arm64.whl", hash = "sha256:02420a0eb7bf617257b9935e2b754d1b63897525d8a289c9d65690d580b4dcf7", size = 443596, upload-time = "2025-05-22T18:15:37.433Z" },
]
[[package]]
name = "typer"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "rich" },
{ name = "shellingham" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c5/8c/7d682431efca5fd290017663ea4588bf6f2c6aad085c7f108c5dbc316e70/typer-0.16.0.tar.gz", hash = "sha256:af377ffaee1dbe37ae9440cb4e8f11686ea5ce4e9bae01b84ae7c63b87f1dd3b", size = 102625, upload-time = "2025-05-26T14:30:31.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/42/3efaf858001d2c2913de7f354563e3a3a2f0decae3efe98427125a8f441e/typer-0.16.0-py3-none-any.whl", hash = "sha256:1f79bed11d4d02d4310e3c1b7ba594183bcedb0ac73b27a9e5f28f6fb5b98855", size = 46317, upload-time = "2025-05-26T14:30:30.523Z" },
]
[[package]]
name = "typing-extensions"
version = "4.14.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" },
]
[[package]]
name = "tzdata"
version = "2025.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
]
[[package]]
name = "urllib3"
version = "2.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
]
[[package]]
name = "uvicorn"
version = "0.35.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473, upload-time = "2025-06-28T16:15:46.058Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" },
]
[[package]]
name = "watchdog"
version = "6.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" },
{ url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" },
{ url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" },
{ url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" },
{ url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" },
{ url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" },
{ url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" },
{ url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" },
{ url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" },
{ url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" },
{ url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" },
{ url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" },
{ url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
]
[[package]]
name = "wcmatch"
version = "10.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "bracex" },
]
sdist = { url = "https://files.pythonhosted.org/packages/79/3e/c0bdc27cf06f4e47680bd5803a07cb3dfd17de84cde92dd217dcb9e05253/wcmatch-10.1.tar.gz", hash = "sha256:f11f94208c8c8484a16f4f48638a85d771d9513f4ab3f37595978801cb9465af", size = 117421, upload-time = "2025-06-22T19:14:02.49Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/d8/0d1d2e9d3fabcf5d6840362adcf05f8cf3cd06a73358140c3a97189238ae/wcmatch-10.1-py3-none-any.whl", hash = "sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a", size = 39854, upload-time = "2025-06-22T19:14:00.978Z" },
]
================================================
FILE: examples/mcp-server-client/context/features/feature_1_mcp_server.md
================================================
# Feature description for: MCP Server with tools
## FEATURE
Implement an **MCP (Message Control Protocol) Server** in Python that exposes three callable tools via structured messages. The server should receive well-formed MCP messages and dispatch tool invocations accordingly. The three tools to be exposed are:
1. **Roll Dice**: Accepts a format like `2d6` or `1d20` and returns the rolled values and total.
2. **Get Weather**: Accepts a city name or coordinates and returns the current weather conditions using a public weather API.
3. **Get Date**: Returns the current date and time in ISO 8601 format or based on a requested timezone.
The server should be modular, testable, and extensible for future tools. Logging, error handling, and message validation should be considered first-class concerns.
## EXAMPLES
Located in `/context/examples`:
* `roll_dice_example.json`: Demonstrates sending `{"tool": "roll_dice", "args": {"notation": "3d6"}}` and receiving `{"result": {"values": [4,2,6], "total": 12}}`.
* `get_weather_example.json`: Sends `{"tool": "get_weather", "args": {"location": "San Francisco"}}` and expects weather data such as temperature, condition, and wind speed.
* `get_date_example.json`: Sends `{"tool": "get_date", "args": {"timezone": "UTC"}}` and receives `{"result": "2025-07-06T16:22:00Z"}`.
These examples cover correct usage and malformed inputs to validate tool response and error handling.
## DOCUMENTATION
* [Open-Meteo API](https://open-meteo.com/en/docs): For retrieving weather information.
* [Python `datetime` module](https://docs.python.org/3/library/datetime.html): For implementing date and time tool.
* [random module (Python)](https://docs.python.org/3/library/random.html): For rolling dice.
* \[MCP Protocol Overview (proprietary/internal if applicable)] or general protocol documentation, if using a specific spec.
Additional context from [context-engineering-intro](https://github.com/qte77/context-engineering-template) will inform message structure and processing strategy.
## OTHER CONSIDERATIONS
* **Tool routing logic** should be clearly separated to allow clean expansion.
* **Input validation** is critical: especially for `roll_dice`, invalid formats (e.g., `3x5`, `0d6`, `d10`) must return informative errors.
* **Weather API failures or rate limits** should be gracefully handled with fallback messages.
* **Timezone parsing** for `get_date` should use `pytz` or `zoneinfo`, and clearly inform users when timezones are unsupported.
* **Security note**: Weather and date APIs should not expose sensitive request metadata or leak internal server details in errors.
* AI coding assistants often:
* Miss edge case handling (e.g., zero dice, negative sides)
* Forget to structure results consistently across tools
* Fail to modularize tool logic, making future expansion difficult
================================================
FILE: examples/mcp-server-client/context/features/feature_2_mcp_client.md
================================================
# Feature description for: MCP Client for Tool Invocation
## FEATURE
Implement a **Python-based MCP Client** capable of sending structured requests to an MCP Server and handling the corresponding responses. The client should:
* Connect to the MCP server over a socket, HTTP, or another configured protocol.
* Serialize requests into the expected MCP message format (e.g., JSON or line-based protocol).
* Provide a command-line interface (CLI) and/or programmatic interface for interacting with the following tools:
1. **Roll Dice** (`roll_dice`) – accepts dice notation like `2d6`, `1d20`.
2. **Get Weather** (`get_weather`) – accepts a location name or coordinates.
3. **Get Date** (`get_date`) – optionally accepts a timezone.
The client should also handle connection errors, invalid tool responses, and retry logic gracefully.
## EXAMPLES
Located in `/context/examples`:
* `client_roll_dice_input.json`: `{ "tool": "roll_dice", "args": { "notation": "2d6" } }`
* `client_get_weather_input.json`: `{ "tool": "get_weather", "args": { "location": "Berlin" } }`
* `client_get_date_input.json`: `{ "tool": "get_date", "args": { "timezone": "UTC" } }`
* `client_invalid_tool.json`: `{ "tool": "fly_to_mars", "args": {} }` → Should trigger a meaningful error from the server
These example requests and expected responses can be used for local testing and automated integration checks.
## DOCUMENTATION
* [Python `socket` module](https://docs.python.org/3/library/socket.html) or [requests](https://docs.python.org/3/library/urllib.request.html) depending on transport.
* [JSON module](https://docs.python.org/3/library/json.html) for message formatting.
* [argparse](https://docs.python.org/3/library/argparse.html) for implementing a simple CLI wrapper.
* Reference the MCP Server protocol spec or internal documentation (e.g. *MCP Protocol Overview* if proprietary).
* [context-engineering-template](https://github.com/qte/context-engineering-template) for usage conventions.
## OTHER CONSIDERATIONS
* Client must validate outgoing messages before sending to avoid malformed requests.
* Handle connection errors, timeouts, and retries in a user-friendly manner.
* The response handler should check for required fields (`result`, `error`, etc.) to avoid crashes on malformed server responses.
* Consider pluggability of tools so future expansions can be supported with minimal refactoring.
* AI assistants often:
* Miss error handling around partial or no server responses.
* Forget to properly close socket connections or handle timeouts.
* Write overly rigid request builders, making CLI usage frustrating.
================================================
FILE: examples/mcp-server-client/context/features/feature_3_streamlit_gui.md
================================================
# Feature description for: Streamlit GUI for MCP Server-Client Interaction Showcase
## FEATURE
Develop a **Streamlit-based graphical user interface (GUI)** to demonstrate and interactively showcase the communication and integration between the MCP Server and MCP Client. The GUI should allow users to:
* Select and invoke any of the three available tools (`roll_dice`, `get_weather`, `get_date`) via intuitive form inputs.
* Enter tool-specific parameters such as dice notation, location, or timezone.
* Display real-time request payloads sent by the client and the corresponding responses received from the server.
* Handle and display error messages gracefully.
* Log interaction history for the current session, allowing users to review previous commands and results.
* Provide clear visual feedback about the status of the connection and request execution.
This GUI acts as both a testing ground and demonstration interface, useful for users unfamiliar with command-line tools or raw protocol messages.
## EXAMPLES
Located in `/context/examples`:
* `streamlit_roll_dice_interaction.json`: Example input/output pairs demonstrating a dice roll session in the GUI.
* `streamlit_get_weather_interaction.json`: Demonstrates user inputs for location and the displayed weather response.
* `streamlit_get_date_interaction.json`: Shows date/time requests with optional timezone selection.
* `streamlit_error_handling.json`: Examples of how the GUI displays server-side validation errors or connection issues.
These examples serve as test cases for GUI input validation and response rendering.
## DOCUMENTATION
* [Streamlit Documentation](https://docs.streamlit.io/) for building interactive Python apps.
* \[MCP Server and Client Protocol Specs] (internal/proprietary or from context-engineering-intro).
* Python libraries for HTTP or socket communication used by the client.
* UI/UX design best practices for interactive demos.
* [context-engineering-intro](https://github.com/coleam00/context-engineering-intro) for project conventions.
## OTHER CONSIDERATIONS
* Ensure asynchronous or non-blocking communication so the UI remains responsive during server interactions.
* Validate inputs in the GUI before sending to the client to minimize server errors.
* Provide helpful tooltips or inline help to explain tool parameters to users unfamiliar with dice notation or timezone formats.
* Consider session state management in Streamlit to maintain history and status.
* AI coding assistants often overlook proper error propagation to the UI and user-friendly messaging.
* Security considerations: if exposing any sensitive endpoints or API keys, avoid hardcoding secrets in the GUI code.
* Design with extensibility in mind to add new tools or more complex workflows easily.
================================================
FILE: examples/mcp-server-client/context/outputs/client_get_date_input.json
================================================
{
"description": "Simple client input format for get_date tool",
"cli_command": "python -m src.main client --server src/mcp_server/server.py get_date --timezone UTC",
"expected_input": {
"tool": "get_date",
"arguments": {
"timezone": "UTC"
}
},
"expected_output_format": {
"success": true,
"tool_name": "get_date",
"result": {
"content": [
{
"type": "text",
"text": "🕐 **Current Date & Time**\n📅 Date: **2025-07-07** (Monday)\n⏰ Time: **14:30:25**\n🌍 Timezone: **UTC**\n📋 ISO 8601: `2025-07-07T14:30:25+00:00`\n🔢 Unix Timestamp: `1720360225`"
}
]
}
},
"examples": [
{
"timezone": "America/New_York",
"description": "Get Eastern Time"
},
{
"timezone": "Europe/London",
"description": "Get London time"
},
{
"timezone": "Asia/Tokyo",
"description": "Get Tokyo time"
},
{
"timezone": "pst",
"description": "Get Pacific Time using alias"
}
]
}
================================================
FILE: examples/mcp-server-client/context/outputs/client_get_weather_input.json
================================================
{
"description": "Simple client input format for get_weather tool",
"cli_command": "python -m src.main client --server src/mcp_server/server.py get_weather --location 'San Francisco'",
"expected_input": {
"tool": "get_weather",
"arguments": {
"location": "San Francisco"
}
},
"expected_output_format": {
"success": true,
"tool_name": "get_weather",
"result": {
"content": [
{
"type": "text",
"text": "🌤️ **Weather for San Francisco**\n🌡️ Temperature: **18.5°C**\n☁️ Condition: **Partly cloudy**\n💨 Wind Speed: **12.3 km/h**\n💧 Humidity: **65%**\n🕐 Updated: 2025-07-07 14:30 UTC"
}
]
}
},
"examples": [
{
"location": "London",
"description": "Get weather for London"
},
{
"location": "New York",
"description": "Get weather for New York"
},
{
"location": "37.7749,-122.4194",
"description": "Get weather using coordinates"
}
]
}
================================================
FILE: examples/mcp-server-client/context/outputs/client_invalid_tool.json
================================================
{
"description": "Error handling example for invalid tool",
"cli_command": "python -m src.main client --server src/mcp_server/server.py invalid_tool --arg value",
"expected_input": {
"tool": "invalid_tool",
"arguments": {
"arg": "value"
}
},
"expected_output_format": {
"success": false,
"tool_name": "invalid_tool",
"error": "Tool 'invalid_tool' not available. Available tools: ['roll_dice', 'get_weather', 'get_date']",
"arguments": {
"arg": "value"
}
},
"error_scenarios": [
{
"scenario": "Tool not available",
"tool": "nonexistent_tool",
"expected_error": "Tool 'nonexistent_tool' not available"
},
{
"scenario": "Server not running",
"server_path": "./nonexistent_server.py",
"expected_error": "Server script not found"
},
{
"scenario": "Invalid server path",
"server_path": "/dev/null",
"expected_error": "Failed to connect to server"
},
{
"scenario": "Connection timeout",
"timeout": 1,
"expected_error": "Connection timeout"
}
]
}
================================================
FILE: examples/mcp-server-client/context/outputs/client_roll_dice_input.json
================================================
{
"description": "Simple client input format for roll_dice tool",
"cli_command": "python -m src.main client --server src/mcp_server/server.py roll_dice --notation 2d6",
"expected_input": {
"tool": "roll_dice",
"arguments": {
"notation": "2d6"
}
},
"expected_output_format": {
"success": true,
"tool_name": "roll_dice",
"result": {
"content": [
{
"type": "text",
"text": "🎲 Rolled 2d6: [3, 5] = **8**"
}
]
}
},
"examples": [
{
"notation": "1d20",
"description": "Roll a 20-sided die"
},
{
"notation": "3d6",
"description": "Roll three 6-sided dice"
},
{
"notation": "2d10",
"description": "Roll two 10-sided dice"
}
]
}
================================================
FILE: examples/mcp-server-client/context/outputs/get_date_example.json
================================================
{
"description": "Example request and response for the get_date tool",
"request": {
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "get_date",
"arguments": {
"timezone": "UTC"
}
},
"id": 3
},
"response": {
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [
{
"type": "text",
"text": "🕐 **Current Date & Time**\n📅 Date: **2025-07-07** (Monday)\n⏰ Time: **14:30:25**\n🌍 Timezone: **UTC**\n📋 ISO 8601: `2025-07-07T14:30:25+00:00`\n🔢 Unix Timestamp: `1720360225`"
}
],
"isError": false
}
},
"examples": {
"valid_requests": [
{
"timezone": "UTC",
"description": "Coordinated Universal Time"
},
{
"timezone": "America/New_York",
"description": "Eastern Time"
},
{
"timezone": "America/Los_Angeles",
"description": "Pacific Time"
},
{
"timezone": "Europe/London",
"description": "British Time"
},
{
"timezone": "Asia/Tokyo",
"description": "Japan Standard Time"
},
{
"timezone": "est",
"description": "Eastern Time alias"
},
{
"timezone": "pst",
"description": "Pacific Time alias"
}
],
"invalid_requests": [
{
"timezone": "Invalid/Timezone",
"error": "Invalid timezone: 'Invalid/Timezone'. Common timezones: UTC, America/New_York, America/Los_Angeles, America/Chicago, Europe/London, Europe/Paris, Asia/Tokyo, Australia/Sydney. Aliases: utc, gmt, est, pst, cst, mst, edt, pdt, cdt, mdt, bst, cet, jst, aest. Use IANA timezone names (e.g., 'America/New_York') or aliases."
},
{
"timezone": "",
"error": "Timezone cannot be empty"
}
]
},
"supported_timezones": {
"aliases": {
"utc": "UTC",
"gmt": "UTC",
"est": "America/New_York",
"pst": "America/Los_Angeles",
"cst": "America/Chicago",
"mst": "America/Denver",
"edt": "America/New_York",
"pdt": "America/Los_Angeles",
"cdt": "America/Chicago",
"mdt": "America/Denver",
"bst": "Europe/London",
"cet": "Europe/Paris",
"jst": "Asia/Tokyo",
"aest": "Australia/Sydney"
},
"common_iana_zones": [
"UTC",
"America/New_York",
"America/Los_Angeles",
"America/Chicago",
"America/Denver",
"Europe/London",
"Europe/Paris",
"Europe/Berlin",
"Asia/Tokyo",
"Asia/Shanghai",
"Australia/Sydney"
]
}
}
================================================
FILE: examples/mcp-server-client/context/outputs/get_weather_example.json
================================================
{
"description": "Example request and response for the get_weather tool",
"request": {
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "get_weather",
"arguments": {
"location": "San Francisco"
}
},
"id": 2
},
"response": {
"jsonrpc": "2.0",
"id": 2,
"result": {
"content": [
{
"type": "text",
"text": "🌤️ **Weather for San Francisco**\n🌡️ Temperature: **18.5°C**\n☁️ Condition: **Partly cloudy**\n💨 Wind Speed: **12.3 km/h**\n💧 Humidity: **65%**\n🕐 Updated: 2025-07-07 14:30 UTC"
}
],
"isError": false
}
},
"examples": {
"valid_requests": [
{
"location": "London",
"description": "Weather for London city"
},
{
"location": "New York",
"description": "Weather for New York city"
},
{
"location": "37.7749,-122.4194",
"description": "Weather using coordinates (San Francisco)"
},
{
"location": "Tokyo",
"description": "Weather for Tokyo city"
}
],
"invalid_requests": [
{
"location": "Unknown City",
"error": "Unknown location: 'Unknown City'. Please use coordinates (lat,lon) or one of: berlin, beijing, cairo, chicago, lagos, london, los angeles, madrid, miami, moscow, mumbai, new york, paris, rome, san francisco, seattle, sydney, tokyo, toronto, vancouver"
},
{
"location": "",
"error": "Location cannot be empty"
},
{
"location": "999,999",
"error": "Unknown location: '999,999'. Please use coordinates (lat,lon) or one of: [city list]"
}
]
},
"supported_cities": [
"San Francisco",
"New York",
"London",
"Paris",
"Tokyo",
"Sydney",
"Los Angeles",
"Chicago",
"Miami",
"Seattle",
"Vancouver",
"Toronto",
"Berlin",
"Rome",
"Madrid",
"Moscow",
"Beijing",
"Mumbai",
"Cairo",
"Lagos"
]
}
================================================
FILE: examples/mcp-server-client/context/outputs/roll_dice_example.json
================================================
{
"description": "Example request and response for the roll_dice tool",
"request": {
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "roll_dice",
"arguments": {
"notation": "3d6"
}
},
"id": 1
},
"response": {
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [
{
"type": "text",
"text": "🎲 Rolled 3d6: [4, 2, 6] = **12**"
}
],
"isError": false
}
},
"examples": {
"valid_requests": [
{
"notation": "1d20",
"description": "Single twenty-sided die"
},
{
"notation": "2d6",
"description": "Two six-sided dice"
},
{
"notation": "4d10",
"description": "Four ten-sided dice"
}
],
"invalid_requests": [
{
"notation": "d6",
"error": "Invalid dice notation: 'd6'. Expected format: 'XdY' (e.g., '2d6', '1d20')"
},
{
"notation": "0d6",
"error": "Dice count must be greater than 0"
},
{
"notation": "101d6",
"error": "Dice count must not exceed 100"
},
{
"notation": "1d0",
"error": "Number of sides must be greater than 0"
},
{
"notation": "abc",
"error": "Invalid dice notation: 'abc'. Expected format: 'XdY' (e.g., '2d6', '1d20')"
}
]
}
}
================================================
FILE: examples/mcp-server-client/context/outputs/streamlit_error_handling.json
================================================
{
"interaction_type": "streamlit_gui",
"tool_name": "roll_dice",
"timestamp": "2024-01-15T10:36:00Z",
"request": {
"method": "tool_invocation",
"parameters": {
"tool": "roll_dice",
"arguments": {
"notation": "invalid_dice"
}
}
},
"response": {
"success": false,
"result": null,
"error": "Invalid dice notation format: invalid_dice",
"tool_name": "roll_dice",
"arguments": {
"notation": "invalid_dice"
}
},
"execution_time": 0.05,
"gui_state": {
"connected": true,
"server_path": "src/mcp_server/server.py",
"available_tools": ["roll_dice", "get_weather", "get_date"]
},
"error_details": {
"validation_error": "Dice notation must follow pattern: NdN (e.g., 2d6, 1d20)",
"user_input": "invalid_dice",
"suggested_corrections": ["2d6", "1d20", "3d10"]
}
}
================================================
FILE: examples/mcp-server-client/context/outputs/streamlit_get_date_interaction.json
================================================
{
"interaction_type": "streamlit_gui",
"tool_name": "get_date",
"timestamp": "2024-01-15T10:34:00Z",
"request": {
"method": "tool_invocation",
"parameters": {
"tool": "get_date",
"arguments": {
"timezone": "America/New_York"
}
}
},
"response": {
"success": true,
"result": {
"datetime": "2024-01-15T05:34:15-05:00",
"timezone": "America/New_York",
"formatted": "Monday, January 15, 2024 at 5:34:15 AM EST",
"unix_timestamp": 1705313655
},
"tool_name": "get_date",
"arguments": {
"timezone": "America/New_York"
}
},
"execution_time": 0.12,
"gui_state": {
"connected": true,
"server_path": "src/mcp_server/server.py",
"available_tools": ["roll_dice", "get_weather", "get_date"]
}
}
================================================
FILE: examples/mcp-server-client/context/outputs/streamlit_get_weather_interaction.json
================================================
{
"interaction_type": "streamlit_gui",
"tool_name": "get_weather",
"timestamp": "2024-01-15T10:32:00Z",
"request": {
"method": "tool_invocation",
"parameters": {
"tool": "get_weather",
"arguments": {
"location": "San Francisco"
}
}
},
"response": {
"success": true,
"result": {
"location": "San Francisco, CA",
"temperature": "18°C",
"condition": "Partly cloudy",
"humidity": "65%",
"wind": "12 mph NW"
},
"tool_name": "get_weather",
"arguments": {
"location": "San Francisco"
}
},
"execution_time": 0.45,
"gui_state": {
"connected": true,
"server_path": "src/mcp_server/server.py",
"available_tools": ["roll_dice", "get_weather", "get_date"]
}
}
================================================
FILE: examples/mcp-server-client/context/outputs/streamlit_roll_dice_interaction.json
================================================
{
"interaction_type": "streamlit_gui",
"tool_name": "roll_dice",
"timestamp": "2024-01-15T10:30:00Z",
"request": {
"method": "tool_invocation",
"parameters": {
"tool": "roll_dice",
"arguments": {
"notation": "2d6"
}
}
},
"response": {
"success": true,
"result": {
"values": [3, 5],
"total": 8,
"notation": "2d6"
},
"tool_name": "roll_dice",
"arguments": {
"notation": "2d6"
}
},
"execution_time": 0.15,
"gui_state": {
"connected": true,
"server_path": "src/mcp_server/server.py",
"available_tools": ["roll_dice", "get_weather", "get_date"]
}
}
================================================
FILE: examples/mcp-server-client/context/PRPs/feature_1_mcp_server.md
================================================
# MCP Server Implementation with Three Core Tools
## Purpose
Product Requirements Prompt (PRP) for implementing a complete MCP (Model Context Protocol) server in Python that exposes three callable tools: dice rolling, weather retrieval, and date/time services. This PRP provides comprehensive context for one-pass implementation success.
## Goal
Build a production-ready MCP server in Python that:
- Implements the MCP protocol for structured message handling
- Exposes three tools: `roll_dice`, `get_weather`, and `get_date`
- Provides robust error handling and input validation
- Follows the project's established patterns and conventions
- Is modular, testable, and extensible for future tools
## Why
- **Standardization**: Implements the emerging MCP protocol standard for AI tool integration
- **Modularity**: Creates a foundation for adding more tools in the future
- **User Value**: Provides practical utilities (dice, weather, time) accessible via structured protocol
- **Learning**: Demonstrates best practices for building MCP servers in Python
## What
### User-visible behavior
- Server accepts MCP protocol messages in JSON format
- Tools can be invoked via structured requests like `{"tool": "roll_dice", "args": {"notation": "3d6"}}`
- Returns structured responses with results or detailed error messages
- Handles malformed inputs gracefully with informative feedback
### Success Criteria
- [ ] MCP server starts and accepts connections via stdio transport
- [ ] All three tools (`roll_dice`, `get_weather`, `get_date`) work correctly
- [ ] Input validation prevents crashes and provides helpful error messages
- [ ] Weather API integration handles rate limits and failures gracefully
- [ ] Timezone support works correctly for date/time requests
- [ ] All code passes linting (ruff) and type checking (mypy)
- [ ] Comprehensive test coverage for all tools and edge cases
- [ ] Server follows project conventions and patterns
## All Needed Context
### Documentation & References
```yaml
# MUST READ - Include these in your context window
- url: https://github.com/modelcontextprotocol/python-sdk
why: Official MCP Python SDK for protocol implementation
- url: https://github.com/jlowin/fastmcp
why: FastMCP framework for simplified MCP server development
- url: https://open-meteo.com/en/docs
why: Weather API documentation for get_weather tool
section: Current weather conditions and forecast endpoints
critical: No API key required for basic usage, supports lat/lon coordinates
- url: https://docs.python.org/3/library/datetime.html
why: Timezone handling and ISO 8601 formatting for get_date tool
section: timezone-aware datetime objects and isoformat()
critical: Use timezone.utc for UTC times, avoid naive datetimes
- url: https://docs.python.org/3/library/random.html
why: Random number generation for dice rolling
section: randint() for generating dice values
critical: Use random.randint(1, sides) for each die roll
- file: /workspaces/context-engineering-template/context/examples/features/feature_1_mcp_server.md
why: Original feature requirements with examples and considerations
- file: /workspaces/context-engineering-template/pyproject.toml
why: Project configuration, dependencies, and tool settings
- file: /workspaces/context-engineering-template/src/main.py
why: Current main entry point pattern to follow
```
### Current Codebase tree
```bash
/workspaces/context-engineering-template
├── AGENTS.md
├── assets/
├── CHANGELOG.md
├── CLAUDE.md
├── context/
│ ├── examples/
│ │ └── features/
│ │ └── feature_1_mcp_server.md
│ └── templates/
├── docs/
├── LICENSE
├── Makefile
├── mkdocs.yaml
├── pyproject.toml
├── README.md
├── src/
│ ├── __init__.py
│ ├── main.py
│ └── py.typed
└── uv.lock
```
### Desired Codebase tree with files to be added
```bash
/workspaces/context-engineering-template
├── src/
│ ├── __init__.py
│ ├── main.py # Modified to support MCP server
│ ├── py.typed
│ ├── mcp_server/
│ │ ├── __init__.py
│ │ ├── server.py # Main MCP server implementation
│ │ ├── tools/
│ │ │ ├── __init__.py
│ │ │ ├── base.py # Base tool interface
│ │ │ ├── dice.py # Roll dice tool
│ │ │ ├── weather.py # Weather API tool
│ │ │ └── date_time.py # Date/time tool
│ │ └── models/
│ │ ├── __init__.py
│ │ └── requests.py # Pydantic models for requests/responses
├── tests/
│ ├── __init__.py
│ ├── test_mcp_server.py
│ ├── test_tools/
│ │ ├── __init__.py
│ │ ├── test_dice.py
│ │ ├── test_weather.py
│ │ └── test_date_time.py
│ └── fixtures/
│ ├── __init__.py
│ └── mcp_messages.py # Test message fixtures
├── context/
│ └── examples/outputs
│ ├── roll_dice_example.json # Example request/response
│ ├── get_weather_example.json # Example request/response
│ └── get_date_example.json # Example request/response
```
### Known Gotchas & Library Quirks
```python
# CRITICAL: MCP protocol requires specific message structure
# Must follow exact JSON-RPC 2.0 format with method/params/id fields
# CRITICAL: FastMCP requires async functions for tool definitions
# Example:
@mcp.tool()
async def roll_dice(notation: str) -> dict:
# Implementation
# CRITICAL: Open-Meteo API requires lat/lon coordinates, not city names
# Must use geocoding service or coordinate lookup for city names
# API endpoint: https://api.open-meteo.com/v1/forecast?latitude=X&longitude=Y
# CRITICAL: Python datetime timezone handling
# Always use timezone-aware datetime objects
# Use datetime.now(timezone.utc) for UTC times
# Use zoneinfo (Python 3.9+) or pytz for timezone conversions
# CRITICAL: Dice notation validation
# Valid formats: "1d6", "2d10", "3d20" (number + 'd' + sides)
# Invalid: "d6", "1x6", "0d6", "1d0", "abc"
# Must validate both dice count and sides > 0
# CRITICAL: Error handling should not expose internal details
# Return user-friendly error messages without stack traces
# Log detailed errors internally for debugging
```
## Implementation Blueprint
### Data models and structure
Create core data models for type safety and validation:
```python
# src/mcp_server/models/requests.py
from pydantic import BaseModel, Field, validator
from typing import Optional, Union, Any
class MCPRequest(BaseModel):
"""Base MCP request structure"""
jsonrpc: str = "2.0"
method: str
params: Optional[dict] = None
id: Optional[Union[str, int]] = None
class MCPResponse(BaseModel):
"""Base MCP response structure"""
jsonrpc: str = "2.0"
id: Optional[Union[str, int]] = None
result: Optional[Any] = None
error: Optional[dict] = None
class DiceRollRequest(BaseModel):
"""Dice roll tool request"""
notation: str = Field(..., description="Dice notation like '2d6' or '1d20'")
@validator('notation')
def validate_notation(cls, v):
# Validate dice notation format
pass
class WeatherRequest(BaseModel):
"""Weather tool request"""
location: str = Field(..., description="City name or coordinates")
class DateTimeRequest(BaseModel):
"""Date/time tool request"""
timezone: Optional[str] = Field("UTC", description="Timezone identifier")
```
### List of tasks to be completed in order
```yaml
Task 1: Setup Dependencies
ADD to pyproject.toml:
- fastmcp>=2.0.0 (or mcp package if using official SDK)
- httpx (for weather API calls)
- pydantic>=2.0.0 (for data validation)
- python-dateutil (for timezone handling)
Task 2: Create Base Tool Interface
CREATE src/mcp_server/tools/base.py:
- DEFINE abstract base class for all tools
- ESTABLISH common interface for tool registration
- INCLUDE error handling patterns
Task 3: Implement Dice Rolling Tool
CREATE src/mcp_server/tools/dice.py:
- IMPLEMENT dice notation parsing (regex: r'(\d+)d(\d+)')
- VALIDATE dice count > 0 and sides > 0
- GENERATE random values using random.randint(1, sides)
- RETURN structured result with individual values and total
Task 4: Implement Weather Tool
CREATE src/mcp_server/tools/weather.py:
- IMPLEMENT city name to coordinates lookup (basic hardcoded mapping)
- INTEGRATE with Open-Meteo API using httpx
- HANDLE API failures and rate limits gracefully
- RETURN structured weather data (temperature, condition, wind)
Task 5: Implement Date/Time Tool
CREATE src/mcp_server/tools/date_time.py:
- IMPLEMENT timezone parsing using zoneinfo
- GENERATE current datetime for requested timezone
- FORMAT output as ISO 8601 string
- HANDLE invalid timezone names gracefully
Task 6: Create MCP Server
CREATE src/mcp_server/server.py:
- IMPLEMENT FastMCP server with tool registration
- SETUP message routing and error handling
- INTEGRATE all three tools with proper decorators
- HANDLE protocol-level errors and validation
Task 7: Update Main Entry Point
MODIFY src/main.py:
- IMPORT MCP server module
- SETUP server initialization and startup
- HANDLE graceful shutdown
- PRESERVE existing async pattern
Task 8: Create Example Files
CREATE context/examples/outputs/*.json:
- GENERATE realistic request/response examples
- INCLUDE both success and error cases
- FOLLOW exact JSON structure from feature requirements
Task 9: Implement Comprehensive Tests
CREATE tests/test_*.py:
- UNIT tests for each tool with edge cases
- INTEGRATION tests for MCP server
- MOCK external API calls for weather tool
- VALIDATE error handling and edge cases
Task 10: Final Integration and Validation
RUN validation commands:
- EXECUTE make ruff (code formatting)
- EXECUTE make type_check (mypy validation)
- EXECUTE make test_all (pytest suite)
- MANUAL test server startup and tool execution
```
### Per task pseudocode
```python
# Task 3: Dice Rolling Tool
class DiceRoller:
def __init__(self):
self.notation_pattern = re.compile(r'^(\d+)d(\d+)$')
async def roll_dice(self, notation: str) -> dict:
# PATTERN: Always validate input first
match = self.notation_pattern.match(notation.lower())
if not match:
raise ValueError(f"Invalid dice notation: {notation}")
dice_count, sides = int(match.group(1)), int(match.group(2))
# GOTCHA: Validate reasonable limits
if dice_count <= 0 or dice_count > 100:
raise ValueError("Dice count must be 1-100")
if sides <= 0 or sides > 1000:
raise ValueError("Sides must be 1-1000")
# PATTERN: Generate results
values = [random.randint(1, sides) for _ in range(dice_count)]
return {
"values": values,
"total": sum(values),
"notation": notation
}
# Task 4: Weather Tool
class WeatherTool:
def __init__(self):
self.api_base = "https://api.open-meteo.com/v1"
self.city_coords = {
"san francisco": (37.7749, -122.4194),
"new york": (40.7128, -74.0060),
# Add more as needed
}
async def get_weather(self, location: str) -> dict:
# PATTERN: Input validation and preprocessing
location_lower = location.lower()
# GOTCHA: Handle coordinate lookup
if location_lower in self.city_coords:
lat, lon = self.city_coords[location_lower]
else:
# Try to parse as "lat,lon" format
try:
lat, lon = map(float, location.split(','))
except ValueError:
raise ValueError(f"Unknown location: {location}")
# CRITICAL: API call with proper error handling
async with httpx.AsyncClient() as client:
try:
response = await client.get(
f"{self.api_base}/forecast",
params={
"latitude": lat,
"longitude": lon,
"current": "temperature_2m,weather_code,wind_speed_10m"
},
timeout=10.0
)
response.raise_for_status()
data = response.json()
return {
"location": location,
"temperature": data["current"]["temperature_2m"],
"condition": self._weather_code_to_text(data["current"]["weather_code"]),
"wind_speed": data["current"]["wind_speed_10m"]
}
except httpx.TimeoutException:
raise Exception("Weather service timeout")
except httpx.HTTPStatusError as e:
raise Exception(f"Weather service error: {e.response.status_code}")
# Task 5: Date/Time Tool
class DateTimeTool:
async def get_date(self, timezone: str = "UTC") -> str:
# PATTERN: Timezone validation
try:
if timezone.upper() == "UTC":
tz = timezone.utc
else:
tz = zoneinfo.ZoneInfo(timezone)
except zoneinfo.ZoneInfoNotFoundError:
raise ValueError(f"Invalid timezone: {timezone}")
# PATTERN: Generate timezone-aware datetime
current_time = datetime.now(tz)
# CRITICAL: Return ISO 8601 format
return current_time.isoformat()
# Task 6: MCP Server Integration
from fastmcp import FastMCP
mcp = FastMCP("dice-weather-datetime-server")
@mcp.tool()
async def roll_dice(notation: str) -> dict:
"""Roll dice using standard notation like '2d6' or '1d20'"""
return await dice_tool.roll_dice(notation)
@mcp.tool()
async def get_weather(location: str) -> dict:
"""Get current weather for a location"""
return await weather_tool.get_weather(location)
@mcp.tool()
async def get_date(timezone: str = "UTC") -> str:
"""Get current date and time for a timezone"""
return await datetime_tool.get_date(timezone)
```
### Integration Points
```yaml
DEPENDENCIES:
- add to: pyproject.toml
- packages: ["fastmcp>=2.0.0", "httpx", "pydantic>=2.0.0", "python-dateutil"]
MAIN_ENTRY:
- modify: src/main.py
- pattern: "async def main() -> None:"
- add server startup and stdio transport setup
TESTING:
- add to: tests/
- pattern: "pytest fixtures for mocking external APIs"
- mock httpx responses for weather API
```
## Validation Loop
### Level 1: Syntax & Style
```bash
# Run these FIRST - fix any errors before proceeding
uv run ruff check src/ --fix
uv run mypy src/
# Expected: No errors. If errors exist, read and fix them.
```
### Level 2: Unit Tests
```python
# CREATE test_dice.py
def test_roll_dice_valid_notation():
"""Test valid dice notation works"""
result = await dice_tool.roll_dice("2d6")
assert len(result["values"]) == 2
assert all(1 <= v <= 6 for v in result["values"])
assert result["total"] == sum(result["values"])
def test_roll_dice_invalid_notation():
"""Test invalid notation raises ValueError"""
with pytest.raises(ValueError, match="Invalid dice notation"):
await dice_tool.roll_dice("invalid")
def test_get_weather_known_city():
"""Test weather for known city"""
with httpx_mock.HTTPXMock() as m:
m.add_response(json={"current": {"temperature_2m": 20, "weather_code": 0, "wind_speed_10m": 5}})
result = await weather_tool.get_weather("San Francisco")
assert result["temperature"] == 20
def test_get_date_utc():
"""Test UTC date retrieval"""
result = await datetime_tool.get_date("UTC")
assert result.endswith("Z") # UTC timezone indicator
```
```bash
# Run and iterate until passing:
uv run pytest tests/ -v
# If failing: Read error, fix code, re-run
```
### Level 3: Integration Test
```bash
# Test MCP server startup
uv run python -m src.main
# Test tool execution (manual verification)
# Send JSON-RPC message to stdin and verify response format
echo '{"jsonrpc": "2.0", "method": "tools/call", "params": {"name": "roll_dice", "arguments": {"notation": "2d6"}}, "id": 1}' | uv run python -m src.main
```
## Final validation Checklist
- [ ] All tests pass: `uv run pytest tests/ -v`
- [ ] No linting errors: `uv run ruff check src/`
- [ ] No type errors: `uv run mypy src/`
- [ ] MCP server starts without errors
- [ ] All three tools respond correctly to valid inputs
- [ ] Invalid inputs return helpful error messages
- [ ] Weather API failures are handled gracefully
- [ ] Timezone handling works for common timezones
- [ ] Example JSON files are valid and match implementation
## Anti-Patterns to Avoid
- ❌ Don't use synchronous functions in async context
- ❌ Don't ignore MCP protocol message structure requirements
- ❌ Don't expose internal error details to users
- ❌ Don't hardcode API endpoints or configuration values
- ❌ Don't skip input validation because "it should work"
- ❌ Don't use naive datetime objects for timezone-aware operations
- ❌ Don't catch all exceptions - be specific about error handling
- ❌ Don't mock away all external dependencies in tests - verify integration points
---
## Quality Score: 8/10
This PRP provides comprehensive context for one-pass implementation:
- ✅ Complete MCP protocol documentation and examples
- ✅ Detailed implementation patterns and gotchas
- ✅ Specific API documentation and error handling strategies
- ✅ Executable validation commands and test patterns
- ✅ Clear task breakdown with pseudocode
- ✅ Anti-patterns and common pitfalls identified
**Areas for improvement:**
- Could include more city coordinate mappings for weather tool
- Could provide more specific timezone examples for testing
**Confidence Level:** High - This PRP contains sufficient technical detail and context for successful implementation by an AI agent with access to the codebase and web search capabilities.
================================================
FILE: examples/mcp-server-client/context/PRPs/feature_2_mcp_client.md
================================================
# MCP Client Implementation for Tool Invocation
## Purpose
Product Requirements Prompt (PRP) for implementing a complete MCP (Model Context Protocol) client in Python that can connect to MCP servers and invoke tools. This PRP provides comprehensive context for one-pass implementation success.
## Goal
Build a production-ready MCP client in Python that:
- Implements the MCP protocol for structured message handling
- Connects to MCP servers via stdio transport
- Provides CLI interface for invoking tools: `roll_dice`, `get_weather`, and `get_date`
- Handles connection errors, timeouts, and retries gracefully
- Follows the project's established patterns and conventions
- Is modular, testable, and extensible for future tools
## Why
- **Standardization**: Implements the MCP protocol standard for AI tool integration
- **Modularity**: Creates a foundation for connecting to any MCP server
- **User Value**: Provides command-line access to remote tools via structured protocol
- **Learning**: Demonstrates best practices for building MCP clients in Python
- **Complement**: Works with the existing MCP server implementation in the codebase
## What
### User-visible behavior
- Client connects to MCP server via stdio transport
- CLI accepts tool invocation commands like `mcp-client roll_dice --notation 3d6`
- Returns structured responses with results or detailed error messages
- Handles server connection failures gracefully with informative feedback
- Supports interactive mode for multiple tool invocations
### Success Criteria
- [ ] MCP client connects to local MCP server via stdio transport
- [ ] All three tools (`roll_dice`, `get_weather`, `get_date`) can be invoked via CLI
- [ ] Connection error handling provides helpful user feedback
- [ ] Tool invocation validation prevents malformed requests
- [ ] JSON-RPC 2.0 protocol compliance for all messages
- [ ] All code passes linting (ruff) and type checking (mypy)
- [ ] Comprehensive test coverage for all client functionality
- [ ] Client follows project conventions and patterns
## All Needed Context
### Documentation & References
```yaml
# MUST READ - Include these in your context window
- url: https://github.com/modelcontextprotocol/python-sdk
why: Official MCP Python SDK for protocol implementation
section: Client implementation patterns and transport methods
critical: Use mcp.ClientSession and StdioServerParameters for connections
- url: https://modelcontextprotocol.io/quickstart/client
why: Official MCP client quickstart guide
section: Client connection patterns and message handling
critical: AsyncExitStack for resource management, proper session handling
- url: https://docs.python.org/3/library/argparse.html
why: CLI argument parsing for tool invocation interface
section: Subcommands and argument validation
critical: Use subparsers for different tool commands
- url: https://docs.python.org/3/library/asyncio.html
why: Asynchronous programming patterns for MCP client
section: async/await patterns and event loop management
critical: Use asyncio.run() for main entry point
- url: https://docs.python.org/3/library/json.html
why: JSON message serialization/deserialization
section: JSON encoding/decoding with proper error handling
critical: Handle malformed JSON responses gracefully
- file: /workspaces/context-engineering-template/src/mcp_server/
why: Complete MCP server implementation to connect to
section: server.py shows the exact tool interface and message format
critical: Tools available: roll_dice, get_weather, get_date with specific argument formats
- file: /workspaces/context-engineering-template/src/mcp_server/models/requests.py
why: Pydantic models for request/response validation
section: MCPRequest, MCPResponse, and tool-specific models
critical: Exact JSON-RPC 2.0 message structure and validation rules
- file: /workspaces/context-engineering-template/context/examples/outputs
why: Example request/response patterns for all tools
section: roll_dice_example.json, get_weather_example.json, get_date_example.json
critical: Exact JSON-RPC message format with method "tools/call"
- file: /workspaces/context-engineering-template/pyproject.toml
why: Project configuration, dependencies, and tool settings
section: Dependencies and development tool configuration
critical: Must use existing dependency management patterns
```
### Current Codebase Structure
```bash
/workspaces/context-engineering-template
├── AGENTS.md # Agent behavior guidelines
├── CLAUDE.md # Project instructions
├── pyproject.toml # Python project configuration
├── src/
│ ├── __init__.py
│ ├── main.py # Main entry point (will be modified)
│ ├── py.typed
│ └── mcp_server/ # Existing MCP server implementation
│ ├── __init__.py
│ ├── server.py # MCP server with 3 tools
│ ├── models/
│ │ ├── __init__.py
│ │ └── requests.py # Pydantic models for validation
│ └── tools/
│ ├── __init__.py
│ ├── base.py # Base tool interface
│ ├── dice.py # roll_dice implementation
│ ├── weather.py # get_weather implementation
│ └── date_time.py # get_date implementation
├── tests/
│ ├── __init__.py
│ └── test_mcp_server.py # Existing server tests
└── context/
└── examples/outputs
├── roll_dice_example.json # Tool invocation examples
├── get_weather_example.json # Tool invocation examples
└── get_date_example.json # Tool invocation examples
```
### Desired Codebase Structure with Files to be Added
```bash
/workspaces/context-engineering-template
├── src/
│ ├── __init__.py
│ ├── main.py # Modified to support MCP client CLI
│ ├── py.typed
│ ├── mcp_server/ # Existing (unchanged)
│ └── mcp_client/ # NEW - MCP client implementation
│ ├── __init__.py
│ ├── client.py # Main MCP client class
│ ├── cli.py # CLI interface for tool invocation
│ ├── transport.py # Connection and transport handling
│ └── models/
│ ├── __init__.py
│ └── responses.py # Client-specific response models
├── tests/
│ ├── __init__.py
│ ├── test_mcp_server.py # Existing (unchanged)
│ ├── test_mcp_client.py # NEW - Client tests
│ └── test_cli.py # NEW - CLI interface tests
└── context/
└── examples/outputs
├── client_roll_dice_input.json # Simple input format
├── client_get_weather_input.json # Simple input format
├── client_get_date_input.json # Simple input format
└── client_invalid_tool.json # Error handling example
```
### Known Gotchas & Library Quirks
```python
# CRITICAL: MCP protocol requires exact JSON-RPC 2.0 message structure
# Client messages must include: jsonrpc, method, params, id
# Example:
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "roll_dice",
"arguments": {"notation": "2d6"}
},
"id": 1
}
# CRITICAL: MCP Python SDK client connection pattern
# Must use AsyncExitStack for proper resource management
async with AsyncExitStack() as stack:
session = await stack.enter_async_context(
stdio_client(StdioServerParameters(
command="python",
args=[server_script_path]
))
)
# CRITICAL: Tool invocation through MCP client session
# Use session.call_tool() method with exact tool name and arguments
result = await session.call_tool("roll_dice", {"notation": "2d6"})
# CRITICAL: Server connection validation
# Must check if server is running and tools are available
tools = await session.list_tools()
if not any(tool.name == "roll_dice" for tool in tools.tools):
raise ValueError("roll_dice tool not available")
# CRITICAL: CLI argument parsing for tool commands
# Use subparsers for different tool commands
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest='tool', help='Available tools')
dice_parser = subparsers.add_parser('roll_dice', help='Roll dice')
dice_parser.add_argument('--notation', required=True, help='Dice notation (e.g., 2d6)')
# CRITICAL: Error handling for connection failures
# Distinguish between connection errors and tool execution errors
try:
result = await session.call_tool(tool_name, arguments)
except Exception as e:
if "connection" in str(e).lower():
print(f"Connection error: {e}")
else:
print(f"Tool execution error: {e}")
# CRITICAL: JSON serialization for complex tool arguments
# Some tools accept complex arguments that need proper JSON handling
import json
arguments = json.loads(args.arguments) if args.arguments else {}
```
## Implementation Blueprint
### Data Models and Structure
Create client-specific models for type safety and validation:
```python
# src/mcp_client/models/responses.py
from pydantic import BaseModel, Field
from typing import Optional, Union, Any, List
class MCPToolResponse(BaseModel):
"""Response from MCP tool execution"""
content: List[dict] = Field(..., description="Response content")
isError: bool = Field(False, description="Whether response indicates error")
class ClientToolResult(BaseModel):
"""Processed tool result for client consumption"""
success: bool
result: Optional[Any] = None
error: Optional[str] = None
tool_name: str
arguments: dict
class ClientSession(BaseModel):
"""Client session information"""
server_path: str
connected: bool = False
available_tools: List[str] = Field(default_factory=list)
```
### List of Tasks to be Completed in Order
```yaml
Task 1: Setup Dependencies
ADD to pyproject.toml:
- mcp>=0.7.0 (official MCP Python SDK)
- Use existing httpx, pydantic, and argparse dependencies
Task 2: Create MCP Client Core
CREATE src/mcp_client/client.py:
- DEFINE MCPClient class with connection management
- IMPLEMENT server connection via stdio transport
- HANDLE tool discovery and validation
- ESTABLISH session lifecycle management
Task 3: Create Transport Layer
CREATE src/mcp_client/transport.py:
- IMPLEMENT stdio transport connection
- HANDLE connection errors and retries
- MANAGE server process lifecycle
- PROVIDE connection health checks
Task 4: Create CLI Interface
CREATE src/mcp_client/cli.py:
- IMPLEMENT argument parsing for tool commands
- CREATE subcommands for each tool (roll_dice, get_weather, get_date)
- HANDLE tool argument validation
- PROVIDE user-friendly error messages
Task 5: Integrate with Main Entry Point
MODIFY src/main.py:
- ADD MCP client CLI commands
- PRESERVE existing functionality
- HANDLE both client and server modes
- MAINTAIN async pattern
Task 6: Create Simple Input Examples
CREATE context/examples/outputs/client_*.json:
- GENERATE simple input format examples
- INCLUDE error case example (invalid tool)
- FOLLOW format from feature specification
- ENABLE automated testing with these examples
Task 7: Implement Response Processing
CREATE src/mcp_client/models/responses.py:
- DEFINE response processing models
- HANDLE successful tool responses
- PROCESS error responses with user-friendly messages
- VALIDATE response format
Task 8: Implement Comprehensive Tests
CREATE tests/test_mcp_client.py and tests/test_cli.py:
- UNIT tests for client connection and tool invocation
- INTEGRATION tests with actual server
- MOCK tests for connection failures
- CLI command tests with argument validation
Task 9: Add Error Handling and Retry Logic
ENHANCE client.py and transport.py:
- IMPLEMENT connection retry with exponential backoff
- HANDLE server startup timeouts
- PROVIDE graceful degradation for tool failures
- LOG errors appropriately for debugging
Task 10: Final Integration and Validation
RUN validation commands:
- EXECUTE make ruff (code formatting)
- EXECUTE make type_check (mypy validation)
- EXECUTE make test_all (pytest suite)
- MANUAL test client-server integration
```
### Per Task Pseudocode
```python
# Task 2: MCP Client Core
class MCPClient:
def __init__(self, server_path: str):
self.server_path = server_path
self.session: Optional[ClientSession] = None
self.available_tools: List[str] = []
async def connect(self) -> None:
"""Connect to MCP server via stdio"""
# PATTERN: Use AsyncExitStack for resource management
self.stack = AsyncExitStack()
# CRITICAL: Validate server script exists
if not os.path.exists(self.server_path):
raise FileNotFoundError(f"Server script not found: {self.server_path}")
# PATTERN: Create stdio connection
server_params = StdioServerParameters(
command="python" if self.server_path.endswith('.py') else "node",
args=[self.server_path]
)
try:
self.session = await self.stack.enter_async_context(
stdio_client(server_params)
)
# CRITICAL: Discover available tools
tools_result = await self.session.list_tools()
self.available_tools = [tool.name for tool in tools_result.tools]
except Exception as e:
await self.stack.aclose()
raise ConnectionError(f"Failed to connect to server: {e}")
async def invoke_tool(self, tool_name: str, arguments: dict) -> ClientToolResult:
"""Invoke a tool on the connected server"""
# PATTERN: Validate tool availability
if tool_name not in self.available_tools:
return ClientToolResult(
success=False,
error=f"Tool '{tool_name}' not available. Available: {self.available_tools}",
tool_name=tool_name,
arguments=arguments
)
try:
# CRITICAL: Use session.call_tool() for invocation
result = await self.session.call_tool(tool_name, arguments)
return ClientToolResult(
success=True,
result=result.content,
tool_name=tool_name,
arguments=arguments
)
except Exception as e:
return ClientToolResult(
success=False,
error=str(e),
tool_name=tool_name,
arguments=arguments
)
async def disconnect(self) -> None:
"""Disconnect from server"""
if self.stack:
await self.stack.aclose()
# Task 4: CLI Interface
class MCPClientCLI:
def __init__(self):
self.parser = self._create_parser()
self.client: Optional[MCPClient] = None
def _create_parser(self) -> argparse.ArgumentParser:
"""Create argument parser with subcommands"""
parser = argparse.ArgumentParser(
description="MCP Client for tool invocation"
)
parser.add_argument(
'--server',
required=True,
help='Path to MCP server script'
)
# PATTERN: Subcommands for each tool
subparsers = parser.add_subparsers(dest='tool', help='Available tools')
# Dice rolling tool
dice_parser = subparsers.add_parser('roll_dice', help='Roll dice')
dice_parser.add_argument(
'--notation',
required=True,
help='Dice notation (e.g., 2d6, 1d20)'
)
# Weather tool
weather_parser = subparsers.add_parser('get_weather', help='Get weather')
weather_parser.add_argument(
'--location',
required=True,
help='Location name or coordinates'
)
# Date tool
date_parser = subparsers.add_parser('get_date', help='Get date/time')
date_parser.add_argument(
'--timezone',
default='UTC',
help='Timezone (default: UTC)'
)
return parser
async def run(self, args: List[str]) -> None:
"""Run CLI with provided arguments"""
parsed_args = self.parser.parse_args(args)
if not parsed_args.tool:
self.parser.print_help()
return
# PATTERN: Connect to server
self.client = MCPClient(parsed_args.server)
try:
await self.client.connect()
# PATTERN: Build tool arguments
tool_args = {}
if parsed_args.tool == 'roll_dice':
tool_args = {'notation': parsed_args.notation}
elif parsed_args.tool == 'get_weather':
tool_args = {'location': parsed_args.location}
elif parsed_args.tool == 'get_date':
tool_args = {'timezone': parsed_args.timezone}
# PATTERN: Invoke tool and display result
result = await self.client.invoke_tool(parsed_args.tool, tool_args)
if result.success:
self._display_success(result)
else:
self._display_error(result)
except Exception as e:
print(f"Error: {e}")
finally:
if self.client:
await self.client.disconnect()
def _display_success(self, result: ClientToolResult) -> None:
"""Display successful tool result"""
print(f"✅ {result.tool_name} executed successfully:")
# PATTERN: Handle different response formats
if result.result and isinstance(result.result, list):
for item in result.result:
if isinstance(item, dict) and 'text' in item:
print(item['text'])
else:
print(json.dumps(item, indent=2))
else:
print(json.dumps(result.result, indent=2))
def _display_error(self, result: ClientToolResult) -> None:
"""Display tool execution error"""
print(f"❌ {result.tool_name} failed:")
print(f"Error: {result.error}")
```
### Integration Points
```yaml
DEPENDENCIES:
- add to: pyproject.toml
- packages: ["mcp>=0.7.0"]
- note: Use existing httpx, pydantic, argparse dependencies
MAIN_ENTRY:
- modify: src/main.py
- pattern: "async def main() -> None:"
- add client CLI command handling alongside server functionality
TESTING:
- add to: tests/
- pattern: "pytest fixtures for client-server integration"
- mock server responses for unit tests
- integration tests with actual server startup
```
## Validation Loop
### Level 1: Syntax & Style
```bash
# Run these FIRST - fix any errors before proceeding
uv run ruff check src/ --fix
uv run mypy src/
# Expected: No errors. If errors exist, read and fix them.
```
### Level 2: Unit Tests
```python
# CREATE test_mcp_client.py
async def test_client_connection():
"""Test client can connect to server"""
client = MCPClient("src/mcp_server/server.py")
await client.connect()
assert len(client.available_tools) == 3
assert "roll_dice" in client.available_tools
await client.disconnect()
async def test_tool_invocation():
"""Test tool invocation works correctly"""
client = MCPClient("src/mcp_server/server.py")
await client.connect()
result = await client.invoke_tool("roll_dice", {"notation": "2d6"})
assert result.success
assert result.tool_name == "roll_dice"
await client.disconnect()
async def test_invalid_tool():
"""Test invalid tool handling"""
client = MCPClient("src/mcp_server/server.py")
await client.connect()
result = await client.invoke_tool("invalid_tool", {})
assert not result.success
assert "not available" in result.error
await client.disconnect()
def test_cli_argument_parsing():
"""Test CLI argument parsing"""
cli = MCPClientCLI()
# Test dice command
args = ['--server', 'server.py', 'roll_dice', '--notation', '2d6']
parsed = cli.parser.parse_args(args)
assert parsed.tool == 'roll_dice'
assert parsed.notation == '2d6'
# Test weather command
args = ['--server', 'server.py', 'get_weather', '--location', 'London']
parsed = cli.parser.parse_args(args)
assert parsed.tool == 'get_weather'
assert parsed.location == 'London'
```
```bash
# Run and iterate until passing:
uv run pytest tests/test_mcp_client.py -v
uv run pytest tests/test_cli.py -v
# If failing: Read error, fix code, re-run
```
### Level 3: Integration Test
```bash
# Test client-server integration
# 1. Start server in background
uv run python -m src.mcp_server.server &
# 2. Test client connection and tool invocation
uv run python -m src.main --server src/mcp_server/server.py roll_dice --notation 2d6
uv run python -m src.main --server src/mcp_server/server.py get_weather --location "San Francisco"
uv run python -m src.main --server src/mcp_server/server.py get_date --timezone UTC
# 3. Test error handling
uv run python -m src.main --server src/mcp_server/server.py roll_dice --notation invalid
```
## Final Validation Checklist
- [ ] All tests pass: `uv run pytest tests/ -v`
- [ ] No linting errors: `uv run ruff check src/`
- [ ] No type errors: `uv run mypy src/`
- [ ] Client connects to server successfully
- [ ] All three tools can be invoked via CLI
- [ ] Invalid tool names return helpful error messages
- [ ] Invalid tool arguments return helpful error messages
- [ ] Connection errors are handled gracefully
- [ ] Simple input JSON files are created and valid
- [ ] CLI help messages are clear and informative
## Anti-Patterns to Avoid
- ❌ Don't use synchronous functions in async context
- ❌ Don't ignore MCP protocol message structure requirements
- ❌ Don't expose internal connection details to CLI users
- ❌ Don't hardcode server paths or tool names
- ❌ Don't skip connection validation before tool invocation
- ❌ Don't catch all exceptions - be specific about error types
- ❌ Don't assume server is always available - handle startup failures
- ❌ Don't block the event loop with synchronous operations
- ❌ Don't ignore resource cleanup - always close connections
---
## Quality Score: 9/10
This PRP provides comprehensive context for one-pass implementation:
- ✅ Complete MCP protocol documentation and client patterns
- ✅ Detailed existing server implementation to connect to
- ✅ Specific CLI interface requirements and argument parsing
- ✅ Executable validation commands and test patterns
- ✅ Clear task breakdown with pseudocode and examples
- ✅ Anti-patterns and common pitfalls identified
- ✅ Integration points with existing codebase clearly defined
- ✅ Example files structure and format specifications
**Areas for improvement:**
- Could include more specific error message examples
- Could provide more detailed transport layer specifications
**Confidence Level:** Very High - This PRP contains comprehensive technical detail, existing implementation context, and clear validation steps for successful implementation by an AI agent with access to the codebase and web search capabilities.
================================================
FILE: examples/mcp-server-client/context/PRPs/feature_3_streamlit_gui.md
================================================
# Streamlit GUI for MCP Server-Client Interaction Showcase
## Purpose
Product Requirements Prompt (PRP) for implementing a comprehensive Streamlit-based GUI that demonstrates MCP Server-Client interaction. This PRP provides complete context for building an interactive interface that showcases tool invocation, real-time communication, and user-friendly error handling.
## Goal
Build a production-ready Streamlit GUI that:
- Provides interactive forms for invoking MCP tools (`roll_dice`, `get_weather`, `get_date`)
- Displays real-time request/response payloads with proper formatting
- Shows connection status and handles errors gracefully
- Maintains interaction history for the current session
- Serves as both a testing interface and demonstration tool
- Follows the project's established patterns and conventions
- Is responsive, user-friendly, and extensible for future tools
## Why
- **User Experience**: Provides non-technical users access to MCP tools without CLI
- **Demonstration**: Visual showcase of MCP protocol communication patterns
- **Testing**: Interactive interface for manual testing and validation
- **Learning**: Demonstrates best practices for building Streamlit applications
- **Integration**: Showcases seamless integration between GUI and backend services
- **Accessibility**: Makes MCP tools accessible to users unfamiliar with command-line interfaces
## What
### User-visible behavior
- Interactive web interface accessible via browser
- Form inputs for each tool with validation and help text
- Real-time display of JSON request/response payloads
- Connection status indicator with health checks
- Session history showing previous interactions
- Error messages displayed with helpful context
- Visual feedback for request processing states
### Success Criteria
- [ ] Streamlit GUI launches and displays tool selection interface
- [ ] All three tools can be invoked through interactive forms
- [ ] Request/response payloads are displayed in real-time
- [ ] Connection status is clearly indicated and updated
- [ ] Error handling provides user-friendly messages
- [ ] Session history tracks all interactions
- [ ] GUI is responsive and provides good user experience
- [ ] All code passes linting (ruff) and type checking (mypy)
- [ ] GUI integration tests validate core functionality
## All Needed Context
### Documentation & References
```yaml
# MUST READ - Include these in your context window
- url: https://docs.streamlit.io/
why: Official Streamlit documentation for building interactive web apps
section: Session state, forms, columns, and real-time updates
critical: Use st.session_state for maintaining connection and history
- url: https://docs.streamlit.io/develop/concepts/architecture/session-state
why: Session state management for maintaining GUI state
section: Session state patterns and best practices
critical: Persistent connection state across user interactions
- url: https://docs.streamlit.io/develop/api-reference/widgets/form
why: Form widgets for tool parameter input
section: Form validation and submission handling
critical: Use st.form() for batch input submission
- url: https://docs.streamlit.io/develop/api-reference/status
why: Status indicators and progress feedback
section: st.spinner, st.success, st.error, st.warning
critical: Real-time feedback for async operations
- url: https://docs.streamlit.io/develop/api-reference/layout
why: Layout components for organized GUI structure
section: Columns, containers, and tabs for organized interface
critical: Use st.columns() for side-by-side layout
- file: /workspaces/context-engineering-template/src/mcp_client/
why: Complete MCP client implementation to integrate with GUI
section: client.py, transport.py, and models/responses.py
critical: Use existing MCPClient class for server communication
- file: /workspaces/context-engineering-template/src/mcp_server/
why: Complete MCP server implementation and tool specifications
section: server.py and tools/ directory for tool interfaces
critical: Exact tool parameters and response formats
- file: /workspaces/context-engineering-template/context/examples/outputs
why: Example request/response patterns and expected formats
section: client_*.json files for input validation patterns
critical: GUI input validation should match CLI patterns
- file: /workspaces/context-engineering-template/pyproject.toml
why: Project dependencies and configuration
section: Current dependencies and development tools
critical: Add streamlit to dependencies while maintaining existing patterns
```
### Current Codebase Structure
```bash
/workspaces/context-engineering-template
├── AGENTS.md # Agent behavior guidelines
├── CLAUDE.md # Project instructions
├── Makefile # Build and development commands
├── pyproject.toml # Python project configuration
├── src/
│ ├── __init__.py
│ ├── main.py # Main entry point (will be modified)
│ ├── py.typed
│ ├── mcp_client/ # Complete MCP client implementation
│ │ ├── __init__.py
│ │ ├── client.py # MCPClient class for server communication
│ │ ├── cli.py # CLI interface (patterns to follow)
│ │ ├── transport.py # Connection and transport handling
│ │ └── models/
│ │ ├── __init__.py
│ │ └── responses.py # Client response models
│ └── mcp_server/ # Complete MCP server implementation
│ ├── __init__.py
│ ├── server.py # MCP server with 3 tools
│ ├── models/
│ │ ├── __init__.py
│ │ └── requests.py # Tool request/response models
│ └── tools/
│ ├── __init__.py
│ ├── base.py # Base tool interface
│ ├── dice.py # roll_dice implementation
│ ├── weather.py # get_weather implementation
│ └── date_time.py # get_date implementation
├── tests/
│ ├── __init__.py
│ ├── test_mcp_client.py # Client tests (patterns to follow)
│ ├── test_mcp_server.py # Server tests
│ └── test_cli.py # CLI tests (patterns to follow)
└── context/
└── examples/outputs
├── client_roll_dice_input.json # Input format examples
├── client_get_weather_input.json # Input format examples
├── client_get_date_input.json # Input format examples
└── client_invalid_tool.json # Error handling examples
```
### Desired Codebase Structure with Files to be Added
```bash
/workspaces/context-engineering-template
├── src/
│ ├── __init__.py
│ ├── main.py # Modified to support GUI launch
│ ├── py.typed
│ ├── mcp_client/ # Existing (unchanged)
│ ├── mcp_server/ # Existing (unchanged)
│ └── gui/ # NEW - Streamlit GUI implementation
│ ├── __init__.py
│ ├── app.py # Main Streamlit application
│ ├── components/ # Reusable GUI components
│ │ ├── __init__.py
│ │ ├── tool_forms.py # Tool-specific form components
│ │ ├── connection.py # Connection status components
│ │ └── history.py # Session history components
│ ├── utils/
│ │ ├── __init__.py
│ │ ├── formatting.py # JSON formatting and display utilities
│ │ └── validation.py # Input validation utilities
│ └── models/
│ ├── __init__.py
│ └── gui_models.py # GUI-specific data models
├── tests/
│ ├── __init__.py
│ ├── test_mcp_client.py # Existing (unchanged)
│ ├── test_mcp_server.py # Existing (unchanged)
│ ├── test_cli.py # Existing (unchanged)
│ └── test_gui.py # NEW - GUI component tests
└── context/
└── examples/outputs
├── streamlit_roll_dice_interaction.json # GUI interaction examples
├── streamlit_get_weather_interaction.json # GUI interaction examples
├── streamlit_get_date_interaction.json # GUI interaction examples
└── streamlit_error_handling.json # GUI error examples
```
### Known Gotchas & Library Quirks
```python
# CRITICAL: Streamlit session state management
# Must use st.session_state for persistent data across reruns
if 'mcp_client' not in st.session_state:
st.session_state.mcp_client = None
st.session_state.connected = False
st.session_state.interaction_history = []
# CRITICAL: Streamlit async operation handling
# Streamlit doesn't natively support async/await - must use asyncio.run()
import asyncio
result = asyncio.run(client.invoke_tool(tool_name, arguments))
# CRITICAL: Streamlit form submission pattern
# Use st.form() to batch inputs and prevent premature submission
with st.form("tool_form"):
param1 = st.text_input("Parameter 1")
param2 = st.text_input("Parameter 2")
submitted = st.form_submit_button("Submit")
if submitted:
# Process form data here
pass
# CRITICAL: Streamlit rerun behavior
# GUI reruns on every interaction - must handle state carefully
# Use st.rerun() to trigger manual updates after async operations
if st.button("Connect"):
asyncio.run(connect_to_server())
st.rerun() # Force refresh after connection
# CRITICAL: MCP client connection management in GUI
# Must handle connection lifecycle properly in Streamlit context
async def ensure_connection():
if st.session_state.mcp_client is None:
st.session_state.mcp_client = MCPClient("src/mcp_server/server.py")
if not st.session_state.mcp_client.connected:
await st.session_state.mcp_client.connect()
st.session_state.connected = True
# CRITICAL: JSON display formatting
# Use st.json() for proper JSON display with syntax highlighting
st.json({"request": request_data, "response": response_data})
# CRITICAL: Error handling in Streamlit
# Use st.error() for error messages and st.exception() for stack traces
try:
result = await client.invoke_tool(tool_name, args)
st.success("Tool executed successfully!")
except Exception as e:
st.error(f"Error: {str(e)}")
st.exception(e) # Show full stack trace in development
# CRITICAL: Real-time updates with spinner
# Use st.spinner() for long-running operations
with st.spinner("Executing tool..."):
result = asyncio.run(client.invoke_tool(tool_name, args))
```
## Implementation Blueprint
### Data Models and Structure
Create GUI-specific models for state management and type safety:
```python
# src/gui/models/gui_models.py
from pydantic import BaseModel, Field
from typing import Optional, Any, List
from datetime import datetime
class GUIInteraction(BaseModel):
"""Single tool interaction record for GUI history"""
timestamp: datetime = Field(default_factory=datetime.now)
tool_name: str
arguments: dict[str, Any]
request_payload: dict[str, Any]
response_payload: dict[str, Any]
success: bool
error_message: Optional[str] = None
execution_time: Optional[float] = None
class GUISession(BaseModel):
"""GUI session state management"""
connected: bool = False
server_path: str = "src/mcp_server/server.py"
available_tools: List[str] = Field(default_factory=list)
interaction_history: List[GUIInteraction] = Field(default_factory=list)
current_tool: Optional[str] = None
class ConnectionStatus(BaseModel):
"""Connection status information"""
connected: bool = False
server_path: str
last_health_check: Optional[datetime] = None
available_tools: List[str] = Field(default_factory=list)
error_message: Optional[str] = None
```
### List of Tasks to be Completed in Order
```yaml
Task 1: Setup Dependencies
ADD to pyproject.toml:
- streamlit>=1.28.0 (latest stable version)
- Use existing asyncio, json, and datetime from stdlib
- PRESERVE existing dependency versions
Task 2: Create Main GUI Application
CREATE src/gui/app.py:
- IMPLEMENT main Streamlit application entry point
- SETUP session state management for persistent connection
- CREATE main page layout with tool selection interface
- ESTABLISH connection management and health checking
Task 3: Create GUI Models
CREATE src/gui/models/gui_models.py:
- DEFINE GUIInteraction, GUISession, and ConnectionStatus models
- IMPLEMENT Pydantic validation for GUI state
- PROVIDE type safety for session management
- ENABLE structured interaction history
Task 4: Create Connection Management Component
CREATE src/gui/components/connection.py:
- IMPLEMENT connection status display with health indicator
- HANDLE connection establishment and error recovery
- PROVIDE real-time connection monitoring
- DISPLAY available tools and server information
Task 5: Create Tool Form Components
CREATE src/gui/components/tool_forms.py:
- IMPLEMENT dice roll form with notation validation
- IMPLEMENT weather form with location input and examples
- IMPLEMENT date/time form with timezone selection
- PROVIDE input validation and helpful user guidance
Task 6: Create History Management Component
CREATE src/gui/components/history.py:
- IMPLEMENT session history display with filtering
- PROVIDE interaction timeline with timestamps
- ENABLE request/response payload inspection
- SUPPORT history export and clearing
Task 7: Create Formatting and Validation Utilities
CREATE src/gui/utils/formatting.py and validation.py:
- IMPLEMENT JSON formatting for request/response display
- PROVIDE syntax highlighting for JSON payloads
- IMPLEMENT input validation with user-friendly messages
- HANDLE error formatting and display
Task 8: Integration with Main Entry Point
MODIFY src/main.py:
- ADD GUI launch command alongside existing CLI
- PRESERVE existing server and client functionality
- HANDLE GUI-specific argument parsing
- MAINTAIN async pattern compatibility
Task 9: Create Example Interaction Files
CREATE context/examples/outputs/streamlit_*.json:
- GENERATE example GUI interaction patterns
- INCLUDE successful tool execution examples
- PROVIDE error handling scenarios
- ENABLE automated testing validation
Task 10: Implement GUI Tests
CREATE tests/test_gui.py:
- UNIT tests for GUI components and utilities
- INTEGRATION tests with MCP client functionality
- MOCK tests for connection scenarios
- STREAMLIT specific testing patterns
Task 11: Add Make Commands
MODIFY Makefile:
- ADD run_gui command to launch Streamlit application
- INTEGRATE GUI testing into existing test suite
- PROVIDE GUI-specific development commands
- MAINTAIN existing command structure
```
### Per Task Pseudocode
```python
# Task 2: Main GUI Application
# src/gui/app.py
import streamlit as st
import asyncio
from ..mcp_client.client import MCPClient
from .models.gui_models import GUISession, GUIInteraction
from .components.connection import ConnectionManager
from .components.tool_forms import ToolForms
from .components.history import HistoryManager
def main():
"""Main Streamlit application entry point"""
st.set_page_config(
page_title="MCP Tool Showcase",
page_icon="🛠️",
layout="wide",
initial_sidebar_state="expanded"
)
# PATTERN: Initialize session state
if 'gui_session' not in st.session_state:
st.session_state.gui_session = GUISession()
if 'mcp_client' not in st.session_state:
st.session_state.mcp_client = None
# PATTERN: Main layout with sidebar
with st.sidebar:
st.title("🛠️ MCP Tool Showcase")
connection_manager = ConnectionManager()
connection_manager.render()
# PATTERN: Main content area
col1, col2 = st.columns([1, 1])
with col1:
st.header("Tool Invocation")
if st.session_state.gui_session.connected:
tool_forms = ToolForms()
tool_forms.render()
else:
st.info("Please connect to the MCP server first")
with col2:
st.header("Request/Response")
if st.session_state.gui_session.interaction_history:
latest_interaction = st.session_state.gui_session.interaction_history[-1]
st.subheader("Latest Request")
st.json(latest_interaction.request_payload)
st.subheader("Latest Response")
st.json(latest_interaction.response_payload)
else:
st.info("No interactions yet")
# PATTERN: History section
st.header("Interaction History")
history_manager = HistoryManager()
history_manager.render()
# Task 4: Connection Management
# src/gui/components/connection.py
class ConnectionManager:
"""Manages MCP server connection in GUI"""
def render(self):
"""Render connection management interface"""
st.subheader("Connection Status")
# PATTERN: Display current status
if st.session_state.gui_session.connected:
st.success("✅ Connected to MCP Server")
st.write(f"Server: {st.session_state.gui_session.server_path}")
st.write(f"Available Tools: {', '.join(st.session_state.gui_session.available_tools)}")
# PATTERN: Health check button
if st.button("Health Check"):
self._perform_health_check()
# PATTERN: Disconnect button
if st.button("Disconnect"):
self._disconnect()
else:
st.error("❌ Not Connected")
# PATTERN: Connection form
server_path = st.text_input(
"Server Path",
value=st.session_state.gui_session.server_path,
help="Path to MCP server script"
)
if st.button("Connect"):
self._connect(server_path)
def _connect(self, server_path: str):
"""Connect to MCP server"""
try:
with st.spinner("Connecting to server..."):
# CRITICAL: Handle async in Streamlit
client = MCPClient(server_path)
asyncio.run(client.connect())
st.session_state.mcp_client = client
st.session_state.gui_session.connected = True
st.session_state.gui_session.server_path = server_path
st.session_state.gui_session.available_tools = client.available_tools
st.success("Connected successfully!")
st.rerun()
except Exception as e:
st.error(f"Connection failed: {str(e)}")
st.exception(e)
def _disconnect(self):
"""Disconnect from MCP server"""
if st.session_state.mcp_client:
asyncio.run(st.session_state.mcp_client.disconnect())
st.session_state.mcp_client = None
st.session_state.gui_session.connected = False
st.session_state.gui_session.available_tools = []
st.success("Disconnected successfully!")
st.rerun()
def _perform_health_check(self):
"""Perform health check on connection"""
if st.session_state.mcp_client:
try:
health = asyncio.run(st.session_state.mcp_client.health_check())
if health:
st.success("Health check passed!")
else:
st.warning("Health check failed - connection may be unhealthy")
except Exception as e:
st.error(f"Health check error: {str(e)}")
# Task 5: Tool Form Components
# src/gui/components/tool_forms.py
class ToolForms:
"""Manages tool-specific form interfaces"""
def render(self):
"""Render tool selection and forms"""
available_tools = st.session_state.gui_session.available_tools
if not available_tools:
st.warning("No tools available")
return
# PATTERN: Tool selection
selected_tool = st.selectbox(
"Select Tool",
options=available_tools,
help="Choose a tool to invoke"
)
# PATTERN: Tool-specific forms
if selected_tool == "roll_dice":
self._render_dice_form()
elif selected_tool == "get_weather":
self._render_weather_form()
elif selected_tool == "get_date":
self._render_date_form()
def _render_dice_form(self):
"""Render dice rolling form"""
with st.form("dice_form"):
st.subheader("🎲 Roll Dice")
notation = st.text_input(
"Dice Notation",
value="2d6",
help="Enter dice notation (e.g., 2d6, 1d20, 3d10)"
)
# PATTERN: Help text with examples
st.caption("Examples: 1d20 (single 20-sided die), 3d6 (three 6-sided dice)")
submitted = st.form_submit_button("Roll Dice")
if submitted:
if self._validate_dice_notation(notation):
self._execute_tool("roll_dice", {"notation": notation})
else:
st.error("Invalid dice notation. Use format like '2d6' or '1d20'")
def _render_weather_form(self):
"""Render weather lookup form"""
with st.form("weather_form"):
st.subheader("🌤️ Get Weather")
location = st.text_input(
"Location",
value="San Francisco",
help="Enter city name or coordinates (lat,lon)"
)
# PATTERN: Common location examples
st.caption("Examples: London, New York, 37.7749,-122.4194")
submitted = st.form_submit_button("Get Weather")
if submitted:
if location.strip():
self._execute_tool("get_weather", {"location": location})
else:
st.error("Please enter a location")
def _render_date_form(self):
"""Render date/time lookup form"""
with st.form("date_form"):
st.subheader("🕐 Get Date & Time")
timezone = st.selectbox(
"Timezone",
options=["UTC", "America/New_York", "America/Los_Angeles", "Europe/London",
"Asia/Tokyo", "Australia/Sydney"],
help="Select timezone or enter custom IANA timezone"
)
custom_timezone = st.text_input(
"Custom Timezone (optional)",
placeholder="e.g., America/Chicago",
help="Enter custom IANA timezone identifier"
)
submitted = st.form_submit_button("Get Date & Time")
if submitted:
tz = custom_timezone.strip() if custom_timezone.strip() else timezone
self._execute_tool("get_date", {"timezone": tz})
def _execute_tool(self, tool_name: str, arguments: dict):
"""Execute tool and update GUI state"""
if not st.session_state.mcp_client:
st.error("Not connected to server")
return
try:
with st.spinner(f"Executing {tool_name}..."):
# CRITICAL: Time execution for performance metrics
start_time = time.time()
# PATTERN: Use existing MCP client
result = asyncio.run(
st.session_state.mcp_client.invoke_tool(tool_name, arguments)
)
execution_time = time.time() - start_time
# PATTERN: Create interaction record
interaction = GUIInteraction(
tool_name=tool_name,
arguments=arguments,
request_payload={
"tool": tool_name,
"arguments": arguments
},
response_payload=result.model_dump(),
success=result.success,
error_message=result.error if not result.success else None,
execution_time=execution_time
)
# PATTERN: Update session state
st.session_state.gui_session.interaction_history.append(interaction)
# PATTERN: Display result
if result.success:
st.success(f"✅ {tool_name} executed successfully!")
st.json(result.model_dump())
else:
st.error(f"❌ {tool_name} failed: {result.error}")
st.rerun()
except Exception as e:
st.error(f"Execution error: {str(e)}")
st.exception(e)
def _validate_dice_notation(self, notation: str) -> bool:
"""Validate dice notation format"""
import re
pattern = r"^(\d+)d(\d+)$"
return bool(re.match(pattern, notation.strip().lower()))
```
### Integration Points
```yaml
DEPENDENCIES:
- add to: pyproject.toml
- packages: ["streamlit>=1.28.0"]
- preserve: existing mcp, httpx, pydantic versions
MAIN_ENTRY:
- modify: src/main.py
- pattern: "async def main() -> None:"
- add gui command alongside existing server/client commands
MAKEFILE:
- add to: Makefile
- command: "run_gui: uv run streamlit run src/gui/app.py"
- integrate: GUI testing into existing test suite
TESTING:
- add to: tests/test_gui.py
- pattern: "pytest fixtures for GUI component testing"
- mock: Streamlit interactions for unit tests
- integration: GUI with MCP client functionality
```
## Validation Loop
### Level 1: Syntax & Style
```bash
# Run these FIRST - fix any errors before proceeding
uv run ruff check src/gui/ --fix
uv run mypy src/gui/
uv run ruff format src/gui/
# Expected: No errors. If errors exist, read and fix them.
```
### Level 2: Unit Tests
```python
# CREATE tests/test_gui.py
import pytest
import asyncio
from unittest.mock import Mock, patch
from src.gui.models.gui_models import GUISession, GUIInteraction
from src.gui.components.connection import ConnectionManager
from src.gui.utils.validation import validate_dice_notation
def test_gui_session_model():
"""Test GUI session model validation"""
session = GUISession()
assert not session.connected
assert session.server_path == "src/mcp_server/server.py"
assert len(session.interaction_history) == 0
def test_gui_interaction_model():
"""Test GUI interaction model"""
interaction = GUIInteraction(
tool_name="roll_dice",
arguments={"notation": "2d6"},
request_payload={"tool": "roll_dice", "arguments": {"notation": "2d6"}},
response_payload={"success": True},
success=True
)
assert interaction.tool_name == "roll_dice"
assert interaction.success is True
assert interaction.timestamp is not None
def test_dice_notation_validation():
"""Test dice notation validation"""
assert validate_dice_notation("2d6") is True
assert validate_dice_notation("1d20") is True
assert validate_dice_notation("invalid") is False
assert validate_dice_notation("2d") is False
@patch('src.gui.components.connection.MCPClient')
def test_connection_manager_connect(mock_client):
"""Test connection manager connection process"""
mock_client_instance = Mock()
mock_client_instance.available_tools = ["roll_dice", "get_weather"]
mock_client.return_value = mock_client_instance
# Test connection logic
manager = ConnectionManager()
# Mock Streamlit session state would go here
# This would need to be adapted for actual Streamlit testing
def test_json_formatting():
"""Test JSON formatting utilities"""
from src.gui.utils.formatting import format_json_for_display
data = {"tool": "roll_dice", "result": {"values": [3, 5], "total": 8}}
formatted = format_json_for_display(data)
assert isinstance(formatted, str)
assert "roll_dice" in formatted
```
```bash
# Run and iterate until passing:
uv run pytest tests/test_gui.py -v
# If failing: Read error, fix code, re-run
```
### Level 3: Integration Test
```bash
# Test Streamlit GUI manually
# 1. Start MCP server in background
uv run python -m src.mcp_server.server &
# 2. Launch Streamlit GUI
uv run streamlit run src/gui/app.py
# 3. Test in browser (http://localhost:8501)
# - Test connection to server
# - Test each tool form (roll_dice, get_weather, get_date)
# - Test error handling with invalid inputs
# - Test session history functionality
# - Test disconnection and reconnection
# 4. Test GUI via make command
make run_gui
# Expected: GUI loads successfully, all tools work, history is maintained
```
## Final Validation Checklist
- [ ] All tests pass: `uv run pytest tests/ -v`
- [ ] No linting errors: `uv run ruff check src/`
- [ ] No type errors: `uv run mypy src/`
- [ ] Streamlit GUI launches successfully via `make run_gui`
- [ ] Connection to MCP server works through GUI
- [ ] All three tools can be invoked through forms
- [ ] Request/response payloads are displayed properly
- [ ] Error handling shows user-friendly messages
- [ ] Session history tracks all interactions
- [ ] Connection status updates in real-time
- [ ] Input validation prevents invalid submissions
- [ ] JSON formatting is readable and highlighted
- [ ] GUI is responsive and user-friendly
- [ ] Example interaction files are created and valid
## Anti-Patterns to Avoid
- ❌ Don't use synchronous functions in Streamlit async context
- ❌ Don't forget to use st.session_state for persistent data
- ❌ Don't ignore Streamlit's rerun behavior - handle state carefully
- ❌ Don't hardcode server paths or tool configurations
- ❌ Don't skip input validation in forms
- ❌ Don't expose raw error messages to users - provide friendly alternatives
- ❌ Don't forget to handle connection lifecycle properly
- ❌ Don't use blocking operations without spinner feedback
- ❌ Don't ignore JSON formatting for request/response display
- ❌ Don't skip session history management
---
## Quality Score: 9/10
This PRP provides comprehensive context for one-pass implementation:
- ✅ Complete Streamlit documentation and GUI patterns
- ✅ Detailed existing MCP client/server implementation context
- ✅ Specific GUI interface requirements and user experience design
- ✅ Executable validation commands and testing approach
- ✅ Clear task breakdown with pseudocode and implementation details
- ✅ Anti-patterns and common pitfalls for Streamlit development
- ✅ Integration points with existing codebase clearly defined
- ✅ Session state management and async operation handling
- ✅ Comprehensive error handling and user feedback patterns
**Areas for improvement:**
- Could include more specific Streamlit testing patterns
- Could provide more detailed responsive design considerations
**Confidence Level:** Very High - This PRP contains comprehensive technical detail about Streamlit development, complete integration context with existing MCP implementation, and clear validation steps for successful GUI implementation by an AI agent with access to the codebase and web search capabilities.
================================================
FILE: examples/mcp-server-client/context/templates/feature_base.md
================================================
# Feature description for: [ Initial template for new features ]
## FEATURE
[Insert your feature here]
## EXAMPLES
[Provide and explain examples that you have in the `/context/examples` folder]
## DOCUMENTATION
[List out any documentation (web pages, sources for an MCP server like Crawl4AI RAG, etc.) that will need to be referenced during development]
## OTHER CONSIDERATIONS
[Any other considerations or specific requirements - great place to include gotchas that you see AI coding assistants miss with your projects a lot]
================================================
FILE: examples/mcp-server-client/context/templates/prp_base.md
================================================
# "Base PRP Template v2 - Context-Rich with Validation Loops"
## Purpose
Product Requirements Prompt (PRP) Template optimized for AI agents to implement features with sufficient context and self-validation capabilities to achieve working code through iterative refinement.
## Core Principles
1. **Context is King**: Include ALL necessary documentation, examples, and caveats
2. **Validation Loops**: Provide executable tests/lints the AI can run and fix
3. **Information Dense**: Use keywords and patterns from the codebase
4. **Progressive Success**: Start simple, validate, then enhance
5. **Global rules**: Be sure to follow all rules in CLAUDE.md
---
## Goal
[What needs to be built - be specific about the end state and desires]
## Why
- [Business value and user impact]
- [Integration with existing features]
- [Problems this solves and for whom]
## What
[User-visible behavior and technical requirements]
### Success Criteria
- [ ] [Specific measurable outcomes]
## All Needed Context
### Documentation & References (list all context needed to implement the feature)
```yaml
# MUST READ - Include these in your context window
- url: [Official API docs URL]
why: [Specific sections/methods you'll need]
- file: [path/to/example.py]
why: [Pattern to follow, gotchas to avoid]
- doc: [Library documentation URL]
section: [Specific section about common pitfalls]
critical: [Key insight that prevents common errors]
- docfile: [PRPs/ai_docs/file.md]
why: [docs that the user has pasted in to the project]
```
### Current Codebase tree (run `tree` in the root of the project) to get an overview of the codebase
```bash
```
### Desired Codebase tree with files to be added and responsibility of file
```bash
```
### Known Gotchas of our codebase & Library Quirks
```python
# CRITICAL: [Library name] requires [specific setup]
# Example: FastAPI requires async functions for endpoints
# Example: This ORM doesn't support batch inserts over 1000 records
# Example: We use pydantic v2 and
```
## Implementation Blueprint
### Data models and structure
Create the core data models, we ensure type safety and consistency.
```python
Examples:
- orm models
- pydantic models
- pydantic schemas
- pydantic validators
```
### list of tasks to be completed to fullfill the PRP in the order they should be completed
```yaml
Task 1:
MODIFY src/existing_module.py:
- FIND pattern: "class OldImplementation"
- INJECT after line containing "def __init__"
- PRESERVE existing method signatures
CREATE src/new_feature.py:
- MIRROR pattern from: src/similar_feature.py
- MODIFY class name and core logic
- KEEP error handling pattern identical
...(...)
Task N:
...
```
### Per task pseudocode as needed added to each task
```python
# Task 1
# Pseudocode with CRITICAL details dont write entire code
async def new_feature(param: str) -> Result:
# PATTERN: Always validate input first (see src/validators.py)
validated = validate_input(param) # raises ValidationError
# GOTCHA: This library requires connection pooling
async with get_connection() as conn: # see src/db/pool.py
# PATTERN: Use existing retry decorator
@retry(attempts=3, backoff=exponential)
async def _inner():
# CRITICAL: API returns 429 if >10 req/sec
await rate_limiter.acquire()
return await external_api.call(validated)
result = await _inner()
# PATTERN: Standardized response format
return format_response(result) # see src/utils/responses.py
```
### Integration Points
```yaml
DATABASE:
- migration: "Add column 'feature_enabled' to users table"
- index: "CREATE INDEX idx_feature_lookup ON users(feature_id)"
CONFIG:
- add to: config/settings.py
- pattern: "FEATURE_TIMEOUT = int(os.getenv('FEATURE_TIMEOUT', '30'))"
ROUTES:
- add to: src/api/routes.py
- pattern: "router.include_router(feature_router, prefix='/feature')"
```
## Validation Loop
### Level 1: Syntax & Style
```bash
# Run these FIRST - fix any errors before proceeding
ruff check src/new_feature.py --fix # Auto-fix what's possible
mypy src/new_feature.py # Type checking
# Expected: No errors. If errors, READ the error and fix.
```
### Level 2: Unit Tests each new feature/file/function use existing test patterns
```python
# CREATE test_new_feature.py with these test cases:
def test_happy_path():
"""Basic functionality works"""
result = new_feature("valid_input")
assert result.status == "success"
def test_validation_error():
"""Invalid input raises ValidationError"""
with pytest.raises(ValidationError):
new_feature("")
def test_external_api_timeout():
"""Handles timeouts gracefully"""
with mock.patch('external_api.call', side_effect=TimeoutError):
result = new_feature("valid")
assert result.status == "error"
assert "timeout" in result.message
```
```bash
# Run and iterate until passing:
uv run pytest test_new_feature.py -v
# If failing: Read error, understand root cause, fix code, re-run (never mock to pass)
```
### Level 3: Integration Test
```bash
# Start the service
uv run python -m src.main --dev
# Test the endpoint
curl -X POST http://localhost:8000/feature \
-H "Content-Type: application/json" \
-d '{"param": "test_value"}'
# Expected: {"status": "success", "data": {...}}
# If error: Check logs at logs/app.log for stack trace
```
## Final validation Checklist
- [ ] All tests pass: `uv run pytest tests/ -v`
- [ ] No linting errors: `uv run ruff check src/`
- [ ] No type errors: `uv run mypy src/`
- [ ] Manual test successful: [specific curl/command]
- [ ] Error cases handled gracefully
- [ ] Logs are informative but not verbose
- [ ] Documentation updated if needed
---
## Anti-Patterns to Avoid
- ❌ Don't create new patterns when existing ones work
- ❌ Don't skip validation because "it should work"
- ❌ Don't ignore failing tests - fix them
- ❌ Don't use sync functions in async context
- ❌ Don't hardcode values that should be config
- ❌ Don't catch all exceptions - be specific
================================================
FILE: examples/mcp-server-client/src/__init__.py
================================================
"""Defines the application version."""
__version__ = "0.0.1"
================================================
FILE: examples/mcp-server-client/src/main.py
================================================
"""Main entry point for the MCP server and client applications."""
import argparse
import logging
import sys
from asyncio import run
from src.mcp_client.cli import MCPClientCLI
from src.mcp_server import run_server
def setup_logging(level: str = "INFO") -> None:
"""Setup logging configuration."""
logging.basicConfig(
level=getattr(logging, level.upper()),
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
async def run_client_async(args) -> int:
"""Run the MCP client in async mode."""
# Create client CLI and run it
cli = MCPClientCLI()
# Build client arguments from parsed args
client_args = [
"--server",
args.server,
"--log-level",
args.log_level,
"--timeout",
str(args.timeout),
]
if args.tool:
client_args.append(args.tool)
# Add tool-specific arguments
if args.tool == "roll_dice":
client_args.extend(["--notation", args.notation])
elif args.tool == "get_weather":
client_args.extend(["--location", args.location])
elif args.tool == "get_date":
client_args.extend(["--timezone", args.timezone])
# Run the client
return await cli.run(client_args)
def main() -> None:
"""Main entry point for MCP applications."""
parser = argparse.ArgumentParser(
description="MCP Server and Client with dice, weather, and date/time tools",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Run MCP server
%(prog)s server
# Run MCP client
%(prog)s client --server ./server.py roll_dice --notation 2d6
%(prog)s client --server ./server.py get_weather --location "San Francisco"
%(prog)s client --server ./server.py get_date --timezone UTC
# Launch Streamlit GUI
%(prog)s gui
%(prog)s gui --port 8502
""",
)
# Add subcommands
subparsers = parser.add_subparsers(
dest="mode", help="Choose server or client mode", metavar="MODE"
)
# Server subcommand
server_parser = subparsers.add_parser("server", help="Run MCP server")
server_parser.add_argument(
"--log-level",
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
default="INFO",
help="Set the logging level",
)
server_parser.add_argument(
"--version",
action="version",
version="MCP Server v1.0.0",
)
# Client subcommand
client_parser = subparsers.add_parser("client", help="Run MCP client")
# GUI subcommand
gui_parser = subparsers.add_parser("gui", help="Launch Streamlit GUI")
gui_parser.add_argument(
"--log-level",
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
default="INFO",
help="Set the logging level",
)
gui_parser.add_argument(
"--port",
type=int,
default=8501,
help="Port for Streamlit server (default: 8501)",
)
client_parser.add_argument(
"--server", required=True, help="Path to MCP server script"
)
client_parser.add_argument(
"--log-level",
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
default="INFO",
help="Set logging level (default: INFO)",
)
client_parser.add_argument(
"--timeout",
type=int,
default=30,
help="Connection timeout in seconds (default: 30)",
)
# Tool subcommands for client
tool_subparsers = client_parser.add_subparsers(
dest="tool", help="Available tools", metavar="TOOL"
)
# Roll dice tool
dice_parser = tool_subparsers.add_parser(
"roll_dice", help="Roll dice using standard notation"
)
dice_parser.add_argument(
"--notation", required=True, help="Dice notation (e.g., 2d6, 1d20, 3d10)"
)
# Weather tool
weather_parser = tool_subparsers.add_parser(
"get_weather", help="Get current weather conditions"
)
weather_parser.add_argument(
"--location", required=True, help="Location name or coordinates (lat,lon)"
)
# Date/time tool
date_parser = tool_subparsers.add_parser(
"get_date", help="Get current date and time"
)
date_parser.add_argument(
"--timezone", default="UTC", help="Timezone identifier (default: UTC)"
)
args = parser.parse_args()
# Check if mode is specified
if not args.mode:
parser.print_help()
sys.exit(1)
# Setup logging
log_level = getattr(args, "log_level", "INFO")
setup_logging(log_level)
logger = logging.getLogger(__name__)
try:
if args.mode == "server":
logger.info("Starting MCP Server application")
# Run the MCP server (this will block until server shuts down)
run_server()
elif args.mode == "client":
logger.info("Starting MCP Client application")
# Run client in async mode
exit_code = run(run_client_async(args))
sys.exit(exit_code)
elif args.mode == "gui":
logger.info("Starting Streamlit GUI application")
# Launch Streamlit GUI
import os
import subprocess
# Set environment variables for Streamlit
env = os.environ.copy()
env["STREAMLIT_SERVER_PORT"] = str(args.port)
env["STREAMLIT_LOGGER_LEVEL"] = args.log_level
# Run Streamlit
cmd = [
"streamlit",
"run",
"src/gui/app.py",
"--server.port",
str(args.port),
"--logger.level",
args.log_level.lower(),
]
subprocess.run(cmd, env=env)
else:
parser.print_help()
sys.exit(1)
except KeyboardInterrupt:
logger.info("Application interrupted by user")
sys.exit(0)
except Exception as e:
logger.error(f"Application error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
================================================
FILE: examples/mcp-server-client/src/py.typed
================================================
# PEP 561 – Distributing and Packaging Type Information
# https://peps.python.org/pep-0561/
================================================
FILE: examples/mcp-server-client/src/gui/__init__.py
================================================
"""GUI module for MCP client interface."""
================================================
FILE: examples/mcp-server-client/src/gui/app.py
================================================
"""Main Streamlit application for MCP Tool Showcase."""
import sys
from pathlib import Path
# Add project root to Python path - must be done before importing local modules
project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root))
# ruff: noqa: E402
import streamlit as st
from src.gui.components.connection import ConnectionManager
from src.gui.components.history import HistoryManager
from src.gui.components.tool_forms import ToolForms
from src.gui.models.gui_models import GUISession
def main() -> None:
"""Main Streamlit application entry point."""
st.set_page_config(
page_title="MCP Tool Showcase",
page_icon="🛠️",
layout="wide",
initial_sidebar_state="expanded",
)
# Initialize session state
if "gui_session" not in st.session_state:
st.session_state.gui_session = GUISession()
if "mcp_client" not in st.session_state:
st.session_state.mcp_client = None
# Main layout with sidebar
with st.sidebar:
st.title("🛠️ MCP Tool Showcase")
st.markdown("---")
# Connection management
connection_manager = ConnectionManager()
connection_manager.render()
# Main content area
col1, col2 = st.columns([1, 1])
with col1:
st.header("Tool Invocation")
if st.session_state.gui_session.connected:
tool_forms = ToolForms()
tool_forms.render()
else:
st.info("Please connect to the MCP server first")
with col2:
st.header("Request/Response")
if st.session_state.gui_session.interaction_history:
latest_interaction = st.session_state.gui_session.interaction_history[-1]
st.subheader("Latest Request")
st.json(latest_interaction.request_payload)
st.subheader("Latest Response")
st.json(latest_interaction.response_payload)
else:
st.info("No interactions yet")
# History section
st.header("Interaction History")
history_manager = HistoryManager()
history_manager.render()
if __name__ == "__main__":
main()
================================================
FILE: examples/mcp-server-client/src/gui/components/__init__.py
================================================
"""GUI components for reusable interface elements."""
================================================
FILE: examples/mcp-server-client/src/gui/components/connection.py
================================================
"""Connection management component for MCP server."""
import logging
import streamlit as st
from src.gui.utils.mcp_wrapper import MCPConnectionManager
logger = logging.getLogger(__name__)
class ConnectionManager:
"""Manages MCP server connection in GUI."""
def render(self) -> None:
"""Render connection management interface."""
st.subheader("Connection Status")
# Display current status
if st.session_state.gui_session.connected:
st.success("✅ Connected to MCP Server")
st.write(f"Server: {st.session_state.gui_session.server_path}")
tools = ", ".join(st.session_state.gui_session.available_tools)
st.write(f"Available Tools: {tools}")
# Health check button
if st.button("Health Check"):
self._perform_health_check()
# Disconnect button
if st.button("Disconnect"):
self._disconnect()
else:
st.error("❌ Not Connected")
# Connection form
server_path = st.text_input(
"Server Path",
value=st.session_state.gui_session.server_path,
help="Path to MCP server script",
)
if st.button("Connect"):
self._connect(server_path)
def _connect(self, server_path: str) -> None:
"""Connect to MCP server."""
try:
with st.spinner("Connecting to server..."):
# Create connection manager if it doesn't exist
if "mcp_connection_manager" not in st.session_state:
st.session_state.mcp_connection_manager = MCPConnectionManager()
manager = st.session_state.mcp_connection_manager
# Connect to server
success = manager.connect(server_path)
if success:
st.session_state.gui_session.connected = True
st.session_state.gui_session.server_path = server_path
st.session_state.gui_session.available_tools = (
manager.available_tools
)
st.success("Connected successfully!")
st.rerun()
else:
st.error("Failed to connect to server")
except Exception as e:
logger.error(f"Connection failed: {e}")
st.error(f"Connection failed: {str(e)}")
def _disconnect(self) -> None:
"""Disconnect from MCP server."""
if "mcp_connection_manager" in st.session_state:
try:
manager = st.session_state.mcp_connection_manager
manager.disconnect()
st.session_state.gui_session.connected = False
st.session_state.gui_session.available_tools = []
st.success("Disconnected successfully!")
st.rerun()
except Exception as e:
logger.error(f"Disconnect failed: {e}")
st.error(f"Disconnect failed: {str(e)}")
def _perform_health_check(self) -> None:
"""Perform health check on connection."""
if "mcp_connection_manager" in st.session_state:
try:
manager = st.session_state.mcp_connection_manager
health = manager.health_check()
if health:
st.success("Health check passed!")
else:
st.warning("Health check failed - connection may be unhealthy")
except Exception as e:
logger.error(f"Health check error: {e}")
st.error(f"Health check error: {str(e)}")
================================================
FILE: examples/mcp-server-client/src/gui/components/history.py
================================================
"""History management component for interaction tracking."""
import streamlit as st
from src.gui.models.gui_models import GUIInteraction
class HistoryManager:
"""Manages session history display and interaction."""
def render(self) -> None:
"""Render interaction history interface."""
history = st.session_state.gui_session.interaction_history
if not history:
st.info(
"No interactions yet. Connect to a server and use tools to see history."
)
return
# History controls
col1, col2 = st.columns([1, 1])
with col1:
st.write(f"**Total Interactions:** {len(history)}")
with col2:
if st.button("Clear History"):
st.session_state.gui_session.interaction_history = []
st.rerun()
# Display history
for i, interaction in enumerate(reversed(history)):
self._render_interaction(i, interaction)
def _render_interaction(self, index: int, interaction: GUIInteraction) -> None:
"""Render a single interaction."""
# Create expander with status indicator
status_icon = "✅" if interaction.success else "❌"
timestamp = interaction.timestamp.strftime("%H:%M:%S")
with st.expander(
f"{status_icon} {interaction.tool_name} - {timestamp}",
expanded=index == 0, # Expand latest interaction
):
# Basic info
col1, col2 = st.columns([1, 1])
with col1:
st.write(f"**Tool:** {interaction.tool_name}")
st.write(
f"**Status:** {'Success' if interaction.success else 'Failed'}"
)
if interaction.execution_time:
st.write(f"**Execution Time:** {interaction.execution_time:.2f}s")
with col2:
timestamp_str = interaction.timestamp.strftime("%Y-%m-%d %H:%M:%S")
st.write(f"**Timestamp:** {timestamp_str}")
if interaction.error_message:
st.write(f"**Error:** {interaction.error_message}")
# Request/Response payloads
req_col, resp_col = st.columns([1, 1])
with req_col:
st.subheader("Request")
st.json(interaction.request_payload)
with resp_col:
st.subheader("Response")
st.json(interaction.response_payload)
================================================
FILE: examples/mcp-server-client/src/gui/components/tool_forms.py
================================================
"""Tool-specific form components for MCP tools."""
import logging
import re
import time
import streamlit as st
from src.gui.models.gui_models import GUIInteraction
logger = logging.getLogger(__name__)
class ToolForms:
"""Manages tool-specific form interfaces."""
def render(self) -> None:
"""Render tool selection and forms."""
available_tools = st.session_state.gui_session.available_tools
if not available_tools:
st.warning("No tools available")
return
# Tool selection
selected_tool = st.selectbox(
"Select Tool", options=available_tools, help="Choose a tool to invoke"
)
# Tool-specific forms
if selected_tool == "roll_dice":
self._render_dice_form()
elif selected_tool == "get_weather":
self._render_weather_form()
elif selected_tool == "get_date":
self._render_date_form()
def _render_dice_form(self) -> None:
"""Render dice rolling form."""
with st.form("dice_form"):
st.subheader("🎲 Roll Dice")
notation = st.text_input(
"Dice Notation",
value="2d6",
help="Enter dice notation (e.g., 2d6, 1d20, 3d10)",
)
# Help text with examples
st.caption("Examples: 1d20 (single 20-sided die), 3d6 (three 6-sided dice)")
submitted = st.form_submit_button("Roll Dice")
if submitted:
if self._validate_dice_notation(notation):
self._execute_tool("roll_dice", {"notation": notation})
else:
st.error("Invalid dice notation. Use format like '2d6' or '1d20'")
def _render_weather_form(self) -> None:
"""Render weather lookup form."""
with st.form("weather_form"):
st.subheader("🌤️ Get Weather")
location = st.text_input(
"Location",
value="San Francisco",
help="Enter city name or coordinates (lat,lon)",
)
# Common location examples
st.caption("Examples: London, New York, 37.7749,-122.4194")
submitted = st.form_submit_button("Get Weather")
if submitted:
if location.strip():
self._execute_tool("get_weather", {"location": location})
else:
st.error("Please enter a location")
def _render_date_form(self) -> None:
"""Render date/time lookup form."""
with st.form("date_form"):
st.subheader("🕐 Get Date & Time")
timezone = st.selectbox(
"Timezone",
options=[
"UTC",
"America/New_York",
"America/Los_Angeles",
"Europe/London",
"Asia/Tokyo",
"Australia/Sydney",
],
help="Select timezone or enter custom IANA timezone",
)
custom_timezone = st.text_input(
"Custom Timezone (optional)",
placeholder="e.g., America/Chicago",
help="Enter custom IANA timezone identifier",
)
submitted = st.form_submit_button("Get Date & Time")
if submitted:
tz = custom_timezone.strip() if custom_timezone.strip() else timezone
self._execute_tool("get_date", {"timezone": tz})
def _execute_tool(self, tool_name: str, arguments: dict[str, str]) -> None:
"""Execute tool and update GUI state."""
if "mcp_connection_manager" not in st.session_state:
st.error("Not connected to server")
return
try:
with st.spinner(f"Executing {tool_name}..."):
# Time execution for performance metrics
start_time = time.time()
# Use connection manager
manager = st.session_state.mcp_connection_manager
result = manager.invoke_tool(tool_name, arguments)
execution_time = time.time() - start_time
# Create interaction record
interaction = GUIInteraction(
tool_name=tool_name,
arguments=arguments,
request_payload={"tool": tool_name, "arguments": arguments},
response_payload=result,
success=result.get("success", False),
error_message=result.get("error")
if not result.get("success", False)
else None,
execution_time=execution_time,
)
# Update session state
st.session_state.gui_session.interaction_history.append(interaction)
# Display result
if result.get("success", False):
st.success(f"✅ {tool_name} executed successfully!")
st.json(result)
else:
st.error(
f"❌ {tool_name} failed: {result.get('error', 'Unknown error')}"
)
st.rerun()
except Exception as e:
logger.error(f"Tool execution error: {e}")
st.error(f"Execution error: {str(e)}")
def _validate_dice_notation(self, notation: str) -> bool:
"""Validate dice notation format."""
pattern = r"^(\d+)d(\d+)$"
return bool(re.match(pattern, notation.strip().lower()))
================================================
FILE: examples/mcp-server-client/src/gui/models/__init__.py
================================================
"""GUI models for state management and data validation."""
================================================
FILE: examples/mcp-server-client/src/gui/models/gui_models.py
================================================
"""GUI-specific models for state management and type safety."""
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
class GUIInteraction(BaseModel):
"""Single tool interaction record for GUI history."""
timestamp: datetime = Field(default_factory=datetime.now)
tool_name: str
arguments: dict[str, Any]
request_payload: dict[str, Any]
response_payload: dict[str, Any]
success: bool
error_message: str | None = None
execution_time: float | None = None
class GUISession(BaseModel):
"""GUI session state management."""
connected: bool = False
server_path: str = "src/mcp_server/server.py"
available_tools: list[str] = Field(default_factory=list)
interaction_history: list[GUIInteraction] = Field(default_factory=list)
current_tool: str | None = None
class ConnectionStatus(BaseModel):
"""Connection status information."""
connected: bool = False
server_path: str
last_health_check: datetime | None = None
available_tools: list[str] = Field(default_factory=list)
error_message: str | None = None
================================================
FILE: examples/mcp-server-client/src/gui/utils/__init__.py
================================================
"""GUI utilities for formatting and validation."""
================================================
FILE: examples/mcp-server-client/src/gui/utils/async_helper.py
================================================
"""Async helper utilities for Streamlit GUI."""
import asyncio
import threading
from collections.abc import Awaitable
from typing import TypeVar
T = TypeVar("T")
def run_async(coro: Awaitable[T]) -> T:
"""Run an async function in Streamlit context.
This handles the case where Streamlit might already have an event loop running.
Args:
coro: The coroutine to run
Returns:
The result of the coroutine
"""
try:
# Try to get the current event loop
# loop = asyncio.get_running_loop()
# If we're already in an event loop, we need to run in a new thread
# with its own event loop
result = None
exception = None
def run_in_new_loop():
nonlocal result, exception
try:
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
try:
result = new_loop.run_until_complete(coro)
finally:
new_loop.close()
except Exception as e:
exception = e
thread = threading.Thread(target=run_in_new_loop)
thread.start()
thread.join()
if exception:
raise exception
return result
except RuntimeError:
# No event loop running, can use asyncio.run directly
return asyncio.run(coro)
class AsyncContextManager:
"""Helper to manage async context across multiple calls."""
def __init__(self):
self._client = None
self._loop = None
self._thread = None
def run_async(self, coro: Awaitable[T]) -> T:
"""Run async function maintaining context."""
return run_async(coro)
================================================
FILE: examples/mcp-server-client/src/gui/utils/formatting.py
================================================
"""Formatting utilities for GUI display."""
import json
from typing import Any
def format_json_for_display(data: Any, indent: int = 2) -> str:
"""Format JSON data for display with proper indentation.
Args:
data: Data to format as JSON
indent: Number of spaces for indentation
Returns:
Formatted JSON string
"""
try:
return json.dumps(data, indent=indent, ensure_ascii=False)
except (TypeError, ValueError) as e:
return f"Error formatting JSON: {str(e)}"
def format_error_message(error: str) -> str:
"""Format error messages for user-friendly display.
Args:
error: Raw error message
Returns:
Formatted error message
"""
# Remove common Python error prefixes
error = error.replace("Exception: ", "")
error = error.replace("Error: ", "")
# Capitalize first letter
if error:
error = error[0].upper() + error[1:]
return error
def format_execution_time(seconds: float) -> str:
"""Format execution time for display.
Args:
seconds: Execution time in seconds
Returns:
Formatted time string
"""
if seconds < 1:
return f"{seconds * 1000:.0f}ms"
elif seconds < 60:
return f"{seconds:.2f}s"
else:
minutes = int(seconds // 60)
remaining_seconds = seconds % 60
return f"{minutes}m {remaining_seconds:.1f}s"
def truncate_text(text: str, max_length: int = 100) -> str:
"""Truncate text with ellipsis if too long.
Args:
text: Text to truncate
max_length: Maximum length before truncation
Returns:
Truncated text with ellipsis if needed
"""
if len(text) <= max_length:
return text
return text[: max_length - 3] + "..."
================================================
FILE: examples/mcp-server-client/src/gui/utils/mcp_wrapper.py
================================================
"""MCP client wrapper for Streamlit GUI with persistent connection."""
import asyncio
import logging
import threading
import time
from queue import Empty, Queue
from typing import Any
from src.mcp_client.client import MCPClient
logger = logging.getLogger(__name__)
class MCPConnectionManager:
"""Manages a persistent MCP connection in a background thread."""
def __init__(self):
self._client = None
self._thread = None
self._loop = None
self._connected = False
self._available_tools = []
self._request_queue = Queue()
self._response_queue = Queue()
self._shutdown_event = threading.Event()
def connect(self, server_path: str) -> bool:
"""Connect to MCP server in background thread."""
if self._connected:
self.disconnect()
self._shutdown_event.clear()
self._thread = threading.Thread(
target=self._run_connection, args=(server_path,)
)
self._thread.daemon = True
self._thread.start()
# Wait for connection to establish (with timeout)
start_time = time.time()
while not self._connected and time.time() - start_time < 10:
time.sleep(0.1)
return self._connected
def disconnect(self) -> None:
"""Disconnect from MCP server."""
if self._thread and self._thread.is_alive():
self._shutdown_event.set()
# Send disconnect command
self._request_queue.put({"action": "disconnect"})
self._thread.join(timeout=5)
self._connected = False
self._available_tools = []
def invoke_tool(self, tool_name: str, arguments: dict[str, Any]) -> dict[str, Any]:
"""Invoke a tool and return the result."""
if not self._connected:
return {
"success": False,
"error": "Not connected to server",
"tool_name": tool_name,
"arguments": arguments,
}
# Send request
request_id = f"{tool_name}_{time.time()}"
self._request_queue.put(
{
"action": "invoke_tool",
"id": request_id,
"tool_name": tool_name,
"arguments": arguments,
}
)
# Wait for response (with timeout)
start_time = time.time()
while time.time() - start_time < 30: # 30 second timeout
try:
response = self._response_queue.get(timeout=1)
if response.get("id") == request_id:
return response.get(
"result",
{
"success": False,
"error": "No result in response",
"tool_name": tool_name,
"arguments": arguments,
},
)
except Empty:
continue
return {
"success": False,
"error": "Request timeout",
"tool_name": tool_name,
"arguments": arguments,
}
def health_check(self) -> bool:
"""Check if connection is healthy."""
if not self._connected:
return False
# Send health check request
request_id = f"health_{time.time()}"
self._request_queue.put({"action": "health_check", "id": request_id})
# Wait for response
start_time = time.time()
while time.time() - start_time < 5: # 5 second timeout
try:
response = self._response_queue.get(timeout=1)
if response.get("id") == request_id:
return response.get("result", False)
except Empty:
continue
return False
@property
def connected(self) -> bool:
"""Check if connected."""
return self._connected
@property
def available_tools(self) -> list[str]:
"""Get available tools."""
return self._available_tools.copy()
def _run_connection(self, server_path: str) -> None:
"""Run the connection in background thread with its own event loop."""
try:
# Create new event loop for this thread
self._loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._loop)
# Run the connection handler
self._loop.run_until_complete(self._connection_handler(server_path))
except Exception as e:
logger.error(f"Connection thread error: {e}")
finally:
if self._loop:
self._loop.close()
self._connected = False
async def _connection_handler(self, server_path: str) -> None:
"""Handle the MCP connection and requests."""
try:
# Create and connect client
self._client = MCPClient(server_path)
await self._client.connect()
self._connected = True
self._available_tools = self._client.available_tools.copy()
logger.info(
f"Connected to MCP server. Available tools: {self._available_tools}"
)
# Process requests until shutdown
while not self._shutdown_event.is_set():
try:
# Check for requests (non-blocking)
request = self._request_queue.get(timeout=0.1)
if request["action"] == "disconnect":
break
elif request["action"] == "invoke_tool":
await self._handle_invoke_tool(request)
elif request["action"] == "health_check":
await self._handle_health_check(request)
except Empty:
# No request, continue
continue
except Exception as e:
logger.error(f"Error processing request: {e}")
except Exception as e:
logger.error(f"Connection handler error: {e}")
self._response_queue.put(
{
"id": "connection_error",
"result": {"success": False, "error": str(e)},
}
)
finally:
# Clean up
if self._client:
try:
await self._client.disconnect()
except Exception as e:
logger.error(f"Error disconnecting client: {e}")
self._connected = False
async def _handle_invoke_tool(self, request: dict[str, Any]) -> None:
"""Handle tool invocation request."""
try:
result = await self._client.invoke_tool(
request["tool_name"], request["arguments"]
)
self._response_queue.put(
{
"id": request["id"],
"result": result.model_dump()
if hasattr(result, "model_dump")
else {
"success": True,
"result": result,
"tool_name": request["tool_name"],
"arguments": request["arguments"],
},
}
)
except Exception as e:
logger.error(f"Tool invocation error: {e}")
self._response_queue.put(
{
"id": request["id"],
"result": {
"success": False,
"error": str(e),
"tool_name": request["tool_name"],
"arguments": request["arguments"],
},
}
)
async def _handle_health_check(self, request: dict[str, Any]) -> None:
"""Handle health check request."""
try:
health = await self._client.health_check()
self._response_queue.put({"id": request["id"], "result": health})
except Exception as e:
logger.error(f"Health check error: {e}")
self._response_queue.put({"id": request["id"], "result": False})
================================================
FILE: examples/mcp-server-client/src/gui/utils/validation.py
================================================
"""Validation utilities for GUI input."""
import re
def validate_dice_notation(notation: str) -> bool:
"""Validate dice notation format.
Args:
notation: Dice notation string (e.g., "2d6", "1d20")
Returns:
True if valid, False otherwise
"""
if not notation or not isinstance(notation, str):
return False
# Pattern: number + 'd' + number
pattern = r"^(\d+)d(\d+)$"
match = re.match(pattern, notation.strip().lower())
if not match:
return False
# Check reasonable limits
num_dice = int(match.group(1))
num_sides = int(match.group(2))
# Validate ranges
if num_dice < 1 or num_dice > 100:
return False
if num_sides < 2 or num_sides > 1000:
return False
return True
def validate_location(location: str) -> bool:
"""Validate location input for weather lookup.
Args:
location: Location string (city name or coordinates)
Returns:
True if valid, False otherwise
"""
if not location or not isinstance(location, str):
return False
location = location.strip()
# Check for coordinates format (lat,lon)
coord_pattern = r"^-?\d+\.?\d*,-?\d+\.?\d*$"
if re.match(coord_pattern, location):
return True
# Check for city name (at least 2 characters, letters and spaces)
city_pattern = r"^[a-zA-Z\s]{2,}$"
if re.match(city_pattern, location):
return True
return False
def validate_timezone(timezone: str) -> bool:
"""Validate timezone identifier.
Args:
timezone: Timezone string (IANA format)
Returns:
True if valid, False otherwise
"""
if not timezone or not isinstance(timezone, str):
return False
timezone = timezone.strip()
# Common timezone patterns
common_timezones = [
"UTC",
"GMT",
"EST",
"PST",
"CST",
"MST",
"America/New_York",
"America/Los_Angeles",
"America/Chicago",
"Europe/London",
"Europe/Paris",
"Asia/Tokyo",
"Australia/Sydney",
]
if timezone in common_timezones:
return True
# IANA timezone format: Area/City or Area/Region/City
iana_pattern = r"^[A-Za-z_]+/[A-Za-z_]+(?:/[A-Za-z_]+)?$"
return bool(re.match(iana_pattern, timezone))
def validate_server_path(path: str) -> str | None:
"""Validate server path and return error message if invalid.
Args:
path: Server path string
Returns:
Error message if invalid, None if valid
"""
if not path or not isinstance(path, str):
return "Server path is required"
path = path.strip()
if not path:
return "Server path cannot be empty"
if not path.endswith(".py"):
return "Server path must be a Python file (.py)"
# Basic path validation
if ".." in path or path.startswith("/"):
return "Server path should be relative to project root"
return None
================================================
FILE: examples/mcp-server-client/src/mcp_client/__init__.py
================================================
"""MCP Client implementation for tool invocation."""
from .cli import MCPClientCLI
from .client import MCPClient
__all__ = ["MCPClient", "MCPClientCLI"]
================================================
FILE: examples/mcp-server-client/src/mcp_client/cli.py
================================================
"""CLI interface for MCP client tool invocation."""
import argparse
import asyncio
import json
import logging
import sys
from typing import Any
from .client import MCPClient
from .models.responses import ClientToolResult
# Configure logging
logger = logging.getLogger(__name__)
class MCPClientCLI:
"""CLI interface for MCP client tool invocation."""
def __init__(self) -> None:
"""Initialize CLI interface."""
self.parser = self._create_parser()
self.client: MCPClient | None = None
def _create_parser(self) -> argparse.ArgumentParser:
"""Create argument parser with subcommands.
Returns:
Configured argument parser
"""
parser = argparse.ArgumentParser(
description="MCP Client for tool invocation",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""Examples:
%(prog)s --server ./server.py roll_dice --notation 2d6
%(prog)s --server ./server.py get_weather --location "San Francisco"
%(prog)s --server ./server.py get_date --timezone UTC
""",
)
# Global arguments
parser.add_argument("--server", required=True, help="Path to MCP server script")
parser.add_argument(
"--log-level",
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
default="INFO",
help="Set logging level (default: INFO)",
)
parser.add_argument(
"--timeout",
type=int,
default=30,
help="Connection timeout in seconds (default: 30)",
)
# Subcommands for tools
subparsers = parser.add_subparsers(
dest="tool", help="Available tools", metavar="TOOL"
)
# Roll dice tool
dice_parser = subparsers.add_parser(
"roll_dice", help="Roll dice using standard notation"
)
dice_parser.add_argument(
"--notation", required=True, help="Dice notation (e.g., 2d6, 1d20, 3d10)"
)
# Weather tool
weather_parser = subparsers.add_parser(
"get_weather", help="Get current weather conditions"
)
weather_parser.add_argument(
"--location", required=True, help="Location name or coordinates (lat,lon)"
)
# Date/time tool
date_parser = subparsers.add_parser(
"get_date", help="Get current date and time"
)
date_parser.add_argument(
"--timezone", default="UTC", help="Timezone identifier (default: UTC)"
)
return parser
def _setup_logging(self, level: str) -> None:
"""Setup logging configuration.
Args:
level: Logging level string
"""
logging.basicConfig(
level=getattr(logging, level.upper()),
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
def _build_tool_arguments(self, args: argparse.Namespace) -> dict[str, Any]:
"""Build tool arguments from parsed CLI arguments.
Args:
args: Parsed command line arguments
Returns:
Dictionary of tool arguments
"""
if args.tool == "roll_dice":
return {"notation": args.notation}
elif args.tool == "get_weather":
return {"location": args.location}
elif args.tool == "get_date":
return {"timezone": args.timezone}
else:
return {}
def _display_success(self, result: ClientToolResult) -> None:
"""Display successful tool result.
Args:
result: Successful tool result
"""
print(f"✅ {result.tool_name} executed successfully:")
print()
# Handle MCP response format
if result.result and hasattr(result.result, "content"):
content = result.result.content
if isinstance(content, list):
for item in content:
if isinstance(item, dict) and "text" in item:
print(item["text"])
else:
print(json.dumps(item, indent=2))
else:
print(json.dumps(content, indent=2))
else:
print(json.dumps(result.result, indent=2))
def _display_error(self, result: ClientToolResult) -> None:
"""Display tool execution error.
Args:
result: Failed tool result
"""
print(f"❌ {result.tool_name} failed:")
print(f"Error: {result.error}")
print()
print(f"Tool: {result.tool_name}")
print(f"Arguments: {json.dumps(result.arguments, indent=2)}")
def _display_connection_error(self, error: Exception) -> None:
"""Display connection error.
Args:
error: Connection error
"""
print("❌ Connection failed:")
print(f"Error: {error}")
print()
print("Troubleshooting:")
print("1. Check that the server script exists")
print("2. Verify the server script is executable")
print("3. Ensure required dependencies are installed")
print("4. Check that the server starts correctly")
async def run(self, args: list[str] | None = None) -> int:
"""Run CLI with provided arguments.
Args:
args: Command line arguments (uses sys.argv if None)
Returns:
Exit code (0 for success, 1 for error)
"""
try:
# Parse arguments
parsed_args = self.parser.parse_args(args)
# Setup logging
self._setup_logging(parsed_args.log_level)
# Check if tool specified
if not parsed_args.tool:
self.parser.print_help()
return 1
# Create client
self.client = MCPClient(parsed_args.server)
# Connect to server with timeout
try:
await asyncio.wait_for(
self.client.connect(), timeout=parsed_args.timeout
)
except TimeoutError:
print(f"❌ Connection timeout after {parsed_args.timeout} seconds")
return 1
except Exception as e:
self._display_connection_error(e)
return 1
# Build tool arguments
tool_args = self._build_tool_arguments(parsed_args)
# Invoke tool
result = await self.client.invoke_tool(parsed_args.tool, tool_args)
# Display result
if result.success:
self._display_success(result)
return 0
else:
self._display_error(result)
return 1
except KeyboardInterrupt:
print("\n❌ Interrupted by user")
return 130
except Exception as e:
logger.error(f"Unexpected error: {e}")
print(f"❌ Unexpected error: {e}")
return 1
finally:
# Cleanup
if self.client:
await self.client.disconnect()
async def main() -> int:
"""Main CLI entry point.
Returns:
Exit code
"""
cli = MCPClientCLI()
return await cli.run()
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
================================================
FILE: examples/mcp-server-client/src/mcp_client/client.py
================================================
"""Main MCP client class for tool invocation."""
import logging
from typing import Any
from .models.responses import ClientToolResult
from .transport import MCPTransport
# Configure logging
logger = logging.getLogger(__name__)
class MCPClient:
"""MCP client for connecting to servers and invoking tools."""
def __init__(self, server_path: str):
"""Initialize MCP client.
Args:
server_path: Path to the MCP server script
"""
self.server_path = server_path
self.transport = MCPTransport(server_path)
self._connected = False
@property
def connected(self) -> bool:
"""Check if client is connected to server."""
return self._connected and self.transport.connected
@property
def available_tools(self) -> list[str]:
"""Get list of available tools."""
return self.transport.available_tools
async def connect(self) -> None:
"""Connect to MCP server.
Raises:
FileNotFoundError: If server script doesn't exist
ConnectionError: If connection fails
ValueError: If server script type is not supported
"""
logger.info(f"Connecting to MCP server: {self.server_path}")
try:
await self.transport.connect()
self._connected = True
logger.info(
f"Connected successfully. Available tools: {self.available_tools}"
)
except Exception as e:
logger.error(f"Failed to connect: {e}")
raise
async def disconnect(self) -> None:
"""Disconnect from MCP server."""
logger.info("Disconnecting from MCP server")
await self.transport.disconnect()
self._connected = False
async def invoke_tool(
self, tool_name: str, arguments: dict[str, Any]
) -> ClientToolResult:
"""Invoke a tool on the connected server.
Args:
tool_name: Name of the tool to invoke
arguments: Arguments to pass to the tool
Returns:
ClientToolResult with success status and result or error
"""
logger.info(f"Invoking tool: {tool_name} with arguments: {arguments}")
# Check if connected
if not self.connected:
error_msg = "Not connected to server"
logger.error(error_msg)
return ClientToolResult(
success=False,
result=None,
error=error_msg,
tool_name=tool_name,
arguments=arguments,
)
# Check if tool is available
if tool_name not in self.available_tools:
error_msg = (
f"Tool '{tool_name}' not available. "
f"Available tools: {self.available_tools}"
)
logger.error(error_msg)
return ClientToolResult(
success=False,
result=None,
error=error_msg,
tool_name=tool_name,
arguments=arguments,
)
try:
# Call the tool through transport
result = await self.transport.call_tool(tool_name, arguments)
# Process the result
logger.info(f"Tool '{tool_name}' executed successfully")
return ClientToolResult(
success=True,
result=result,
error=None,
tool_name=tool_name,
arguments=arguments,
)
except Exception as e:
error_msg = f"Tool execution failed: {str(e)}"
logger.error(error_msg)
return ClientToolResult(
success=False,
result=None,
error=error_msg,
tool_name=tool_name,
arguments=arguments,
)
async def health_check(self) -> bool:
"""Check if connection is healthy.
Returns:
True if connection is healthy, False otherwise
"""
if not self.connected:
return False
return await self.transport.health_check()
async def __aenter__(self) -> "MCPClient":
"""Async context manager entry."""
await self.connect()
return self
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
"""Async context manager exit."""
await self.disconnect()
class MCPClientError(Exception):
"""Base exception for MCP client errors."""
pass
class MCPConnectionError(MCPClientError):
"""Raised when connection to MCP server fails."""
pass
class MCPToolError(MCPClientError):
"""Raised when tool execution fails."""
pass
================================================
FILE: examples/mcp-server-client/src/mcp_client/transport.py
================================================
"""Transport layer for MCP client connections."""
import os
from contextlib import AsyncExitStack
from typing import Any
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
class MCPTransport:
"""Handles MCP server connections via stdio transport."""
def __init__(self, server_path: str):
"""Initialize transport with server path.
Args:
server_path: Path to the MCP server script
"""
self.server_path = server_path
self.session: ClientSession | None = None
self.exit_stack: AsyncExitStack | None = None
self.connected = False
self.available_tools: list[str] = []
async def connect(self) -> None:
"""Connect to MCP server via stdio transport.
Raises:
FileNotFoundError: If server script doesn't exist
ConnectionError: If connection fails
ValueError: If server script type is not supported
"""
# Validate server script exists
if not os.path.exists(self.server_path):
raise FileNotFoundError(f"Server script not found: {self.server_path}")
# Determine server type and command
if self.server_path.endswith(".py"):
# Use uv run python for proper environment
command = "uv"
args = ["run", "python", self.server_path]
elif self.server_path.endswith(".js"):
command = "node"
args = [self.server_path]
else:
raise ValueError(f"Unsupported server script type: {self.server_path}")
# Create server parameters
server_params = StdioServerParameters(command=command, args=args, env=None)
try:
# Setup resource management
self.exit_stack = AsyncExitStack()
# Connect to server
read, write = await self.exit_stack.enter_async_context(
stdio_client(server_params)
)
# Create session
self.session = await self.exit_stack.enter_async_context(
ClientSession(read, write)
)
# Initialize connection
await self.session.initialize()
# Discover available tools
tools_response = await self.session.list_tools()
self.available_tools = [tool.name for tool in tools_response.tools]
self.connected = True
except Exception as e:
# Clean up on connection failure
if self.exit_stack:
await self.exit_stack.aclose()
self.exit_stack = None
raise ConnectionError(f"Failed to connect to server: {e}")
async def disconnect(self) -> None:
"""Disconnect from MCP server."""
if self.exit_stack:
await self.exit_stack.aclose()
self.exit_stack = None
self.session = None
self.connected = False
self.available_tools = []
async def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any:
"""Call a tool on the connected server.
Args:
tool_name: Name of the tool to call
arguments: Arguments to pass to the tool
Returns:
Tool response content
Raises:
RuntimeError: If not connected to server
ValueError: If tool is not available
"""
if not self.connected or not self.session:
raise RuntimeError("Not connected to server")
if tool_name not in self.available_tools:
raise ValueError(
f"Tool '{tool_name}' not available. "
f"Available tools: {self.available_tools}"
)
# Call the tool
result = await self.session.call_tool(tool_name, arguments)
return result
async def health_check(self) -> bool:
"""Check if connection is healthy.
Returns:
True if connection is healthy, False otherwise
"""
if not self.connected or not self.session:
return False
try:
# Try to list tools as a health check
await self.session.list_tools()
return True
except Exception:
return False
async def __aenter__(self) -> "MCPTransport":
"""Async context manager entry."""
await self.connect()
return self
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
"""Async context manager exit."""
await self.disconnect()
================================================
FILE: examples/mcp-server-client/src/mcp_client/models/__init__.py
================================================
"""MCP Client models for type safety and validation."""
from .responses import ClientToolResult, MCPToolResponse
__all__ = ["ClientToolResult", "MCPToolResponse"]
================================================
FILE: examples/mcp-server-client/src/mcp_client/models/responses.py
================================================
"""Client-specific response models for MCP tool invocation."""
from typing import Any
from pydantic import BaseModel, Field
class MCPToolResponse(BaseModel):
"""Response from MCP tool execution."""
content: list[dict[str, Any]] = Field(..., description="Response content")
isError: bool = Field(False, description="Whether response indicates error")
class ClientToolResult(BaseModel):
"""Processed tool result for client consumption."""
success: bool = Field(..., description="Whether tool execution was successful")
result: Any | None = Field(None, description="Tool execution result")
error: str | None = Field(None, description="Error message if execution failed")
tool_name: str = Field(..., description="Name of the tool that was executed")
arguments: dict[str, Any] = Field(..., description="Arguments passed to the tool")
class ClientSession(BaseModel):
"""Client session information."""
server_path: str = Field(..., description="Path to the MCP server script")
connected: bool = Field(False, description="Whether client is connected to server")
available_tools: list[str] = Field(
default_factory=list, description="List of available tools"
)
================================================
FILE: examples/mcp-server-client/src/mcp_server/__init__.py
================================================
"""MCP server package."""
from .server import mcp, run_server
__all__ = ["mcp", "run_server"]
================================================
FILE: examples/mcp-server-client/src/mcp_server/server.py
================================================
"""MCP server implementation with dice, weather, and date/time tools."""
import logging
import sys
from pathlib import Path
from typing import Any
# Add project root to Python path
project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root))
from mcp.server.fastmcp import FastMCP
from src.mcp_server.tools.date_time import DateTimeTool
from src.mcp_server.tools.dice import DiceRollTool
from src.mcp_server.tools.weather import WeatherTool
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Create MCP server instance
mcp = FastMCP("dice-weather-datetime-server")
# Initialize tool instances
dice_tool = DiceRollTool()
weather_tool = WeatherTool()
datetime_tool = DateTimeTool()
@mcp.tool()
async def roll_dice(notation: str) -> dict[str, Any]:
"""Roll dice using standard notation like '2d6' or '1d20'.
Args:
notation: Dice notation (e.g., "2d6", "1d20", "3d10")
Returns:
Dict containing dice roll results, total, and formatted display
"""
logger.info(f"Tool call: roll_dice(notation='{notation}')")
return await dice_tool.safe_execute(notation=notation)
@mcp.tool()
async def get_weather(location: str) -> dict[str, Any]:
"""Get current weather conditions for a location.
Args:
location: City name or coordinates (lat,lon)
Returns:
Dict containing weather data including temperature, condition, and wind speed
"""
logger.info(f"Tool call: get_weather(location='{location}')")
return await weather_tool.safe_execute(location=location)
@mcp.tool()
async def get_date(timezone: str = "UTC") -> dict[str, Any]:
"""Get current date and time for a specific timezone.
Args:
timezone: Timezone identifier (e.g., "UTC", "America/New_York") or alias
Returns:
Dict containing current date/time in ISO 8601 format with timezone info
"""
logger.info(f"Tool call: get_date(timezone='{timezone}')")
return await datetime_tool.safe_execute(timezone=timezone)
@mcp.resource("mcp://tools/help")
async def get_help() -> str:
"""Get help information about available tools."""
help_text = """
🎲 **MCP Server - Available Tools**
**roll_dice** - Roll dice using standard notation
- Usage: roll_dice(notation="2d6")
- Examples: "1d20", "3d6", "2d10"
- Returns individual values and total
**get_weather** - Get current weather conditions
- Usage: get_weather(location="San Francisco")
- Supports city names or coordinates (lat,lon)
- Returns temperature, condition, wind speed
**get_date** - Get current date and time
- Usage: get_date(timezone="UTC")
- Supports IANA timezones and common aliases
- Returns ISO 8601 formatted datetime
**Examples:**
- roll_dice("2d6") → Roll two six-sided dice
- get_weather("London") → Weather for London
- get_date("America/New_York") → NYC current time
"""
return help_text
async def cleanup_server():
"""Cleanup server resources."""
logger.info("Cleaning up server resources...")
try:
await weather_tool.cleanup()
logger.info("Server cleanup completed")
except Exception as e:
logger.error(f"Error during cleanup: {e}")
# Server lifecycle management
async def startup():
"""Server startup handler."""
logger.info("MCP Server starting up...")
logger.info("Tools available: roll_dice, get_weather, get_date")
async def shutdown():
"""Server shutdown handler."""
logger.info("MCP Server shutting down...")
await cleanup_server()
def run_server():
"""Run the MCP server."""
try:
logger.info("Starting MCP server...")
mcp.run()
except KeyboardInterrupt:
logger.info("Server interrupted by user")
except Exception as e:
logger.error(f"Server error: {e}")
raise
if __name__ == "__main__":
run_server()
================================================
FILE: examples/mcp-server-client/src/mcp_server/models/__init__.py
================================================
"""MCP server data models."""
from .requests import (
DateTimeRequest,
DateTimeResponse,
DiceRollRequest,
DiceRollResponse,
MCPError,
MCPRequest,
MCPResponse,
ToolCallRequest,
ToolCallResponse,
WeatherRequest,
WeatherResponse,
)
__all__ = [
"DateTimeRequest",
"DateTimeResponse",
"DiceRollRequest",
"DiceRollResponse",
"MCPError",
"MCPRequest",
"MCPResponse",
"ToolCallRequest",
"ToolCallResponse",
"WeatherRequest",
"WeatherResponse",
]
================================================
FILE: examples/mcp-server-client/src/mcp_server/models/requests.py
================================================
"""Pydantic models for MCP server request/response validation."""
import re
from typing import Any
from pydantic import BaseModel, Field, field_validator
class MCPRequest(BaseModel):
"""Base MCP request structure following JSON-RPC 2.0 format."""
jsonrpc: str = "2.0"
method: str
params: dict | None = None
id: str | int | None = None
class MCPResponse(BaseModel):
"""Base MCP response structure following JSON-RPC 2.0 format."""
jsonrpc: str = "2.0"
id: str | int | None = None
result: Any | None = None
error: dict | None = None
class MCPError(BaseModel):
"""MCP error structure."""
code: int
message: str
data: dict | None = None
class DiceRollRequest(BaseModel):
"""Dice roll tool request with notation validation."""
notation: str = Field(..., description="Dice notation like '2d6' or '1d20'")
@field_validator("notation")
@classmethod
def validate_notation(cls, v: str) -> str:
"""Validate dice notation format."""
if not isinstance(v, str):
raise ValueError("Notation must be a string")
# Remove spaces and convert to lowercase
notation = v.strip().lower()
# Validate format using regex
pattern = re.compile(r"^(\d+)d(\d+)$")
match = pattern.match(notation)
if not match:
raise ValueError(
f"Invalid dice notation: '{v}'. "
f"Expected format: 'XdY' (e.g., '2d6', '1d20')"
)
dice_count = int(match.group(1))
sides = int(match.group(2))
# Validate reasonable limits
if dice_count <= 0:
raise ValueError("Dice count must be greater than 0")
if dice_count > 100:
raise ValueError("Dice count must not exceed 100")
if sides <= 0:
raise ValueError("Number of sides must be greater than 0")
if sides > 1000:
raise ValueError("Number of sides must not exceed 1000")
return notation
class DiceRollResponse(BaseModel):
"""Dice roll tool response."""
values: list[int] = Field(..., description="Individual dice roll results")
total: int = Field(..., description="Sum of all dice rolls")
notation: str = Field(..., description="Original dice notation")
class WeatherRequest(BaseModel):
"""Weather tool request with location validation."""
location: str = Field(..., description="City name or coordinates (lat,lon)")
@field_validator("location")
@classmethod
def validate_location(cls, v: str) -> str:
"""Validate location format."""
if not isinstance(v, str):
raise ValueError("Location must be a string")
location = v.strip()
if not location:
raise ValueError("Location cannot be empty")
return location
class WeatherResponse(BaseModel):
"""Weather tool response."""
location: str = Field(..., description="Requested location")
temperature: float = Field(..., description="Temperature in Celsius")
condition: str = Field(..., description="Weather condition description")
wind_speed: float = Field(..., description="Wind speed in km/h")
humidity: float | None = Field(None, description="Humidity percentage")
timestamp: str | None = Field(None, description="Data timestamp")
class DateTimeRequest(BaseModel):
"""Date/time tool request with timezone validation."""
timezone: str = Field(
"UTC", description="Timezone identifier (e.g., 'UTC', 'America/New_York')"
)
@field_validator("timezone")
@classmethod
def validate_timezone(cls, v: str) -> str:
"""Validate timezone format."""
if not isinstance(v, str):
raise ValueError("Timezone must be a string")
timezone = v.strip()
if not timezone:
raise ValueError("Timezone cannot be empty")
return timezone
class DateTimeResponse(BaseModel):
"""Date/time tool response."""
datetime: str = Field(..., description="ISO 8601 formatted date/time")
timezone: str = Field(..., description="Timezone identifier")
timestamp: float = Field(..., description="Unix timestamp")
class ToolCallRequest(BaseModel):
"""Generic tool call request."""
name: str = Field(..., description="Tool name")
arguments: dict = Field(..., description="Tool arguments")
class ToolCallResponse(BaseModel):
"""Generic tool call response."""
content: list[dict] = Field(..., description="Tool response content")
isError: bool = Field(False, description="Whether this is an error response")
================================================
FILE: examples/mcp-server-client/src/mcp_server/tools/__init__.py
================================================
"""MCP server tools package."""
from .base import (
AsyncHttpMixin,
BaseTool,
ExternalServiceError,
ToolError,
ValidationToolError,
)
__all__ = [
"AsyncHttpMixin",
"BaseTool",
"ExternalServiceError",
"ToolError",
"ValidationToolError",
]
================================================
FILE: examples/mcp-server-client/src/mcp_server/tools/base.py
================================================
"""Base tool interface and common patterns for MCP server tools."""
import logging
from abc import ABC, abstractmethod
from typing import Any
import httpx
from pydantic import BaseModel, ValidationError
logger = logging.getLogger(__name__)
class ToolError(Exception):
"""Base exception for tool-related errors."""
def __init__(
self, message: str, code: int = -1, data: dict[str, Any] | None = None
):
super().__init__(message)
self.message = message
self.code = code
self.data = data or {}
class ValidationToolError(ToolError):
"""Exception for input validation errors."""
def __init__(self, message: str, validation_errors: list | None = None):
super().__init__(message, code=-32602) # Invalid params error code
self.validation_errors = validation_errors or []
class ExternalServiceError(ToolError):
"""Exception for external service errors."""
def __init__(self, message: str, service_name: str, status_code: int | None = None):
super().__init__(message, code=-32603) # Internal error code
self.service_name = service_name
self.status_code = status_code
class BaseTool(ABC):
"""Abstract base class for all MCP server tools."""
def __init__(self, name: str, description: str):
self.name = name
self.description = description
self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
@abstractmethod
async def execute(self, **kwargs: Any) -> Any:
"""Execute the tool with the given arguments."""
pass
def validate_input(
self, input_data: dict[str, Any], model_class: type[BaseModel]
) -> BaseModel:
"""Validate input data against a Pydantic model."""
try:
return model_class(**input_data)
except ValidationError as e:
error_messages = []
for error in e.errors():
field = " -> ".join(str(x) for x in error["loc"])
message = error["msg"]
error_messages.append(f"{field}: {message}")
raise ValidationToolError(
f"Invalid input for {self.name}: {'; '.join(error_messages)}",
validation_errors=e.errors(),
)
def create_success_response(self, data: Any) -> dict[str, Any]:
"""Create a successful tool response."""
return {
"content": [
{
"type": "text",
"text": str(data) if not isinstance(data, dict | list) else data,
}
],
"isError": False,
}
def create_error_response(self, error: Exception) -> dict[str, Any]:
"""Create an error tool response."""
if isinstance(error, ToolError):
error_message = error.message
else:
error_message = f"An unexpected error occurred: {str(error)}"
# Log the full error for debugging
self.logger.error(f"Tool {self.name} error: {error}", exc_info=True)
return {
"content": [
{
"type": "text",
"text": error_message,
}
],
"isError": True,
}
async def safe_execute(self, **kwargs) -> dict[str, Any]:
"""Execute the tool with error handling."""
try:
result = await self.execute(**kwargs)
return self.create_success_response(result)
except Exception as e:
return self.create_error_response(e)
class AsyncHttpMixin:
"""Mixin for tools that need HTTP client capabilities."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._http_client = None
@property
def http_client(self):
"""Get or create HTTP client."""
if self._http_client is None:
import httpx
self._http_client = httpx.AsyncClient(
timeout=30.0,
headers={"User-Agent": "MCP-Server/1.0"},
)
return self._http_client
async def cleanup(self):
"""Cleanup HTTP client resources."""
if self._http_client:
await self._http_client.aclose()
self._http_client = None
async def make_request(
self, method: str, url: str, timeout: float = 10.0, **kwargs
) -> dict[str, Any]:
"""Make an HTTP request with error handling."""
try:
response = await self.http_client.request(
method=method, url=url, timeout=timeout, **kwargs
)
response.raise_for_status()
return response.json()
except httpx.TimeoutException:
raise ExternalServiceError(
f"Request to {url} timed out after {timeout} seconds",
service_name="HTTP",
)
except httpx.HTTPStatusError as e:
raise ExternalServiceError(
f"HTTP error {e.response.status_code}: {e.response.text}",
service_name="HTTP",
status_code=e.response.status_code,
)
except Exception as e:
raise ExternalServiceError(
f"Failed to make request to {url}: {str(e)}",
service_name="HTTP",
)
================================================
FILE: examples/mcp-server-client/src/mcp_server/tools/date_time.py
================================================
"""Date and time tool for MCP server with timezone support."""
import zoneinfo
from datetime import UTC, datetime
from typing import Any
from ..models import DateTimeRequest, DateTimeResponse
from .base import BaseTool, ToolError
class DateTimeTool(BaseTool):
"""Tool for getting current date and time in various timezones."""
def __init__(self):
super().__init__(
name="get_date",
description="Get current date and time in ISO 8601 format for any timezone",
)
# Common timezone aliases for user convenience
self.timezone_aliases = {
"utc": "UTC",
"gmt": "UTC",
"est": "America/New_York",
"pst": "America/Los_Angeles",
"cst": "America/Chicago",
"mst": "America/Denver",
"edt": "America/New_York",
"pdt": "America/Los_Angeles",
"cdt": "America/Chicago",
"mdt": "America/Denver",
"bst": "Europe/London",
"cet": "Europe/Paris",
"jst": "Asia/Tokyo",
"aest": "Australia/Sydney",
}
def parse_timezone(self, timezone_str: str) -> zoneinfo.ZoneInfo | type[UTC]:
"""Parse timezone string to ZoneInfo object."""
# Normalize timezone string
tz_lower = timezone_str.lower().strip()
# Handle UTC as a special case
if tz_lower in ("utc", "gmt"):
return UTC
# Check aliases
if tz_lower in self.timezone_aliases:
timezone_str = self.timezone_aliases[tz_lower]
# Try to create ZoneInfo object
try:
if timezone_str.upper() == "UTC":
return UTC
else:
return zoneinfo.ZoneInfo(timezone_str)
except zoneinfo.ZoneInfoNotFoundError:
# Provide helpful error message with suggestions
common_timezones = [
"UTC",
"America/New_York",
"America/Los_Angeles",
"America/Chicago",
"Europe/London",
"Europe/Paris",
"Asia/Tokyo",
"Australia/Sydney",
]
suggestions = ", ".join(common_timezones)
aliases = ", ".join(self.timezone_aliases.keys())
raise ToolError(
f"Invalid timezone: '{timezone_str}'. "
f"Common timezones: {suggestions}. "
f"Aliases: {aliases}. "
f"Use IANA timezone names (e.g., 'America/New_York') or aliases."
)
async def execute(self, **kwargs: Any) -> DateTimeResponse:
"""Get current date and time for the specified timezone."""
timezone = kwargs.get("timezone", "UTC")
# Validate input
request = self.validate_input({"timezone": timezone}, DateTimeRequest)
# Parse timezone
tz = self.parse_timezone(request.timezone)
self.logger.info(f"Getting current time for timezone: {timezone}")
# Get current time in the specified timezone
if isinstance(tz, zoneinfo.ZoneInfo):
current_time = datetime.now(tz)
tz_name = str(tz)
else: # UTC
current_time = datetime.now(tz)
tz_name = "UTC"
# Format as ISO 8601
iso_datetime = current_time.isoformat()
# Get Unix timestamp
timestamp = current_time.timestamp()
self.logger.info(f"Current time in {tz_name}: {iso_datetime}")
return DateTimeResponse(
datetime=iso_datetime,
timezone=tz_name,
timestamp=timestamp,
)
def format_result(self, response: DateTimeResponse) -> str:
"""Format date/time data for display."""
# Parse the ISO datetime to extract components
try:
dt = datetime.fromisoformat(response.datetime)
date_part = dt.strftime("%Y-%m-%d")
time_part = dt.strftime("%H:%M:%S")
weekday = dt.strftime("%A")
result = "🕐 **Current Date & Time**\n"
result += f"📅 Date: **{date_part}** ({weekday})\n"
result += f"⏰ Time: **{time_part}**\n"
result += f"🌍 Timezone: **{response.timezone}**\n"
result += f"📋 ISO 8601: `{response.datetime}`\n"
result += f"🔢 Unix Timestamp: `{int(response.timestamp)}`"
return result
except ValueError:
# Fallback if datetime parsing fails
return (
f"🕐 **Current Date & Time**\n"
f"📋 ISO 8601: `{response.datetime}`\n"
f"🌍 Timezone: **{response.timezone}**\n"
f"🔢 Unix Timestamp: `{int(response.timestamp)}`"
)
async def safe_execute(self, **kwargs) -> dict[str, Any]:
"""Execute date/time lookup with formatted output."""
try:
result = await self.execute(**kwargs)
formatted_result = self.format_result(result)
return {
"content": [
{
"type": "text",
"text": formatted_result,
}
],
"isError": False,
}
except Exception as e:
return self.create_error_response(e)
def get_available_timezones(self) -> list[str]:
"""Get a list of common available timezones."""
common_zones = [
"UTC",
"America/New_York",
"America/Los_Angeles",
"America/Chicago",
"America/Denver",
"America/Phoenix",
"America/Anchorage",
"America/Honolulu",
"America/Toronto",
"America/Vancouver",
"Europe/London",
"Europe/Paris",
"Europe/Berlin",
"Europe/Rome",
"Europe/Madrid",
"Europe/Amsterdam",
"Europe/Stockholm",
"Europe/Moscow",
"Asia/Tokyo",
"Asia/Shanghai",
"Asia/Kolkata",
"Asia/Dubai",
"Asia/Singapore",
"Australia/Sydney",
"Australia/Melbourne",
"Pacific/Auckland",
]
return sorted(common_zones + list(self.timezone_aliases.values()))
================================================
FILE: examples/mcp-server-client/src/mcp_server/tools/dice.py
================================================
"""Dice rolling tool for MCP server."""
import random
import re
from typing import Any
from ..models import DiceRollRequest, DiceRollResponse
from .base import BaseTool, ToolError
class DiceRollTool(BaseTool):
"""Tool for rolling dice using standard notation."""
def __init__(self):
super().__init__(
name="roll_dice",
description="Roll dice using standard notation like '2d6' or '1d20'",
)
self.notation_pattern = re.compile(r"^(\d+)d(\d+)$")
async def execute(self, **kwargs: Any) -> DiceRollResponse:
"""Execute dice roll with the given notation."""
notation = kwargs.get("notation")
if not notation:
raise ToolError("Missing required parameter: notation")
# Validate input using Pydantic model
request = self.validate_input({"notation": notation}, DiceRollRequest)
# Parse the notation
match = self.notation_pattern.match(request.notation)
if not match:
raise ToolError(f"Invalid dice notation: {notation}")
dice_count = int(match.group(1))
sides = int(match.group(2))
self.logger.info(f"Rolling {dice_count}d{sides}")
# Generate random values for each die
values = []
for _ in range(dice_count):
roll = random.randint(1, sides)
values.append(roll)
total = sum(values)
self.logger.info(f"Dice roll result: {values} (total: {total})")
# Return structured response
return DiceRollResponse(
values=values,
total=total,
notation=str(notation), # Return original notation as provided
)
def format_result(self, response: DiceRollResponse) -> str:
"""Format dice roll result for display."""
if len(response.values) == 1:
return f"🎲 Rolled {response.notation}: **{response.values[0]}**"
else:
values_str = ", ".join(map(str, response.values))
return (
f"🎲 Rolled {response.notation}: [{values_str}] = **{response.total}**"
)
async def safe_execute(self, **kwargs) -> dict[str, Any]:
"""Execute dice roll with formatted output."""
try:
result = await self.execute(**kwargs)
formatted_result = self.format_result(result)
return {
"content": [
{
"type": "text",
"text": formatted_result,
}
],
"isError": False,
}
except Exception as e:
return self.create_error_response(e)
================================================
FILE: examples/mcp-server-client/src/mcp_server/tools/weather.py
================================================
"""Weather tool for MCP server using Open-Meteo API."""
from datetime import datetime
from typing import Any
from ..models import WeatherRequest, WeatherResponse
from .base import AsyncHttpMixin, BaseTool, ExternalServiceError, ToolError
class WeatherTool(BaseTool, AsyncHttpMixin):
"""Tool for getting current weather data."""
def __init__(self):
super().__init__(
name="get_weather",
description="Get current weather conditions for a location",
)
self.api_base = "https://api.open-meteo.com/v1"
# Basic city to coordinates mapping
# In production, this would use a proper geocoding service
self.city_coords = {
"san francisco": (37.7749, -122.4194),
"new york": (40.7128, -74.0060),
"london": (51.5074, -0.1278),
"paris": (48.8566, 2.3522),
"tokyo": (35.6762, 139.6503),
"sydney": (-33.8688, 151.2093),
"los angeles": (34.0522, -118.2437),
"chicago": (41.8781, -87.6298),
"miami": (25.7617, -80.1918),
"seattle": (47.6062, -122.3321),
"vancouver": (49.2827, -123.1207),
"toronto": (43.6532, -79.3832),
"berlin": (52.5200, 13.4050),
"rome": (41.9028, 12.4964),
"madrid": (40.4168, -3.7038),
"moscow": (55.7558, 37.6176),
"beijing": (39.9042, 116.4074),
"mumbai": (19.0760, 72.8777),
"cairo": (30.0444, 31.2357),
"lagos": (6.5244, 3.3792),
}
# Weather code to description mapping (subset of WMO codes)
self.weather_codes = {
0: "Clear sky",
1: "Mainly clear",
2: "Partly cloudy",
3: "Overcast",
45: "Fog",
48: "Depositing rime fog",
51: "Light drizzle",
53: "Moderate drizzle",
55: "Dense drizzle",
61: "Slight rain",
63: "Moderate rain",
65: "Heavy rain",
71: "Slight snow",
73: "Moderate snow",
75: "Heavy snow",
77: "Snow grains",
80: "Slight rain showers",
81: "Moderate rain showers",
82: "Violent rain showers",
85: "Slight snow showers",
86: "Heavy snow showers",
95: "Thunderstorm",
96: "Thunderstorm with slight hail",
99: "Thunderstorm with heavy hail",
}
def parse_location(self, location: str) -> tuple[float, float]:
"""Parse location string to get coordinates."""
location_lower = location.lower().strip()
# Check if it's in our city mapping
if location_lower in self.city_coords:
return self.city_coords[location_lower]
# Try to parse as "lat,lon" coordinates
try:
parts = location.split(",")
if len(parts) == 2:
lat = float(parts[0].strip())
lon = float(parts[1].strip())
# Basic validation for reasonable coordinate ranges
if -90 <= lat <= 90 and -180 <= lon <= 180:
return lat, lon
except ValueError:
pass
# If we can't parse the location, raise an error
available_cities = ", ".join(sorted(self.city_coords.keys()))
raise ToolError(
f"Unknown location: '{location}'. "
f"Please use coordinates (lat,lon) or one of: {available_cities}"
)
def weather_code_to_text(self, code: int) -> str:
"""Convert weather code to readable description."""
return self.weather_codes.get(code, f"Unknown weather condition (code: {code})")
async def execute(self, **kwargs: Any) -> WeatherResponse:
"""Get weather data for the specified location."""
location = kwargs.get("location")
if not location:
raise ToolError("Missing required parameter: location")
# Validate input
request = self.validate_input({"location": location}, WeatherRequest)
# Parse location to coordinates
lat, lon = self.parse_location(request.location)
self.logger.info(f"Getting weather for {location} ({lat}, {lon})")
# Make API request to Open-Meteo
try:
data = await self.make_request(
method="GET",
url=f"{self.api_base}/forecast",
params={
"latitude": lat,
"longitude": lon,
"current": (
"temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m"
),
"timezone": "auto",
},
timeout=15.0,
)
# Extract current weather data
current = data.get("current", {})
if not current:
raise ExternalServiceError(
"No current weather data available",
service_name="Open-Meteo",
)
temperature = current.get("temperature_2m")
humidity = current.get("relative_humidity_2m")
weather_code = current.get("weather_code")
wind_speed = current.get("wind_speed_10m")
timestamp = current.get("time")
if temperature is None or weather_code is None or wind_speed is None:
raise ExternalServiceError(
"Incomplete weather data received",
service_name="Open-Meteo",
)
condition = self.weather_code_to_text(weather_code)
self.logger.info(
f"Weather data retrieved: {temperature}°C, {condition}, "
f"{wind_speed} km/h wind"
)
return WeatherResponse(
location=str(location),
temperature=temperature,
condition=condition,
wind_speed=wind_speed,
humidity=humidity,
timestamp=timestamp,
)
except ExternalServiceError:
raise
except Exception as e:
raise ExternalServiceError(
f"Failed to retrieve weather data: {str(e)}",
service_name="Open-Meteo",
)
def format_result(self, response: WeatherResponse) -> str:
"""Format weather data for display."""
result = f"🌤️ **Weather for {response.location}**\n"
result += f"🌡️ Temperature: **{response.temperature}°C**\n"
result += f"☁️ Condition: **{response.condition}**\n"
result += f"💨 Wind Speed: **{response.wind_speed} km/h**"
if response.humidity is not None:
result += f"\n💧 Humidity: **{response.humidity}%**"
if response.timestamp:
try:
# Parse and format timestamp
dt = datetime.fromisoformat(response.timestamp.replace("Z", "+00:00"))
result += f"\n🕐 Updated: {dt.strftime('%Y-%m-%d %H:%M UTC')}"
except ValueError:
pass
return result
async def safe_execute(self, **kwargs) -> dict[str, Any]:
"""Execute weather lookup with formatted output."""
try:
result = await self.execute(**kwargs)
formatted_result = self.format_result(result)
return {
"content": [
{
"type": "text",
"text": formatted_result,
}
],
"isError": False,
}
except Exception as e:
return self.create_error_response(e)
================================================
FILE: examples/mcp-server-client/tests/__init__.py
================================================
================================================
FILE: examples/mcp-server-client/tests/test_cli.py
================================================
"""Tests for MCP client CLI interface."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.mcp_client.cli import MCPClientCLI
from src.mcp_client.models.responses import ClientToolResult
class TestMCPClientCLI:
"""Test cases for MCPClientCLI class."""
def test_init(self):
"""Test CLI initialization."""
cli = MCPClientCLI()
assert cli.parser is not None
assert cli.client is None
def test_parser_creation(self):
"""Test argument parser creation."""
cli = MCPClientCLI()
parser = cli.parser
# Test that parser exists and has expected subcommands
assert parser is not None
# Test help doesn't crash
help_text = parser.format_help()
assert "MCP Client" in help_text
assert "--server" in help_text
def test_build_tool_arguments_roll_dice(self):
"""Test building arguments for roll_dice tool."""
cli = MCPClientCLI()
# Mock parsed arguments
args = MagicMock()
args.tool = "roll_dice"
args.notation = "2d6"
result = cli._build_tool_arguments(args)
assert result == {"notation": "2d6"}
def test_build_tool_arguments_get_weather(self):
"""Test building arguments for get_weather tool."""
cli = MCPClientCLI()
# Mock parsed arguments
args = MagicMock()
args.tool = "get_weather"
args.location = "San Francisco"
result = cli._build_tool_arguments(args)
assert result == {"location": "San Francisco"}
def test_build_tool_arguments_get_date(self):
"""Test building arguments for get_date tool."""
cli = MCPClientCLI()
# Mock parsed arguments
args = MagicMock()
args.tool = "get_date"
args.timezone = "UTC"
result = cli._build_tool_arguments(args)
assert result == {"timezone": "UTC"}
def test_build_tool_arguments_unknown(self):
"""Test building arguments for unknown tool."""
cli = MCPClientCLI()
# Mock parsed arguments
args = MagicMock()
args.tool = "unknown_tool"
result = cli._build_tool_arguments(args)
assert result == {}
def test_parse_roll_dice_arguments(self):
"""Test parsing roll_dice command arguments."""
cli = MCPClientCLI()
args = cli.parser.parse_args(
["--server", "test_server.py", "roll_dice", "--notation", "3d6"]
)
assert args.server == "test_server.py"
assert args.tool == "roll_dice"
assert args.notation == "3d6"
assert args.log_level == "INFO" # default
assert args.timeout == 30 # default
def test_parse_get_weather_arguments(self):
"""Test parsing get_weather command arguments."""
cli = MCPClientCLI()
args = cli.parser.parse_args(
[
"--server",
"test_server.py",
"--log-level",
"DEBUG",
"get_weather",
"--location",
"London",
]
)
assert args.server == "test_server.py"
assert args.tool == "get_weather"
assert args.location == "London"
assert args.log_level == "DEBUG"
def test_parse_get_date_arguments(self):
"""Test parsing get_date command arguments."""
cli = MCPClientCLI()
args = cli.parser.parse_args(
[
"--server",
"test_server.py",
"--timeout",
"60",
"get_date",
"--timezone",
"America/New_York",
]
)
assert args.server == "test_server.py"
assert args.tool == "get_date"
assert args.timezone == "America/New_York"
assert args.timeout == 60
def test_parse_missing_server(self):
"""Test parsing with missing required --server argument."""
cli = MCPClientCLI()
with pytest.raises(SystemExit):
cli.parser.parse_args(["roll_dice", "--notation", "2d6"])
def test_parse_missing_tool_arguments(self):
"""Test parsing with missing required tool arguments."""
cli = MCPClientCLI()
with pytest.raises(SystemExit):
cli.parser.parse_args(["--server", "test.py", "roll_dice"])
@pytest.mark.asyncio
async def test_run_no_tool_specified(self, capsys):
"""Test running CLI without specifying a tool."""
cli = MCPClientCLI()
exit_code = await cli.run(["--server", "test_server.py"])
assert exit_code == 1
captured = capsys.readouterr()
assert "usage:" in captured.out
@pytest.mark.asyncio
async def test_run_connection_timeout(self):
"""Test running CLI with connection timeout."""
cli = MCPClientCLI()
with patch("src.mcp_client.cli.MCPClient") as mock_client_class:
mock_client = AsyncMock()
mock_client_class.return_value = mock_client
mock_client.connect.side_effect = TimeoutError()
exit_code = await cli.run(
[
"--server",
"test_server.py",
"--timeout",
"1",
"roll_dice",
"--notation",
"2d6",
]
)
assert exit_code == 1
@pytest.mark.asyncio
async def test_run_connection_error(self):
"""Test running CLI with connection error."""
cli = MCPClientCLI()
with patch("src.mcp_client.cli.MCPClient") as mock_client_class:
mock_client = AsyncMock()
mock_client_class.return_value = mock_client
mock_client.connect.side_effect = ConnectionError("Server not found")
exit_code = await cli.run(
["--server", "test_server.py", "roll_dice", "--notation", "2d6"]
)
assert exit_code == 1
@pytest.mark.asyncio
async def test_run_successful_tool_execution(self):
"""Test running CLI with successful tool execution."""
cli = MCPClientCLI()
# Mock successful result
mock_result = ClientToolResult(
success=True,
result=MagicMock(content=[{"text": "🎲 Rolled 2d6: [3, 5] = **8**"}]),
tool_name="roll_dice",
arguments={"notation": "2d6"},
)
with patch("src.mcp_client.cli.MCPClient") as mock_client_class:
mock_client = AsyncMock()
mock_client_class.return_value = mock_client
mock_client.connect.return_value = None
mock_client.invoke_tool.return_value = mock_result
mock_client.disconnect.return_value = None
exit_code = await cli.run(
["--server", "test_server.py", "roll_dice", "--notation", "2d6"]
)
assert exit_code == 0
mock_client.connect.assert_called_once()
mock_client.invoke_tool.assert_called_once_with(
"roll_dice", {"notation": "2d6"}
)
mock_client.disconnect.assert_called_once()
@pytest.mark.asyncio
async def test_run_failed_tool_execution(self):
"""Test running CLI with failed tool execution."""
cli = MCPClientCLI()
# Mock failed result
mock_result = ClientToolResult(
success=False,
error="Tool execution failed",
tool_name="roll_dice",
arguments={"notation": "invalid"},
)
with patch("src.mcp_client.cli.MCPClient") as mock_client_class:
mock_client = AsyncMock()
mock_client_class.return_value = mock_client
mock_client.connect.return_value = None
mock_client.invoke_tool.return_value = mock_result
mock_client.disconnect.return_value = None
exit_code = await cli.run(
["--server", "test_server.py", "roll_dice", "--notation", "invalid"]
)
assert exit_code == 1
mock_client.invoke_tool.assert_called_once_with(
"roll_dice", {"notation": "invalid"}
)
@pytest.mark.asyncio
async def test_run_keyboard_interrupt(self):
"""Test running CLI with keyboard interrupt."""
cli = MCPClientCLI()
with patch("src.mcp_client.cli.MCPClient") as mock_client_class:
mock_client = AsyncMock()
mock_client_class.return_value = mock_client
mock_client.connect.side_effect = KeyboardInterrupt()
exit_code = await cli.run(
["--server", "test_server.py", "roll_dice", "--notation", "2d6"]
)
assert exit_code == 130
@pytest.mark.asyncio
async def test_run_unexpected_error(self):
"""Test running CLI with unexpected error."""
cli = MCPClientCLI()
with patch("src.mcp_client.cli.MCPClient") as mock_client_class:
mock_client = AsyncMock()
mock_client_class.return_value = mock_client
mock_client.connect.side_effect = RuntimeError("Unexpected error")
exit_code = await cli.run(
["--server", "test_server.py", "roll_dice", "--notation", "2d6"]
)
assert exit_code == 1
@pytest.mark.asyncio
async def test_run_cleanup_on_success(self):
"""Test that client is properly cleaned up on success."""
cli = MCPClientCLI()
mock_result = ClientToolResult(
success=True,
result=MagicMock(content=[{"text": "Success"}]),
tool_name="roll_dice",
arguments={"notation": "2d6"},
)
with patch("src.mcp_client.cli.MCPClient") as mock_client_class:
mock_client = AsyncMock()
mock_client_class.return_value = mock_client
mock_client.connect.return_value = None
mock_client.invoke_tool.return_value = mock_result
mock_client.disconnect.return_value = None
await cli.run(
["--server", "test_server.py", "roll_dice", "--notation", "2d6"]
)
# Verify cleanup was called
mock_client.disconnect.assert_called_once()
@pytest.mark.asyncio
async def test_run_cleanup_on_error(self):
"""Test that client is properly cleaned up on error."""
cli = MCPClientCLI()
with patch("src.mcp_client.cli.MCPClient") as mock_client_class:
mock_client = AsyncMock()
mock_client_class.return_value = mock_client
mock_client.connect.return_value = None
mock_client.invoke_tool.side_effect = Exception("Test error")
mock_client.disconnect.return_value = None
await cli.run(
["--server", "test_server.py", "roll_dice", "--notation", "2d6"]
)
# Verify cleanup was called even on error
mock_client.disconnect.assert_called_once()
def test_display_success_with_text_content(self, capsys):
"""Test displaying successful result with text content."""
cli = MCPClientCLI()
result = ClientToolResult(
success=True,
result=MagicMock(content=[{"text": "🎲 Rolled 2d6: [3, 5] = **8**"}]),
tool_name="roll_dice",
arguments={"notation": "2d6"},
)
cli._display_success(result)
captured = capsys.readouterr()
assert "✅ roll_dice executed successfully" in captured.out
assert "🎲 Rolled 2d6: [3, 5] = **8**" in captured.out
def test_display_success_with_json_content(self, capsys):
"""Test displaying successful result with JSON content."""
cli = MCPClientCLI()
result = ClientToolResult(
success=True,
result=MagicMock(content=[{"data": "test", "value": 42}]),
tool_name="test_tool",
arguments={},
)
cli._display_success(result)
captured = capsys.readouterr()
assert "✅ test_tool executed successfully" in captured.out
assert '"data": "test"' in captured.out
assert '"value": 42' in captured.out
def test_display_error(self, capsys):
"""Test displaying error result."""
cli = MCPClientCLI()
result = ClientToolResult(
success=False,
error="Tool execution failed",
tool_name="roll_dice",
arguments={"notation": "invalid"},
)
cli._display_error(result)
captured = capsys.readouterr()
assert "❌ roll_dice failed" in captured.out
assert "Error: Tool execution failed" in captured.out
assert "Tool: roll_dice" in captured.out
assert '"notation": "invalid"' in captured.out
def test_display_connection_error(self, capsys):
"""Test displaying connection error."""
cli = MCPClientCLI()
error = ConnectionError("Server not found")
cli._display_connection_error(error)
captured = capsys.readouterr()
assert "❌ Connection failed" in captured.out
assert "Error: Server not found" in captured.out
assert "Troubleshooting:" in captured.out
assert "Check that the server script exists" in captured.out
@pytest.mark.asyncio
async def test_main_function():
"""Test main function."""
with patch("src.mcp_client.cli.MCPClientCLI") as mock_cli_class:
mock_cli = AsyncMock()
mock_cli_class.return_value = mock_cli
mock_cli.run.return_value = 0
from src.mcp_client.cli import main
with patch(
"sys.argv",
["cli.py", "--server", "test.py", "roll_dice", "--notation", "2d6"],
):
result = await main()
assert result == 0
mock_cli.run.assert_called_once()
================================================
FILE: examples/mcp-server-client/tests/test_gui.py
================================================
"""Tests for GUI components and functionality."""
from datetime import datetime
from unittest.mock import Mock, patch
import pytest
from src.gui.models.gui_models import ConnectionStatus, GUIInteraction, GUISession
from src.gui.utils.formatting import (
format_error_message,
format_execution_time,
format_json_for_display,
)
from src.gui.utils.validation import (
validate_dice_notation,
validate_location,
validate_timezone,
)
class TestGUIModels:
"""Test GUI data models."""
def test_gui_session_model(self):
"""Test GUI session model validation."""
session = GUISession()
assert not session.connected
assert session.server_path == "src/mcp_server/server.py"
assert len(session.interaction_history) == 0
assert session.current_tool is None
def test_gui_interaction_model(self):
"""Test GUI interaction model."""
interaction = GUIInteraction(
tool_name="roll_dice",
arguments={"notation": "2d6"},
request_payload={"tool": "roll_dice", "arguments": {"notation": "2d6"}},
response_payload={
"success": True,
"result": {"values": [3, 5], "total": 8},
},
success=True,
)
assert interaction.tool_name == "roll_dice"
assert interaction.success is True
assert interaction.timestamp is not None
assert isinstance(interaction.timestamp, datetime)
def test_connection_status_model(self):
"""Test connection status model."""
status = ConnectionStatus(
server_path="src/mcp_server/server.py",
connected=True,
available_tools=["roll_dice", "get_weather"],
)
assert status.connected is True
assert len(status.available_tools) == 2
assert "roll_dice" in status.available_tools
class TestValidationUtils:
"""Test validation utilities."""
def test_dice_notation_validation(self):
"""Test dice notation validation."""
# Valid notations
assert validate_dice_notation("2d6") is True
assert validate_dice_notation("1d20") is True
assert validate_dice_notation("10d10") is True
# Invalid notations
assert validate_dice_notation("invalid") is False
assert validate_dice_notation("2d") is False
assert validate_dice_notation("d6") is False
assert validate_dice_notation("") is False
assert validate_dice_notation(None) is False
# Edge cases
assert validate_dice_notation("0d6") is False # No dice
assert validate_dice_notation("2d1") is False # Invalid sides
assert validate_dice_notation("101d6") is False # Too many dice
def test_location_validation(self):
"""Test location validation."""
# Valid locations
assert validate_location("San Francisco") is True
assert validate_location("New York") is True
assert validate_location("37.7749,-122.4194") is True
assert validate_location("40.7128,-74.0060") is True
# Invalid locations
assert validate_location("") is False
assert validate_location("A") is False # Too short
assert validate_location("123") is False # Numbers only
assert validate_location(None) is False
def test_timezone_validation(self):
"""Test timezone validation."""
# Valid timezones
assert validate_timezone("UTC") is True
assert validate_timezone("America/New_York") is True
assert validate_timezone("Europe/London") is True
assert validate_timezone("Asia/Tokyo") is True
# Invalid timezones
assert validate_timezone("") is False
assert validate_timezone("Invalid/Zone") is True # Would pass pattern check
assert validate_timezone("123") is False
assert validate_timezone(None) is False
class TestFormattingUtils:
"""Test formatting utilities."""
def test_json_formatting(self):
"""Test JSON formatting for display."""
data = {"tool": "roll_dice", "result": {"values": [3, 5], "total": 8}}
formatted = format_json_for_display(data)
assert isinstance(formatted, str)
assert "roll_dice" in formatted
assert "values" in formatted
# Test with invalid data
formatted_error = format_json_for_display(
set()
) # Sets aren't JSON serializable
assert "Error formatting JSON" in formatted_error
def test_error_message_formatting(self):
"""Test error message formatting."""
# Test removal of prefixes
assert (
format_error_message("Exception: Something went wrong")
== "Something went wrong"
)
assert format_error_message("Error: Invalid input") == "Invalid input"
# Test capitalization
assert format_error_message("invalid dice notation") == "Invalid dice notation"
# Test empty string
assert format_error_message("") == ""
def test_execution_time_formatting(self):
"""Test execution time formatting."""
# Test milliseconds
assert format_execution_time(0.5) == "500ms"
assert format_execution_time(0.123) == "123ms"
# Test seconds
assert format_execution_time(1.5) == "1.50s"
assert format_execution_time(30.25) == "30.25s"
# Test minutes
assert format_execution_time(65.5) == "1m 5.5s"
assert format_execution_time(125.75) == "2m 5.8s"
class TestConnectionManager:
"""Test connection manager component."""
@patch("src.gui.components.connection.MCPClient")
def test_connection_success(self, mock_client_class):
"""Test successful connection."""
# Mock client instance
mock_client = Mock()
mock_client.available_tools = ["roll_dice", "get_weather", "get_date"]
mock_client_class.return_value = mock_client
# This would require mocking Streamlit session state
# which is complex, so we're testing the basic structure
assert mock_client_class is not None
def test_connection_failure(self):
"""Test connection failure handling."""
# This would test error handling in connection
# Placeholder for more complex Streamlit testing
pass
class TestToolForms:
"""Test tool form components."""
def test_dice_form_validation(self):
"""Test dice form validation logic."""
from src.gui.components.tool_forms import ToolForms
tool_forms = ToolForms()
assert tool_forms._validate_dice_notation("2d6") is True
assert tool_forms._validate_dice_notation("invalid") is False
def test_tool_execution_flow(self):
"""Test tool execution flow."""
# This would test the complete flow from form submission
# to result display - requires Streamlit mocking
pass
@pytest.mark.asyncio
async def test_async_operations():
"""Test async operations in GUI components."""
# Placeholder for testing async MCP client operations
# This would test the asyncio.run() calls in the GUI
pass
def test_session_state_management():
"""Test session state management patterns."""
# Test session initialization
session = GUISession()
assert session.connected is False
assert len(session.interaction_history) == 0
# Test adding interactions
interaction = GUIInteraction(
tool_name="test_tool",
arguments={"test": "value"},
request_payload={"tool": "test_tool"},
response_payload={"success": True},
success=True,
)
session.interaction_history.append(interaction)
assert len(session.interaction_history) == 1
assert session.interaction_history[0].tool_name == "test_tool"
def test_history_management():
"""Test history management functionality."""
# Create test interactions
interactions = []
for i in range(3):
interaction = GUIInteraction(
tool_name=f"tool_{i}",
arguments={"index": i},
request_payload={"tool": f"tool_{i}"},
response_payload={"success": True, "index": i},
success=True,
)
interactions.append(interaction)
# Test history ordering (latest first)
assert interactions[-1].arguments["index"] == 2
assert interactions[0].arguments["index"] == 0
================================================
FILE: examples/mcp-server-client/tests/test_mcp_client.py
================================================
"""Tests for MCP client functionality."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.mcp_client.client import MCPClient
from src.mcp_client.models.responses import ClientToolResult
from src.mcp_client.transport import MCPTransport
class TestMCPTransport:
"""Test cases for MCPTransport class."""
def test_init(self):
"""Test transport initialization."""
transport = MCPTransport("test_server.py")
assert transport.server_path == "test_server.py"
assert transport.session is None
assert transport.connected is False
assert transport.available_tools == []
@pytest.mark.asyncio
async def test_connect_file_not_found(self):
"""Test connection with non-existent server file."""
transport = MCPTransport("nonexistent_server.py")
with pytest.raises(FileNotFoundError, match="Server script not found"):
await transport.connect()
@pytest.mark.asyncio
async def test_connect_unsupported_file_type(self, tmp_path):
"""Test connection with unsupported file type."""
server_file = tmp_path / "server.txt"
server_file.write_text("# not a valid server script")
transport = MCPTransport(str(server_file))
with pytest.raises(ValueError, match="Unsupported server script type"):
await transport.connect()
@pytest.mark.asyncio
async def test_connect_success(self, tmp_path):
"""Test successful connection."""
server_file = tmp_path / "server.py"
server_file.write_text("# mock server script")
transport = MCPTransport(str(server_file))
# Mock the connection components
with (
patch("src.mcp_client.transport.stdio_client") as mock_stdio,
patch("src.mcp_client.transport.ClientSession") as mock_session_class,
):
# Setup mocks
mock_read, mock_write = AsyncMock(), AsyncMock()
mock_stdio.return_value.__aenter__.return_value = (mock_read, mock_write)
mock_session = AsyncMock()
mock_session_class.return_value.__aenter__.return_value = mock_session
# Mock session methods
mock_session.initialize = AsyncMock()
mock_tools_response = MagicMock()
mock_tool1 = MagicMock()
mock_tool1.name = "roll_dice"
mock_tool2 = MagicMock()
mock_tool2.name = "get_weather"
mock_tool3 = MagicMock()
mock_tool3.name = "get_date"
mock_tools_response.tools = [mock_tool1, mock_tool2, mock_tool3]
mock_session.list_tools.return_value = mock_tools_response
# Test connection
await transport.connect()
# Verify connection state
assert transport.connected is True
assert transport.available_tools == ["roll_dice", "get_weather", "get_date"]
assert transport.session == mock_session
@pytest.mark.asyncio
async def test_disconnect(self):
"""Test disconnect functionality."""
transport = MCPTransport("test_server.py")
# Mock exit stack
mock_exit_stack = AsyncMock()
transport.exit_stack = mock_exit_stack
transport.connected = True
transport.available_tools = ["test_tool"]
await transport.disconnect()
mock_exit_stack.aclose.assert_called_once()
assert transport.session is None
assert transport.connected is False
assert transport.available_tools == []
@pytest.mark.asyncio
async def test_call_tool_not_connected(self):
"""Test tool call when not connected."""
transport = MCPTransport("test_server.py")
with pytest.raises(RuntimeError, match="Not connected to server"):
await transport.call_tool("test_tool", {})
@pytest.mark.asyncio
async def test_call_tool_unavailable(self):
"""Test tool call with unavailable tool."""
transport = MCPTransport("test_server.py")
transport.connected = True
transport.session = AsyncMock() # Add session mock
transport.available_tools = ["available_tool"]
with pytest.raises(ValueError, match="Tool 'unavailable_tool' not available"):
await transport.call_tool("unavailable_tool", {})
@pytest.mark.asyncio
async def test_call_tool_success(self):
"""Test successful tool call."""
transport = MCPTransport("test_server.py")
transport.connected = True
transport.available_tools = ["test_tool"]
# Mock session
mock_session = AsyncMock()
transport.session = mock_session
mock_result = MagicMock()
mock_session.call_tool.return_value = mock_result
result = await transport.call_tool("test_tool", {"arg": "value"})
mock_session.call_tool.assert_called_once_with("test_tool", {"arg": "value"})
assert result == mock_result
@pytest.mark.asyncio
async def test_health_check_not_connected(self):
"""Test health check when not connected."""
transport = MCPTransport("test_server.py")
result = await transport.health_check()
assert result is False
@pytest.mark.asyncio
async def test_health_check_success(self):
"""Test successful health check."""
transport = MCPTransport("test_server.py")
transport.connected = True
mock_session = AsyncMock()
transport.session = mock_session
mock_session.list_tools.return_value = MagicMock()
result = await transport.health_check()
assert result is True
@pytest.mark.asyncio
async def test_health_check_failure(self):
"""Test health check with exception."""
transport = MCPTransport("test_server.py")
transport.connected = True
mock_session = AsyncMock()
transport.session = mock_session
mock_session.list_tools.side_effect = Exception("Connection lost")
result = await transport.health_check()
assert result is False
class TestMCPClient:
"""Test cases for MCPClient class."""
def test_init(self):
"""Test client initialization."""
client = MCPClient("test_server.py")
assert client.server_path == "test_server.py"
assert isinstance(client.transport, MCPTransport)
assert client.connected is False
@pytest.mark.asyncio
async def test_connect_success(self):
"""Test successful connection."""
client = MCPClient("test_server.py")
with patch.object(
client.transport, "connect", new_callable=AsyncMock
) as mock_connect:
mock_connect.return_value = None
client.transport.connected = True
client.transport.available_tools = ["test_tool"]
await client.connect()
mock_connect.assert_called_once()
assert client.connected is True
@pytest.mark.asyncio
async def test_connect_failure(self):
"""Test connection failure."""
client = MCPClient("test_server.py")
with patch.object(
client.transport, "connect", new_callable=AsyncMock
) as mock_connect:
mock_connect.side_effect = ConnectionError("Failed to connect")
with pytest.raises(ConnectionError, match="Failed to connect"):
await client.connect()
@pytest.mark.asyncio
async def test_disconnect(self):
"""Test disconnect functionality."""
client = MCPClient("test_server.py")
client._connected = True
with patch.object(
client.transport, "disconnect", new_callable=AsyncMock
) as mock_disconnect:
await client.disconnect()
mock_disconnect.assert_called_once()
assert client.connected is False
@pytest.mark.asyncio
async def test_invoke_tool_not_connected(self):
"""Test tool invocation when not connected."""
client = MCPClient("test_server.py")
result = await client.invoke_tool("test_tool", {})
assert result.success is False
assert "Not connected to server" in result.error
assert result.tool_name == "test_tool"
@pytest.mark.asyncio
async def test_invoke_tool_unavailable(self):
"""Test tool invocation with unavailable tool."""
client = MCPClient("test_server.py")
client._connected = True
client.transport.connected = True
client.transport.available_tools = ["available_tool"]
result = await client.invoke_tool("unavailable_tool", {})
assert result.success is False
assert "not available" in result.error
assert result.tool_name == "unavailable_tool"
@pytest.mark.asyncio
async def test_invoke_tool_success(self):
"""Test successful tool invocation."""
client = MCPClient("test_server.py")
client._connected = True
client.transport.connected = True
client.transport.available_tools = ["test_tool"]
mock_result = MagicMock()
mock_result.content = [{"type": "text", "text": "Success"}]
with patch.object(
client.transport, "call_tool", new_callable=AsyncMock
) as mock_call:
mock_call.return_value = mock_result
result = await client.invoke_tool("test_tool", {"arg": "value"})
mock_call.assert_called_once_with("test_tool", {"arg": "value"})
assert result.success is True
assert result.result == mock_result
assert result.tool_name == "test_tool"
@pytest.mark.asyncio
async def test_invoke_tool_exception(self):
"""Test tool invocation with exception."""
client = MCPClient("test_server.py")
client._connected = True
client.transport.connected = True
client.transport.available_tools = ["test_tool"]
with patch.object(
client.transport, "call_tool", new_callable=AsyncMock
) as mock_call:
mock_call.side_effect = Exception("Tool execution failed")
result = await client.invoke_tool("test_tool", {"arg": "value"})
assert result.success is False
assert "Tool execution failed" in result.error
assert result.tool_name == "test_tool"
@pytest.mark.asyncio
async def test_health_check_not_connected(self):
"""Test health check when not connected."""
client = MCPClient("test_server.py")
result = await client.health_check()
assert result is False
@pytest.mark.asyncio
async def test_health_check_success(self):
"""Test successful health check."""
client = MCPClient("test_server.py")
client._connected = True
client.transport.connected = True # Also set transport connected
with patch.object(
client.transport, "health_check", new_callable=AsyncMock
) as mock_health:
mock_health.return_value = True
result = await client.health_check()
assert result is True
@pytest.mark.asyncio
async def test_context_manager(self):
"""Test client as async context manager."""
client = MCPClient("test_server.py")
with (
patch.object(client, "connect", new_callable=AsyncMock) as mock_connect,
patch.object(
client, "disconnect", new_callable=AsyncMock
) as mock_disconnect,
):
async with client as context_client:
assert context_client == client
mock_connect.assert_called_once()
mock_disconnect.assert_called_once()
class TestClientToolResult:
"""Test cases for ClientToolResult model."""
def test_successful_result(self):
"""Test successful tool result."""
result = ClientToolResult(
success=True,
result={"data": "test"},
tool_name="test_tool",
arguments={"arg": "value"},
)
assert result.success is True
assert result.result == {"data": "test"}
assert result.error is None
assert result.tool_name == "test_tool"
assert result.arguments == {"arg": "value"}
def test_failed_result(self):
"""Test failed tool result."""
result = ClientToolResult(
success=False,
error="Tool execution failed",
tool_name="test_tool",
arguments={"arg": "value"},
)
assert result.success is False
assert result.result is None
assert result.error == "Tool execution failed"
assert result.tool_name == "test_tool"
assert result.arguments == {"arg": "value"}
================================================
FILE: examples/mcp-server-client/tests/test_mcp_server.py
================================================
"""Tests for the MCP server integration."""
from unittest.mock import AsyncMock, patch
import pytest
from src.mcp_server.server import cleanup_server, datetime_tool, dice_tool, weather_tool
from tests.fixtures.mcp_messages import WeatherAPIFixtures
class TestMCPServerIntegration:
"""Test suite for MCP server integration."""
@pytest.mark.asyncio
async def test_dice_tool_integration(self):
"""Test dice tool integration through MCP server."""
# Import the MCP tool function
from src.mcp_server.server import roll_dice
result = await roll_dice(notation="2d6")
assert "content" in result
assert "isError" in result
assert result["isError"] is False
assert "🎲" in result["content"][0]["text"]
assert "2d6" in result["content"][0]["text"]
@pytest.mark.asyncio
async def test_weather_tool_integration(self):
"""Test weather tool integration through MCP server."""
from src.mcp_server.server import get_weather
mock_response = WeatherAPIFixtures.current_weather_response()
with patch.object(weather_tool, "make_request", return_value=mock_response):
result = await get_weather(location="San Francisco")
assert "content" in result
assert "isError" in result
assert result["isError"] is False
assert "🌤️" in result["content"][0]["text"]
assert "San Francisco" in result["content"][0]["text"]
@pytest.mark.asyncio
async def test_datetime_tool_integration(self):
"""Test date/time tool integration through MCP server."""
from src.mcp_server.server import get_date
result = await get_date(timezone="UTC")
assert "content" in result
assert "isError" in result
assert result["isError"] is False
assert "🕐" in result["content"][0]["text"]
assert "UTC" in result["content"][0]["text"]
@pytest.mark.asyncio
async def test_dice_tool_error_handling(self):
"""Test error handling in dice tool integration."""
from src.mcp_server.server import roll_dice
result = await roll_dice(notation="invalid")
assert "content" in result
assert "isError" in result
assert result["isError"] is True
assert "Invalid input" in result["content"][0]["text"]
@pytest.mark.asyncio
async def test_weather_tool_error_handling(self):
"""Test error handling in weather tool integration."""
from src.mcp_server.server import get_weather
result = await get_weather(location="Unknown City")
assert "content" in result
assert "isError" in result
assert result["isError"] is True
assert "Unknown location" in result["content"][0]["text"]
@pytest.mark.asyncio
async def test_datetime_tool_error_handling(self):
"""Test error handling in date/time tool integration."""
from src.mcp_server.server import get_date
result = await get_date(timezone="Invalid/Timezone")
assert "content" in result
assert "isError" in result
assert result["isError"] is True
assert "Invalid timezone" in result["content"][0]["text"]
@pytest.mark.asyncio
async def test_help_resource(self):
"""Test help resource is available."""
from src.mcp_server.server import get_help
help_text = await get_help()
assert isinstance(help_text, str)
assert "roll_dice" in help_text
assert "get_weather" in help_text
assert "get_date" in help_text
assert "🎲" in help_text
@pytest.mark.asyncio
async def test_cleanup_server(self):
"""Test server cleanup functionality."""
# Mock the weather tool's cleanup method
weather_tool.cleanup = AsyncMock()
await cleanup_server()
weather_tool.cleanup.assert_called_once()
@pytest.mark.asyncio
async def test_cleanup_server_with_error(self):
"""Test server cleanup handles errors gracefully."""
# Mock the weather tool's cleanup method to raise an error
weather_tool.cleanup = AsyncMock(side_effect=Exception("Cleanup error"))
# Should not raise an exception
await cleanup_server()
weather_tool.cleanup.assert_called_once()
def test_tool_instances_exist(self):
"""Test that tool instances are properly created."""
assert dice_tool is not None
assert weather_tool is not None
assert datetime_tool is not None
assert dice_tool.name == "roll_dice"
assert weather_tool.name == "get_weather"
assert datetime_tool.name == "get_date"
@pytest.mark.asyncio
async def test_mcp_tool_docstrings(self):
"""Test that MCP tool functions have proper docstrings."""
from src.mcp_server.server import get_date, get_weather, roll_dice
assert roll_dice.__doc__ is not None
assert "dice" in roll_dice.__doc__.lower()
assert "notation" in roll_dice.__doc__.lower()
assert get_weather.__doc__ is not None
assert "weather" in get_weather.__doc__.lower()
assert "location" in get_weather.__doc__.lower()
assert get_date.__doc__ is not None
assert "date" in get_date.__doc__.lower()
assert "timezone" in get_date.__doc__.lower()
@pytest.mark.asyncio
async def test_all_tools_return_proper_format(self):
"""Test that all tools return the expected MCP response format."""
from src.mcp_server.server import get_date, roll_dice
# Test dice tool
dice_result = await roll_dice(notation="1d6")
assert "content" in dice_result
assert "isError" in dice_result
assert isinstance(dice_result["content"], list)
assert len(dice_result["content"]) == 1
assert "type" in dice_result["content"][0]
assert "text" in dice_result["content"][0]
# Test date tool
date_result = await get_date(timezone="UTC")
assert "content" in date_result
assert "isError" in date_result
assert isinstance(date_result["content"], list)
assert len(date_result["content"]) == 1
assert "type" in date_result["content"][0]
assert "text" in date_result["content"][0]
@pytest.mark.asyncio
async def test_weather_tool_with_coordinates(self):
"""Test weather tool with coordinate input."""
from src.mcp_server.server import get_weather
mock_response = WeatherAPIFixtures.current_weather_response()
with patch.object(weather_tool, "make_request", return_value=mock_response):
result = await get_weather(location="37.7749,-122.4194")
assert "content" in result
assert "isError" in result
assert result["isError"] is False
assert "37.7749,-122.4194" in result["content"][0]["text"]
================================================
FILE: examples/mcp-server-client/tests/fixtures/__init__.py
================================================
================================================
FILE: examples/mcp-server-client/tests/fixtures/mcp_messages.py
================================================
"""Test fixtures for MCP messages and responses."""
from typing import Any
class MCPMessageFixtures:
"""Collection of MCP message fixtures for testing."""
@staticmethod
def tool_call_request(
tool_name: str, arguments: dict[str, Any], request_id: int = 1
) -> dict[str, Any]:
"""Create a standard MCP tool call request."""
return {
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": tool_name,
"arguments": arguments,
},
"id": request_id,
}
@staticmethod
def success_response(content: Any, request_id: int = 1) -> dict[str, Any]:
"""Create a successful MCP response."""
return {
"jsonrpc": "2.0",
"id": request_id,
"result": {
"content": [
{
"type": "text",
"text": content,
}
],
"isError": False,
},
}
@staticmethod
def error_response(error_message: str, request_id: int = 1) -> dict[str, Any]:
"""Create an error MCP response."""
return {
"jsonrpc": "2.0",
"id": request_id,
"result": {
"content": [
{
"type": "text",
"text": error_message,
}
],
"isError": True,
},
}
class WeatherAPIFixtures:
"""Collection of weather API response fixtures."""
@staticmethod
def current_weather_response(
temperature: float = 20.0,
weather_code: int = 0,
wind_speed: float = 10.0,
humidity: float = 65.0,
) -> dict[str, Any]:
"""Create a mock Open-Meteo API response."""
return {
"current": {
"time": "2025-07-07T14:30:00Z",
"temperature_2m": temperature,
"relative_humidity_2m": humidity,
"weather_code": weather_code,
"wind_speed_10m": wind_speed,
},
"current_units": {
"time": "iso8601",
"temperature_2m": "°C",
"relative_humidity_2m": "%",
"weather_code": "wmo code",
"wind_speed_10m": "km/h",
},
}
@staticmethod
def api_error_response(status_code: int = 500) -> dict[str, Any]:
"""Create a mock API error response."""
return {
"error": True,
"reason": "Internal server error" if status_code == 500 else "Bad request",
}
================================================
FILE: examples/mcp-server-client/tests/test_tools/__init__.py
================================================
================================================
FILE: examples/mcp-server-client/tests/test_tools/test_date_time.py
================================================
"""Tests for the date/time tool."""
import zoneinfo
from datetime import UTC, datetime
from unittest.mock import patch
import pytest
from src.mcp_server.tools.base import ToolError, ValidationToolError
from src.mcp_server.tools.date_time import DateTimeTool
class TestDateTimeTool:
"""Test suite for DateTimeTool."""
@pytest.fixture
def datetime_tool(self):
"""Create a DateTimeTool instance for testing."""
return DateTimeTool()
def test_parse_timezone_utc(self, datetime_tool):
"""Test parsing UTC timezone."""
tz = datetime_tool.parse_timezone("UTC")
assert tz == UTC
tz = datetime_tool.parse_timezone("utc")
assert tz == UTC
tz = datetime_tool.parse_timezone("GMT")
assert tz == UTC
def test_parse_timezone_iana(self, datetime_tool):
"""Test parsing IANA timezone names."""
tz = datetime_tool.parse_timezone("America/New_York")
assert isinstance(tz, zoneinfo.ZoneInfo)
assert str(tz) == "America/New_York"
tz = datetime_tool.parse_timezone("Europe/London")
assert isinstance(tz, zoneinfo.ZoneInfo)
assert str(tz) == "Europe/London"
def test_parse_timezone_aliases(self, datetime_tool):
"""Test parsing timezone aliases."""
tz = datetime_tool.parse_timezone("est")
assert isinstance(tz, zoneinfo.ZoneInfo)
assert str(tz) == "America/New_York"
tz = datetime_tool.parse_timezone("pst")
assert isinstance(tz, zoneinfo.ZoneInfo)
assert str(tz) == "America/Los_Angeles"
def test_parse_timezone_invalid(self, datetime_tool):
"""Test parsing invalid timezone raises ToolError."""
with pytest.raises(ToolError) as exc_info:
datetime_tool.parse_timezone("Invalid/Timezone")
assert "Invalid timezone" in str(exc_info.value)
assert "Invalid/Timezone" in str(exc_info.value)
@pytest.mark.asyncio
async def test_execute_utc(self, datetime_tool):
"""Test execution with UTC timezone."""
with patch("src.mcp_server.tools.date_time.datetime") as mock_datetime:
mock_now = datetime(2025, 7, 7, 14, 30, 25, tzinfo=UTC)
mock_datetime.now.return_value = mock_now
mock_datetime.fromisoformat = datetime.fromisoformat
result = await datetime_tool.execute(timezone="UTC")
assert result.timezone == "UTC"
assert result.datetime == "2025-07-07T14:30:25+00:00"
assert isinstance(result.timestamp, float)
@pytest.mark.asyncio
async def test_execute_iana_timezone(self, datetime_tool):
"""Test execution with IANA timezone."""
with patch("src.mcp_server.tools.date_time.datetime") as mock_datetime:
ny_tz = zoneinfo.ZoneInfo("America/New_York")
mock_now = datetime(2025, 7, 7, 10, 30, 25, tzinfo=ny_tz)
mock_datetime.now.return_value = mock_now
mock_datetime.fromisoformat = datetime.fromisoformat
result = await datetime_tool.execute(timezone="America/New_York")
assert result.timezone == "America/New_York"
assert "2025-07-07T10:30:25" in result.datetime
assert isinstance(result.timestamp, float)
@pytest.mark.asyncio
async def test_execute_alias(self, datetime_tool):
"""Test execution with timezone alias."""
with patch("src.mcp_server.tools.date_time.datetime") as mock_datetime:
ny_tz = zoneinfo.ZoneInfo("America/New_York")
mock_now = datetime(2025, 7, 7, 10, 30, 25, tzinfo=ny_tz)
mock_datetime.now.return_value = mock_now
mock_datetime.fromisoformat = datetime.fromisoformat
result = await datetime_tool.execute(timezone="est")
assert result.timezone == "America/New_York"
assert isinstance(result.timestamp, float)
@pytest.mark.asyncio
async def test_execute_default_timezone(self, datetime_tool):
"""Test execution with default timezone (UTC)."""
with patch("src.mcp_server.tools.date_time.datetime") as mock_datetime:
mock_now = datetime(2025, 7, 7, 14, 30, 25, tzinfo=UTC)
mock_datetime.now.return_value = mock_now
mock_datetime.fromisoformat = datetime.fromisoformat
result = await datetime_tool.execute() # No timezone argument
assert result.timezone == "UTC"
assert "2025-07-07T14:30:25" in result.datetime
@pytest.mark.asyncio
async def test_execute_invalid_timezone(self, datetime_tool):
"""Test execution with invalid timezone."""
with pytest.raises(ToolError) as exc_info:
await datetime_tool.execute(timezone="Invalid/Timezone")
assert "Invalid timezone" in str(exc_info.value)
@pytest.mark.asyncio
async def test_execute_empty_timezone(self, datetime_tool):
"""Test execution with empty timezone."""
with pytest.raises(ValidationToolError) as exc_info:
await datetime_tool.execute(timezone="")
assert "Timezone cannot be empty" in str(exc_info.value)
def test_format_result(self, datetime_tool):
"""Test formatting date/time result for display."""
from src.mcp_server.models import DateTimeResponse
response = DateTimeResponse(
datetime="2025-07-07T14:30:25+00:00", timezone="UTC", timestamp=1720360225.0
)
formatted = datetime_tool.format_result(response)
assert "🕐" in formatted
assert "2025-07-07" in formatted
assert "14:30:25" in formatted
assert "UTC" in formatted
assert "2025-07-07T14:30:25+00:00" in formatted
assert "1720360225" in formatted
def test_format_result_weekday(self, datetime_tool):
"""Test formatting includes weekday information."""
from src.mcp_server.models import DateTimeResponse
# Monday
response = DateTimeResponse(
datetime="2025-07-07T14:30:25+00:00", timezone="UTC", timestamp=1720360225.0
)
formatted = datetime_tool.format_result(response)
assert "Monday" in formatted
def test_format_result_invalid_datetime(self, datetime_tool):
"""Test formatting with invalid datetime falls back gracefully."""
from src.mcp_server.models import DateTimeResponse
response = DateTimeResponse(
datetime="invalid-datetime", timezone="UTC", timestamp=1720360225.0
)
formatted = datetime_tool.format_result(response)
# Should still show the basic information
assert "🕐" in formatted
assert "UTC" in formatted
assert "invalid-datetime" in formatted
assert "1720360225" in formatted
@pytest.mark.asyncio
async def test_safe_execute_success(self, datetime_tool):
"""Test safe_execute returns proper success format."""
with patch("src.mcp_server.tools.date_time.datetime") as mock_datetime:
mock_now = datetime(2025, 7, 7, 14, 30, 25, tzinfo=UTC)
mock_datetime.now.return_value = mock_now
mock_datetime.fromisoformat = datetime.fromisoformat
result = await datetime_tool.safe_execute(timezone="UTC")
assert "content" in result
assert "isError" in result
assert result["isError"] is False
assert len(result["content"]) == 1
assert result["content"][0]["type"] == "text"
assert "🕐" in result["content"][0]["text"]
@pytest.mark.asyncio
async def test_safe_execute_error(self, datetime_tool):
"""Test safe_execute returns proper error format."""
result = await datetime_tool.safe_execute(timezone="Invalid/Timezone")
assert "content" in result
assert "isError" in result
assert result["isError"] is True
assert len(result["content"]) == 1
assert result["content"][0]["type"] == "text"
assert "Invalid timezone" in result["content"][0]["text"]
def test_get_available_timezones(self, datetime_tool):
"""Test get_available_timezones returns a list."""
timezones = datetime_tool.get_available_timezones()
assert isinstance(timezones, list)
assert "UTC" in timezones
assert "America/New_York" in timezones
assert "Europe/London" in timezones
assert len(timezones) > 10
@pytest.mark.asyncio
async def test_timezone_case_insensitive(self, datetime_tool):
"""Test timezone aliases are case insensitive."""
with patch("src.mcp_server.tools.date_time.datetime") as mock_datetime:
ny_tz = zoneinfo.ZoneInfo("America/New_York")
mock_now = datetime(2025, 7, 7, 10, 30, 25, tzinfo=ny_tz)
mock_datetime.now.return_value = mock_now
mock_datetime.fromisoformat = datetime.fromisoformat
result1 = await datetime_tool.execute(timezone="EST")
result2 = await datetime_tool.execute(timezone="est")
assert result1.timezone == result2.timezone == "America/New_York"
@pytest.mark.asyncio
async def test_whitespace_handling(self, datetime_tool):
"""Test timezone input with whitespace is handled correctly."""
with patch("src.mcp_server.tools.date_time.datetime") as mock_datetime:
mock_now = datetime(2025, 7, 7, 14, 30, 25, tzinfo=UTC)
mock_datetime.now.return_value = mock_now
mock_datetime.fromisoformat = datetime.fromisoformat
result = await datetime_tool.execute(timezone=" UTC ")
assert result.timezone == "UTC"
================================================
FILE: examples/mcp-server-client/tests/test_tools/test_dice.py
================================================
"""Tests for the dice rolling tool."""
import pytest
from src.mcp_server.tools.base import ValidationToolError
from src.mcp_server.tools.dice import DiceRollTool
class TestDiceRollTool:
"""Test suite for DiceRollTool."""
@pytest.fixture
def dice_tool(self):
"""Create a DiceRollTool instance for testing."""
return DiceRollTool()
@pytest.mark.asyncio
async def test_roll_dice_valid_notation(self, dice_tool):
"""Test valid dice notation works correctly."""
result = await dice_tool.execute(notation="2d6")
assert isinstance(result.values, list)
assert len(result.values) == 2
assert all(1 <= v <= 6 for v in result.values)
assert result.total == sum(result.values)
assert result.notation == "2d6"
@pytest.mark.asyncio
async def test_roll_dice_single_die(self, dice_tool):
"""Test rolling a single die."""
result = await dice_tool.execute(notation="1d20")
assert len(result.values) == 1
assert 1 <= result.values[0] <= 20
assert result.total == result.values[0]
assert result.notation == "1d20"
@pytest.mark.asyncio
async def test_roll_dice_multiple_dice(self, dice_tool):
"""Test rolling multiple dice."""
result = await dice_tool.execute(notation="4d10")
assert len(result.values) == 4
assert all(1 <= v <= 10 for v in result.values)
assert result.total == sum(result.values)
assert result.notation == "4d10"
@pytest.mark.asyncio
async def test_roll_dice_invalid_notation_format(self, dice_tool):
"""Test invalid dice notation raises ValidationError."""
with pytest.raises(ValidationToolError) as exc_info:
await dice_tool.execute(notation="d6")
assert "Invalid dice notation" in str(exc_info.value)
@pytest.mark.asyncio
async def test_roll_dice_invalid_notation_no_d(self, dice_tool):
"""Test notation without 'd' raises ValidationError."""
with pytest.raises(ValidationToolError) as exc_info:
await dice_tool.execute(notation="2x6")
assert "Invalid dice notation" in str(exc_info.value)
@pytest.mark.asyncio
async def test_roll_dice_zero_dice_count(self, dice_tool):
"""Test zero dice count raises ValidationError."""
with pytest.raises(ValidationToolError) as exc_info:
await dice_tool.execute(notation="0d6")
assert "Dice count must be greater than 0" in str(exc_info.value)
@pytest.mark.asyncio
async def test_roll_dice_zero_sides(self, dice_tool):
"""Test zero sides raises ValidationError."""
with pytest.raises(ValidationToolError) as exc_info:
await dice_tool.execute(notation="1d0")
assert "Number of sides must be greater than 0" in str(exc_info.value)
@pytest.mark.asyncio
async def test_roll_dice_too_many_dice(self, dice_tool):
"""Test too many dice raises ValidationError."""
with pytest.raises(ValidationToolError) as exc_info:
await dice_tool.execute(notation="101d6")
assert "Dice count must not exceed 100" in str(exc_info.value)
@pytest.mark.asyncio
async def test_roll_dice_too_many_sides(self, dice_tool):
"""Test too many sides raises ValidationError."""
with pytest.raises(ValidationToolError) as exc_info:
await dice_tool.execute(notation="1d1001")
assert "Number of sides must not exceed 1000" in str(exc_info.value)
@pytest.mark.asyncio
async def test_roll_dice_random_text(self, dice_tool):
"""Test random text raises ValidationError."""
with pytest.raises(ValidationToolError) as exc_info:
await dice_tool.execute(notation="abc")
assert "Invalid dice notation" in str(exc_info.value)
def test_format_result_single_die(self, dice_tool):
"""Test formatting result for single die."""
from src.mcp_server.models import DiceRollResponse
response = DiceRollResponse(values=[15], total=15, notation="1d20")
formatted = dice_tool.format_result(response)
assert "🎲" in formatted
assert "1d20" in formatted
assert "**15**" in formatted
def test_format_result_multiple_dice(self, dice_tool):
"""Test formatting result for multiple dice."""
from src.mcp_server.models import DiceRollResponse
response = DiceRollResponse(values=[4, 2, 6], total=12, notation="3d6")
formatted = dice_tool.format_result(response)
assert "🎲" in formatted
assert "3d6" in formatted
assert "[4, 2, 6]" in formatted
assert "**12**" in formatted
@pytest.mark.asyncio
async def test_safe_execute_success(self, dice_tool):
"""Test safe_execute returns proper success format."""
result = await dice_tool.safe_execute(notation="2d6")
assert "content" in result
assert "isError" in result
assert result["isError"] is False
assert len(result["content"]) == 1
assert result["content"][0]["type"] == "text"
assert "🎲" in result["content"][0]["text"]
@pytest.mark.asyncio
async def test_safe_execute_error(self, dice_tool):
"""Test safe_execute returns proper error format."""
result = await dice_tool.safe_execute(notation="invalid")
assert "content" in result
assert "isError" in result
assert result["isError"] is True
assert len(result["content"]) == 1
assert result["content"][0]["type"] == "text"
assert "Invalid input" in result["content"][0]["text"]
@pytest.mark.asyncio
async def test_roll_dice_case_insensitive(self, dice_tool):
"""Test dice notation is case insensitive."""
result1 = await dice_tool.execute(notation="2D6")
result2 = await dice_tool.execute(notation="2d6")
# Both should work and produce valid results
assert len(result1.values) == 2
assert len(result2.values) == 2
assert all(1 <= v <= 6 for v in result1.values)
assert all(1 <= v <= 6 for v in result2.values)
================================================
FILE: examples/mcp-server-client/tests/test_tools/test_weather.py
================================================
"""Tests for the weather tool."""
from unittest.mock import AsyncMock, patch
import httpx
import pytest
from src.mcp_server.tools.base import (
ExternalServiceError,
ToolError,
)
from src.mcp_server.tools.weather import WeatherTool
from tests.fixtures.mcp_messages import WeatherAPIFixtures
class TestWeatherTool:
"""Test suite for WeatherTool."""
@pytest.fixture
def weather_tool(self):
"""Create a WeatherTool instance for testing."""
return WeatherTool()
@pytest.fixture
def mock_http_client(self):
"""Create a mock HTTP client."""
client = AsyncMock()
return client
def test_parse_location_known_city(self, weather_tool):
"""Test parsing known city names."""
lat, lon = weather_tool.parse_location("San Francisco")
assert lat == 37.7749
assert lon == -122.4194
lat, lon = weather_tool.parse_location("new york")
assert lat == 40.7128
assert lon == -74.0060
def test_parse_location_coordinates(self, weather_tool):
"""Test parsing coordinate strings."""
lat, lon = weather_tool.parse_location("37.7749,-122.4194")
assert lat == 37.7749
assert lon == -122.4194
lat, lon = weather_tool.parse_location("51.5074, -0.1278")
assert lat == 51.5074
assert lon == -0.1278
def test_parse_location_invalid(self, weather_tool):
"""Test parsing invalid locations raises ToolError."""
with pytest.raises(ToolError) as exc_info:
weather_tool.parse_location("Unknown City")
assert "Unknown location" in str(exc_info.value)
def test_parse_location_invalid_coordinates(self, weather_tool):
"""Test parsing invalid coordinates raises ToolError."""
with pytest.raises(ToolError):
weather_tool.parse_location("abc,def")
with pytest.raises(ToolError):
weather_tool.parse_location("200,300") # Out of range
def test_weather_code_to_text(self, weather_tool):
"""Test weather code conversion."""
assert weather_tool.weather_code_to_text(0) == "Clear sky"
assert weather_tool.weather_code_to_text(61) == "Slight rain"
assert weather_tool.weather_code_to_text(95) == "Thunderstorm"
assert "Unknown weather condition" in weather_tool.weather_code_to_text(999)
@pytest.mark.asyncio
async def test_execute_success_known_city(self, weather_tool, mock_http_client):
"""Test successful weather retrieval for known city."""
# Mock the API response
mock_response = WeatherAPIFixtures.current_weather_response(
temperature=18.5, weather_code=2, wind_speed=12.3, humidity=65.0
)
with patch.object(weather_tool, "make_request", return_value=mock_response):
result = await weather_tool.execute(location="San Francisco")
assert result.location == "San Francisco"
assert result.temperature == 18.5
assert result.condition == "Partly cloudy"
assert result.wind_speed == 12.3
assert result.humidity == 65.0
@pytest.mark.asyncio
async def test_execute_success_coordinates(self, weather_tool):
"""Test successful weather retrieval using coordinates."""
mock_response = WeatherAPIFixtures.current_weather_response()
with patch.object(weather_tool, "make_request", return_value=mock_response):
result = await weather_tool.execute(location="37.7749,-122.4194")
assert result.location == "37.7749,-122.4194"
assert result.temperature == 20.0
assert result.condition == "Clear sky"
@pytest.mark.asyncio
async def test_execute_invalid_location(self, weather_tool):
"""Test execution with invalid location."""
with pytest.raises(ToolError) as exc_info:
await weather_tool.execute(location="Unknown City")
assert "Unknown location" in str(exc_info.value)
@pytest.mark.asyncio
async def test_execute_empty_location(self, weather_tool):
"""Test execution with empty location."""
with pytest.raises(ToolError) as exc_info:
await weather_tool.execute(location="")
assert "Missing required parameter" in str(exc_info.value)
@pytest.mark.asyncio
async def test_execute_api_timeout(self, weather_tool):
"""Test handling of API timeout."""
with patch.object(
weather_tool, "make_request", side_effect=httpx.TimeoutException("Timeout")
):
with pytest.raises(ExternalServiceError) as exc_info:
await weather_tool.execute(location="San Francisco")
assert "timeout" in str(exc_info.value).lower()
@pytest.mark.asyncio
async def test_execute_api_http_error(self, weather_tool):
"""Test handling of HTTP errors."""
error = httpx.HTTPStatusError(
"500 Server Error",
request=httpx.Request("GET", "http://test"),
response=httpx.Response(500, text="Server Error"),
)
with patch.object(weather_tool, "make_request", side_effect=error):
with pytest.raises(ExternalServiceError) as exc_info:
await weather_tool.execute(location="San Francisco")
assert "500" in str(exc_info.value)
@pytest.mark.asyncio
async def test_execute_incomplete_data(self, weather_tool):
"""Test handling of incomplete API data."""
incomplete_response = {"current": {"time": "2025-07-07T14:30:00Z"}}
with patch.object(
weather_tool, "make_request", return_value=incomplete_response
):
with pytest.raises(ExternalServiceError) as exc_info:
await weather_tool.execute(location="San Francisco")
assert "Incomplete weather data" in str(exc_info.value)
@pytest.mark.asyncio
async def test_execute_no_current_data(self, weather_tool):
"""Test handling of missing current weather data."""
no_current_response = {"forecast": {}}
with patch.object(
weather_tool, "make_request", return_value=no_current_response
):
with pytest.raises(ExternalServiceError) as exc_info:
await weather_tool.execute(location="San Francisco")
assert "No current weather data" in str(exc_info.value)
def test_format_result(self, weather_tool):
"""Test formatting weather result for display."""
from src.mcp_server.models import WeatherResponse
response = WeatherResponse(
location="San Francisco",
temperature=18.5,
condition="Partly cloudy",
wind_speed=12.3,
humidity=65.0,
timestamp="2025-07-07T14:30:00Z",
)
formatted = weather_tool.format_result(response)
assert "🌤️" in formatted
assert "San Francisco" in formatted
assert "18.5°C" in formatted
assert "Partly cloudy" in formatted
assert "12.3 km/h" in formatted
assert "65.0%" in formatted
def test_format_result_no_humidity(self, weather_tool):
"""Test formatting weather result without humidity."""
from src.mcp_server.models import WeatherResponse
response = WeatherResponse(
location="Test City",
temperature=20.0,
condition="Clear sky",
wind_speed=10.0,
humidity=None,
timestamp=None,
)
formatted = weather_tool.format_result(response)
assert "🌤️" in formatted
assert "Test City" in formatted
assert "20.0°C" in formatted
assert "💧" not in formatted # Humidity should not appear
@pytest.mark.asyncio
async def test_safe_execute_success(self, weather_tool):
"""Test safe_execute returns proper success format."""
mock_response = WeatherAPIFixtures.current_weather_response()
with patch.object(weather_tool, "make_request", return_value=mock_response):
result = await weather_tool.safe_execute(location="San Francisco")
assert "content" in result
assert "isError" in result
assert result["isError"] is False
assert len(result["content"]) == 1
assert result["content"][0]["type"] == "text"
assert "🌤️" in result["content"][0]["text"]
@pytest.mark.asyncio
async def test_safe_execute_error(self, weather_tool):
"""Test safe_execute returns proper error format."""
result = await weather_tool.safe_execute(location="Unknown City")
assert "content" in result
assert "isError" in result
assert result["isError"] is True
assert len(result["content"]) == 1
assert result["content"][0]["type"] == "text"
assert "Unknown location" in result["content"][0]["text"]
@pytest.mark.asyncio
async def test_cleanup(self, weather_tool):
"""Test cleanup method closes HTTP client."""
# Mock the HTTP client
mock_client = AsyncMock()
weather_tool._http_client = mock_client
await weather_tool.cleanup()
mock_client.aclose.assert_called_once()
assert weather_tool._http_client is None
================================================
FILE: src/__init__.py
================================================
"""Defines the application version."""
__version__ = "0.0.2"
================================================
FILE: src/main.py
================================================
"""Contains the entrypoint to the app."""
pass
================================================
FILE: src/py.typed
================================================
# PEP 561 – Distributing and Packaging Type Information
# https://peps.python.org/pep-0561/
================================================
FILE: .claude/settings.local.json
================================================
{
"permissions": {
"allow": [
"Bash(cat:*)",
"Bash(find:*)",
"Bash(git:diff*)",
"Bash(git:log*)",
"Bash(git:status*)",
"Bash(grep:*)",
"Bash(ls:*)",
"Bash(mkdir:*)",
"Bash(source:*)",
"Bash(touch:*)",
"Bash(tree:*)",
"Bash(uv:run*)",
"Edit(AGENTS.md)",
"Edit(docs/**/*.md)",
"Edit(src/**/*.py)",
"Edit(src/**/*.json)",
"Edit(tests/**/*.py)",
"WebFetch(domain:docs.anthropic.com)"
],
"deny": [
"Bash(mv:*)",
"Bash(rm:*)",
"Edit(CLAUDE.md)"
]
}
}
================================================
FILE: .claude/commands/execute-prp.md
================================================
# Execute Product Requirements Prompt (PRP)
Implement a feature using using the PRP file.
## PRP File: $ARGUMENTS
## Execution Process
1. **Load PRP**
- Read the specified PRP file
- Understand all context and requirements
- Follow all instructions in the PRP and extend the research if needed
- Ensure you have all needed context to implement the PRP fully
- Do more web searches and codebase exploration as needed
2. **ULTRATHINK**
- Think hard before you execute the plan. Create a comprehensive plan addressing all requirements.
- Break down complex tasks into smaller, manageable steps using your todos tools.
- Use the TodoWrite tool to create and track your implementation plan.
- Identify implementation patterns from existing code to follow.
3. **Execute the plan**
- Execute the PRP
- Implement all the code
4. **Validate**
- Run each validation command
- Fix any failures
- Re-run until all pass
5. **Complete**
- Ensure all checklist items done
- Run final validation suite
- Report completion status
- Read the PRP again to ensure you have implemented everything
6. **Reference the PRP**
- You can always reference the PRP again if needed
Note: If validation fails, use error patterns in PRP to fix and retry.
================================================
FILE: .claude/commands/generate-prp.md
================================================
# Create Product Requirements Prompt (PRP)
## Feature file: $ARGUMENTS
Generate a complete PRP (Product Requirements Prompt) for general feature implementation with thorough research. Ensure context is passed to the AI agent to enable self-validation and iterative refinement. Read the feature file first to understand what needs to be created, how the examples provided help, and any other considerations.
The AI agent only gets the context you are appending to the PRP and training data. Assume the AI agent has access to the codebase and the same knowledge cutoff as you, so its important that your research findings are included or referenced in the PRP. The Agent has Websearch capabilities, so pass urls to documentation and examples.
- Use `/context/PRPs` as `$base_path`
- Extract only the filename from `$ARGUMENTS` into `$file_name`
## Research Process
1. **Codebase Analysis**
- Search for similar features/patterns in the codebase
- Identify files to reference in PRP
- Note existing conventions to follow
- Check test patterns for validation approach
2. **External Research**
- Search for similar features/patterns online
- Library documentation (include specific URLs)
- Implementation examples (GitHub/StackOverflow/blogs)
- Best practices and common pitfalls
3. **User Clarification** (if needed)
- Specific patterns to mirror and where to find them?
- Integration requirements and where to find them?
## PRP Generation
- Use `${base_path}/templates/prp_base.md` in the base folder as template
### Critical Context to Include and pass to the AI agent as part of the PRP
- **Documentation**: URLs with specific sections
- **Code Examples**: Real snippets from codebase
- **Gotchas**: Library quirks, version issues
- **Patterns**: Existing approaches to follow
### Implementation Blueprint
- Start with pseudocode showing approach
- Reference real files for patterns
- Include error handling strategy
- list tasks to be completed to fullfill the PRP in the order they should be completed
### Validation Gates (Must be Executable) eg for python
```bash
# Syntax/Style
make ruff
make check_types
# Unit Tests
make coverage_all
```
***CRITICAL AFTER YOU ARE DONE RESEARCHING AND EXPLORING THE CODEBASE BEFORE YOU START WRITING THE PRP***
***ULTRATHINK ABOUT THE PRP AND PLAN YOUR APPROACH THEN START WRITING THE PRP***
## Output
- Save the result to `${base_path}/${file_name}`
## Quality Checklist
- [ ] All necessary context included
- [ ] Validation gates are executable by AI
- [ ] References existing patterns
- [ ] Clear implementation path
- [ ] Error handling documented
Score the PRP on a scale of 1-10 (confidence level to succeed in one-pass implementation using claude codes)
Remember: The goal is one-pass implementation success through comprehensive context.
================================================
FILE: .devcontainer/setup_python_claude/devcontainer.json
================================================
{
"name": "setup_python_claude",
"image": "mcr.microsoft.com/vscode/devcontainers/python:3.13",
"features": {
"ghcr.io/devcontainers/features/node:1": {}
},
"customizations": {
"vscode": {
"extensions": [
"anthropic.claude-code"
]
}
},
"postCreateCommand": "make setup_python_claude"
}
================================================
FILE: .github/dependabot.yaml
================================================
---
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
...
================================================
FILE: .github/scripts/create_pr.sh
================================================
#!/bin/bash
# 1 base ref, 2 target ref, 3 title suffix
# 4 current version, 5 bumped
pr_title="PR $2 $3"
pr_body="PR automatically created from \`$1\` to bump from \`$4\` to \`$5\` on \`$2\`. Tag \`v$5\` will be created and has to be deleted manually if PR gets closed without merge."
gh pr create \
--base $1 \
--head $2 \
--title "${pr_title}" \
--body "${pr_body}"
# --label "bump"
================================================
FILE: .github/scripts/delete_branch_pr_tag.sh
================================================
#!/bin/bash
# 1 repo, 2 target ref, 3 current version
tag_to_delete="v$3"
branch_del_api_call="repos/$1/git/refs/heads/$2"
del_msg="'$2' force deletion attempted."
close_msg="Closing PR '$2' to rollback after failure"
echo "Tag $tag_to_delete for $del_msg"
git tag -d "$tag_to_delete"
echo "PR for $del_msg"
gh pr close "$2" --comment "$close_msg"
echo "Branch $del_msg"
gh api "$branch_del_api_call" -X DELETE && \
echo "Branch without error return deleted."
================================================
FILE: .github/workflows/bump-my-version.yaml
================================================
name: bump-my-version
on:
# pull_request:
# types: [closed]
# branches: [main]
workflow_dispatch:
inputs:
bump_type:
description: '[major|minor|patch]'
required: true
default: 'patch'
type: choice
options:
- 'major'
- 'minor'
- 'patch'
env:
BRANCH_NEW: "bump-${{ github.run_number }}-${{ github.ref_name }}"
SKIP_PR_HINT: "[skip ci bump]"
SCRIPT_PATH: ".github/scripts"
jobs:
bump_my_version:
# TODO bug? currently resulting in: Unrecognized named-value: 'env'.
# https://stackoverflow.com/questions/61238849/github-actions-if-contains-function-not-working-with-env-variable/61240761
# if: !contains(
# github.event.pull_request.title,
# ${{ env.SKIP_PR_HINT }}
# )
# TODO check for PR closed by bot to avoid PR creation loop
# github.actor != 'github-actions'
if: >
github.event_name == 'workflow_dispatch' ||
( github.event.pull_request.merged == true &&
github.event.pull_request.closed_by != 'github-actions' )
runs-on: ubuntu-latest
outputs:
branch_new: ${{ steps.create_branch.outputs.branch_new }}
summary_data: ${{ steps.set_summary.outputs.summary_data }}
permissions:
actions: read
checks: write
contents: write
pull-requests: write
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Set git cfg and create branch
id: create_branch
run: |
git config user.email "github-actions@users.noreply.github.com"
git config user.name "github-actions[bot]"
git checkout -b "${{ env.BRANCH_NEW }}"
echo "branch_new=${{ env.BRANCH_NEW }}" >> $GITHUB_OUTPUT
- name: Bump version
id: bump
uses: callowayproject/bump-my-version@0.29.0
env:
BUMPVERSION_TAG: "true"
with:
args: ${{ inputs.bump_type }}
branch: ${{ env.BRANCH_NEW }}
- name: "Create PR '${{ env.BRANCH_NEW }}'"
if: steps.bump.outputs.bumped == 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
src="${{ env.SCRIPT_PATH }}/create_pr.sh"
chmod +x "$src"
$src "${{ github.ref_name }}" "${{ env.BRANCH_NEW }}" "${{ env.SKIP_PR_HINT }}" "${{ steps.bump.outputs.previous-version }}" "${{ steps.bump.outputs.current-version }}"
- name: Delete branch, PR and tag in case of failure or cancel
if: failure() || cancelled()
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
src="${{ env.SCRIPT_PATH }}/delete_branch_pr_tag.sh"
chmod +x "$src"
$src "${{ github.repository }}" "${{ env.BRANCH_NEW }}" "${{ steps.bump.outputs.current-version }}"
- name: Set summary data
id: set_summary
if: ${{ always() }}
run: echo "summary_data=${GITHUB_STEP_SUMMARY}" >> $GITHUB_OUTPUT
generate_summary:
name: Generate Summary Report
if: ${{ always() }}
needs: bump_my_version
uses: ./.github/workflows/summarize-jobs-reusable.yaml
with:
branch_to_summarize: ${{ needs.bump_my_version.outputs.branch_new }}
summary_data: ${{ needs.bump_my_version.outputs.summary_data }}
================================================
FILE: .github/workflows/codeql.yaml
================================================
---
# https://github.blog/changelog/2023-01-18-code-scanning-codeql-action-v1-is-now-deprecated/
name: "CodeQL"
on:
push:
pull_request:
types: [closed]
branches: [ main ]
schedule:
- cron: '27 11 * * 0'
workflow_dispatch:
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: python
- name: Autobuild
uses: github/codeql-action/autobuild@v3
# if autobuild fails
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
#- name: sarif
# uses: github/codeql-action/upload-sarif@v2
...
================================================
FILE: .github/workflows/generate-deploy-mkdocs-ghpages.yaml
================================================
---
name: Deploy Docs
on:
pull_request:
types: [closed]
branches: [main]
workflow_dispatch:
env:
DOCSTRINGS_FILE: "docstrings.md"
DOC_DIR: "docs"
SRC_DIR: "src"
SITE_DIR: "site"
IMG_DIR: "assets/images"
jobs:
build-and-deploy:
runs-on: ubuntu-latest
permissions:
contents: read
pages: write
id-token: write
environment:
name: github-pages
steps:
- name: Checkout the repository
uses: actions/checkout@v4.0.0
with:
ref:
${{
github.event.pull_request.merged == true &&
'main' ||
github.ref_name
}}
fetch-depth: 0
- uses: actions/configure-pages@v5.0.0
# caching instead of actions/cache@v4.0.0
# https://docs.astral.sh/uv/guides/integration/github/#caching
- name: Install uv with cache dependency glob
uses: astral-sh/setup-uv@v5.0.0
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
# setup python from pyproject.toml using uv
# instead of using actions/setup-python@v5.0.0
# https://docs.astral.sh/uv/guides/integration/github/#setting-up-python
- name: "Set up Python"
run: uv python install
- name: Install only doc deps
run: uv sync --only-group docs # --frozen
- name: Get repo info and stream into mkdocs.yaml
id: repo_info
run: |
REPO_INFO=$(curl -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Accept: application/vnd.github.v3+json" \
https://api.github.com/repos/${{ github.repository }})
REPO_URL="${{ github.server_url }}/${{ github.repository }}"
REPO_URL=$(echo ${REPO_URL} | sed 's|/|\\/|g')
SITE_NAME=$(sed '1!d' README.md | sed '0,/# /{s/# //}')
SITE_DESC=$(echo $REPO_INFO | jq -r .description)
sed -i "s//${REPO_URL}/g" mkdocs.yaml
sed -i "s//${SITE_NAME}/g" mkdocs.yaml
sed -i "s//${SITE_DESC}/g" mkdocs.yaml
- name: Copy text files to be included
run: |
CFG_PATH="src/app/config"
mkdir -p "${DOC_DIR}/${CFG_PATH}"
cp README.md "${DOC_DIR}/index.md"
cp CHANGELOG.md LICENSE "${DOC_DIR}"
# Auxiliary files
cp .env.example "${DOC_DIR}"
- name: Generate code docstrings concat file
run: |
PREFIX="::: "
find "${SRC_DIR}" -type f -name "*.py" \
-type f -not -name "__*__*" -printf "%P\n" | \
sed 's/\//./g' | sed 's/\.py$//' | \
sed "s/^/${PREFIX}/" | sort > \
"${DOC_DIR}/${DOCSTRINGS_FILE}"
- name: Build documentation
run: uv run --locked --only-group docs mkdocs build
- name: Copy image files to be included
run: |
# copy images, mkdocs does not by default
# mkdocs also overwrites pre-made directories
dir="${{ env.SITE_DIR }}/${{ env.IMG_DIR }}"
if [ -d "${{ env.IMG_DIR }}" ]; then
mkdir -p "${dir}"
cp "${{ env.IMG_DIR }}"/* "${dir}"
fi
# - name: Push to gh-pages
# run: uv run mkdocs gh-deploy --force
- name: Upload artifact
uses: actions/upload-pages-artifact@v3.0.0
with:
path: "${{ env.SITE_DIR }}"
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4.0.0
...
================================================
FILE: .github/workflows/links-fail-fast.yaml
================================================
---
# https://github.com/lycheeverse/lychee-action
# https://github.com/marketplace/actions/lychee-broken-link-checker
name: "Link Checker"
on:
workflow_dispatch:
push:
branches-ignore: [main]
pull_request:
types: [closed]
branches: [main]
schedule:
- cron: "00 00 * * 0"
jobs:
linkChecker:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- uses: actions/checkout@v4
- name: Link Checker
id: lychee
uses: lycheeverse/lychee-action@v2
- name: Create Issue From File
if: steps.lychee.outputs.exit_code != 0
uses: peter-evans/create-issue-from-file@v5
with:
title: lychee Link Checker Report
content-filepath: ./lychee/out.md
labels: report, automated issue
...
================================================
FILE: .github/workflows/pytest.yaml
================================================
name: pytest
on:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.12'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest
- name: Run tests
run: pytest
================================================
FILE: .github/workflows/ruff.yaml
================================================
---
# https://github.com/astral-sh/ruff-action
# https://github.com/astral-sh/ruff
name: ruff
on:
push:
pull_request:
types: [closed]
branches: [main]
schedule:
- cron: "0 0 * * 0"
workflow_dispatch:
jobs:
ruff:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/ruff-action@v3
...
================================================
FILE: .github/workflows/summarize-jobs-reusable.yaml
================================================
---
# https://ecanarys.com/supercharging-github-actions-with-job-summaries-and-pull-request-comments/
# FIXME currently bug in gha summaries ? $GITHUB_STEP_SUMMARY files are empty
# https://github.com/orgs/community/discussions/110283
# https://github.com/orgs/community/discussions/67991
# Possible workaround
# echo ${{ fromJSON(step).name }}" >> $GITHUB_STEP_SUMMARY
# echo ${{ fromJSON(step).outcome }}" >> $GITHUB_STEP_SUMMARY
# echo ${{ fromJSON(step).conclusion }}"
name: Summarize workflow jobs
on:
workflow_call:
outputs:
summary:
description: "Outputs summaries of jobs in a workflow"
value: ${{ jobs.generate_summary.outputs.summary }}
inputs:
branch_to_summarize:
required: false
default: 'main'
type: string
summary_data:
required: false
type: string
jobs:
generate_summary:
name: Generate Summary
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
checks: read
pull-requests: none
outputs:
summary: ${{ steps.add_changed_files.outputs.summary }}
steps:
- name: Add general information
id: general_info
run: |
echo "# Job Summaries" >> $GITHUB_STEP_SUMMARY
echo "Job: `${{ github.job }}`" >> $GITHUB_STEP_SUMMARY
echo "Date: $(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_STEP_SUMMARY
- name: Add step states
id: step_states
run: |
echo "### Steps:" >> $GITHUB_STEP_SUMMARY
# loop summary_data if valid json
if jq -e . >/dev/null 2>&1 <<< "${{ inputs.summary_data }}"; then
jq -r '
.steps[]
| select(.conclusion != null)
| "- **\(.name)**: \(
if .conclusion == "success" then ":white_check_mark:"
elif .conclusion == "failure" then ":x:"
else ":warning:" end
)"
' <<< "${{ inputs.summary_data }}" >> $GITHUB_STEP_SUMMARY
else
echo "Invalid JSON in summary data." >> $GITHUB_STEP_SUMMARY
fi
- name: Checkout repo
uses: actions/checkout@v4
with:
ref: "${{ inputs.branch_to_summarize }}"
fetch-depth: 0
- name: Add changed files since last push
id: add_changed_files
run: |
# Get the tags
# Use disabled lines to get last two commits
# current=$(git show -s --format=%ci HEAD)
# previous=$(git show -s --format=%ci HEAD~1)
# git diff --name-only HEAD^ HEAD >> $GITHUB_STEP_SUMMARY
version_tag_regex="^v[0-9]+\.[0-9]+\.[0-9]+$" # v0.0.0
tags=$(git tag --sort=-version:refname | \
grep -E "${version_tag_regex}" || echo "")
# Get latest and previous tags
latest_tag=$(echo "${tags}" | head -n 1)
previous_tag=$(echo "${tags}" | head -n 2 | tail -n 1)
echo "tags: latest '${latest_tag}', previous '${previous_tag}'"
# Write to summary
error_msg="No files to output. Tag not found:"
echo ${{ steps.step_states.outputs.summary }} >> $GITHUB_STEP_SUMMARY
echo "## Changed files on '${{ inputs.branch_to_summarize }}'" >> $GITHUB_STEP_SUMMARY
if [ -z "${latest_tag}" ]; then
echo "${error_msg} latest" >> $GITHUB_STEP_SUMMARY
elif [ -z "${previous_tag}" ]; then
echo "${error_msg} previous" >> $GITHUB_STEP_SUMMARY
elif [ "${latest_tag}" == "${previous_tag}" ]; then
echo "Latest and previous tags are the same: '${latest_tag}'" >> $GITHUB_STEP_SUMMARY
else
# Get commit dates and hashes
latest_date=$(git log -1 --format=%ci $latest_tag)
previous_date=$(git log -1 --format=%ci $previous_tag)
current_hash=$(git rev-parse --short $latest_tag)
previous_hash=$(git rev-parse --short $previous_tag)
# Append summary to the job summary
echo "Latest Tag Commit: '${latest_tag}' (${current_hash}) ${latest_date}" >> $GITHUB_STEP_SUMMARY
echo "Previous Tag Commit: '${previous_tag}' (${previous_hash}) ${previous_date}" >> $GITHUB_STEP_SUMMARY
echo "Files changed:" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
git diff --name-only $previous_tag..$latest_tag >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
fi
- name: Output error message in case of failure or cancel
if: failure() || cancelled()
run: |
if [ "${{ job.status }}" == "cancelled" ]; then
out_msg="## Workflow was cancelled"
else
out_msg="## Error in previous step"
fi
echo $out_msg >> $GITHUB_STEP_SUMMARY
...
================================================
FILE: .github/workflows/write-llms-txt.yaml
================================================
# TODO use local installation of repo to text
# https://github.com/itsitgroup/repo2txt
name: Write repo llms.txt
on:
push:
branches: [main]
workflow_dispatch:
inputs:
LLMS_TXT_PATH:
description: 'Path to the directory to save llsm.txt'
required: true
default: 'docs'
type: string
LLMS_TXT_NAME:
description: 'Path to the directory to save llsm.txt'
required: true
default: 'llms.txt'
type: string
CONVERTER_URL:
description: '[uithub|gittodoc]' # |repo2txt
required: true
default: 'uithub.com'
type: choice
options:
- 'uithub.com'
- 'gittodoc.com'
# - 'repo2txt.com'
jobs:
generate-file:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Construct and create llms.txt path
id: construct_and_create_llms_txt_path
run: |
LLMS_TXT_PATH="${{ inputs.LLMS_TXT_PATH }}"
LLMS_TXT_PATH="${LLMS_TXT_PATH:-docs}"
LLMS_TXT_NAME="${{ inputs.LLMS_TXT_NAME }}"
LLMS_TXT_NAME="${LLMS_TXT_NAME:-llms.txt}"
echo "LLMS_TXT_FULL=${LLMS_TXT_PATH}/${LLMS_TXT_NAME}" >> $GITHUB_OUTPUT
mkdir -p "${LLMS_TXT_PATH}"
- name: Fetch TXT from URL
run: |
LLMS_TXT_FULL=${{ steps.construct_and_create_llms_txt_path.outputs.LLMS_TXT_FULL }}
URL="https://${{ inputs.CONVERTER_URL }}/${{ github.repository }}"
echo "Fetching content from: ${URL}"
echo "Saving content to: ${LLMS_TXT_FULL}"
curl -s "${URL}" > "${LLMS_TXT_FULL}"
- name: Commit and push file
run: |
LLMS_TXT_FULL=${{ steps.construct_and_create_llms_txt_path.outputs.LLMS_TXT_FULL }}
commit_msg="feat(docs): Add/Update ${LLMS_TXT_FULL}, a flattened repo as single text file, inspired by [llmstxt.org](https://llmstxt.org/)."
git config user.name "github-actions"
git config user.email "github-actions@github.com"
git add "${LLMS_TXT_FULL}"
git commit -m "${commit_msg}"
git push