https://github.com/brunvelop/refract
One Python function â REST API, CLI, MCP tools for AI agents & Web UI. Zero boilerplate.
https://github.com/brunvelop/refract
ai-agents cli fastapi llm-tools mcp python
Last synced: 3 months ago
JSON representation
One Python function â REST API, CLI, MCP tools for AI agents & Web UI. Zero boilerplate.
- Host: GitHub
- URL: https://github.com/brunvelop/refract
- Owner: Brunvelop
- License: mit
- Created: 2026-03-23T18:20:30.000Z (3 months ago)
- Default Branch: main
- Last Pushed: 2026-03-26T16:04:55.000Z (3 months ago)
- Last Synced: 2026-03-27T02:35:26.108Z (3 months ago)
- Topics: ai-agents, cli, fastapi, llm-tools, mcp, python
- Language: Python
- Homepage:
- Size: 170 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- License: LICENSE
Awesome Lists containing this project
README
# refract đ
> **One Python function. Four interfaces. Zero boilerplate.**
[](https://www.python.org/downloads/)
[](LICENSE)
[]()
Define a typed Python function once â and automatically get a **REST API**, a **CLI**, **MCP tools** for AI agents, and a **Frontend SDK**.
```python
from pydantic import BaseModel
from refract import Refract, register_function
class Sum(BaseModel):
result: int
@register_function()
def add(a: int, b: int) -> Sum:
"""Add two numbers.
Args:
a: First number
b: Second number
"""
return Sum(result=a + b)
app = Refract("my-project")
```
That's it. No routers, no argument parsers, no tool definitions.
---
## What you get
| Interface | How to get it | What it gives you |
|-----------|--------------|-------------------|
| **REST API** | `app.api()` | FastAPI app with auto-generated endpoints |
| **CLI** | `app.cli()` | Click group with `serve`, `list`, and one command per function |
| **MCP tools** | `app.mcp()` | FastAPI + MCP server for AI agents / LLMs |
| **Frontend SDK** | `import { RefractClient } from '/refract/client.js'` | Typed JS client for browsers and frameworks |
---
## đ Quick start
**Install:**
```bash
pip install git+https://github.com/Brunvelop/refract
```
**Try the demo right now:**
```bash
git clone https://github.com/Brunvelop/refract && cd refract
python demo.py serve # API + MCP + Web UI at http://localhost:8000
python demo.py list # List all registered functions
python demo.py add --a 3 --b 5
python demo.py greet --name World
```
---
## đ§Š Define functions
Every function follows the same pattern: **typed signature + docstring + `BaseModel` return type**.
```python
from pydantic import BaseModel
from refract import register_function
class SearchResponse(BaseModel):
items: list[str]
total: int
@register_function(http_methods=["GET"])
def search(query: str, limit: int = 10) -> SearchResponse:
"""Search items.
Args:
query: Search term
limit: Maximum number of results
"""
results = ["foo", "bar", "baz"][:limit]
return SearchResponse(items=results, total=len(results))
```
The return type becomes the FastAPI `response_model` â precise OpenAPI schema, full type safety, and the exact same shape on every interface.
### Decorator options
```python
@register_function(
http_methods=["GET", "POST"], # default: ["GET", "POST"]
interfaces=["api", "cli"], # default: ["api", "cli", "mcp"]
streaming=False, # default: False
stream_func=None, # required if streaming=True
)
```
> **Note:** `async def` functions are fully supported on **API** and **MCP** interfaces â they will be properly awaited. The **CLI** interface only supports synchronous functions; async functions are automatically skipped with a warning at CLI build time.
> **CLI limitations:** Parameters with complex types (`list`, `dict`, `List[str]`, `Dict[str, int]`, etc.) fall back to plain strings in the CLI interface. Click only natively supports `int`, `float`, `bool`, and `str`. For functions that require structured inputs, consider restricting them to `interfaces=["api", "mcp"]`, or accept a JSON string and parse it inside the function.
---
## đ Setup modes
The setup scales progressively. Each mode adds one or two lines â never a new file.
### Mode 1 â One line (recommended default)
```python
# my_project/app.py
from refract import Refract
app = Refract("my-project", discover=["my_project.core"])
```
Wire it up as a CLI entry point:
```toml
# pyproject.toml
[project.scripts]
my-project = "my_project.app:app.run_cli"
```
You immediately get:
```bash
my-project serve # Start unified server (API + MCP + UI) at http://0.0.0.0:8000
my-project serve-api # Start REST API only at http://127.0.0.1:8000
my-project serve-mcp # Start MCP-only server at http://127.0.0.1:8001
my-project list # List all registered functions
my-project add --a 1 --b 2 # Call any registered function directly
my-project --verbose serve # Enable DEBUG logging
```
> **No boilerplate.** The `discover=` list tells Refract which packages to scan for `@register_function` decorators. Everything else is automatic.
> **Custom views and static assets** â Pass `views` to replace the default dashboard with your own HTML pages, and `static_dirs` to serve additional static directories:
>
> ```python
> app = Refract("my-project",
> discover=["my_project.core"],
> views={"/": "templates/index.html", "/about": "templates/about.html"},
> static_dirs=[("/static", "my_app/static")],
> )
> ```
>
> The `/refract/` namespace is reserved for the SDK JS files and must not be used in `static_dirs`.
> **Advanced uvicorn options** â The built-in `serve` commands are convenience wrappers that support `--host` and `--port`. Features like auto-reload, multiple workers, or custom log levels require uvicorn to be invoked with a **string import path** pointing to an ASGI application. `app.run_cli` is a Click group, not an ASGI app â expose `app.api()` as a module-level variable instead:
>
> ```python
> # my_project/app.py
> from refract import Refract
>
> app = Refract("my-project", discover=["my_project.core"])
> fastapi_app = app.api() # ASGI app â this is what uvicorn needs
> ```
>
> ```bash
> # Auto-reload in development
> uvicorn my_project.app:fastapi_app --reload
>
> # Multiple workers for production
> uvicorn my_project.app:fastapi_app --workers 4 --host 0.0.0.0 --port 8000
>
> # Custom log level
> uvicorn my_project.app:fastapi_app --log-level warning
> ```
>
> See the [uvicorn docs](https://www.uvicorn.org/settings/) for the full list of available options.
### Mode 2 â Custom CLI commands
Same file, add `@app.command()`:
```python
# my_project/app.py
from refract import Refract
app = Refract("my-project", discover=["my_project.core"])
@app.command()
def health_check():
"""Run project health checks."""
import subprocess
subprocess.run(["pytest", "tests/health/", "-q"])
```
```bash
my-project health-check # Your custom command, alongside serve/list
```
Custom commands use Click under the hood â `click.echo`, `click.option`, etc. work normally.
### Mode 3 â Bring your own FastAPI app
Use `app.router()` to mount only the function endpoints onto your own FastAPI instance:
```python
# my_project/app.py
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from refract import Refract
refract_app = Refract("my-project", discover=["my_project.core"])
my_app = FastAPI(title="My Project", version="1.0.0")
my_app.add_middleware(CORSMiddleware, allow_origins=["*"])
my_app.include_router(refract_app.router())
@my_app.get("/status")
def status():
return {"ok": True}
```
`app.router()` includes:
- `POST/GET /{funcName}` â one endpoint per registered function
- `GET /functions/details` â JSON Schema discovery for the frontend
- `GET /health` â basic health check
### MCP sidecar â `mcp_only()`
For dedicated AI agent deployments, use `mcp_only()` to spin up a minimal FastAPI app with **only** MCP tool endpoints and a `/health` check â no REST API, no HTML pages, no static files:
```python
# Sidecar deployment: MCP tools without a full REST API
mcp_app = app.mcp_only()
```
This is also what `serve-mcp` uses under the hood, making it ideal for a separate MCP process alongside an existing REST API server.
### Accessing the current instance â `Refract.current()`
Following the Flask `current_app` pattern, `Refract.current()` returns the most recently created instance. Useful in business-logic modules that must not import the app module directly (to avoid circular imports):
```python
from refract import Refract
# In a module that must not import app.py:
instance = Refract.current()
mcp_functions = instance.get_functions_for_interface("mcp")
```
---
## đĄ Streaming (SSE)
For long-running functions (LLM calls, background jobs), use Server-Sent Events:
```python
import json
import asyncio
from pydantic import BaseModel
from refract import register_function
from refract.sse import format_sse
class ProcessResult(BaseModel):
text: str
async def _stream_process(text: str):
"""Async generator that yields SSE-formatted events."""
words = text.split()
for word in words:
await asyncio.sleep(0.1)
yield format_sse("token", json.dumps({"chunk": word}))
yield format_sse("complete", json.dumps({"message": "done"}))
@register_function(streaming=True, stream_func=_stream_process)
def process_text(text: str) -> ProcessResult:
"""Process text word by word.
Args:
text: Text to process
"""
return ProcessResult(text=text)
```
On the frontend, consume it with `RefractClient.stream()`:
```javascript
import { RefractClient } from '/refract/client.js';
const api = new RefractClient();
for await (const { event, data } of api.stream('process_text', { text: 'hello world' })) {
if (event === 'token') console.log(data.chunk);
if (event === 'complete') console.log('Done:', data.message);
}
```
---
## đ Frontend
### `RefractClient` (vanilla JS, no framework)
Pure HTTP client for calling Refract functions from JavaScript.
Works in any context â vanilla JS, React, Vue, Lit, tests.
```javascript
import { RefractClient } from '/refract/client.js';
const api = new RefractClient();
// Call a function â schemas are auto-loaded and cached on the first call
const data = await api.call('add', { a: 1, b: 2 });
// â { result: 3 } â your Pydantic model, as returned by the API
// Stream (SSE)
for await (const { event, data } of api.stream('process', { text: 'hello' })) {
if (event === 'token') console.log(data.chunk);
if (event === 'complete') console.log('Done');
}
// Access schemas (type info from Python)
await api.loadSchemas();
const schema = api.getSchema('add');
// schema.parameters â [{ name: 'a', type: 'int', required: true }, ...]
// schema.response_schema â { properties: { result: { type: 'integer' } } }
// Validate before calling (for form UX)
const { valid, errors } = api.validate('add', { a: 1 });
// â { valid: false, errors: { b: 'Required' } }
```
Parameters are validated against Python type definitions before each call (opt-in via `{ validate: true }`).
No type coercion â pass the correct JS types matching your Python signatures.
The response is returned as-is: it is the serialised form of your Pydantic model.
---
## đĨ Dashboard
`app.api()` (and `serve`) include a built-in dashboard at `/` and `/functions`. No configuration needed:
- **Health badge** â live status from `GET /health` (function count + healthy/unreachable)
- **Quick links** â one-click access to Swagger UI, ReDoc, MCP endpoint, Health check, and Schema JSON
- **Registry table** â all registered functions with name, description, HTTP methods, and interface badges (API / CLI / MCP / SSE)
- **MCP panel** â the full endpoint URL and connection string (with copy buttons), automatically hidden when MCP is not mounted
For interactive testing of your functions, use **Swagger UI** (`/docs`) â it's purpose-built for that.
---
## đ Schema sharing (back â front)
Every function schema includes a `response_schema` â the JSON Schema generated by Pydantic from the return type. This is the contract between backend and frontend.
```
GET /functions/details
```
```json
{
"functions": {
"add": {
"name": "add",
"description": "Add two numbers.",
"http_methods": ["GET", "POST"],
"parameters": [
{ "name": "a", "type": "int", "required": true, "description": "First number" },
{ "name": "b", "type": "int", "required": true, "description": "Second number" }
],
"streaming": false,
"response_schema": {
"properties": {
"result": { "title": "Result", "type": "integer" }
},
"required": ["result"],
"title": "Sum",
"type": "object"
}
}
}
}
```
The frontend always knows the exact shape of the response â enabling runtime validation, type-safe consumers, and future codegen.
---
## đ Discovery logging
Discovery is **lazy** â modules are imported on the first registry query, not at instantiation time. When Refract scans your packages, you see exactly what happened:
```
[refract:my-project] Scanning my_project.core...
[refract:my-project] â
my_project.core.math â 2 functions
[refract:my-project] â
my_project.core.search â 1 function
[refract:my-project] â ī¸ my_project.core.ai â skipped (ImportError: dspy not installed)
[refract:my-project] âšī¸ my_project.core.models â no @register_function found
[refract:my-project] Total: 3 functions registered, 1 module skipped
```
Discovery is resilient by default â import errors are logged and skipped. Use `--verbose` to enable DEBUG-level output.
---
## đ Architecture
```
refract/
âââ refract/
â âââ __init__.py # Public API: Refract, register_function (version falls back to `0.0.0-dev` when not installed)
â âââ models.py # ParamSchema, FunctionInfo, FunctionSchema
â âââ registry.py # @register_function decorator + Registry class
â âââ refract.py # Refract facade class
â âââ api.py # FastAPI app/router factories
â âââ cli.py # Click group factory
â âââ mcp.py # FastAPI + MCP factory
â âââ sse.py # format_sse(), _create_stream_handler()
â âââ log_config.py # configure_cli_logging(), configure_api_logging()
â âââ web/
â âââ client.js # RefractClient â typed JS client (vanilla JS, no framework)
â âââ views/
â âââ dashboard.html # Default web UI (served at / and /functions)
âââ demo.py # Runnable demo â try it right now
âââ tests/
âââ conftest.py
âââ test_models.py
âââ test_registry.py
âââ test_api.py
âââ test_cli.py
âââ test_mcp.py
âââ test_sse.py
```
---
## đ API Reference
### `Refract(name, discover=None, views=None, static_dirs=None)`
| Parameter | Type | Default | Description |
|---|---|---|---|
| `name` | `str` | â | Human-readable name for this instance (used in logs and API title) |
| `discover` | `list[str] \| None` | `None` | Package paths to scan for `@register_function` decorators |
| `views` | `dict[str, str] \| None` | `None` | URL path â HTML file path mapping; replaces the default dashboard |
| `static_dirs` | `list[tuple[str, str]] \| None` | `None` | Extra `(mount_path, directory)` pairs for static file serving |
| Method / Property | Returns | Description |
|---|---|---|
| `.api()` | `FastAPI` | Complete FastAPI app with static files and HTML views |
| `.router()` | `APIRouter` | Only the function endpoints (mount in your own app) |
| `.cli()` | `click.Group` | Click group with built-in and function commands |
| `.mcp()` | `FastAPI` | FastAPI app with full API + MCP integration |
| `.mcp_only()` | `FastAPI` | MCP-only FastAPI app (no REST API, no UI, no static files) |
| `.run_cli` | `click.Group` | Cached property â use as `pyproject.toml` entry point |
| `@.command(name=None, **kwargs)` | decorator | Register a custom Click command; `name` defaults to the function name with `_` â `-` |
| `.current()` | `Registry` | Classmethod â returns the most recently created instance (Flask `current_app` pattern) |
| `.get_all_functions()` | `list[FunctionInfo]` | All registered functions |
| `.get_all_schemas()` | `list[FunctionSchema]` | Serialisable schemas (JSON-safe) |
| `.get_function_by_name(name)` | `FunctionInfo \| None` | Look up a function by name |
| `.function_count()` | `int` | Number of registered functions |
### `register_function(...)`
| Parameter | Type | Default | Description |
|---|---|---|---|
| `http_methods` | `list[str]` | `["GET", "POST"]` | HTTP verbs to expose on the API |
| `interfaces` | `list[str]` | `["api", "cli", "mcp"]` | Which interfaces to enable |
| `streaming` | `bool` | `False` | Enable SSE streaming mode |
| `stream_func` | `Callable \| None` | `None` | Async generator for streaming (required if `streaming=True`) |
### `format_sse(event, data)`
```python
from refract.sse import format_sse
line = format_sse("token", '{"chunk": "hello"}')
# â "event: token\ndata: {\"chunk\": \"hello\"}\n\n"
```
---
## License
MIT â see [LICENSE](LICENSE).