Metadata-Version: 2.1
Name: metrify-sdk
Version: 0.2.1
Summary: Billing SDK for MCP servers
License: MIT
Project-URL: Homepage, https://github.com/mangelico/metrify-sdk
Requires-Python: >=3.9
Description-Content-Type: text/markdown
Requires-Dist: httpx>=0.27.0
Requires-Dist: pydantic>=2.0
Requires-Dist: python-dotenv>=1.0.0
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
Requires-Dist: pytest-mock>=3.12; extra == "dev"
Requires-Dist: respx>=0.21; extra == "dev"

# metrify

**Billing SDK for MCP servers.** Add pay-per-call monetization to any MCP tool
in under 5 minutes — no payment infrastructure required.

---

## Quickstart

### 1. Register as a provider

Sign up at the Metrify dashboard and get your `pk_live_...` provider key.

### 2. Install the SDK

```bash
pip install metrify
```

### 3. Configure your provider key

```bash
export METRIFY_PROVIDER_KEY="pk_live_your_key_here"
```

Or pass it directly in code (see step 4).

### 4. Decorate your tools and register them

```python
from metrify import Metrify

m = Metrify()  # reads METRIFY_PROVIDER_KEY from env

@m.tool(price=0.01, unit="per_call")
async def summarize(consumer_api_key: str, text: str) -> str:
    """Summarize a block of text."""
    # Balance is pre-checked before this runs; charge happens only on success
    return f"Summary of: {text[:50]}..."

# On startup, push all decorated tools to the Metrify dashboard:
await m.register_tools()
```

`register_tools()` is idempotent — run it every time your server starts.
Tools with no docstring get `description=None`; pass `description=` explicitly
to override:

```python
@m.tool(price=0.05, unit="per_call", description="OCR a PDF page")
async def ocr_page(consumer_api_key: str, page_url: str) -> str:
    ...
```

### 5. Deploy

Your tool now charges consumers on every call. The `consumer_api_key` is
extracted automatically by the decorator — consumers pass it as the first
argument or as a keyword argument.

---

## Full example with FastMCP

```python
from metrify import Metrify
from mcp.server.fastmcp import FastMCP

m = Metrify(provider_key="pk_live_your_key_here")
server = FastMCP("my-mcp-server")

@server.tool()
@m.tool(price=0.05, unit="per_call")
async def analyze_document(consumer_api_key: str, content: str) -> str:
    """Analyze a document and return insights."""
    # billing.charge() runs before this line
    analysis = await run_analysis(content)
    return analysis

@server.tool()
@m.tool(price=0.001, unit="per_token")
async def generate_text(consumer_api_key: str, prompt: str) -> str:
    """Generate text from a prompt."""
    result = await llm.complete(prompt)
    return result
```

---

## Billing flow

The decorator runs a **two-phase billing** flow on every call:

1. **Pre-check** — verify the consumer has enough balance (no charge yet).
2. **Execute** — run your tool function.
3. **Charge** — deduct only if the function completed without error.

If the function raises any exception the consumer is never charged.

## Exception reference

| Exception | Raised by | Behavior |
|-----------|-----------|----------|
| `InsufficientBalanceError` | Pre-check | Propagated — consumer has no funds |
| `UpstreamError` | Your tool | Caught — returns message to consumer, no charge |
| `ProviderSuspendedError` | Pre-check / charge | Propagated — your account is suspended |
| `ConsumerSuspendedError` | Pre-check / charge | Propagated — consumer account is suspended |
| `ProviderNotFoundError` | Pre-check / charge | Propagated — your provider key is invalid |
| `GatewayError` | Pre-check / charge | Propagated — network error or backend 5xx |
| Any other `Exception` | Your tool | Caught — returns `"Tool error: ..."`, no charge |

### Signalling upstream failures with `UpstreamError`

Use `UpstreamError` when an external API your tool depends on fails. The
decorator catches it, skips billing, and returns your message to the consumer:

```python
from metrify import Metrify, UpstreamError

m = Metrify()

@m.tool(price=0.05, unit="per_call")
async def summarize(consumer_api_key: str, text: str) -> str:
    try:
        result = await anthropic_client.messages.create(...)
    except anthropic.APIError as e:
        raise UpstreamError(f"Anthropic API error: {e.message}")
    return result.content[0].text
```

The consumer receives `"Anthropic API error: ..."` and is not charged.

```python
from metrify.exceptions import InsufficientBalanceError, GatewayError

# In your MCP server error handler:
try:
    result = await my_tool(consumer_api_key=ck, query=q)
except InsufficientBalanceError:
    return "Insufficient balance. Please top up at the Metrify dashboard."
except GatewayError:
    return "Billing service temporarily unavailable. Please retry."
```

---

## Environment variables

| Variable | Default | Description |
|----------|---------|-------------|
| `METRIFY_PROVIDER_KEY` | — | Your provider key (`pk_live_...`) |
| `METRIFY_GATEWAY_URL` | `https://airy-wholeness-production-fcc4.up.railway.app` | Override backend URL |
| `METRIFY_MCP_URL` | — | Public URL of your MCP server (e.g. `https://myserver.railway.app/mcp`) |

---

## Supported billing units (V1)

All units charge a flat `price` per call. The `unit` field is displayed in your
dashboard for reporting purposes.

| Unit | Use case |
|------|----------|
| `per_call` | Fixed price per tool invocation |
| `per_token` | LLM completions (flat in V1) |
| `per_minute` | Audio/video processing |
| `per_image` | Image generation or analysis |
| `per_page` | Document processing |

---

## License

MIT
