Upload 15 files
Browse files- .dockerignore +68 -0
- .env.example +28 -0
- Dockerfile +39 -0
- README.md +636 -0
- app.py +93 -0
- config.py +129 -0
- docs/client_examples.md +597 -0
- logo.png +0 -0
- logo_1024.png +0 -0
- models.py +96 -0
- proportion_server.py +361 -0
- pyproject.toml +63 -0
- requirements.txt +9 -0
- styles.css +148 -0
- tests/test_tools.py +428 -0
.dockerignore
ADDED
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Python
|
2 |
+
__pycache__/
|
3 |
+
*.py[cod]
|
4 |
+
*$py.class
|
5 |
+
*.so
|
6 |
+
.Python
|
7 |
+
build/
|
8 |
+
develop-eggs/
|
9 |
+
dist/
|
10 |
+
downloads/
|
11 |
+
eggs/
|
12 |
+
.eggs/
|
13 |
+
lib/
|
14 |
+
lib64/
|
15 |
+
parts/
|
16 |
+
sdist/
|
17 |
+
var/
|
18 |
+
wheels/
|
19 |
+
*.egg-info/
|
20 |
+
.installed.cfg
|
21 |
+
*.egg
|
22 |
+
|
23 |
+
# Virtual environments
|
24 |
+
.env
|
25 |
+
.venv
|
26 |
+
env/
|
27 |
+
venv/
|
28 |
+
ENV/
|
29 |
+
env.bak/
|
30 |
+
venv.bak/
|
31 |
+
|
32 |
+
# IDE
|
33 |
+
.vscode/
|
34 |
+
.idea/
|
35 |
+
*.swp
|
36 |
+
*.swo
|
37 |
+
*~
|
38 |
+
|
39 |
+
# Testing
|
40 |
+
.pytest_cache/
|
41 |
+
.coverage
|
42 |
+
htmlcov/
|
43 |
+
.tox/
|
44 |
+
.cache
|
45 |
+
nosetests.xml
|
46 |
+
coverage.xml
|
47 |
+
*.cover
|
48 |
+
.hypothesis/
|
49 |
+
|
50 |
+
# Documentation
|
51 |
+
docs/_build/
|
52 |
+
|
53 |
+
# OS
|
54 |
+
.DS_Store
|
55 |
+
.DS_Store?
|
56 |
+
._*
|
57 |
+
.Spotlight-V100
|
58 |
+
.Trashes
|
59 |
+
ehthumbs.db
|
60 |
+
Thumbs.db
|
61 |
+
|
62 |
+
# Git
|
63 |
+
.git/
|
64 |
+
.gitignore
|
65 |
+
|
66 |
+
# Project specific
|
67 |
+
*.md
|
68 |
+
.env.example
|
.env.example
ADDED
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Proportion MCP Server Configuration
|
2 |
+
# Copy this file to .env and set your values
|
3 |
+
|
4 |
+
# Server Configuration
|
5 |
+
SERVER_NAME=0.0.0.0
|
6 |
+
SERVER_PORT=7860
|
7 |
+
|
8 |
+
# Security Configuration (REQUIRED for production)
|
9 |
+
# Generate a secure token: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
10 |
+
MCP_TOKEN=your-secure-token-here
|
11 |
+
|
12 |
+
# Request Limits
|
13 |
+
MAX_REQUEST_SIZE=65536
|
14 |
+
|
15 |
+
# Development Settings
|
16 |
+
DEBUG=false
|
17 |
+
RELOAD=false
|
18 |
+
LOG_LEVEL=INFO
|
19 |
+
|
20 |
+
# Example production configuration:
|
21 |
+
# MCP_TOKEN=abc123xyz789secure_token_here
|
22 |
+
# DEBUG=false
|
23 |
+
# LOG_LEVEL=WARNING
|
24 |
+
|
25 |
+
# Example development configuration:
|
26 |
+
# DEBUG=true
|
27 |
+
# RELOAD=true
|
28 |
+
# LOG_LEVEL=DEBUG
|
Dockerfile
ADDED
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Simple Docker build for MCP server
|
2 |
+
FROM python:3.11-slim
|
3 |
+
|
4 |
+
# Set working directory
|
5 |
+
WORKDIR /app
|
6 |
+
|
7 |
+
# Create non-root user for security
|
8 |
+
RUN groupadd -r appuser && useradd -r -g appuser appuser
|
9 |
+
|
10 |
+
# Copy requirements first for better caching
|
11 |
+
COPY requirements.txt .
|
12 |
+
|
13 |
+
# Install Python dependencies
|
14 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
15 |
+
pip install --no-cache-dir -r requirements.txt
|
16 |
+
|
17 |
+
# Copy application code
|
18 |
+
COPY --chown=appuser:appuser . .
|
19 |
+
|
20 |
+
# Set environment variables
|
21 |
+
ENV PYTHONPATH=/app
|
22 |
+
ENV PYTHONUNBUFFERED=1
|
23 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
24 |
+
|
25 |
+
# Create pytest cache directory with proper permissions
|
26 |
+
RUN mkdir -p /app/.pytest_cache && chown -R appuser:appuser /app/.pytest_cache
|
27 |
+
|
28 |
+
# Security: run as non-root user
|
29 |
+
USER appuser
|
30 |
+
|
31 |
+
# Expose port
|
32 |
+
EXPOSE 7860
|
33 |
+
|
34 |
+
# Add healthcheck
|
35 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
36 |
+
CMD python -c "import requests; requests.get('http://localhost:7860')" || exit 1
|
37 |
+
|
38 |
+
# Run the application with proper signal handling
|
39 |
+
CMD ["python", "-u", "app.py"]
|
README.md
ADDED
@@ -0,0 +1,636 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
---
|
2 |
+
title: Proportio – Precision Proportion Calculator
|
3 |
+
emoji: 🧮
|
4 |
+
colorFrom: red
|
5 |
+
colorTo: gray
|
6 |
+
sdk: gradio
|
7 |
+
app_file: app.py
|
8 |
+
pinned: false
|
9 |
+
license: apache-2.0
|
10 |
+
tags:
|
11 |
+
- mcp-server
|
12 |
+
- proportion-calculator
|
13 |
+
- gradio
|
14 |
+
- python
|
15 |
+
- mathematics
|
16 |
+
- llm-tools
|
17 |
+
---
|
18 |
+
|
19 |
+
<div align="center">
|
20 |
+
<img src="logo_1024.png" alt="Proportio Logo" width="200" height="200">
|
21 |
+
</div>
|
22 |
+
|
23 |
+
[](LICENSE)
|
24 |
+
[](https://python.org)
|
25 |
+
[](Dockerfile)
|
26 |
+
[](tests/)
|
27 |
+
[](https://modelcontextprotocol.io)
|
28 |
+
|
29 |
+
**Professional mathematical calculations for proportions, percentages, and scaling operations with assertion-based validation and MCP server integration.**
|
30 |
+
|
31 |
+
---
|
32 |
+
|
33 |
+
## 🎯 Overview
|
34 |
+
|
35 |
+
Proportio is a specialized mathematical calculation server designed for LLM agents and applications requiring precise proportion calculations. Built with **assertion-based validation** and **zero-tolerance error handling**, it provides reliable mathematical operations through both a web interface and Model Context Protocol (MCP) integration.
|
36 |
+
|
37 |
+
### Key Use Cases
|
38 |
+
|
39 |
+
- **Recipe Scaling**: Scale ingredient quantities for different serving sizes
|
40 |
+
- **Financial Calculations**: Calculate percentages, ratios, and proportional growth
|
41 |
+
- **Engineering**: Resize dimensions, scale measurements, and maintain proportional relationships
|
42 |
+
- **Data Analysis**: Compute percentages, ratios, and proportional transformations
|
43 |
+
- **LLM Integration**: Provide reliable mathematical operations through MCP protocol
|
44 |
+
|
45 |
+
---
|
46 |
+
|
47 |
+
|
48 |
+
https://github.com/user-attachments/assets/96d30b20-1bf0-4b2b-a1ea-d0a5776f547c
|
49 |
+
|
50 |
+
---
|
51 |
+
## ✨ Features
|
52 |
+
|
53 |
+
### 🔢 Mathematical Functions
|
54 |
+
- **Percentage Calculations** - Convert parts to percentages with precision
|
55 |
+
- **Proportion Solving** - Solve missing terms in a/b = c/d relationships
|
56 |
+
- **Ratio Scaling** - Scale values by precise ratios
|
57 |
+
- **Proportionality Constants** - Find k in y = kx relationships
|
58 |
+
- **Dimension Resizing** - Uniform scaling of width/height pairs
|
59 |
+
|
60 |
+
### 🛡️ Validation Architecture
|
61 |
+
- **Assertion-Based Validation** - Explicit mathematical preconditions
|
62 |
+
- **Zero Exception Handling** - No try-catch blocks, fast failure detection
|
63 |
+
- **Precise Error Messages** - Clear, actionable error descriptions
|
64 |
+
- **Type Safety** - Robust input validation and type checking
|
65 |
+
|
66 |
+
### 🌐 Integration Options
|
67 |
+
- **Web Interface** - Professional Gradio-based UI with custom branding
|
68 |
+
- **MCP Server** - Native Model Context Protocol support for LLM agents
|
69 |
+
- **Docker Ready** - Containerized deployment with security best practices
|
70 |
+
- **API Access** - Direct function calls with comprehensive documentation
|
71 |
+
|
72 |
+
### 🎨 Professional Design
|
73 |
+
- **Custom Branding** - Red-black-white theme with geometric logo
|
74 |
+
- **Responsive Layout** - Optimized for desktop and mobile devices
|
75 |
+
- **Split Results** - Clear separation of input/output sections
|
76 |
+
- **Error Handling** - User-friendly error messages and validation
|
77 |
+
|
78 |
+
|
79 |
+
---
|
80 |
+
|
81 |
+
## 📋 Table of Contents
|
82 |
+
|
83 |
+
- [🎯 Overview](#-overview)
|
84 |
+
- [✨ Features](#-features)
|
85 |
+
- [🚀 Quick Start](#-quick-start)
|
86 |
+
- [🔧 Core Functions](#-core-functions)
|
87 |
+
- [🏗️ Architecture](#️-architecture)
|
88 |
+
- [📦 Installation](#-installation)
|
89 |
+
- [🐳 Docker Deployment](#-docker-deployment)
|
90 |
+
- [🧪 Testing](#-testing)
|
91 |
+
- [🔌 MCP Integration](#-mcp-integration)
|
92 |
+
- [📖 API Reference](#-api-reference)
|
93 |
+
- [🛠️ Development](#️-development)
|
94 |
+
- [📝 License](#-license)
|
95 |
+
|
96 |
+
---
|
97 |
+
|
98 |
+
## 🚀 Quick Start
|
99 |
+
|
100 |
+
### Using Docker (Recommended)
|
101 |
+
|
102 |
+
```bash
|
103 |
+
# Clone the repository
|
104 |
+
git clone https://github.com/leksval/proportio.git
|
105 |
+
cd proportio
|
106 |
+
|
107 |
+
# Build and run with Docker
|
108 |
+
docker build -t proportio-server .
|
109 |
+
docker run -p 7860:7860 proportio-server
|
110 |
+
|
111 |
+
# Access the web interface
|
112 |
+
open http://localhost:7860
|
113 |
+
```
|
114 |
+
|
115 |
+
### Local Development
|
116 |
+
|
117 |
+
```bash
|
118 |
+
# Install dependencies
|
119 |
+
pip install -r requirements.txt
|
120 |
+
|
121 |
+
# Run the server
|
122 |
+
python proportion_server.py
|
123 |
+
|
124 |
+
# Access the web interface
|
125 |
+
open http://localhost:7860
|
126 |
+
```
|
127 |
+
|
128 |
+
### Quick Function Examples
|
129 |
+
|
130 |
+
```python
|
131 |
+
from proportion_server import percent_of, solve_proportion, resize_dimensions
|
132 |
+
|
133 |
+
# Calculate percentage
|
134 |
+
result = percent_of(25, 100) # Returns: 25.0
|
135 |
+
|
136 |
+
# Solve proportion: 3/4 = 6/?
|
137 |
+
result = solve_proportion(3, 4, 6, None) # Returns: 8.0
|
138 |
+
|
139 |
+
# Resize dimensions by 2x
|
140 |
+
width, height = resize_dimensions(100, 50, 2.0) # Returns: (200.0, 100.0)
|
141 |
+
```
|
142 |
+
|
143 |
+
---
|
144 |
+
|
145 |
+
## 🔧 Core Functions
|
146 |
+
|
147 |
+
### 1. **`percent_of(part, whole)`**
|
148 |
+
Calculate what percentage the part is of the whole.
|
149 |
+
|
150 |
+
```python
|
151 |
+
percent_of(25, 100) # → 25.0%
|
152 |
+
percent_of(3, 4) # → 75.0%
|
153 |
+
percent_of(150, 100) # → 150.0%
|
154 |
+
```
|
155 |
+
|
156 |
+
**Mathematical Preconditions:**
|
157 |
+
- `whole != 0` (division by zero protection)
|
158 |
+
|
159 |
+
**Real-world Examples:**
|
160 |
+
- Sales conversion rates
|
161 |
+
- Test score percentages
|
162 |
+
- Growth rate calculations
|
163 |
+
|
164 |
+
### 2. **`solve_proportion(a, b, c, d)`**
|
165 |
+
Solve missing term in proportion a/b = c/d (exactly one parameter must be None).
|
166 |
+
|
167 |
+
```python
|
168 |
+
solve_proportion(3, 4, 6, None) # → 8.0 (3/4 = 6/8)
|
169 |
+
solve_proportion(None, 4, 6, 8) # → 3.0 (?/4 = 6/8)
|
170 |
+
solve_proportion(2, None, 6, 9) # → 3.0 (2/? = 6/9)
|
171 |
+
```
|
172 |
+
|
173 |
+
**Mathematical Preconditions:**
|
174 |
+
- Exactly one value must be None (missing)
|
175 |
+
- Division denominators != 0 (varies by missing value)
|
176 |
+
|
177 |
+
**Real-world Examples:**
|
178 |
+
- Recipe scaling (4 servings : 2 cups = 6 servings : ? cups)
|
179 |
+
- Currency exchange rates
|
180 |
+
- Map scale calculations
|
181 |
+
|
182 |
+
### 3. **`scale_by_ratio(value, ratio)`**
|
183 |
+
Scale a value by a given ratio.
|
184 |
+
|
185 |
+
```python
|
186 |
+
scale_by_ratio(100, 1.5) # → 150.0
|
187 |
+
scale_by_ratio(200, 0.5) # → 100.0
|
188 |
+
scale_by_ratio(50, 2.0) # → 100.0
|
189 |
+
```
|
190 |
+
|
191 |
+
**Use Cases:**
|
192 |
+
- Applying discount percentages
|
193 |
+
- Scaling measurements
|
194 |
+
- Financial calculations
|
195 |
+
|
196 |
+
### 4. **`direct_k(x, y)`**
|
197 |
+
Find proportionality constant k in direct variation y = kx.
|
198 |
+
|
199 |
+
```python
|
200 |
+
direct_k(5, 15) # → 3.0 (15 = 3 × 5)
|
201 |
+
direct_k(4, 12) # → 3.0 (12 = 3 × 4)
|
202 |
+
direct_k(2, 7) # → 3.5 (7 = 3.5 × 2)
|
203 |
+
```
|
204 |
+
|
205 |
+
**Mathematical Preconditions:**
|
206 |
+
- `x != 0` (division by zero protection)
|
207 |
+
|
208 |
+
**Applications:**
|
209 |
+
- Physics calculations (force = k × displacement)
|
210 |
+
- Economics (cost = k × quantity)
|
211 |
+
- Engineering (stress = k × strain)
|
212 |
+
|
213 |
+
### 5. **`resize_dimensions(width, height, scale)`**
|
214 |
+
Resize dimensions with uniform scale factor.
|
215 |
+
|
216 |
+
```python
|
217 |
+
resize_dimensions(100, 50, 2.0) # → (200.0, 100.0)
|
218 |
+
resize_dimensions(200, 100, 0.5) # → (100.0, 50.0)
|
219 |
+
resize_dimensions(150, 75, 1.5) # → (225.0, 112.5)
|
220 |
+
```
|
221 |
+
|
222 |
+
**Mathematical Preconditions:**
|
223 |
+
- `width >= 0` (dimensions must be non-negative)
|
224 |
+
- `height >= 0` (dimensions must be non-negative)
|
225 |
+
- `scale > 0` (scale factor must be positive)
|
226 |
+
|
227 |
+
**Applications:**
|
228 |
+
- Image resizing
|
229 |
+
- Screen resolution scaling
|
230 |
+
- Architectural drawings
|
231 |
+
|
232 |
+
---
|
233 |
+
|
234 |
+
## 🏗️ Architecture
|
235 |
+
|
236 |
+
### Assertion-Based Validation
|
237 |
+
|
238 |
+
Proportio uses **assertion-based validation** throughout, providing several key advantages:
|
239 |
+
|
240 |
+
```python
|
241 |
+
def percent_of(part: float, whole: float) -> float:
|
242 |
+
# Mathematical preconditions
|
243 |
+
assert whole != 0, "Division by zero: whole cannot be zero"
|
244 |
+
|
245 |
+
# Direct calculation
|
246 |
+
percentage = (part / whole) * 100
|
247 |
+
return percentage
|
248 |
+
```
|
249 |
+
|
250 |
+
**Benefits:**
|
251 |
+
- **Fast Failure**: Immediate error detection with precise messages
|
252 |
+
- **No Exception Overhead**: Zero try-catch complexity
|
253 |
+
- **Clear Preconditions**: Mathematical requirements explicitly documented
|
254 |
+
- **Predictable Behavior**: Consistent error handling across all functions
|
255 |
+
|
256 |
+
### Project Structure
|
257 |
+
|
258 |
+
```
|
259 |
+
proportio/
|
260 |
+
├── proportion_server.py # Core mathematical functions + Gradio server
|
261 |
+
├── models.py # Pydantic data models (simplified)
|
262 |
+
├── config.py # Configuration and logging setup
|
263 |
+
├── styles.css # Custom branding and responsive design
|
264 |
+
├── tests/
|
265 |
+
│ └── test_tools.py # Comprehensive test suite (58 tests)
|
266 |
+
├── requirements.txt # Minimal dependencies (3 packages)
|
267 |
+
├── Dockerfile # Single-stage containerization
|
268 |
+
└── README.md # This documentation
|
269 |
+
```
|
270 |
+
|
271 |
+
### Dependency Architecture
|
272 |
+
|
273 |
+
**Streamlined Dependencies** (only 3 required):
|
274 |
+
- **`gradio[mcp]>=5.0.0`** - Web framework with MCP server capabilities
|
275 |
+
- **`pydantic>=2.8.0`** - Data validation and parsing
|
276 |
+
- **`pytest>=8.0.0`** - Testing framework
|
277 |
+
|
278 |
+
### Error Handling Philosophy
|
279 |
+
|
280 |
+
**No Try-Catch Blocks** - All validation done through assertions:
|
281 |
+
|
282 |
+
```python
|
283 |
+
# ❌ Old approach (complex exception handling)
|
284 |
+
try:
|
285 |
+
if whole == 0:
|
286 |
+
raise ValueError("Division by zero")
|
287 |
+
result = part / whole
|
288 |
+
except ValueError as e:
|
289 |
+
# Handle error...
|
290 |
+
|
291 |
+
# ✅ New approach (assertion-based)
|
292 |
+
assert whole != 0, "Division by zero: whole cannot be zero"
|
293 |
+
result = part / whole
|
294 |
+
```
|
295 |
+
|
296 |
+
---
|
297 |
+
|
298 |
+
## 📦 Installation
|
299 |
+
|
300 |
+
### System Requirements
|
301 |
+
|
302 |
+
- **Python 3.11+**
|
303 |
+
- **pip** package manager
|
304 |
+
- **Docker** (optional, for containerized deployment)
|
305 |
+
|
306 |
+
### Local Installation
|
307 |
+
|
308 |
+
```bash
|
309 |
+
# Clone repository
|
310 |
+
git clone https://github.com/leksval/proportio.git
|
311 |
+
cd proportio
|
312 |
+
|
313 |
+
# Create virtual environment (recommended)
|
314 |
+
python -m venv venv
|
315 |
+
source venv/bin/activate # On Windows: venv\Scripts\activate
|
316 |
+
|
317 |
+
# Install dependencies
|
318 |
+
pip install -r requirements.txt
|
319 |
+
|
320 |
+
# Verify installation
|
321 |
+
python -c "from proportion_server import percent_of; print(percent_of(25, 100))"
|
322 |
+
```
|
323 |
+
|
324 |
+
### Development Installation
|
325 |
+
|
326 |
+
```bash
|
327 |
+
# Install with development dependencies
|
328 |
+
pip install -r requirements.txt
|
329 |
+
|
330 |
+
# Run tests to verify setup
|
331 |
+
python -m pytest tests/test_tools.py -v
|
332 |
+
|
333 |
+
# Start development server
|
334 |
+
python proportion_server.py
|
335 |
+
```
|
336 |
+
|
337 |
+
---
|
338 |
+
|
339 |
+
## 🐳 Docker Deployment
|
340 |
+
|
341 |
+
### Building the Container
|
342 |
+
|
343 |
+
```bash
|
344 |
+
# Build image
|
345 |
+
docker build -t proportio-server .
|
346 |
+
|
347 |
+
# Run container
|
348 |
+
docker run -p 7860:7860 proportio-server
|
349 |
+
|
350 |
+
# Run with custom configuration
|
351 |
+
docker run -p 8080:7860 -e PORT=7860 proportio-server
|
352 |
+
```
|
353 |
+
|
354 |
+
### Container Features
|
355 |
+
|
356 |
+
- **Security**: Non-root user execution
|
357 |
+
- **Optimization**: Single-stage build for minimal image size
|
358 |
+
- **Flexibility**: Configurable port and environment settings
|
359 |
+
- **Health**: Automatic process management
|
360 |
+
|
361 |
+
### Production Deployment
|
362 |
+
|
363 |
+
```bash
|
364 |
+
# Run detached with restart policy
|
365 |
+
docker run -d \
|
366 |
+
--name proportio \
|
367 |
+
--restart unless-stopped \
|
368 |
+
-p 7860:7860 \
|
369 |
+
proportio-server
|
370 |
+
|
371 |
+
# View logs
|
372 |
+
docker logs proportio
|
373 |
+
|
374 |
+
# Stop container
|
375 |
+
docker stop proportio
|
376 |
+
```
|
377 |
+
|
378 |
+
---
|
379 |
+
|
380 |
+
## 🧪 Testing
|
381 |
+
|
382 |
+
### Test Suite Coverage
|
383 |
+
|
384 |
+
**58 comprehensive tests** covering:
|
385 |
+
- ✅ Basic functionality for all 5 core functions
|
386 |
+
- ✅ Edge cases and boundary conditions
|
387 |
+
- ✅ Error handling and assertion validation
|
388 |
+
- ✅ Integration workflows and chained calculations
|
389 |
+
- ✅ Floating-point precision and mathematical accuracy
|
390 |
+
- ✅ Type validation and input sanitization
|
391 |
+
|
392 |
+
### Running Tests
|
393 |
+
|
394 |
+
```bash
|
395 |
+
# Run all tests
|
396 |
+
python -m pytest tests/test_tools.py -v
|
397 |
+
|
398 |
+
# Run specific test class
|
399 |
+
python -m pytest tests/test_tools.py::TestPercentOf -v
|
400 |
+
|
401 |
+
# Run with coverage (if pytest-cov installed)
|
402 |
+
python -m pytest tests/test_tools.py --cov=proportion_server
|
403 |
+
|
404 |
+
# Run tests in Docker
|
405 |
+
docker run --rm proportio-server python -m pytest tests/test_tools.py -v
|
406 |
+
```
|
407 |
+
|
408 |
+
### Test Categories
|
409 |
+
|
410 |
+
#### **Unit Tests**
|
411 |
+
- Individual function validation
|
412 |
+
- Mathematical accuracy verification
|
413 |
+
- Error condition testing
|
414 |
+
|
415 |
+
#### **Integration Tests**
|
416 |
+
- Chained calculation workflows
|
417 |
+
- Real-world scenario testing
|
418 |
+
- Cross-function compatibility
|
419 |
+
|
420 |
+
#### **Edge Case Tests**
|
421 |
+
- Floating-point precision limits
|
422 |
+
- Very large and very small numbers
|
423 |
+
- Boundary condition validation
|
424 |
+
|
425 |
+
### Sample Test Output
|
426 |
+
|
427 |
+
```
|
428 |
+
==================== test session starts ====================
|
429 |
+
collected 58 items
|
430 |
+
|
431 |
+
tests/test_tools.py::TestPercentOf::test_basic_percentage PASSED
|
432 |
+
tests/test_tools.py::TestPercentOf::test_zero_part PASSED
|
433 |
+
tests/test_tools.py::TestPercentOf::test_negative_values PASSED
|
434 |
+
...
|
435 |
+
tests/test_tools.py::TestIntegration::test_real_world_recipe_scaling PASSED
|
436 |
+
tests/test_tools.py::TestIntegration::test_financial_calculation_workflow PASSED
|
437 |
+
|
438 |
+
==================== 58 passed in 0.45s ====================
|
439 |
+
```
|
440 |
+
|
441 |
+
---
|
442 |
+
|
443 |
+
## 🔌 MCP Integration
|
444 |
+
|
445 |
+
### Model Context Protocol Support
|
446 |
+
|
447 |
+
Proportio provides native **MCP server capabilities** for seamless LLM integration:
|
448 |
+
|
449 |
+
```python
|
450 |
+
# Launch with MCP support
|
451 |
+
demo.launch(
|
452 |
+
server_name="0.0.0.0",
|
453 |
+
server_port=7860,
|
454 |
+
mcp_server=True, # Enable MCP functionality
|
455 |
+
show_error=True
|
456 |
+
)
|
457 |
+
```
|
458 |
+
|
459 |
+
### Using with LLM Agents
|
460 |
+
|
461 |
+
The MCP server exposes all mathematical functions as tools that LLMs can call directly:
|
462 |
+
|
463 |
+
**Available MCP Tools:**
|
464 |
+
- `percent_of` - Calculate percentage relationships
|
465 |
+
- `solve_proportion` - Solve missing proportion terms
|
466 |
+
- `scale_by_ratio` - Apply scaling ratios
|
467 |
+
- `direct_k` - Find proportionality constants
|
468 |
+
- `resize_dimensions` - Scale dimensional pairs
|
469 |
+
|
470 |
+
### MCP Connection Example
|
471 |
+
|
472 |
+
```json
|
473 |
+
{
|
474 |
+
"name": "proportio",
|
475 |
+
"type": "sse",
|
476 |
+
"url": "http://localhost:7860/mcp"
|
477 |
+
}
|
478 |
+
```
|
479 |
+
|
480 |
+
### Integration Benefits
|
481 |
+
|
482 |
+
- **Reliable Math**: LLMs can delegate complex calculations
|
483 |
+
- **Error Handling**: Clear error messages for invalid inputs
|
484 |
+
- **Type Safety**: Automatic input validation and conversion
|
485 |
+
- **Performance**: Fast, direct mathematical operations
|
486 |
+
|
487 |
+
---
|
488 |
+
|
489 |
+
## 📖 API Reference
|
490 |
+
|
491 |
+
### Function Signatures
|
492 |
+
|
493 |
+
```python
|
494 |
+
def percent_of(part: float, whole: float) -> float:
|
495 |
+
"""Calculate percentage that part is of whole."""
|
496 |
+
|
497 |
+
def solve_proportion(
|
498 |
+
a: Optional[float] = None,
|
499 |
+
b: Optional[float] = None,
|
500 |
+
c: Optional[float] = None,
|
501 |
+
d: Optional[float] = None
|
502 |
+
) -> float:
|
503 |
+
"""Solve missing term in proportion a/b = c/d."""
|
504 |
+
|
505 |
+
def scale_by_ratio(value: float, ratio: float) -> float:
|
506 |
+
"""Scale value by given ratio."""
|
507 |
+
|
508 |
+
def direct_k(x: float, y: float) -> float:
|
509 |
+
"""Find proportionality constant k where y = kx."""
|
510 |
+
|
511 |
+
def resize_dimensions(width: float, height: float, scale: float) -> Tuple[float, float]:
|
512 |
+
"""Resize dimensions with uniform scale factor."""
|
513 |
+
```
|
514 |
+
|
515 |
+
### Error Messages
|
516 |
+
|
517 |
+
All functions provide clear, actionable error messages:
|
518 |
+
|
519 |
+
```python
|
520 |
+
# Division by zero errors
|
521 |
+
"Division by zero: whole cannot be zero"
|
522 |
+
"Division by zero: x cannot be zero"
|
523 |
+
"Division by zero: denominator"
|
524 |
+
|
525 |
+
# Validation errors
|
526 |
+
"Exactly one value must be None"
|
527 |
+
"Width must be non-negative"
|
528 |
+
"Height must be non-negative"
|
529 |
+
"Scale factor must be positive"
|
530 |
+
```
|
531 |
+
|
532 |
+
### Return Types
|
533 |
+
|
534 |
+
- **Single Values**: `float` for mathematical results
|
535 |
+
- **Dimension Pairs**: `Tuple[float, float]` for width/height
|
536 |
+
- **Errors**: `AssertionError` with descriptive messages
|
537 |
+
|
538 |
+
---
|
539 |
+
|
540 |
+
## 🛠️ Development
|
541 |
+
|
542 |
+
### Project Philosophy
|
543 |
+
|
544 |
+
1. **Assertion-Based Validation** - No try-catch complexity
|
545 |
+
2. **Mathematical Precision** - Accurate calculations with clear preconditions
|
546 |
+
3. **Minimal Dependencies** - Only essential packages
|
547 |
+
4. **Comprehensive Testing** - High test coverage with edge cases
|
548 |
+
5. **Professional Design** - Clean, branded user interface
|
549 |
+
|
550 |
+
### Code Style
|
551 |
+
|
552 |
+
```python
|
553 |
+
# Clear function signatures with type hints
|
554 |
+
def function_name(param: Type) -> ReturnType:
|
555 |
+
"""
|
556 |
+
Brief description.
|
557 |
+
|
558 |
+
Args:
|
559 |
+
param: Parameter description
|
560 |
+
|
561 |
+
Returns:
|
562 |
+
Return value description
|
563 |
+
|
564 |
+
Mathematical preconditions:
|
565 |
+
- Explicit constraint documentation
|
566 |
+
"""
|
567 |
+
# Assertion-based validation
|
568 |
+
assert condition, "Clear error message"
|
569 |
+
|
570 |
+
# Direct calculation
|
571 |
+
result = calculation
|
572 |
+
|
573 |
+
# Optional logging
|
574 |
+
logger.debug(f"Operation completed: {result}")
|
575 |
+
|
576 |
+
return result
|
577 |
+
```
|
578 |
+
|
579 |
+
### Adding New Functions
|
580 |
+
|
581 |
+
1. **Implement Core Logic** - Add function to `proportion_server.py`
|
582 |
+
2. **Add Mathematical Preconditions** - Document constraints explicitly
|
583 |
+
3. **Create Demo Function** - Add Gradio interface wrapper
|
584 |
+
4. **Write Comprehensive Tests** - Cover all edge cases
|
585 |
+
5. **Update Documentation** - Add examples and use cases
|
586 |
+
|
587 |
+
### Contributing Guidelines
|
588 |
+
|
589 |
+
1. **Fork the Repository** - Create your feature branch
|
590 |
+
2. **Follow Code Style** - Use assertion-based validation
|
591 |
+
3. **Add Tests** - Ensure comprehensive test coverage
|
592 |
+
4. **Update Documentation** - Keep README current
|
593 |
+
5. **Submit Pull Request** - Include description of changes
|
594 |
+
|
595 |
+
---
|
596 |
+
|
597 |
+
## 📝 License
|
598 |
+
|
599 |
+
This project is licensed under the **Apache License 2.0** - see the [LICENSE](LICENSE) file for details.
|
600 |
+
|
601 |
+
### Key License Points
|
602 |
+
|
603 |
+
- ✅ **Commercial Use** - Use in commercial applications
|
604 |
+
- ✅ **Modification** - Modify and distribute changes
|
605 |
+
- ✅ **Distribution** - Distribute original or modified versions
|
606 |
+
- ✅ **Patent Use** - Grant of patent rights from contributors
|
607 |
+
- ⚠️ **Attribution** - Must include license and copyright notice
|
608 |
+
- ⚠️ **State Changes** - Must document modifications
|
609 |
+
|
610 |
+
---
|
611 |
+
|
612 |
+
## 🤝 Support
|
613 |
+
|
614 |
+
### Getting Help
|
615 |
+
|
616 |
+
- **Issues**: [GitHub Issues](https://github.com/leksval/proportio/issues)
|
617 |
+
- **Documentation**: This README and inline code documentation
|
618 |
+
- **Examples**: See `tests/test_tools.py` for usage examples
|
619 |
+
|
620 |
+
### Contributing
|
621 |
+
|
622 |
+
We welcome contributions! Please see the [Development](#️-development) section for guidelines.
|
623 |
+
|
624 |
+
### Reporting Bugs
|
625 |
+
|
626 |
+
When reporting bugs, please include:
|
627 |
+
1. **Environment Details** (Python version, OS, Docker version)
|
628 |
+
2. **Reproduction Steps** (minimal code example)
|
629 |
+
3. **Expected vs Actual Behavior**
|
630 |
+
4. **Error Messages** (full stack trace if applicable)
|
631 |
+
|
632 |
+
---
|
633 |
+
|
634 |
+
**Built with ❤️ for mathematical precision and LLM integration**
|
635 |
+
|
636 |
+
*Proportio - Where Mathematics Meets Reliability*
|
app.py
ADDED
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
Main application entry point for Proportion MCP Server.
|
3 |
+
Handles both local development and cloud deployment.
|
4 |
+
"""
|
5 |
+
|
6 |
+
import logging
|
7 |
+
import os
|
8 |
+
import signal
|
9 |
+
import sys
|
10 |
+
|
11 |
+
from proportion_server import create_gradio_app
|
12 |
+
from config import get_settings, setup_logging, is_huggingface_space
|
13 |
+
|
14 |
+
# Initialize logging and settings
|
15 |
+
setup_logging()
|
16 |
+
logger = logging.getLogger(__name__)
|
17 |
+
settings = get_settings()
|
18 |
+
|
19 |
+
|
20 |
+
def signal_handler(signum, frame):
|
21 |
+
"""Handle shutdown signals gracefully."""
|
22 |
+
logger.info(f"📡 Received signal {signum}, shutting down gracefully...")
|
23 |
+
sys.exit(0)
|
24 |
+
|
25 |
+
|
26 |
+
def main() -> None:
|
27 |
+
"""Main application entry point."""
|
28 |
+
|
29 |
+
# Set up signal handlers for graceful shutdown
|
30 |
+
signal.signal(signal.SIGINT, signal_handler)
|
31 |
+
signal.signal(signal.SIGTERM, signal_handler)
|
32 |
+
|
33 |
+
logger.info("🚀 Starting Proportion MCP Server")
|
34 |
+
logger.info(f"Settings: {settings.model_dump_json(indent=2)}")
|
35 |
+
|
36 |
+
# Create Gradio app
|
37 |
+
demo = create_gradio_app()
|
38 |
+
assert demo is not None, "Failed to create Gradio application"
|
39 |
+
logger.info("✅ Gradio application created successfully")
|
40 |
+
|
41 |
+
# Launch configuration
|
42 |
+
launch_kwargs = {
|
43 |
+
"server_name": settings.server_name,
|
44 |
+
"server_port": settings.server_port,
|
45 |
+
"mcp_server": True, # Enable MCP server functionality
|
46 |
+
"show_error": settings.debug,
|
47 |
+
"quiet": not settings.debug,
|
48 |
+
"prevent_thread_lock": False, # Keep main thread alive
|
49 |
+
}
|
50 |
+
|
51 |
+
# Development-specific settings
|
52 |
+
if not settings.is_production():
|
53 |
+
launch_kwargs.update({
|
54 |
+
"debug": settings.debug,
|
55 |
+
})
|
56 |
+
|
57 |
+
# Hugging Face Spaces specific settings
|
58 |
+
if is_huggingface_space():
|
59 |
+
launch_kwargs.update({
|
60 |
+
"server_name": "0.0.0.0", # Required for HF Spaces
|
61 |
+
"share": False, # Don't create gradio.live links on HF
|
62 |
+
"quiet": True, # Reduce logging noise
|
63 |
+
})
|
64 |
+
logger.info("🤗 Configured for Hugging Face Spaces deployment")
|
65 |
+
|
66 |
+
# Log the MCP endpoint information
|
67 |
+
if is_huggingface_space():
|
68 |
+
space_url = os.environ.get("SPACE_URL", "https://your-space.hf.space")
|
69 |
+
logger.info(f"🔗 MCP Server will be available at: {space_url}")
|
70 |
+
else:
|
71 |
+
logger.info(f"🔗 MCP Server will be available at: http://{settings.server_name}:{settings.server_port}")
|
72 |
+
|
73 |
+
logger.info("🔧 MCP Tools exposed: percent_of, solve_proportion, scale_by_ratio, direct_k, resize_dimensions")
|
74 |
+
logger.info("📖 Functions with proper type hints and docstrings are automatically exposed as MCP tools")
|
75 |
+
|
76 |
+
# Launch the server
|
77 |
+
logger.info("🌐 Starting server...")
|
78 |
+
demo.launch(**launch_kwargs)
|
79 |
+
|
80 |
+
# Keep the main thread alive
|
81 |
+
logger.info("✅ Server is running, press Ctrl+C to stop")
|
82 |
+
if hasattr(signal, 'pause'):
|
83 |
+
# Unix systems
|
84 |
+
signal.pause()
|
85 |
+
else:
|
86 |
+
# Windows systems
|
87 |
+
import time
|
88 |
+
while True:
|
89 |
+
time.sleep(1)
|
90 |
+
|
91 |
+
|
92 |
+
if __name__ == "__main__":
|
93 |
+
main()
|
config.py
ADDED
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
Configuration management using Pydantic Settings.
|
3 |
+
Handles environment variables with validation and defaults.
|
4 |
+
"""
|
5 |
+
|
6 |
+
import os
|
7 |
+
from typing import Optional
|
8 |
+
from pydantic import Field
|
9 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
10 |
+
|
11 |
+
|
12 |
+
class Settings(BaseSettings):
|
13 |
+
"""Application settings with environment variable support."""
|
14 |
+
|
15 |
+
model_config = SettingsConfigDict(
|
16 |
+
env_file=".env",
|
17 |
+
env_file_encoding="utf-8",
|
18 |
+
case_sensitive=False,
|
19 |
+
extra="ignore"
|
20 |
+
)
|
21 |
+
|
22 |
+
# Server configuration
|
23 |
+
server_name: str = Field(
|
24 |
+
default="0.0.0.0",
|
25 |
+
description="Server bind address"
|
26 |
+
)
|
27 |
+
|
28 |
+
server_port: int = Field(
|
29 |
+
default=7860,
|
30 |
+
ge=1024,
|
31 |
+
le=65535,
|
32 |
+
description="Server port (1024-65535)"
|
33 |
+
)
|
34 |
+
|
35 |
+
# Security configuration
|
36 |
+
mcp_token: Optional[str] = Field(
|
37 |
+
default=None,
|
38 |
+
description="MCP authentication token (required for production)"
|
39 |
+
)
|
40 |
+
|
41 |
+
# Request limits
|
42 |
+
max_request_size: int = Field(
|
43 |
+
default=65536, # 64KB
|
44 |
+
ge=1024,
|
45 |
+
le=1048576, # 1MB max
|
46 |
+
description="Maximum request body size in bytes"
|
47 |
+
)
|
48 |
+
|
49 |
+
# Development settings
|
50 |
+
debug: bool = Field(
|
51 |
+
default=False,
|
52 |
+
description="Enable debug mode"
|
53 |
+
)
|
54 |
+
|
55 |
+
reload: bool = Field(
|
56 |
+
default=False,
|
57 |
+
description="Enable hot reload in development"
|
58 |
+
)
|
59 |
+
|
60 |
+
# Logging configuration
|
61 |
+
log_level: str = Field(
|
62 |
+
default="INFO",
|
63 |
+
description="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)"
|
64 |
+
)
|
65 |
+
|
66 |
+
def is_production(self) -> bool:
|
67 |
+
"""Check if running in production mode."""
|
68 |
+
return not self.debug and self.mcp_token is not None
|
69 |
+
|
70 |
+
def validate_production_settings(self) -> None:
|
71 |
+
"""Validate settings for production deployment."""
|
72 |
+
if self.is_production():
|
73 |
+
assert self.mcp_token, "MCP_TOKEN environment variable is required for production deployment"
|
74 |
+
|
75 |
+
|
76 |
+
# Global settings instance
|
77 |
+
settings = Settings()
|
78 |
+
|
79 |
+
|
80 |
+
def get_settings() -> Settings:
|
81 |
+
"""Get the current settings instance."""
|
82 |
+
return settings
|
83 |
+
|
84 |
+
|
85 |
+
def setup_logging() -> None:
|
86 |
+
"""Configure logging based on settings."""
|
87 |
+
import logging
|
88 |
+
|
89 |
+
logging.basicConfig(
|
90 |
+
level=getattr(logging, settings.log_level.upper()),
|
91 |
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
92 |
+
datefmt="%Y-%m-%d %H:%M:%S"
|
93 |
+
)
|
94 |
+
|
95 |
+
# Set Gradio logging level
|
96 |
+
if not settings.debug:
|
97 |
+
logging.getLogger("gradio").setLevel(logging.WARNING)
|
98 |
+
logging.getLogger("uvicorn").setLevel(logging.WARNING)
|
99 |
+
|
100 |
+
|
101 |
+
# Environment detection helpers
|
102 |
+
def is_huggingface_space() -> bool:
|
103 |
+
"""Check if running on Hugging Face Spaces."""
|
104 |
+
return "SPACE_ID" in os.environ
|
105 |
+
|
106 |
+
|
107 |
+
def is_docker() -> bool:
|
108 |
+
"""Check if running in Docker container."""
|
109 |
+
return os.path.exists("/.dockerenv") or "DOCKER_CONTAINER" in os.environ
|
110 |
+
|
111 |
+
|
112 |
+
def get_deployment_info() -> dict[str, str]:
|
113 |
+
"""Get deployment environment information."""
|
114 |
+
info = {
|
115 |
+
"environment": "unknown",
|
116 |
+
"platform": "local"
|
117 |
+
}
|
118 |
+
|
119 |
+
if is_huggingface_space():
|
120 |
+
info["platform"] = "huggingface_spaces"
|
121 |
+
info["environment"] = "production"
|
122 |
+
elif is_docker():
|
123 |
+
info["platform"] = "docker"
|
124 |
+
info["environment"] = "production" if settings.is_production() else "development"
|
125 |
+
else:
|
126 |
+
info["platform"] = "local"
|
127 |
+
info["environment"] = "development"
|
128 |
+
|
129 |
+
return info
|
docs/client_examples.md
ADDED
@@ -0,0 +1,597 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# MCP Client Configuration Examples
|
2 |
+
|
3 |
+
This document provides configuration examples for various MCP clients to connect to the Proportio server.
|
4 |
+
|
5 |
+
## Cursor IDE
|
6 |
+
|
7 |
+
### Method 1: Settings JSON
|
8 |
+
|
9 |
+
Add to your Cursor settings (`Cmd/Ctrl + ,` → Open Settings JSON):
|
10 |
+
|
11 |
+
```json
|
12 |
+
{
|
13 |
+
"mcpServers": {
|
14 |
+
"proportio": {
|
15 |
+
"url": "http://localhost:7860/gradio_api/mcp/sse",
|
16 |
+
"headers": {
|
17 |
+
"X-Api-Key": "your-secure-token-here"
|
18 |
+
}
|
19 |
+
}
|
20 |
+
}
|
21 |
+
}
|
22 |
+
```
|
23 |
+
|
24 |
+
### Method 2: Workspace Configuration
|
25 |
+
|
26 |
+
Create `.cursor/mcp.json` in your project root:
|
27 |
+
|
28 |
+
```json
|
29 |
+
{
|
30 |
+
"proportio": {
|
31 |
+
"url": "http://localhost:7860/gradio_api/mcp/sse",
|
32 |
+
"headers": {
|
33 |
+
"X-Api-Key": "your-secure-token-here"
|
34 |
+
}
|
35 |
+
}
|
36 |
+
}
|
37 |
+
```
|
38 |
+
|
39 |
+
## Claude Desktop
|
40 |
+
|
41 |
+
### Configuration File Location
|
42 |
+
|
43 |
+
- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
44 |
+
- **Windows**: `%APPDATA%/Claude/claude_desktop_config.json`
|
45 |
+
|
46 |
+
### Local Server Configuration
|
47 |
+
|
48 |
+
```json
|
49 |
+
{
|
50 |
+
"mcpServers": {
|
51 |
+
"proportio": {
|
52 |
+
"command": "python",
|
53 |
+
"args": ["/path/to/proportio/app.py"],
|
54 |
+
"env": {
|
55 |
+
"MCP_TOKEN": "your-secure-token-here"
|
56 |
+
}
|
57 |
+
}
|
58 |
+
}
|
59 |
+
}
|
60 |
+
```
|
61 |
+
|
62 |
+
### Remote Server Configuration
|
63 |
+
|
64 |
+
```json
|
65 |
+
{
|
66 |
+
"mcpServers": {
|
67 |
+
"proportio": {
|
68 |
+
"url": "https://your-server.com/gradio_api/mcp/sse",
|
69 |
+
"headers": {
|
70 |
+
"X-Api-Key": "your-secure-token-here"
|
71 |
+
}
|
72 |
+
}
|
73 |
+
}
|
74 |
+
}
|
75 |
+
```
|
76 |
+
|
77 |
+
## CrewAI
|
78 |
+
|
79 |
+
### Basic Setup
|
80 |
+
|
81 |
+
```python
|
82 |
+
from crewai import Agent, Task, Crew
|
83 |
+
from crewai.tools import MCPTool
|
84 |
+
|
85 |
+
# Configure the MCP tool
|
86 |
+
proportio_tool = MCPTool(
|
87 |
+
name="proportio_calculator",
|
88 |
+
url="http://localhost:7860/gradio_api/mcp/sse",
|
89 |
+
headers={"X-Api-Key": "your-secure-token-here"}
|
90 |
+
)
|
91 |
+
|
92 |
+
# Create an agent with the tool
|
93 |
+
math_agent = Agent(
|
94 |
+
role="Mathematical Calculator",
|
95 |
+
goal="Perform accurate proportion and percentage calculations",
|
96 |
+
backstory="You are an expert mathematician who uses reliable tools for calculations.",
|
97 |
+
tools=[proportio_tool],
|
98 |
+
verbose=True
|
99 |
+
)
|
100 |
+
|
101 |
+
# Example task
|
102 |
+
calculation_task = Task(
|
103 |
+
description="Calculate what percentage 75 is of 300, then scale that result by 1.5",
|
104 |
+
agent=math_agent,
|
105 |
+
expected_output="The percentage and scaled result with explanations"
|
106 |
+
)
|
107 |
+
|
108 |
+
# Run the crew
|
109 |
+
crew = Crew(
|
110 |
+
agents=[math_agent],
|
111 |
+
tasks=[calculation_task]
|
112 |
+
)
|
113 |
+
|
114 |
+
result = crew.kickoff()
|
115 |
+
print(result)
|
116 |
+
```
|
117 |
+
|
118 |
+
### Advanced CrewAI Setup with Multiple Tools
|
119 |
+
|
120 |
+
```python
|
121 |
+
from crewai import Agent, Task, Crew
|
122 |
+
from crewai.tools import MCPTool
|
123 |
+
|
124 |
+
class ProportioTools:
|
125 |
+
def __init__(self, base_url="http://localhost:7860", api_key="your-token"):
|
126 |
+
self.base_url = f"{base_url}/gradio_api/mcp/sse"
|
127 |
+
self.headers = {"X-Api-Key": api_key}
|
128 |
+
|
129 |
+
def get_tools(self):
|
130 |
+
return [
|
131 |
+
MCPTool(
|
132 |
+
name="percent_calculator",
|
133 |
+
url=self.base_url,
|
134 |
+
headers=self.headers,
|
135 |
+
description="Calculate percentages accurately"
|
136 |
+
),
|
137 |
+
MCPTool(
|
138 |
+
name="proportion_solver",
|
139 |
+
url=self.base_url,
|
140 |
+
headers=self.headers,
|
141 |
+
description="Solve proportion equations"
|
142 |
+
),
|
143 |
+
MCPTool(
|
144 |
+
name="dimension_resizer",
|
145 |
+
url=self.base_url,
|
146 |
+
headers=self.headers,
|
147 |
+
description="Resize dimensions with scaling"
|
148 |
+
)
|
149 |
+
]
|
150 |
+
|
151 |
+
# Usage
|
152 |
+
proportio = ProportioTools(api_key="your-secure-token-here")
|
153 |
+
tools = proportio.get_tools()
|
154 |
+
|
155 |
+
agent = Agent(
|
156 |
+
role="Design Calculator",
|
157 |
+
goal="Help with design calculations and proportions",
|
158 |
+
tools=tools
|
159 |
+
)
|
160 |
+
```
|
161 |
+
|
162 |
+
## OpenAI Agents SDK
|
163 |
+
|
164 |
+
### Basic Configuration
|
165 |
+
|
166 |
+
```python
|
167 |
+
from openai_agents import Agent
|
168 |
+
from openai_agents.mcp import MCPConnection
|
169 |
+
|
170 |
+
# Setup MCP connection
|
171 |
+
mcp_conn = MCPConnection(
|
172 |
+
url="http://localhost:7860/gradio_api/mcp/sse",
|
173 |
+
headers={"X-Api-Key": "your-secure-token-here"}
|
174 |
+
)
|
175 |
+
|
176 |
+
# Create agent with MCP tools
|
177 |
+
agent = Agent(
|
178 |
+
name="Math Assistant",
|
179 |
+
model="gpt-4",
|
180 |
+
mcp_connections=[mcp_conn]
|
181 |
+
)
|
182 |
+
|
183 |
+
# Use the agent
|
184 |
+
response = agent.run("What percentage is 45 out of 180?")
|
185 |
+
print(response)
|
186 |
+
```
|
187 |
+
|
188 |
+
## Generic HTTP Client (for testing)
|
189 |
+
|
190 |
+
### Using curl
|
191 |
+
|
192 |
+
```bash
|
193 |
+
# Test percent_of tool
|
194 |
+
curl -X POST http://localhost:7860/gradio_api/mcp/sse \
|
195 |
+
-H "X-Api-Key: your-secure-token-here" \
|
196 |
+
-H "Content-Type: application/json" \
|
197 |
+
-d '{
|
198 |
+
"jsonrpc": "2.0",
|
199 |
+
"id": 1,
|
200 |
+
"method": "tools/call",
|
201 |
+
"params": {
|
202 |
+
"name": "percent_of",
|
203 |
+
"arguments": {
|
204 |
+
"part": 25,
|
205 |
+
"whole": 100
|
206 |
+
}
|
207 |
+
}
|
208 |
+
}'
|
209 |
+
```
|
210 |
+
|
211 |
+
### Using Python requests
|
212 |
+
|
213 |
+
```python
|
214 |
+
import requests
|
215 |
+
import json
|
216 |
+
|
217 |
+
def call_proportio_tool(tool_name, arguments, api_key="your-token"):
|
218 |
+
url = "http://localhost:7860/gradio_api/mcp/sse"
|
219 |
+
headers = {
|
220 |
+
"X-Api-Key": api_key,
|
221 |
+
"Content-Type": "application/json"
|
222 |
+
}
|
223 |
+
|
224 |
+
payload = {
|
225 |
+
"jsonrpc": "2.0",
|
226 |
+
"id": 1,
|
227 |
+
"method": "tools/call",
|
228 |
+
"params": {
|
229 |
+
"name": tool_name,
|
230 |
+
"arguments": arguments
|
231 |
+
}
|
232 |
+
}
|
233 |
+
|
234 |
+
response = requests.post(url, headers=headers, json=payload)
|
235 |
+
return response.json()
|
236 |
+
|
237 |
+
# Examples
|
238 |
+
result1 = call_proportio_tool("percent_of", {"part": 75, "whole": 300})
|
239 |
+
print(f"Percentage: {result1}")
|
240 |
+
|
241 |
+
result2 = call_proportio_tool("solve_proportion", {"a": 3, "b": 4, "c": 6, "d": None})
|
242 |
+
print(f"Missing value: {result2}")
|
243 |
+
|
244 |
+
result3 = call_proportio_tool("resize_dimensions", {
|
245 |
+
"width": 1920,
|
246 |
+
"height": 1080,
|
247 |
+
"scale": 0.5
|
248 |
+
})
|
249 |
+
print(f"New dimensions: {result3}")
|
250 |
+
```
|
251 |
+
|
252 |
+
## Environment-Specific Configurations
|
253 |
+
|
254 |
+
### Development Environment
|
255 |
+
|
256 |
+
```bash
|
257 |
+
# .env file
|
258 |
+
MCP_TOKEN=dev-token-123
|
259 |
+
DEBUG=true
|
260 |
+
LOG_LEVEL=DEBUG
|
261 |
+
RELOAD=true
|
262 |
+
```
|
263 |
+
|
264 |
+
### Production Environment
|
265 |
+
|
266 |
+
```bash
|
267 |
+
# .env file
|
268 |
+
MCP_TOKEN=prod-secure-token-xyz789
|
269 |
+
DEBUG=false
|
270 |
+
LOG_LEVEL=WARNING
|
271 |
+
MAX_REQUEST_SIZE=32768
|
272 |
+
```
|
273 |
+
|
274 |
+
### Hugging Face Spaces
|
275 |
+
|
276 |
+
Set these as Space secrets:
|
277 |
+
|
278 |
+
- `MCP_TOKEN`: Your secure authentication token
|
279 |
+
- `LOG_LEVEL`: `INFO` or `WARNING`
|
280 |
+
|
281 |
+
Access URL: `https://your-username-proportio.hf.space/gradio_api/mcp/sse`
|
282 |
+
|
283 |
+
## Security Best Practices
|
284 |
+
|
285 |
+
### Token Generation
|
286 |
+
|
287 |
+
Generate secure tokens:
|
288 |
+
|
289 |
+
```bash
|
290 |
+
# Python
|
291 |
+
python -c "import secrets; print(secrets.token_urlsafe(32))"
|
292 |
+
|
293 |
+
# OpenSSL
|
294 |
+
openssl rand -base64 32
|
295 |
+
|
296 |
+
# Node.js
|
297 |
+
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
|
298 |
+
```
|
299 |
+
|
300 |
+
### Token Rotation
|
301 |
+
|
302 |
+
1. Generate new token
|
303 |
+
2. Update all client configurations
|
304 |
+
3. Update server environment variable
|
305 |
+
4. Restart server
|
306 |
+
5. Verify all clients work with new token
|
307 |
+
|
308 |
+
### Network Security
|
309 |
+
|
310 |
+
- Use HTTPS in production
|
311 |
+
- Implement rate limiting at reverse proxy level
|
312 |
+
- Monitor for unusual usage patterns
|
313 |
+
- Keep logs for security auditing
|
314 |
+
|
315 |
+
## Troubleshooting
|
316 |
+
|
317 |
+
### Common Connection Issues
|
318 |
+
|
319 |
+
1. **"Connection refused"**
|
320 |
+
- Check if server is running: `curl http://localhost:7860`
|
321 |
+
- Verify port is not blocked by firewall
|
322 |
+
|
323 |
+
2. **"Invalid API key"**
|
324 |
+
- Ensure `X-Api-Key` header is included
|
325 |
+
- Check token matches `MCP_TOKEN` environment variable
|
326 |
+
- Verify no extra spaces or characters in token
|
327 |
+
|
328 |
+
3. **"Request timeout"**
|
329 |
+
- Check server logs for errors
|
330 |
+
- Verify network connectivity
|
331 |
+
- Increase client timeout settings
|
332 |
+
|
333 |
+
### Debug Mode
|
334 |
+
|
335 |
+
Enable debug logging to troubleshoot issues:
|
336 |
+
|
337 |
+
```bash
|
338 |
+
export DEBUG=true
|
339 |
+
export LOG_LEVEL=DEBUG
|
340 |
+
python app.py
|
341 |
+
```
|
342 |
+
|
343 |
+
This will show detailed logs of all MCP requests and responses.
|
344 |
+
|
345 |
+
## Roo Code Assistant
|
346 |
+
|
347 |
+
### Configuration
|
348 |
+
|
349 |
+
Roo uses MCP servers automatically when they're running locally. Simply start the Proportio server:
|
350 |
+
|
351 |
+
```bash
|
352 |
+
python app.py
|
353 |
+
```
|
354 |
+
|
355 |
+
Roo will detect the running MCP server at `http://localhost:7860/gradio_api/mcp/sse` and make the tools available.
|
356 |
+
|
357 |
+
### Usage Examples
|
358 |
+
|
359 |
+
```
|
360 |
+
Human: Calculate what percentage 45 is of 180
|
361 |
+
|
362 |
+
Roo: I'll use the proportion calculator to find what percentage 45 is of 180.
|
363 |
+
|
364 |
+
[Using percent_of tool with part=45, whole=180]
|
365 |
+
|
366 |
+
45 is 25% of 180.
|
367 |
+
```
|
368 |
+
|
369 |
+
```
|
370 |
+
Human: I need to resize an image from 1920x1080 to half the size
|
371 |
+
|
372 |
+
Roo: I'll help you calculate the new dimensions using the resize tool.
|
373 |
+
|
374 |
+
[Using resize_dimensions tool with width=1920, height=1080, scale=0.5]
|
375 |
+
|
376 |
+
The new dimensions would be 960 × 540 pixels.
|
377 |
+
```
|
378 |
+
|
379 |
+
## OpenWebUI
|
380 |
+
|
381 |
+
### Configuration File
|
382 |
+
|
383 |
+
Add to your OpenWebUI configuration (`config.yaml` or environment):
|
384 |
+
|
385 |
+
```yaml
|
386 |
+
mcp:
|
387 |
+
servers:
|
388 |
+
proportio:
|
389 |
+
url: "http://localhost:7860/gradio_api/mcp/sse"
|
390 |
+
headers:
|
391 |
+
X-Api-Key: "your-secure-token-here"
|
392 |
+
tools:
|
393 |
+
- percent_of
|
394 |
+
- solve_proportion
|
395 |
+
- scale_by_ratio
|
396 |
+
- direct_k
|
397 |
+
- resize_dimensions
|
398 |
+
```
|
399 |
+
|
400 |
+
### Environment Variables
|
401 |
+
|
402 |
+
```bash
|
403 |
+
# OpenWebUI MCP configuration
|
404 |
+
OPENWEBUI_MCP_SERVERS='[
|
405 |
+
{
|
406 |
+
"name": "proportio",
|
407 |
+
"url": "http://localhost:7860/gradio_api/mcp/sse",
|
408 |
+
"headers": {"X-Api-Key": "your-secure-token-here"}
|
409 |
+
}
|
410 |
+
]'
|
411 |
+
```
|
412 |
+
|
413 |
+
### Docker Compose with OpenWebUI
|
414 |
+
|
415 |
+
```yaml
|
416 |
+
version: '3.8'
|
417 |
+
services:
|
418 |
+
proportio:
|
419 |
+
build: .
|
420 |
+
ports:
|
421 |
+
- "7860:7860"
|
422 |
+
environment:
|
423 |
+
- MCP_TOKEN=your-secure-token-here
|
424 |
+
networks:
|
425 |
+
- webui_network
|
426 |
+
|
427 |
+
openwebui:
|
428 |
+
image: ghcr.io/open-webui/open-webui:main
|
429 |
+
ports:
|
430 |
+
- "3000:8080"
|
431 |
+
environment:
|
432 |
+
- OPENWEBUI_MCP_SERVERS=[{"name":"proportio","url":"http://proportio:7860/gradio_api/mcp/sse","headers":{"X-Api-Key":"your-secure-token-here"}}]
|
433 |
+
depends_on:
|
434 |
+
- proportio
|
435 |
+
networks:
|
436 |
+
- webui_network
|
437 |
+
|
438 |
+
networks:
|
439 |
+
webui_network:
|
440 |
+
driver: bridge
|
441 |
+
```
|
442 |
+
|
443 |
+
## Msty
|
444 |
+
|
445 |
+
### Configuration
|
446 |
+
|
447 |
+
In Msty's settings, add the MCP server:
|
448 |
+
|
449 |
+
```json
|
450 |
+
{
|
451 |
+
"mcp_servers": {
|
452 |
+
"proportio": {
|
453 |
+
"type": "sse",
|
454 |
+
"url": "http://localhost:7860/gradio_api/mcp/sse",
|
455 |
+
"auth": {
|
456 |
+
"type": "header",
|
457 |
+
"header_name": "X-Api-Key",
|
458 |
+
"header_value": "your-secure-token-here"
|
459 |
+
},
|
460 |
+
"description": "Mathematical proportion calculations"
|
461 |
+
}
|
462 |
+
}
|
463 |
+
}
|
464 |
+
```
|
465 |
+
|
466 |
+
### Using with Msty
|
467 |
+
|
468 |
+
1. Start the Proportio server: `python app.py`
|
469 |
+
2. Open Msty and go to Settings → MCP Servers
|
470 |
+
3. Add a new server with the configuration above
|
471 |
+
4. The tools will be available in chat sessions
|
472 |
+
|
473 |
+
### Example Msty Usage
|
474 |
+
|
475 |
+
```
|
476 |
+
You: I need to solve this proportion: 3/4 = x/12. What is x?
|
477 |
+
|
478 |
+
Msty: I'll use the proportion solver to find the missing value.
|
479 |
+
|
480 |
+
[Using solve_proportion tool]
|
481 |
+
|
482 |
+
The missing value x is 9. So 3/4 = 9/12.
|
483 |
+
```
|
484 |
+
|
485 |
+
## Local Development Setup Script
|
486 |
+
|
487 |
+
Create a setup script for easy local development:
|
488 |
+
|
489 |
+
```bash
|
490 |
+
#!/bin/bash
|
491 |
+
# setup_local_mcp.sh
|
492 |
+
|
493 |
+
# Generate secure token
|
494 |
+
TOKEN=$(python -c "import secrets; print(secrets.token_urlsafe(32))")
|
495 |
+
|
496 |
+
# Create .env file
|
497 |
+
cat > .env << EOF
|
498 |
+
MCP_TOKEN=$TOKEN
|
499 |
+
DEBUG=true
|
500 |
+
LOG_LEVEL=DEBUG
|
501 |
+
RELOAD=true
|
502 |
+
EOF
|
503 |
+
|
504 |
+
echo "Generated secure token: $TOKEN"
|
505 |
+
echo "Add this to your MCP clients' X-Api-Key header"
|
506 |
+
|
507 |
+
# Start the server
|
508 |
+
python app.py
|
509 |
+
```
|
510 |
+
|
511 |
+
Make it executable: `chmod +x setup_local_mcp.sh`
|
512 |
+
|
513 |
+
## Testing Your MCP Connection
|
514 |
+
|
515 |
+
### Quick Test Script
|
516 |
+
|
517 |
+
```python
|
518 |
+
#!/usr/bin/env python3
|
519 |
+
"""Test script for Proportio MCP server connection"""
|
520 |
+
|
521 |
+
import requests
|
522 |
+
import json
|
523 |
+
import sys
|
524 |
+
|
525 |
+
def test_mcp_connection(base_url="http://localhost:7860", api_key="your-token"):
|
526 |
+
"""Test MCP server connection and tools."""
|
527 |
+
|
528 |
+
url = f"{base_url}/gradio_api/mcp/sse"
|
529 |
+
headers = {
|
530 |
+
"X-Api-Key": api_key,
|
531 |
+
"Content-Type": "application/json"
|
532 |
+
}
|
533 |
+
|
534 |
+
# Test cases
|
535 |
+
tests = [
|
536 |
+
{
|
537 |
+
"name": "Percentage Calculation",
|
538 |
+
"tool": "percent_of",
|
539 |
+
"args": {"part": 25, "whole": 100},
|
540 |
+
"expected": 25.0
|
541 |
+
},
|
542 |
+
{
|
543 |
+
"name": "Proportion Solving",
|
544 |
+
"tool": "solve_proportion",
|
545 |
+
"args": {"a": 3, "b": 4, "c": 6, "d": None},
|
546 |
+
"expected": 8.0
|
547 |
+
},
|
548 |
+
{
|
549 |
+
"name": "Dimension Resizing",
|
550 |
+
"tool": "resize_dimensions",
|
551 |
+
"args": {"width": 100, "height": 50, "scale": 2.0},
|
552 |
+
"expected": {"new_width": 200.0, "new_height": 100.0}
|
553 |
+
}
|
554 |
+
]
|
555 |
+
|
556 |
+
print(f"Testing MCP server at {url}")
|
557 |
+
print(f"Using API key: {api_key[:8]}...")
|
558 |
+
print("-" * 50)
|
559 |
+
|
560 |
+
for test in tests:
|
561 |
+
try:
|
562 |
+
payload = {
|
563 |
+
"jsonrpc": "2.0",
|
564 |
+
"id": 1,
|
565 |
+
"method": "tools/call",
|
566 |
+
"params": {
|
567 |
+
"name": test["tool"],
|
568 |
+
"arguments": test["args"]
|
569 |
+
}
|
570 |
+
}
|
571 |
+
|
572 |
+
response = requests.post(url, headers=headers, json=payload, timeout=10)
|
573 |
+
|
574 |
+
if response.status_code == 200:
|
575 |
+
result = response.json()
|
576 |
+
print(f"✅ {test['name']}: PASSED")
|
577 |
+
print(f" Result: {result}")
|
578 |
+
else:
|
579 |
+
print(f"❌ {test['name']}: FAILED (HTTP {response.status_code})")
|
580 |
+
print(f" Response: {response.text}")
|
581 |
+
|
582 |
+
except Exception as e:
|
583 |
+
print(f"❌ {test['name']}: ERROR - {str(e)}")
|
584 |
+
|
585 |
+
print()
|
586 |
+
|
587 |
+
if __name__ == "__main__":
|
588 |
+
api_key = input("Enter your API key (or press Enter for 'test-token'): ").strip()
|
589 |
+
if not api_key:
|
590 |
+
api_key = "test-token"
|
591 |
+
|
592 |
+
test_mcp_connection(api_key=api_key)
|
593 |
+
```
|
594 |
+
|
595 |
+
Save as `test_mcp.py` and run: `python test_mcp.py`
|
596 |
+
|
597 |
+
This comprehensive guide covers all major MCP clients including Roo, OpenWebUI, and Msty, providing practical examples for integrating the Proportio server into your development workflow.
|
logo.png
ADDED
![]() |
logo_1024.png
ADDED
![]() |
models.py
ADDED
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
Pydantic models for proportion calculation inputs and outputs.
|
3 |
+
Provides robust validation and clear error messages.
|
4 |
+
"""
|
5 |
+
|
6 |
+
from typing import Optional
|
7 |
+
from pydantic import BaseModel, Field, model_validator
|
8 |
+
|
9 |
+
|
10 |
+
class PercentOfInput(BaseModel):
|
11 |
+
"""Input for calculating what percentage part is of whole."""
|
12 |
+
|
13 |
+
part: float = Field(description="The part value")
|
14 |
+
whole: float = Field(description="The whole value (must not be zero)")
|
15 |
+
|
16 |
+
|
17 |
+
|
18 |
+
class PercentOfOutput(BaseModel):
|
19 |
+
"""Output for percentage calculation."""
|
20 |
+
|
21 |
+
percentage: float = Field(description="The calculated percentage")
|
22 |
+
|
23 |
+
|
24 |
+
class ProportionInput(BaseModel):
|
25 |
+
"""Input for solving proportion a/b = c/d with exactly one missing value."""
|
26 |
+
|
27 |
+
a: Optional[float] = Field(None, description="First numerator")
|
28 |
+
b: Optional[float] = Field(None, description="First denominator")
|
29 |
+
c: Optional[float] = Field(None, description="Second numerator")
|
30 |
+
d: Optional[float] = Field(None, description="Second denominator")
|
31 |
+
|
32 |
+
@model_validator(mode='after')
|
33 |
+
def validate_exactly_one_none(self) -> 'ProportionInput':
|
34 |
+
"""Ensure exactly one value is None (missing)."""
|
35 |
+
values = [self.a, self.b, self.c, self.d]
|
36 |
+
none_count = sum(1 for v in values if v is None)
|
37 |
+
|
38 |
+
assert none_count == 1, "Exactly one value must be None (missing) to solve proportion"
|
39 |
+
|
40 |
+
return self
|
41 |
+
|
42 |
+
|
43 |
+
class ProportionOutput(BaseModel):
|
44 |
+
"""Output for proportion calculation."""
|
45 |
+
|
46 |
+
missing: float = Field(description="The calculated missing value")
|
47 |
+
|
48 |
+
|
49 |
+
class ScaleByRatioInput(BaseModel):
|
50 |
+
"""Input for scaling a value by a ratio."""
|
51 |
+
|
52 |
+
value: float = Field(description="The value to scale")
|
53 |
+
ratio: float = Field(description="The scaling ratio")
|
54 |
+
|
55 |
+
|
56 |
+
class ScaleByRatioOutput(BaseModel):
|
57 |
+
"""Output for ratio scaling."""
|
58 |
+
|
59 |
+
result: float = Field(description="The scaled result")
|
60 |
+
|
61 |
+
|
62 |
+
class DirectKInput(BaseModel):
|
63 |
+
"""Input for finding constant of proportionality k in y = kx."""
|
64 |
+
|
65 |
+
x: float = Field(description="The x value (cannot be zero)")
|
66 |
+
y: float = Field(description="The y value")
|
67 |
+
|
68 |
+
|
69 |
+
|
70 |
+
class DirectKOutput(BaseModel):
|
71 |
+
"""Output for proportionality constant calculation."""
|
72 |
+
|
73 |
+
k: float = Field(description="The proportionality constant")
|
74 |
+
|
75 |
+
|
76 |
+
class ResizeDimensionsInput(BaseModel):
|
77 |
+
"""Input for resizing dimensions with uniform scale factor."""
|
78 |
+
|
79 |
+
width: float = Field(description="Original width (must be non-negative)")
|
80 |
+
height: float = Field(description="Original height (must be non-negative)")
|
81 |
+
scale: float = Field(description="Scale factor (must be positive)")
|
82 |
+
|
83 |
+
|
84 |
+
class ResizeDimensionsOutput(BaseModel):
|
85 |
+
"""Output for dimension resizing."""
|
86 |
+
|
87 |
+
new_width: float = Field(description="The new width after scaling")
|
88 |
+
new_height: float = Field(description="The new height after scaling")
|
89 |
+
|
90 |
+
|
91 |
+
# Error response model for consistent error handling
|
92 |
+
class ErrorResponse(BaseModel):
|
93 |
+
"""Standard error response format."""
|
94 |
+
|
95 |
+
error: str = Field(description="Error message")
|
96 |
+
detail: Optional[str] = Field(None, description="Additional error details")
|
proportion_server.py
ADDED
@@ -0,0 +1,361 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
Core proportion calculation tools with Gradio MCP server capabilities.
|
3 |
+
Provides reliable mathematical operations for LLM agents.
|
4 |
+
"""
|
5 |
+
|
6 |
+
import logging
|
7 |
+
from typing import Optional, Tuple
|
8 |
+
import gradio as gr
|
9 |
+
|
10 |
+
from config import get_settings, setup_logging, get_deployment_info
|
11 |
+
|
12 |
+
# Setup logging
|
13 |
+
setup_logging()
|
14 |
+
logger = logging.getLogger(__name__)
|
15 |
+
settings = get_settings()
|
16 |
+
|
17 |
+
|
18 |
+
def percent_of(part: float, whole: float) -> float:
|
19 |
+
"""
|
20 |
+
Calculate what percentage 'part' is of 'whole'.
|
21 |
+
|
22 |
+
Args:
|
23 |
+
part: The part value
|
24 |
+
whole: The whole value (must not be zero)
|
25 |
+
|
26 |
+
Returns:
|
27 |
+
The percentage value
|
28 |
+
|
29 |
+
Mathematical preconditions:
|
30 |
+
- whole != 0 (to avoid division by zero)
|
31 |
+
"""
|
32 |
+
# Mathematical preconditions
|
33 |
+
assert whole != 0, "Division by zero: whole cannot be zero"
|
34 |
+
|
35 |
+
# Calculate percentage
|
36 |
+
percentage = (part / whole) * 100
|
37 |
+
|
38 |
+
logger.debug(f"Calculated {part} is {percentage:.2f}% of {whole}")
|
39 |
+
|
40 |
+
return percentage
|
41 |
+
|
42 |
+
|
43 |
+
def solve_proportion(
|
44 |
+
a: Optional[float] = None,
|
45 |
+
b: Optional[float] = None,
|
46 |
+
c: Optional[float] = None,
|
47 |
+
d: Optional[float] = None
|
48 |
+
) -> float:
|
49 |
+
"""
|
50 |
+
Solve missing term in proportion a/b = c/d.
|
51 |
+
Exactly one parameter must be None (missing).
|
52 |
+
|
53 |
+
Args:
|
54 |
+
a: First numerator (optional)
|
55 |
+
b: First denominator (optional, cannot be zero)
|
56 |
+
c: Second numerator (optional)
|
57 |
+
d: Second denominator (optional, cannot be zero)
|
58 |
+
|
59 |
+
Returns:
|
60 |
+
The missing value
|
61 |
+
|
62 |
+
Mathematical preconditions:
|
63 |
+
- Exactly one value must be None (missing)
|
64 |
+
- Division denominators != 0 (varies by missing value)
|
65 |
+
"""
|
66 |
+
# Mathematical preconditions - exactly one value must be None
|
67 |
+
values = [a, b, c, d]
|
68 |
+
none_count = sum(1 for v in values if v is None)
|
69 |
+
assert none_count == 1, "Exactly one value must be None"
|
70 |
+
|
71 |
+
# Solve for missing value using cross multiplication: a*d = b*c
|
72 |
+
if a is None:
|
73 |
+
assert d != 0, "Division by zero: denominator"
|
74 |
+
missing = (b * c) / d
|
75 |
+
elif b is None:
|
76 |
+
assert c != 0, "Division by zero: denominator"
|
77 |
+
missing = (a * d) / c
|
78 |
+
elif c is None:
|
79 |
+
assert b != 0, "Division by zero: denominator"
|
80 |
+
missing = (a * d) / b
|
81 |
+
else: # d is None
|
82 |
+
assert a != 0, "Division by zero: denominator"
|
83 |
+
missing = (b * c) / a
|
84 |
+
|
85 |
+
logger.debug(f"Solved proportion: missing value = {missing}")
|
86 |
+
|
87 |
+
return missing
|
88 |
+
|
89 |
+
|
90 |
+
def scale_by_ratio(value: float, ratio: float) -> float:
|
91 |
+
"""
|
92 |
+
Scale a value by a given ratio.
|
93 |
+
|
94 |
+
Args:
|
95 |
+
value: The value to scale
|
96 |
+
ratio: The scaling ratio
|
97 |
+
|
98 |
+
Returns:
|
99 |
+
The scaled value
|
100 |
+
"""
|
101 |
+
# Calculate scaled result
|
102 |
+
scaled_result = value * ratio
|
103 |
+
|
104 |
+
logger.debug(f"Scaled {value} by ratio {ratio} = {scaled_result}")
|
105 |
+
|
106 |
+
return scaled_result
|
107 |
+
|
108 |
+
|
109 |
+
def direct_k(x: float, y: float) -> float:
|
110 |
+
"""
|
111 |
+
Find constant of proportionality k in direct variation y = kx.
|
112 |
+
|
113 |
+
Args:
|
114 |
+
x: The x value (cannot be zero)
|
115 |
+
y: The y value
|
116 |
+
|
117 |
+
Returns:
|
118 |
+
The proportionality constant k
|
119 |
+
|
120 |
+
Mathematical preconditions:
|
121 |
+
- x != 0 (to avoid division by zero)
|
122 |
+
"""
|
123 |
+
# Mathematical preconditions
|
124 |
+
assert x != 0, "Division by zero: x cannot be zero"
|
125 |
+
|
126 |
+
# Calculate proportionality constant
|
127 |
+
k = y / x
|
128 |
+
|
129 |
+
logger.debug(f"Found proportionality constant k = {k} for y = {y}, x = {x}")
|
130 |
+
|
131 |
+
return k
|
132 |
+
|
133 |
+
|
134 |
+
def resize_dimensions(width: float, height: float, scale: float) -> Tuple[float, float]:
|
135 |
+
"""
|
136 |
+
Resize dimensions with uniform scale factor.
|
137 |
+
|
138 |
+
Args:
|
139 |
+
width: Original width (must be non-negative)
|
140 |
+
height: Original height (must be non-negative)
|
141 |
+
scale: Scale factor (must be positive)
|
142 |
+
|
143 |
+
Returns:
|
144 |
+
Tuple of (new_width, new_height)
|
145 |
+
|
146 |
+
Mathematical preconditions:
|
147 |
+
- width >= 0 (dimensions must be non-negative)
|
148 |
+
- height >= 0 (dimensions must be non-negative)
|
149 |
+
- scale > 0 (scale factor must be positive)
|
150 |
+
"""
|
151 |
+
# Mathematical preconditions
|
152 |
+
assert width >= 0, "Width must be non-negative"
|
153 |
+
assert height >= 0, "Height must be non-negative"
|
154 |
+
assert scale > 0, "Scale factor must be positive"
|
155 |
+
|
156 |
+
# Calculate new dimensions
|
157 |
+
new_width = width * scale
|
158 |
+
new_height = height * scale
|
159 |
+
|
160 |
+
logger.debug(f"Resized {width}x{height} by {scale} = {new_width}x{new_height}")
|
161 |
+
|
162 |
+
return (new_width, new_height)
|
163 |
+
|
164 |
+
|
165 |
+
# Demo functions for Gradio interface
|
166 |
+
def demo_percent_of(part: float, whole: float) -> str:
|
167 |
+
"""Demo function for percent_of calculation."""
|
168 |
+
result = percent_of(part, whole)
|
169 |
+
return f"{part} is {result:.2f}% of {whole}"
|
170 |
+
|
171 |
+
|
172 |
+
def demo_solve_proportion(a: Optional[str], b: Optional[str], c: Optional[str], d: Optional[str]) -> str:
|
173 |
+
"""Demo function for proportion solving."""
|
174 |
+
# Convert string inputs to float or None
|
175 |
+
a_val = None if a == "" or a is None else float(a)
|
176 |
+
b_val = None if b == "" or b is None else float(b)
|
177 |
+
c_val = None if c == "" or c is None else float(c)
|
178 |
+
d_val = None if d == "" or d is None else float(d)
|
179 |
+
|
180 |
+
result = solve_proportion(a_val, b_val, c_val, d_val)
|
181 |
+
return f"Missing value: {result:.4f}"
|
182 |
+
|
183 |
+
|
184 |
+
def demo_scale_by_ratio(value: float, ratio: float) -> str:
|
185 |
+
"""Demo function for scaling by ratio."""
|
186 |
+
result = scale_by_ratio(value, ratio)
|
187 |
+
return f"{value} × {ratio} = {result:.4f}"
|
188 |
+
|
189 |
+
|
190 |
+
def demo_direct_k(x: float, y: float) -> str:
|
191 |
+
"""Demo function for finding proportionality constant."""
|
192 |
+
result = direct_k(x, y)
|
193 |
+
return f"k = {result:.4f} (where y = kx)"
|
194 |
+
|
195 |
+
|
196 |
+
def demo_resize_dimensions(width: float, height: float, scale: float) -> str:
|
197 |
+
"""Demo function for resizing dimensions."""
|
198 |
+
new_width, new_height = resize_dimensions(width, height, scale)
|
199 |
+
return f"New dimensions: {new_width:.2f} × {new_height:.2f}"
|
200 |
+
|
201 |
+
|
202 |
+
def create_gradio_app():
|
203 |
+
"""Create and return the Gradio interface."""
|
204 |
+
with gr.Blocks(
|
205 |
+
title="PROPORTIO",
|
206 |
+
css_paths=["styles.css"],
|
207 |
+
theme=gr.themes.Base(),
|
208 |
+
head='<link rel="icon" type="image/png" href="/file=logo.png"><link rel="shortcut icon" type="image/png" href="/file=logo.png">'
|
209 |
+
) as demo:
|
210 |
+
with gr.Row():
|
211 |
+
with gr.Column(scale=1, min_width=72):
|
212 |
+
gr.Image("logo.png", height=72, width=72, show_label=False, show_download_button=False, show_fullscreen_button=False, container=False, interactive=False)
|
213 |
+
with gr.Column(scale=10):
|
214 |
+
gr.HTML("""
|
215 |
+
<div class="p-brand">
|
216 |
+
<h1 class="p-title">PROPORTIO</h1>
|
217 |
+
<div class="p-tagline">Mathematical Precision & Proportion Calculator</div>
|
218 |
+
</div>
|
219 |
+
""")
|
220 |
+
|
221 |
+
with gr.Tabs():
|
222 |
+
with gr.TabItem("📊 Percentage Calculator"):
|
223 |
+
gr.HTML('<div class="tab-title">Calculate Percentage</div>')
|
224 |
+
gr.HTML('<div class="tab-desc">Find what percentage the part is of the whole</div>')
|
225 |
+
|
226 |
+
with gr.Row():
|
227 |
+
with gr.Column(scale=1):
|
228 |
+
part_input = gr.Number(label="Part", value=25, info="The part value")
|
229 |
+
whole_input = gr.Number(label="Whole", value=100, info="The whole value (cannot be zero)")
|
230 |
+
percent_btn = gr.Button("Calculate Percentage", variant="secondary", size="lg")
|
231 |
+
|
232 |
+
with gr.Column(scale=1):
|
233 |
+
percent_output = gr.Textbox(label="Result", interactive=False, show_label=True)
|
234 |
+
gr.HTML('<div class="formula-box"><strong>Formula:</strong> (Part ÷ Whole) × 100</div>')
|
235 |
+
|
236 |
+
with gr.TabItem("⚖️ Proportion Solver"):
|
237 |
+
gr.HTML('<div class="tab-title">Solve Proportion (a/b = c/d)</div>')
|
238 |
+
gr.HTML('<div class="tab-desc">Leave exactly one field empty to solve for the missing value</div>')
|
239 |
+
|
240 |
+
with gr.Row():
|
241 |
+
with gr.Column(scale=1):
|
242 |
+
with gr.Row():
|
243 |
+
a_input = gr.Textbox(label="a", value="3", info="First numerator")
|
244 |
+
b_input = gr.Textbox(label="b", value="4", info="First denominator")
|
245 |
+
with gr.Row():
|
246 |
+
c_input = gr.Textbox(label="c", value="6", info="Second numerator")
|
247 |
+
d_input = gr.Textbox(label="d", value="", info="Second denominator")
|
248 |
+
proportion_btn = gr.Button("Solve Proportion", variant="secondary", size="lg")
|
249 |
+
|
250 |
+
with gr.Column(scale=1):
|
251 |
+
proportion_output = gr.Textbox(label="Result", interactive=False, show_label=True)
|
252 |
+
gr.HTML('<div class="formula-box"><strong>Formula:</strong> a × d = b × c</div>')
|
253 |
+
|
254 |
+
with gr.TabItem("📏 Scale by Ratio"):
|
255 |
+
gr.HTML('<div class="tab-title">Scale Value by Ratio</div>')
|
256 |
+
gr.HTML('<div class="tab-desc">Multiply a value by a scaling ratio</div>')
|
257 |
+
|
258 |
+
with gr.Row():
|
259 |
+
with gr.Column(scale=1):
|
260 |
+
value_input = gr.Number(label="Value", value=10, info="The value to scale")
|
261 |
+
ratio_input = gr.Number(label="Ratio", value=1.5, info="The scaling factor")
|
262 |
+
scale_btn = gr.Button("Scale Value", variant="secondary", size="lg")
|
263 |
+
|
264 |
+
with gr.Column(scale=1):
|
265 |
+
scale_output = gr.Textbox(label="Result", interactive=False, show_label=True)
|
266 |
+
gr.HTML('<div class="formula-box"><strong>Formula:</strong> Value × Ratio</div>')
|
267 |
+
|
268 |
+
with gr.TabItem("🔢 Find Constant k"):
|
269 |
+
gr.HTML('<div class="tab-title">Find Proportionality Constant (y = kx)</div>')
|
270 |
+
gr.HTML('<div class="tab-desc">Calculate the constant k in direct variation</div>')
|
271 |
+
|
272 |
+
with gr.Row():
|
273 |
+
with gr.Column(scale=1):
|
274 |
+
x_input = gr.Number(label="x", value=5, info="The x value (cannot be zero)")
|
275 |
+
y_input = gr.Number(label="y", value=15, info="The y value")
|
276 |
+
direct_btn = gr.Button("Find k", variant="secondary", size="lg")
|
277 |
+
|
278 |
+
with gr.Column(scale=1):
|
279 |
+
direct_output = gr.Textbox(label="Result", interactive=False, show_label=True)
|
280 |
+
gr.HTML('<div class="formula-box"><strong>Formula:</strong> k = y ÷ x</div>')
|
281 |
+
|
282 |
+
with gr.TabItem("📐 Resize Dimensions"):
|
283 |
+
gr.HTML('<div class="tab-title">Resize Dimensions</div>')
|
284 |
+
gr.HTML('<div class="tab-desc">Scale width and height by a uniform factor</div>')
|
285 |
+
|
286 |
+
with gr.Row():
|
287 |
+
with gr.Column(scale=1):
|
288 |
+
width_input = gr.Number(label="Width", value=100, info="Original width (≥ 0)")
|
289 |
+
height_input = gr.Number(label="Height", value=50, info="Original height (≥ 0)")
|
290 |
+
scale_dim_input = gr.Number(label="Scale Factor", value=2.0, info="Scaling factor (> 0)")
|
291 |
+
resize_btn = gr.Button("Resize", variant="secondary", size="lg")
|
292 |
+
|
293 |
+
with gr.Column(scale=1):
|
294 |
+
resize_output = gr.Textbox(label="Result", interactive=False, show_label=True)
|
295 |
+
gr.HTML('<div class="formula-box"><strong>Formula:</strong> New = Original × Scale</div>')
|
296 |
+
|
297 |
+
# Event handlers
|
298 |
+
percent_btn.click(
|
299 |
+
demo_percent_of,
|
300 |
+
inputs=[part_input, whole_input],
|
301 |
+
outputs=[percent_output]
|
302 |
+
)
|
303 |
+
|
304 |
+
proportion_btn.click(
|
305 |
+
demo_solve_proportion,
|
306 |
+
inputs=[a_input, b_input, c_input, d_input],
|
307 |
+
outputs=[proportion_output]
|
308 |
+
)
|
309 |
+
|
310 |
+
scale_btn.click(
|
311 |
+
demo_scale_by_ratio,
|
312 |
+
inputs=[value_input, ratio_input],
|
313 |
+
outputs=[scale_output]
|
314 |
+
)
|
315 |
+
|
316 |
+
direct_btn.click(
|
317 |
+
demo_direct_k,
|
318 |
+
inputs=[x_input, y_input],
|
319 |
+
outputs=[direct_output]
|
320 |
+
)
|
321 |
+
|
322 |
+
resize_btn.click(
|
323 |
+
demo_resize_dimensions,
|
324 |
+
inputs=[width_input, height_input, scale_dim_input],
|
325 |
+
outputs=[resize_output]
|
326 |
+
)
|
327 |
+
|
328 |
+
gr.HTML("""
|
329 |
+
<div class="footer">
|
330 |
+
<div class="footer-content">
|
331 |
+
<div class="footer-section">
|
332 |
+
<div class="footer-title">About</div>
|
333 |
+
<div class="footer-text">Professional mathematical calculations for proportions, percentages, and scaling operations.</div>
|
334 |
+
</div>
|
335 |
+
<div class="footer-section">
|
336 |
+
<div class="footer-title">Features</div>
|
337 |
+
<div class="footer-text">• Assertion-based validation<br>• Precise error handling<br>• MCP server integration</div>
|
338 |
+
</div>
|
339 |
+
</div>
|
340 |
+
</div>
|
341 |
+
""")
|
342 |
+
|
343 |
+
return demo
|
344 |
+
|
345 |
+
|
346 |
+
if __name__ == "__main__":
|
347 |
+
# Get deployment info
|
348 |
+
deployment_info = get_deployment_info()
|
349 |
+
logger.info(f"Starting Proportio Calculator - {deployment_info}")
|
350 |
+
|
351 |
+
# Create and launch the interface
|
352 |
+
demo = create_gradio_app()
|
353 |
+
|
354 |
+
# Launch with MCP server capabilities
|
355 |
+
demo.launch(
|
356 |
+
server_name="0.0.0.0",
|
357 |
+
server_port=settings.PORT,
|
358 |
+
share=settings.SHARE,
|
359 |
+
mcp_server=True, # Enable MCP server functionality
|
360 |
+
show_error=True
|
361 |
+
)
|
pyproject.toml
ADDED
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
[build-system]
|
2 |
+
requires = ["setuptools>=68.0"]
|
3 |
+
build-backend = "setuptools.build_meta"
|
4 |
+
|
5 |
+
[project]
|
6 |
+
name = "proportio-mcp-server"
|
7 |
+
version = "1.0.0"
|
8 |
+
description = "Gradio-hosted MCP server for reliable proportion calculations"
|
9 |
+
authors = [{name = "Proportio Team"}]
|
10 |
+
readme = "README.md"
|
11 |
+
requires-python = ">=3.11"
|
12 |
+
dependencies = [
|
13 |
+
"gradio[mcp]>=5.0.0",
|
14 |
+
"pydantic>=2.8.0",
|
15 |
+
"pytest>=8.0.0",
|
16 |
+
]
|
17 |
+
|
18 |
+
[tool.black]
|
19 |
+
line-length = 88
|
20 |
+
target-version = ['py311']
|
21 |
+
include = '\.pyi?$'
|
22 |
+
|
23 |
+
[tool.ruff]
|
24 |
+
target-version = "py311"
|
25 |
+
line-length = 88
|
26 |
+
select = [
|
27 |
+
"E", # pycodestyle errors
|
28 |
+
"W", # pycodestyle warnings
|
29 |
+
"F", # pyflakes
|
30 |
+
"I", # isort
|
31 |
+
"B", # flake8-bugbear
|
32 |
+
"C4", # flake8-comprehensions
|
33 |
+
"UP", # pyupgrade
|
34 |
+
]
|
35 |
+
ignore = []
|
36 |
+
|
37 |
+
[tool.ruff.per-file-ignores]
|
38 |
+
"tests/*" = ["S101"]
|
39 |
+
|
40 |
+
[tool.mypy]
|
41 |
+
python_version = "3.11"
|
42 |
+
warn_return_any = true
|
43 |
+
warn_unused_configs = true
|
44 |
+
disallow_untyped_defs = true
|
45 |
+
disallow_incomplete_defs = true
|
46 |
+
check_untyped_defs = true
|
47 |
+
disallow_untyped_decorators = true
|
48 |
+
no_implicit_optional = true
|
49 |
+
warn_redundant_casts = true
|
50 |
+
warn_unused_ignores = true
|
51 |
+
warn_no_return = true
|
52 |
+
warn_unreachable = true
|
53 |
+
strict_equality = true
|
54 |
+
|
55 |
+
[tool.pytest.ini_options]
|
56 |
+
testpaths = ["tests"]
|
57 |
+
python_files = ["test_*.py"]
|
58 |
+
python_classes = ["Test*"]
|
59 |
+
python_functions = ["test_*"]
|
60 |
+
addopts = [
|
61 |
+
"--strict-markers",
|
62 |
+
"--strict-config",
|
63 |
+
]
|
requirements.txt
ADDED
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Core dependencies
|
2 |
+
gradio[mcp]>=5.0.0
|
3 |
+
pydantic>=2.8.0
|
4 |
+
pydantic-settings>=2.0.0
|
5 |
+
websockets>=13.0,<14.0
|
6 |
+
requests>=2.31.0
|
7 |
+
|
8 |
+
# Testing
|
9 |
+
pytest>=8.0.0
|
styles.css
ADDED
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/* ---------- Proportio Minimal Theme ---------- */
|
2 |
+
:root{
|
3 |
+
--primary: #7c1212;
|
4 |
+
--secondary: #a10907;
|
5 |
+
--accent: #8f9190;
|
6 |
+
}
|
7 |
+
|
8 |
+
/* Logo visibility and alignment fix */
|
9 |
+
.gradio-row {
|
10 |
+
display: flex !important;
|
11 |
+
align-items: center !important;
|
12 |
+
gap: 16px !important;
|
13 |
+
}
|
14 |
+
|
15 |
+
.gradio-column {
|
16 |
+
display: flex !important;
|
17 |
+
align-items: center !important;
|
18 |
+
height: auto !important;
|
19 |
+
}
|
20 |
+
|
21 |
+
/* Target the first column (logo) */
|
22 |
+
.gradio-column:first-child {
|
23 |
+
justify-content: center !important;
|
24 |
+
align-items: center !important;
|
25 |
+
}
|
26 |
+
|
27 |
+
/* Target the second column (text) */
|
28 |
+
.gradio-column:last-child {
|
29 |
+
justify-content: flex-start !important;
|
30 |
+
align-items: center !important;
|
31 |
+
}
|
32 |
+
|
33 |
+
.gradio-image {
|
34 |
+
display: flex !important;
|
35 |
+
align-items: center !important;
|
36 |
+
justify-content: center !important;
|
37 |
+
margin: 0 !important;
|
38 |
+
padding: 0 !important;
|
39 |
+
}
|
40 |
+
|
41 |
+
.gradio-image img {
|
42 |
+
display: block !important;
|
43 |
+
opacity: 1 !important;
|
44 |
+
visibility: visible !important;
|
45 |
+
}
|
46 |
+
|
47 |
+
/* Ensure the brand section is vertically centered */
|
48 |
+
.p-brand {
|
49 |
+
display: flex !important;
|
50 |
+
flex-direction: column !important;
|
51 |
+
justify-content: center !important;
|
52 |
+
margin: 0 !important;
|
53 |
+
padding: 0 !important;
|
54 |
+
}
|
55 |
+
|
56 |
+
.p-title {
|
57 |
+
margin: 0 !important;
|
58 |
+
line-height: 1.2 !important;
|
59 |
+
}
|
60 |
+
|
61 |
+
.p-tagline {
|
62 |
+
margin: 0 !important;
|
63 |
+
line-height: 1.2 !important;
|
64 |
+
}
|
65 |
+
|
66 |
+
/* Override blue colors with primary red */
|
67 |
+
.gradio-tabs button {
|
68 |
+
background-color: var(--primary) !important;
|
69 |
+
border-color: var(--primary) !important;
|
70 |
+
}
|
71 |
+
|
72 |
+
.gradio-tabs button:hover {
|
73 |
+
background-color: var(--secondary) !important;
|
74 |
+
border-color: var(--secondary) !important;
|
75 |
+
}
|
76 |
+
|
77 |
+
.gradio-tabs button[aria-selected="true"] {
|
78 |
+
background-color: var(--primary) !important;
|
79 |
+
border-color: var(--primary) !important;
|
80 |
+
color: var(--accent) !important;
|
81 |
+
}
|
82 |
+
|
83 |
+
.gradio-tabs button[aria-selected="true"] span {
|
84 |
+
color: var(--accent) !important;
|
85 |
+
}
|
86 |
+
|
87 |
+
/* Override any blue accents */
|
88 |
+
.tab-nav button {
|
89 |
+
background-color: var(--primary) !important;
|
90 |
+
border-color: var(--primary) !important;
|
91 |
+
}
|
92 |
+
|
93 |
+
/* Target selected tab text colors */
|
94 |
+
button[role="tab"][aria-selected="true"] {
|
95 |
+
color: var(--accent) !important;
|
96 |
+
}
|
97 |
+
|
98 |
+
button[role="tab"][aria-selected="true"] span {
|
99 |
+
color: var(--accent) !important;
|
100 |
+
}
|
101 |
+
|
102 |
+
/* Override blue text in tabs */
|
103 |
+
.gradio-tabs button span {
|
104 |
+
color: var(--accent) !important;
|
105 |
+
}
|
106 |
+
|
107 |
+
/* Override blue underlines/borders on selected tabs */
|
108 |
+
.gradio-tabitem,
|
109 |
+
.gradio-tabs .tab-nav button[aria-selected="true"]::after,
|
110 |
+
.gradio-tabs button[aria-selected="true"]::after,
|
111 |
+
button[role="tab"][aria-selected="true"]::after {
|
112 |
+
border-bottom-color: var(--secondary) !important;
|
113 |
+
background-color: var(--secondary) !important;
|
114 |
+
}
|
115 |
+
|
116 |
+
/* Target the tab indicator line - more specific selectors */
|
117 |
+
.gradio-tabs .tab-nav .tab-indicator,
|
118 |
+
.gradio-tabs .selected-tab-indicator,
|
119 |
+
.gradio-tabs button[aria-selected="true"] + .tab-indicator,
|
120 |
+
.gradio-tabs button[aria-selected="true"]::before,
|
121 |
+
.gradio-tabs button[data-selected="true"]::before,
|
122 |
+
div[role="tablist"] button[aria-selected="true"]::before,
|
123 |
+
div[role="tablist"] button[aria-selected="true"]::after,
|
124 |
+
.gradio-tabs .tab-nav button::before,
|
125 |
+
.gradio-tabs .tab-nav button::after {
|
126 |
+
background-color: var(--secondary) !important;
|
127 |
+
border-color: var(--secondary) !important;
|
128 |
+
background: var(--secondary) !important;
|
129 |
+
}
|
130 |
+
|
131 |
+
/* Override any remaining blue accents - comprehensive approach */
|
132 |
+
.gradio-tabs button[aria-selected="true"] {
|
133 |
+
border-bottom: 2px solid var(--secondary) !important;
|
134 |
+
box-shadow: inset 0 -2px 0 var(--secondary) !important;
|
135 |
+
}
|
136 |
+
|
137 |
+
/* Target Gradio's internal tab styling */
|
138 |
+
.gradio-tabs button[data-selected="true"],
|
139 |
+
button[role="tab"][data-selected="true"] {
|
140 |
+
border-bottom-color: var(--secondary) !important;
|
141 |
+
box-shadow: inset 0 -2px 0 var(--secondary) !important;
|
142 |
+
}
|
143 |
+
|
144 |
+
/* Override blue focus states */
|
145 |
+
.gradio-tabs button:focus {
|
146 |
+
outline-color: var(--secondary) !important;
|
147 |
+
border-color: var(--secondary) !important;
|
148 |
+
}
|
tests/test_tools.py
ADDED
@@ -0,0 +1,428 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
Comprehensive test suite for proportion calculation tools.
|
3 |
+
Tests all functions with various inputs, edge cases, and error conditions.
|
4 |
+
"""
|
5 |
+
|
6 |
+
import pytest
|
7 |
+
from proportion_server import (
|
8 |
+
percent_of, solve_proportion, scale_by_ratio,
|
9 |
+
direct_k, resize_dimensions
|
10 |
+
)
|
11 |
+
|
12 |
+
|
13 |
+
class TestPercentOf:
|
14 |
+
"""Test the percent_of function."""
|
15 |
+
|
16 |
+
def test_basic_percentage(self):
|
17 |
+
"""Test basic percentage calculations."""
|
18 |
+
assert percent_of(25, 100) == 25.0
|
19 |
+
assert percent_of(1, 4) == 25.0
|
20 |
+
assert percent_of(3, 4) == 75.0
|
21 |
+
|
22 |
+
def test_zero_part(self):
|
23 |
+
"""Test with zero part."""
|
24 |
+
assert percent_of(0, 100) == 0.0
|
25 |
+
assert percent_of(0, 1) == 0.0
|
26 |
+
|
27 |
+
def test_negative_values(self):
|
28 |
+
"""Test with negative values."""
|
29 |
+
assert percent_of(-25, 100) == -25.0
|
30 |
+
assert percent_of(25, -100) == -25.0
|
31 |
+
assert percent_of(-25, -100) == 25.0
|
32 |
+
|
33 |
+
def test_decimal_precision(self):
|
34 |
+
"""Test decimal precision in calculations."""
|
35 |
+
result = percent_of(1, 3)
|
36 |
+
assert abs(result - 33.333333333333336) < 1e-10
|
37 |
+
|
38 |
+
def test_large_numbers(self):
|
39 |
+
"""Test with large numbers."""
|
40 |
+
assert percent_of(1000000, 4000000) == 25.0
|
41 |
+
|
42 |
+
def test_very_small_numbers(self):
|
43 |
+
"""Test with very small numbers."""
|
44 |
+
result = percent_of(1e-10, 1e-8)
|
45 |
+
assert abs(result - 1.0) < 1e-10
|
46 |
+
|
47 |
+
def test_zero_whole_error(self):
|
48 |
+
"""Test assertion error when whole is zero."""
|
49 |
+
with pytest.raises(AssertionError, match="Division by zero: whole cannot be zero"):
|
50 |
+
percent_of(10, 0)
|
51 |
+
|
52 |
+
def test_negative_whole_edge_cases(self):
|
53 |
+
"""Test edge cases with negative whole values."""
|
54 |
+
assert percent_of(10, -5) == -200.0
|
55 |
+
assert percent_of(-10, -5) == 200.0
|
56 |
+
|
57 |
+
|
58 |
+
class TestSolveProportion:
|
59 |
+
"""Test the solve_proportion function."""
|
60 |
+
|
61 |
+
def test_solve_a(self):
|
62 |
+
"""Test solving for 'a' in proportion a/b = c/d."""
|
63 |
+
result = solve_proportion(None, 4, 6, 8)
|
64 |
+
assert result == 3.0
|
65 |
+
|
66 |
+
def test_solve_b(self):
|
67 |
+
"""Test solving for 'b' in proportion a/b = c/d."""
|
68 |
+
result = solve_proportion(3, None, 6, 8)
|
69 |
+
assert result == 4.0
|
70 |
+
|
71 |
+
def test_solve_c(self):
|
72 |
+
"""Test solving for 'c' in proportion a/b = c/d."""
|
73 |
+
result = solve_proportion(3, 4, None, 8)
|
74 |
+
assert result == 6.0
|
75 |
+
|
76 |
+
def test_solve_d(self):
|
77 |
+
"""Test solving for 'd' in proportion a/b = c/d."""
|
78 |
+
result = solve_proportion(3, 4, 6, None)
|
79 |
+
assert result == 8.0
|
80 |
+
|
81 |
+
def test_negative_values(self):
|
82 |
+
"""Test with negative values."""
|
83 |
+
result = solve_proportion(-3, 4, 6, None)
|
84 |
+
assert result == -8.0
|
85 |
+
|
86 |
+
def test_decimal_results(self):
|
87 |
+
"""Test with decimal results."""
|
88 |
+
result = solve_proportion(1, 3, 2, None)
|
89 |
+
assert abs(result - 6.0) < 1e-10
|
90 |
+
|
91 |
+
def test_multiple_none_error(self):
|
92 |
+
"""Test assertion error when multiple values are None."""
|
93 |
+
with pytest.raises(AssertionError, match="Exactly one value must be None"):
|
94 |
+
solve_proportion(None, None, 6, 8)
|
95 |
+
|
96 |
+
with pytest.raises(AssertionError, match="Exactly one value must be None"):
|
97 |
+
solve_proportion(None, 4, None, 8)
|
98 |
+
|
99 |
+
with pytest.raises(AssertionError, match="Exactly one value must be None"):
|
100 |
+
solve_proportion(None, None, None, None)
|
101 |
+
|
102 |
+
def test_no_none_error(self):
|
103 |
+
"""Test assertion error when no values are None."""
|
104 |
+
with pytest.raises(AssertionError, match="Exactly one value must be None"):
|
105 |
+
solve_proportion(3, 4, 6, 8)
|
106 |
+
|
107 |
+
def test_zero_denominator_error(self):
|
108 |
+
"""Test assertion error when denominators are zero."""
|
109 |
+
with pytest.raises(AssertionError, match="Division by zero: denominator"):
|
110 |
+
solve_proportion(None, 2, 3, 0) # Solving for a, divides by d=0
|
111 |
+
|
112 |
+
with pytest.raises(AssertionError, match="Division by zero: denominator"):
|
113 |
+
solve_proportion(2, None, 0, 3) # Solving for b, divides by c=0
|
114 |
+
|
115 |
+
def test_division_by_zero_calculation(self):
|
116 |
+
"""Test assertion error for division by zero during calculation."""
|
117 |
+
with pytest.raises(AssertionError, match="Division by zero: denominator"):
|
118 |
+
# This would require dividing by zero in calculation
|
119 |
+
solve_proportion(None, 4, 0, 0)
|
120 |
+
|
121 |
+
def test_large_numbers(self):
|
122 |
+
"""Test with large numbers."""
|
123 |
+
result = solve_proportion(1000000, 2000000, 500000, None)
|
124 |
+
assert result == 1000000.0
|
125 |
+
|
126 |
+
def test_very_small_numbers(self):
|
127 |
+
"""Test with very small numbers."""
|
128 |
+
result = solve_proportion(1e-10, 2e-10, 5e-11, None)
|
129 |
+
assert abs(result - 1e-10) < 1e-20
|
130 |
+
|
131 |
+
|
132 |
+
class TestScaleByRatio:
|
133 |
+
"""Test the scale_by_ratio function."""
|
134 |
+
|
135 |
+
def test_positive_scaling(self):
|
136 |
+
"""Test scaling with positive ratios."""
|
137 |
+
assert scale_by_ratio(10, 2.5) == 25.0
|
138 |
+
assert scale_by_ratio(100, 0.5) == 50.0
|
139 |
+
|
140 |
+
def test_negative_values(self):
|
141 |
+
"""Test scaling with negative values."""
|
142 |
+
assert scale_by_ratio(-10, 2.0) == -20.0
|
143 |
+
assert scale_by_ratio(10, -2.0) == -20.0
|
144 |
+
assert scale_by_ratio(-10, -2.0) == 20.0
|
145 |
+
|
146 |
+
def test_zero_scaling(self):
|
147 |
+
"""Test scaling with zero ratio."""
|
148 |
+
assert scale_by_ratio(100, 0) == 0.0
|
149 |
+
|
150 |
+
def test_zero_value(self):
|
151 |
+
"""Test scaling zero value."""
|
152 |
+
assert scale_by_ratio(0, 5.0) == 0.0
|
153 |
+
|
154 |
+
def test_identity_scaling(self):
|
155 |
+
"""Test scaling with ratio of 1."""
|
156 |
+
assert scale_by_ratio(42, 1.0) == 42.0
|
157 |
+
|
158 |
+
def test_fractional_scaling(self):
|
159 |
+
"""Test scaling with fractional ratios."""
|
160 |
+
assert scale_by_ratio(9, 1/3) == 3.0
|
161 |
+
|
162 |
+
def test_large_numbers(self):
|
163 |
+
"""Test scaling with large numbers."""
|
164 |
+
result = scale_by_ratio(1e6, 1e-3)
|
165 |
+
assert result == 1000.0
|
166 |
+
|
167 |
+
def test_very_small_numbers(self):
|
168 |
+
"""Test scaling with very small numbers."""
|
169 |
+
result = scale_by_ratio(1e-10, 1e10)
|
170 |
+
assert result == 1.0
|
171 |
+
|
172 |
+
def test_precision_edge_cases(self):
|
173 |
+
"""Test precision with edge cases."""
|
174 |
+
result = scale_by_ratio(1/3, 3)
|
175 |
+
assert abs(result - 1.0) < 1e-15
|
176 |
+
|
177 |
+
|
178 |
+
class TestDirectK:
|
179 |
+
"""Test the direct_k function."""
|
180 |
+
|
181 |
+
def test_positive_values(self):
|
182 |
+
"""Test with positive values."""
|
183 |
+
assert direct_k(5, 15) == 3.0
|
184 |
+
assert direct_k(2, 10) == 5.0
|
185 |
+
|
186 |
+
def test_negative_values(self):
|
187 |
+
"""Test with negative values."""
|
188 |
+
assert direct_k(-4, 12) == -3.0
|
189 |
+
assert direct_k(4, -12) == -3.0
|
190 |
+
assert direct_k(-4, -12) == 3.0
|
191 |
+
|
192 |
+
def test_zero_y(self):
|
193 |
+
"""Test with zero y value."""
|
194 |
+
assert direct_k(5, 0) == 0.0
|
195 |
+
|
196 |
+
def test_zero_x_error(self):
|
197 |
+
"""Test assertion error when x is zero."""
|
198 |
+
with pytest.raises(AssertionError, match="Division by zero: x cannot be zero"):
|
199 |
+
direct_k(0, 10)
|
200 |
+
|
201 |
+
def test_decimal_precision(self):
|
202 |
+
"""Test decimal precision."""
|
203 |
+
result = direct_k(3, 1)
|
204 |
+
assert abs(result - 0.3333333333333333) < 1e-15
|
205 |
+
|
206 |
+
def test_large_numbers(self):
|
207 |
+
"""Test with large numbers."""
|
208 |
+
assert direct_k(1e6, 2e6) == 2.0
|
209 |
+
|
210 |
+
def test_very_small_numbers(self):
|
211 |
+
"""Test with very small numbers."""
|
212 |
+
result = direct_k(1e-10, 5e-10)
|
213 |
+
assert abs(result - 5.0) < 1e-10
|
214 |
+
|
215 |
+
def test_fractional_results(self):
|
216 |
+
"""Test fractional results."""
|
217 |
+
result = direct_k(7, 2)
|
218 |
+
assert abs(result - 2/7) < 1e-15
|
219 |
+
|
220 |
+
|
221 |
+
class TestResizeDimensions:
|
222 |
+
"""Test the resize_dimensions function."""
|
223 |
+
|
224 |
+
def test_uniform_scaling(self):
|
225 |
+
"""Test uniform scaling of dimensions."""
|
226 |
+
result = resize_dimensions(100, 50, 2.0)
|
227 |
+
assert result == (200.0, 100.0)
|
228 |
+
|
229 |
+
def test_zero_dimensions(self):
|
230 |
+
"""Test with zero dimensions."""
|
231 |
+
result = resize_dimensions(0, 0, 3.0)
|
232 |
+
assert result == (0.0, 0.0)
|
233 |
+
|
234 |
+
result = resize_dimensions(100, 0, 2.0)
|
235 |
+
assert result == (200.0, 0.0)
|
236 |
+
|
237 |
+
def test_identity_scaling(self):
|
238 |
+
"""Test scaling with factor of 1."""
|
239 |
+
result = resize_dimensions(100, 50, 1.0)
|
240 |
+
assert result == (100.0, 50.0)
|
241 |
+
|
242 |
+
def test_large_scaling(self):
|
243 |
+
"""Test with large scale factors."""
|
244 |
+
result = resize_dimensions(10, 20, 100.0)
|
245 |
+
assert result == (1000.0, 2000.0)
|
246 |
+
|
247 |
+
def test_small_scaling(self):
|
248 |
+
"""Test with small scale factors."""
|
249 |
+
result = resize_dimensions(100, 200, 0.1)
|
250 |
+
assert result == (10.0, 20.0)
|
251 |
+
|
252 |
+
def test_decimal_dimensions(self):
|
253 |
+
"""Test with decimal dimensions."""
|
254 |
+
result = resize_dimensions(10.5, 20.7, 2.0)
|
255 |
+
assert result == (21.0, 41.4)
|
256 |
+
|
257 |
+
def test_negative_dimensions_error(self):
|
258 |
+
"""Test assertion error with negative dimensions."""
|
259 |
+
with pytest.raises(AssertionError, match="Width must be non-negative"):
|
260 |
+
resize_dimensions(-100, 50, 2.0)
|
261 |
+
|
262 |
+
with pytest.raises(AssertionError, match="Height must be non-negative"):
|
263 |
+
resize_dimensions(100, -50, 2.0)
|
264 |
+
|
265 |
+
with pytest.raises(AssertionError, match="Width must be non-negative"):
|
266 |
+
resize_dimensions(-100, -50, 2.0)
|
267 |
+
|
268 |
+
def test_zero_scale_error(self):
|
269 |
+
"""Test assertion error with zero scale factor."""
|
270 |
+
with pytest.raises(AssertionError, match="Scale factor must be positive"):
|
271 |
+
resize_dimensions(100, 50, 0)
|
272 |
+
|
273 |
+
def test_negative_scale_error(self):
|
274 |
+
"""Test assertion error with negative scale factor."""
|
275 |
+
with pytest.raises(AssertionError, match="Scale factor must be positive"):
|
276 |
+
resize_dimensions(100, 50, -1.5)
|
277 |
+
|
278 |
+
with pytest.raises(AssertionError, match="Scale factor must be positive"):
|
279 |
+
resize_dimensions(100, 50, -0.1)
|
280 |
+
|
281 |
+
def test_very_large_dimensions(self):
|
282 |
+
"""Test with very large dimensions."""
|
283 |
+
result = resize_dimensions(1e6, 1e7, 0.001)
|
284 |
+
assert result == (1000.0, 10000.0)
|
285 |
+
|
286 |
+
def test_precision_edge_cases(self):
|
287 |
+
"""Test precision edge cases."""
|
288 |
+
result = resize_dimensions(1/3, 2/3, 3.0)
|
289 |
+
expected_width = (1/3) * 3.0
|
290 |
+
expected_height = (2/3) * 3.0
|
291 |
+
assert abs(result[0] - expected_width) < 1e-15
|
292 |
+
assert abs(result[1] - expected_height) < 1e-15
|
293 |
+
|
294 |
+
|
295 |
+
class TestIntegration:
|
296 |
+
"""Integration tests combining multiple functions."""
|
297 |
+
|
298 |
+
def test_chained_calculations(self):
|
299 |
+
"""Test chaining multiple calculations."""
|
300 |
+
# Start with a percentage
|
301 |
+
percent = percent_of(25, 100) # 25%
|
302 |
+
|
303 |
+
# Use result in proportion
|
304 |
+
result = solve_proportion(percent, 100, None, 200) # 25/100 = x/200
|
305 |
+
assert result == 50.0
|
306 |
+
|
307 |
+
def test_proportion_verification(self):
|
308 |
+
"""Test proportion verification using cross multiplication."""
|
309 |
+
a, b, c = 3, 4, 6
|
310 |
+
d = solve_proportion(a, b, c, None)
|
311 |
+
|
312 |
+
# Verify: a/b should equal c/d
|
313 |
+
ratio1 = a / b
|
314 |
+
ratio2 = c / d
|
315 |
+
assert abs(ratio1 - ratio2) < 1e-15
|
316 |
+
|
317 |
+
def test_resize_and_scale_workflow(self):
|
318 |
+
"""Test resizing and scaling workflow."""
|
319 |
+
# Original dimensions
|
320 |
+
width, height = 100, 50
|
321 |
+
|
322 |
+
# Resize by factor of 2
|
323 |
+
new_width, new_height = resize_dimensions(width, height, 2.0)
|
324 |
+
assert new_width == 200.0
|
325 |
+
assert new_height == 100.0
|
326 |
+
|
327 |
+
# Scale the area by ratio
|
328 |
+
original_area = width * height
|
329 |
+
new_area = new_width * new_height
|
330 |
+
area_ratio = new_area / original_area
|
331 |
+
assert area_ratio == 4.0 # 2^2 = 4
|
332 |
+
|
333 |
+
def test_percentage_and_proportion_workflow(self):
|
334 |
+
"""Test workflow combining percentages and proportions."""
|
335 |
+
# What percentage is 15 of 60?
|
336 |
+
percent = percent_of(15, 60) # 25%
|
337 |
+
|
338 |
+
# If 25% of some number is 30, what's the number?
|
339 |
+
result = solve_proportion(percent, 100, 30, None)
|
340 |
+
assert result == 120.0
|
341 |
+
|
342 |
+
def test_scaling_chain(self):
|
343 |
+
"""Test chain of scaling operations."""
|
344 |
+
value = 100
|
345 |
+
|
346 |
+
# Scale by 1.5
|
347 |
+
value = scale_by_ratio(value, 1.5) # 150
|
348 |
+
|
349 |
+
# Scale by 2/3
|
350 |
+
value = scale_by_ratio(value, 2/3) # 100
|
351 |
+
|
352 |
+
# Should be back to original
|
353 |
+
assert abs(value - 100.0) < 1e-15
|
354 |
+
|
355 |
+
def test_real_world_recipe_scaling(self):
|
356 |
+
"""Test real-world recipe scaling scenario."""
|
357 |
+
# Original recipe serves 4, we want to serve 6
|
358 |
+
# Original calls for 2 cups flour
|
359 |
+
original_servings = 4
|
360 |
+
new_servings = 6
|
361 |
+
original_flour = 2
|
362 |
+
|
363 |
+
# Find scaling ratio
|
364 |
+
ratio = new_servings / original_servings # 1.5
|
365 |
+
|
366 |
+
# Scale flour amount
|
367 |
+
new_flour = scale_by_ratio(original_flour, ratio)
|
368 |
+
assert new_flour == 3.0
|
369 |
+
|
370 |
+
# Verify using proportion
|
371 |
+
flour_check = solve_proportion(original_flour, original_servings, None, new_servings)
|
372 |
+
assert flour_check == 3.0
|
373 |
+
|
374 |
+
def test_financial_calculation_workflow(self):
|
375 |
+
"""Test financial calculation workflow."""
|
376 |
+
# Investment grows from $1000 to $1250
|
377 |
+
initial = 1000
|
378 |
+
final = 1250
|
379 |
+
|
380 |
+
# What's the growth percentage?
|
381 |
+
growth_amount = final - initial # 250
|
382 |
+
growth_percent = percent_of(growth_amount, initial) # 25%
|
383 |
+
|
384 |
+
# Find the growth factor
|
385 |
+
growth_factor = direct_k(initial, final) # 1.25
|
386 |
+
|
387 |
+
# Verify by scaling
|
388 |
+
verification = scale_by_ratio(initial, growth_factor)
|
389 |
+
assert verification == final
|
390 |
+
|
391 |
+
|
392 |
+
class TestErrorHandling:
|
393 |
+
"""Test error handling and edge cases."""
|
394 |
+
|
395 |
+
def test_type_validation_errors(self):
|
396 |
+
"""Test that functions handle type validation properly."""
|
397 |
+
# These should work with int inputs (converted to float)
|
398 |
+
assert percent_of(1, 4) == 25.0
|
399 |
+
assert solve_proportion(1, 2, 3, None) == 6.0
|
400 |
+
assert scale_by_ratio(10, 2) == 20.0
|
401 |
+
assert direct_k(2, 6) == 3.0
|
402 |
+
assert resize_dimensions(10, 20, 2) == (20.0, 40.0)
|
403 |
+
|
404 |
+
def test_boundary_conditions(self):
|
405 |
+
"""Test mathematical boundary conditions."""
|
406 |
+
# Actual zero denominators should raise error
|
407 |
+
with pytest.raises(AssertionError):
|
408 |
+
percent_of(10, 0)
|
409 |
+
|
410 |
+
# Very small but non-zero numbers should work
|
411 |
+
result = percent_of(10, 1e-100)
|
412 |
+
assert result == 1e103
|
413 |
+
|
414 |
+
# Large numbers should work
|
415 |
+
result = percent_of(1e50, 1e60)
|
416 |
+
assert abs(result - 1e-8) < 1e-15
|
417 |
+
|
418 |
+
def test_floating_point_precision_limits(self):
|
419 |
+
"""Test floating point precision limits."""
|
420 |
+
# Very small numbers
|
421 |
+
result = scale_by_ratio(1e-300, 1e-300)
|
422 |
+
# Should not raise error, might be 0.0 due to underflow
|
423 |
+
assert isinstance(result, float)
|
424 |
+
|
425 |
+
# Very large numbers
|
426 |
+
result = scale_by_ratio(1e100, 1e100)
|
427 |
+
# Should not raise error, might be inf due to overflow
|
428 |
+
assert isinstance(result, float)
|