Metadata-Version: 2.5
Name: lane-mcp-auth
Version: 0.8.1
Summary: OAuth 2.1 resource server and consent gate for an MCP server behind Lane. FastMCP-friendly.
Project-URL: Homepage, https://github.com/Lane-Technologies-Inc/lane-mcp-auth
Project-URL: Source, https://github.com/Lane-Technologies-Inc/lane-mcp-auth
License: MIT
License-File: LICENSE
Keywords: fastmcp,mcp,model-context-protocol,oauth,oauth2,rfc8693,rfc9728
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27
Requires-Dist: pyjwt[crypto]>=2.8
Provides-Extra: fastmcp
Requires-Dist: fastmcp>=2.0; extra == 'fastmcp'
Description-Content-Type: text/markdown

# lane-mcp-auth

OAuth 2.1 resource server and consent gate for an MCP server behind Lane — the
Python distribution of [`@getonlane/mcp-auth`](../README.md).

**Docs** — [overview](https://docs.getonlane.com/sell/mcp-auth/overview) · [quickstart](https://docs.getonlane.com/sell/mcp-auth/quickstart) · [the gate](https://docs.getonlane.com/sell/mcp-auth/the-gate) · [reference](https://docs.getonlane.com/sell/mcp-auth/reference)

```sh
pip install lane-mcp-auth          # core
pip install 'lane-mcp-auth[fastmcp]'   # + the FastMCP helpers
```

Your server verifies bearer tokens, publishes the discovery document clients
need, and refuses every tool until the caller holds a recorded, revocable grant.

Set up with one command where the server runs. It pairs with the Lane console
and writes the credential to `.env`; the secret never passes through a browser:

```sh
python -m lane_mcp_auth setup https://acme.example/mcp --name="Acme shop"
```

Pass `client_id` and `announce_secret` and the server announces the resource it
serves on its first guarded call, so Lane binds the hostname to the credential.

```python
from lane_mcp_auth import LaneMcpAuth

auth = LaneMcpAuth(
    resource="https://acme.example/mcp",
    connections=connections,   # yours: this is server-side state
    exchanger=exchanger,
)
```

## The three tiers

| tier | reached by | declared |
|---|---|---|
| authenticated only | — | **not registrable** |
| any connected caller | completing the step-up | no scope |
| a specific authority | the step-up granting it | `scope="…"` |

Registration is the floor. Omitting a scope means *any connected caller*, never
*anyone*: a session that has not registered has nothing anyone can revoke, so a
tool answering it would be answering something nobody can withdraw.

## With FastMCP

```python
from lane_mcp_auth.fastmcp import register_step_up_tool, guarded

register_step_up_tool(mcp, auth)

@mcp.tool()
@guarded(auth, "read_orders", scope="email")
async def read_orders(ctx) -> str:
    return await orders_for(ctx)
```

## Purchases

A tool marked with `@price` never buys when an agent calls it. It answers with a
proposal and a ticket; the agent builds a plan with the five `lane_plan_*` tools
this package registers, and the user approves it in their Lane wallet. Lane then
calls the tool once per approved step with an execution grant.

```python
from lane_mcp_auth import HttpProposalRecorder, PlanForwarder
from lane_mcp_auth.fastmcp import enable_lane_auth, lane, price

enable_lane_auth(
    mcp,
    auth,
    purchase={"host": "courts.example", "recorder": HttpProposalRecorder(base_url=LANE, org_key=ORG_KEY)},
    plans=PlanForwarder(exchanger=exchanger, lane_mcp_url="https://mcp.getonlane.com/mcp"),
)

@mcp.tool()
@price(cents=2500)
async def book_court(ctx, court: str) -> str:
    return await book(court)          # runs only under an execution grant

@mcp.tool()
@price(dynamic=True)
async def book_flight(ctx, offer_id: str) -> str:
    if not lane().executing:
        quote = await price_for(offer_id)
        return await lane().propose(amount_cents=quote.cents, description=quote.summary)
    return await book(offer_id, lane().approved_amount_cents)
```

The docs page [charging for a purchase](https://docs.getonlane.com/sell/auth/purchases) covers reservations, what runs when, and the 7-day plan limit.

## Tools that cost less than a cent

Lane bills a tool marked with `@subcent` on every call, in USD microdollars,
from 1 to 9,999. The user approves no single call. Lane reserves the amount
before your handler runs, commits it after a good result, and releases it after
a failed one.

```python
from lane_mcp_auth import HttpBillingClient, SubcentBilling
from lane_mcp_auth.fastmcp import enable_lane_auth, subcent

billing = SubcentBilling(
    client=HttpBillingClient(base_url=LANE, org_key=ORG_KEY),
    resource="https://tools.example/mcp",
)

enable_lane_auth(mcp, auth, subcent=billing)

@mcp.tool()
@subcent(micros=2500)  # $0.0025 a call
async def search_products(ctx, q: str) -> str:
    """Search the merchant catalog."""
    return await search(q)
```

The price reaches `tools/list` as `lane/price`. Lane hashes what the tool
publishes -- the name, the description, the schemas, the Lane tags and the
price -- and that hash names the revision Lane bills. Lane bills a call only
while it holds that exact revision as active. Lane refuses a changed tool with
`tool_revision_not_active` until it activates the new revision. Every other tool
on the server still runs.

## Never authorize on the token's scopes

`claims.scopes` is the token's own claim: empty before the step-up, stale after.
Authority lives in the connection and is reachable only through `has_scope()` /
`effective_scopes()`, because Lane decides it at exchange time and can refuse an
exchange it would previously have allowed. A signed claim cannot be withdrawn.

## Parity with the TypeScript

Same invariants, same names where Python idiom allows. The TypeScript package is
the reference implementation; where the two could drift — scope filtering, the
metadata paths, the gate's decision table — see `SCOPE.md` for what is
implemented here and what is not yet.

## Licence

MIT
