slice-gateway 0.2.2__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
- # The slice-gateway distribution is the CLI only. Keep the sdist to the CLI module,
2
- # its PyPI readme, the license, and build metadata. Nothing from the server repo.
1
+ # The slice-gateway distribution is the CLI plus the standalone MCP server. Keep the sdist
2
+ # to those two, the PyPI readme, the license, and build metadata. Nothing else from the
3
+ # server repo (mcp_server is kept, so it is not pruned here).
3
4
  prune tests
4
5
  prune app
5
6
  prune infra
@@ -12,7 +13,6 @@ prune demo
12
13
  prune scripts
13
14
  prune guardrails
14
15
  prune docs
15
- prune mcp_server
16
16
  prune gateway
17
17
  prune pipeline
18
18
  prune rag_store
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slice-gateway
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Command-line client for slice, the self-hosted LLM gateway.
5
5
  Author: Jishnu Janardanan
6
6
  License-Expression: MIT
@@ -15,6 +15,7 @@ Description-Content-Type: text/markdown
15
15
  License-File: LICENSE
16
16
  Requires-Dist: typer
17
17
  Requires-Dist: httpx
18
+ Requires-Dist: mcp<2,>=1.9
18
19
  Dynamic: license-file
19
20
 
20
21
  # slice-gateway
@@ -43,12 +44,24 @@ This package installs the `slice` command. It logs you in through GitHub, then p
43
44
 
44
45
  Run `slice use anthropic`, `slice use openai`, `slice use curl`, or `slice use claude-code` to print the exact lines for your tool.
45
46
 
47
+ ## Claude Code MCP
48
+
49
+ This package also installs `slice-mcp`, a stdio MCP server. Add it to Claude Code with one command:
50
+
51
+ claude mcp add slice -s user -e SLICE_API_KEY=slk_live_... -- slice-mcp
52
+
53
+ It exposes six read and write tools over your slice account: check spend against budget, list recent requests, show the eval pass rate, and list, add, or delete model-routing rules. It talks to the hosted gateway by default; set `SLICE_BASE_URL` for a self-hosted one.
54
+
46
55
  ## The gateway
47
56
 
48
57
  This is only the client. The slice gateway itself, the server that does the routing, caching, budgets, and dashboards, lives on GitHub at https://github.com/jjk30/slice.
49
58
 
50
59
  Homepage: https://sliceapp.dev
51
60
 
61
+ ## What's new in 0.2.3
62
+
63
+ The package now installs `slice-mcp`, the slice MCP server, so `pipx install slice-gateway` gives you both the CLI and the Claude Code integration. See the Claude Code MCP section above.
64
+
52
65
  ## What's new in 0.2.2
53
66
 
54
67
  `slice login` now ends by printing your dashboard link and opening it in the browser.
@@ -24,12 +24,24 @@ This package installs the `slice` command. It logs you in through GitHub, then p
24
24
 
25
25
  Run `slice use anthropic`, `slice use openai`, `slice use curl`, or `slice use claude-code` to print the exact lines for your tool.
26
26
 
27
+ ## Claude Code MCP
28
+
29
+ This package also installs `slice-mcp`, a stdio MCP server. Add it to Claude Code with one command:
30
+
31
+ claude mcp add slice -s user -e SLICE_API_KEY=slk_live_... -- slice-mcp
32
+
33
+ It exposes six read and write tools over your slice account: check spend against budget, list recent requests, show the eval pass rate, and list, add, or delete model-routing rules. It talks to the hosted gateway by default; set `SLICE_BASE_URL` for a self-hosted one.
34
+
27
35
  ## The gateway
28
36
 
29
37
  This is only the client. The slice gateway itself, the server that does the routing, caching, budgets, and dashboards, lives on GitHub at https://github.com/jjk30/slice.
30
38
 
31
39
  Homepage: https://sliceapp.dev
32
40
 
41
+ ## What's new in 0.2.3
42
+
43
+ The package now installs `slice-mcp`, the slice MCP server, so `pipx install slice-gateway` gives you both the CLI and the Claude Code integration. See the Claude Code MCP section above.
44
+
33
45
  ## What's new in 0.2.2
34
46
 
35
47
  `slice login` now ends by printing your dashboard link and opening it in the browser.
@@ -0,0 +1,23 @@
1
+ """slice MCP server (phase 14).
2
+
3
+ A standalone stdio MCP server that exposes slice's data as tools inside Claude Code.
4
+ It is a *thin adapter* over slice's existing HTTP gateway: it makes short async httpx
5
+ calls to the running gateway and shapes the JSON into compact, human-readable text. It
6
+ holds no gateway logic, no database access, and no business rules of its own: every
7
+ number it reports comes from an endpoint the gateway already serves.
8
+
9
+ Run it as ``python -m mcp_server`` (stdio). Configuration is two env vars, read by
10
+ ``mcp_server.config``: ``SLICE_BASE_URL`` (default ``http://localhost:8080``) and
11
+ ``SLICE_API_KEY`` (the slice key, sent as ``Authorization: Bearer <key>``, the same way
12
+ the phase-12 gateway auth reads it). With no key set the server still works against a
13
+ gateway running in local/unlocked mode; a call that comes back 401 tells the user to set
14
+ the key.
15
+
16
+ Layout:
17
+
18
+ - ``config``: env-driven settings.
19
+ - ``client``: the httpx wrapper and its typed errors (unreachable / unauthorized / other).
20
+ - ``tools``: the tool functions. Pure ``(SliceClient, args) -> str``; this is the layer
21
+ the tests drive with a mocked gateway. No dependency on the ``mcp`` SDK.
22
+ - ``server``: wires the tool functions into an ``mcp`` ``FastMCP`` server as stdio tools.
23
+ """
@@ -0,0 +1,21 @@
1
+ """Entry point: ``python -m mcp_server`` or the ``slice-mcp`` console script runs the
2
+ slice MCP server over stdio.
3
+
4
+ Reads ``SLICE_BASE_URL`` / ``SLICE_API_KEY`` from the environment (see
5
+ ``mcp_server.config``), builds the FastMCP server, and hands control to the ``mcp`` SDK's
6
+ stdio transport. Claude Code (or any MCP client) speaks to it over stdin/stdout. Exits
7
+ with a one-line message when ``SLICE_API_KEY`` is unset (see ``load_settings``).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from mcp_server.config import load_settings
13
+ from mcp_server.server import build_server
14
+
15
+
16
+ def main() -> None:
17
+ build_server(load_settings()).run("stdio")
18
+
19
+
20
+ if __name__ == "__main__":
21
+ main()
@@ -0,0 +1,136 @@
1
+ """The httpx wrapper around slice's gateway, with typed errors the tools turn into text.
2
+
3
+ This is the only place that talks to the network. It builds one short-timeout
4
+ ``httpx.AsyncClient`` against ``SLICE_BASE_URL``, attaches the slice key on every call,
5
+ and classifies the outcome into three failure kinds the tools can render cleanly:
6
+
7
+ - ``GatewayUnreachable``: the gateway isn't answering (connection refused, DNS, a
8
+ timeout). The tools turn this into "slice gateway not running at <url>", never a stack
9
+ trace. This is by far the most common failure while developing locally.
10
+ - ``GatewayUnauthorized``: the gateway answered 401. The slice key is missing, wrong, or
11
+ revoked; the tools tell the user to set ``SLICE_API_KEY``.
12
+ - ``GatewayError``: any other non-2xx (4xx/5xx). Carries the status and the gateway's own
13
+ error message when it sent one, so a 404/503 surfaces the gateway's words.
14
+
15
+ Nothing here raises a bare httpx exception up to the tools: ``request`` maps them all.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from typing import Any
22
+
23
+ import httpx
24
+
25
+ from mcp_server.config import Settings
26
+
27
+ # Short timeouts: this is a local adapter, not a long-running proxy. A gateway that is up
28
+ # answers a read in milliseconds; one that is down should fail fast, not hang Claude Code.
29
+ TIMEOUT = httpx.Timeout(5.0, connect=2.0)
30
+
31
+
32
+ class GatewayUnreachable(Exception):
33
+ """The gateway could not be reached at all (connection error or timeout)."""
34
+
35
+ def __init__(self, base_url: str, detail: str = "") -> None:
36
+ self.base_url = base_url
37
+ self.detail = detail
38
+ super().__init__(f"slice gateway not reachable at {base_url}")
39
+
40
+
41
+ class GatewayUnauthorized(Exception):
42
+ """The gateway answered 401: the slice key is missing, invalid, or revoked."""
43
+
44
+ def __init__(self, message: str = "") -> None:
45
+ self.message = message
46
+ super().__init__("unauthorized")
47
+
48
+
49
+ class GatewayError(Exception):
50
+ """Any other non-2xx response from the gateway."""
51
+
52
+ def __init__(self, status_code: int, message: str = "") -> None:
53
+ self.status_code = status_code
54
+ self.message = message
55
+ super().__init__(f"gateway returned {status_code}")
56
+
57
+
58
+ def _error_message(response: httpx.Response) -> str:
59
+ """Pull the gateway's own error text out of a non-2xx body, in any of its shapes.
60
+
61
+ The gateway speaks three error shapes (see ``app.auth.middleware.error_body``):
62
+ ``{"error": {"message": ...}}`` (admin/dashboard), the OpenAI ``{"error": {...}}``,
63
+ and the Anthropic ``{"error": {"type", "message"}}``. All nest the message under
64
+ ``error.message``, so one lookup covers them; a non-JSON body falls back to the text.
65
+ """
66
+ try:
67
+ body = response.json()
68
+ except (json.JSONDecodeError, ValueError):
69
+ return response.text.strip()[:200]
70
+ if isinstance(body, dict):
71
+ err = body.get("error")
72
+ if isinstance(err, dict) and isinstance(err.get("message"), str):
73
+ return err["message"]
74
+ if isinstance(err, str):
75
+ return err
76
+ if isinstance(body.get("detail"), str):
77
+ return body["detail"]
78
+ return ""
79
+
80
+
81
+ class SliceClient:
82
+ """A thin async client for the slice gateway. Owns one httpx client for its lifetime."""
83
+
84
+ def __init__(self, settings: Settings, http: httpx.AsyncClient) -> None:
85
+ self._settings = settings
86
+ self._http = http
87
+
88
+ @property
89
+ def base_url(self) -> str:
90
+ return self._settings.base_url
91
+
92
+ @property
93
+ def has_key(self) -> bool:
94
+ return self._settings.api_key is not None
95
+
96
+ @classmethod
97
+ def build(cls, settings: Settings) -> "SliceClient":
98
+ """A client with its own httpx.AsyncClient. Close it with ``aclose``."""
99
+ http = httpx.AsyncClient(
100
+ base_url=settings.base_url,
101
+ headers=settings.auth_headers(),
102
+ timeout=TIMEOUT,
103
+ )
104
+ return cls(settings, http)
105
+
106
+ async def aclose(self) -> None:
107
+ await self._http.aclose()
108
+
109
+ async def request(
110
+ self, method: str, path: str, *, params: dict | None = None, json_body: Any = None
111
+ ) -> Any:
112
+ """Make one gateway call and return parsed JSON, or raise a typed gateway error.
113
+
114
+ Every transport failure becomes ``GatewayUnreachable``; a 401 becomes
115
+ ``GatewayUnauthorized``; any other non-2xx becomes ``GatewayError``. A 2xx with an
116
+ empty or non-JSON body returns ``None`` rather than raising: a few write
117
+ responses carry only a short JSON object, and this stays lenient about the rest.
118
+ """
119
+ try:
120
+ response = await self._http.request(method, path, params=params, json=json_body)
121
+ except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as exc:
122
+ raise GatewayUnreachable(self._settings.base_url, str(exc)) from exc
123
+ except httpx.RequestError as exc: # DNS, protocol, pool: still "can't reach it".
124
+ raise GatewayUnreachable(self._settings.base_url, str(exc)) from exc
125
+
126
+ if response.status_code == 401:
127
+ raise GatewayUnauthorized(_error_message(response))
128
+ if response.status_code >= 400:
129
+ raise GatewayError(response.status_code, _error_message(response))
130
+
131
+ if not response.content:
132
+ return None
133
+ try:
134
+ return response.json()
135
+ except (json.JSONDecodeError, ValueError):
136
+ return None
@@ -0,0 +1,53 @@
1
+ """Environment-driven configuration for the slice MCP server.
2
+
3
+ Two knobs, both read from the environment at construction time so a test can set them
4
+ with ``monkeypatch.setenv`` and build a fresh ``Settings``:
5
+
6
+ - ``SLICE_BASE_URL``: where the gateway is (default ``https://api.sliceapp.dev``, the
7
+ hosted gateway). Point it at ``http://localhost:8080`` for a self-hosted gateway. A
8
+ trailing slash is trimmed so paths join cleanly.
9
+ - ``SLICE_API_KEY``: the slice key. It rides on every gateway call as
10
+ ``Authorization: Bearer <key>``, the header the phase-12 auth middleware reads (see
11
+ ``app.auth.keys.bearer_token``). ``Settings.from_env`` leaves it unset when absent (a
12
+ self-hosted gateway in local/unlocked mode needs no key); the ``slice-mcp`` entry point
13
+ calls ``load_settings``, which requires it, since the hosted default has auth on.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import sys
20
+ from dataclasses import dataclass
21
+
22
+ DEFAULT_BASE_URL = "https://api.sliceapp.dev"
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Settings:
27
+ base_url: str = DEFAULT_BASE_URL
28
+ api_key: str | None = None
29
+
30
+ @classmethod
31
+ def from_env(cls, environ: dict | None = None) -> "Settings":
32
+ env = os.environ if environ is None else environ
33
+ base_url = (env.get("SLICE_BASE_URL") or DEFAULT_BASE_URL).strip().rstrip("/")
34
+ if not base_url:
35
+ base_url = DEFAULT_BASE_URL
36
+ key = env.get("SLICE_API_KEY")
37
+ key = key.strip() if isinstance(key, str) and key.strip() else None
38
+ return cls(base_url=base_url, api_key=key)
39
+
40
+ def auth_headers(self) -> dict[str, str]:
41
+ """The Authorization header the gateway expects, or nothing when no key is set."""
42
+ if self.api_key:
43
+ return {"Authorization": f"Bearer {self.api_key}"}
44
+ return {}
45
+
46
+
47
+ def load_settings() -> Settings:
48
+ """Settings for the ``slice-mcp`` entry point. Exits with a one-line message when
49
+ SLICE_API_KEY is unset, since the default hosted gateway requires a slice key."""
50
+ settings = Settings.from_env()
51
+ if settings.api_key is None:
52
+ sys.exit("slice-mcp: set SLICE_API_KEY to your slice key (slk_live_...).")
53
+ return settings
@@ -0,0 +1,91 @@
1
+ """Wire the slice tools into an ``mcp`` FastMCP stdio server.
2
+
3
+ Thin on purpose: this module only maps the pure tool coroutines in ``mcp_server.tools``
4
+ onto the official SDK's ``@mcp.tool()`` decorators, giving each the user-facing signature
5
+ Claude Code sees (no ``SliceClient`` argument, that is supplied internally from one
6
+ shared, long-lived client). All behavior, formatting, and error handling live in
7
+ ``tools``; nothing gateway-specific lives here.
8
+
9
+ The shared ``SliceClient`` is opened when the server starts and closed on shutdown via the
10
+ server's lifespan, so the process holds exactly one httpx client.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from contextlib import asynccontextmanager
16
+
17
+ from mcp.server.fastmcp import FastMCP
18
+
19
+ from mcp_server import tools
20
+ from mcp_server.client import SliceClient
21
+ from mcp_server.config import Settings
22
+
23
+ INSTRUCTIONS = (
24
+ "slice, the AI cost gateway. You're connected to a running slice gateway, so when "
25
+ "the user first engages, greet them warmly, mention they're connected to slice, and "
26
+ "offer to show how much of their monthly API budget is left (call get_spend).\n\n"
27
+ "What you can do here: check spend vs budget (get_spend), list/add/delete model-"
28
+ "routing rules (list_rules, add_rule, delete_rule), show recent requests "
29
+ "(get_recent_requests), and show the eval pass rate (get_eval_summary).\n\n"
30
+ "Write safety: add_rule and delete_rule require confirm=true to actually apply. "
31
+ "Without it they only preview the change and leave the gateway untouched."
32
+ )
33
+
34
+
35
+ def build_server(settings: Settings | None = None) -> FastMCP:
36
+ settings = settings or Settings.from_env()
37
+ # One client for the whole process. httpx builds its connection pool lazily, so
38
+ # constructing it here (before the event loop) is fine; the lifespan closes it.
39
+ client = SliceClient.build(settings)
40
+
41
+ @asynccontextmanager
42
+ async def lifespan(_server: FastMCP):
43
+ try:
44
+ yield {}
45
+ finally:
46
+ await client.aclose()
47
+
48
+ mcp = FastMCP(name="slice", instructions=INSTRUCTIONS, lifespan=lifespan)
49
+
50
+ @mcp.tool(description="Current-month slice spend vs budget, with the warn ratio and whether the cap is hit.")
51
+ async def get_spend() -> str:
52
+ return await tools.get_spend(client)
53
+
54
+ @mcp.tool(description="List the current switch (model-routing) rules for the slice account.")
55
+ async def list_rules() -> str:
56
+ return await tools.list_rules(client)
57
+
58
+ @mcp.tool(
59
+ description=(
60
+ "The last N slice requests (model, status, cost, routed-from, cached, time). "
61
+ "N defaults to 10 and is capped at 50."
62
+ )
63
+ )
64
+ async def get_recent_requests(limit: int = tools.RECENT_DEFAULT) -> str:
65
+ return await tools.get_recent_requests(client, limit)
66
+
67
+ @mcp.tool(description="The slice RAGAS eval pass-rate summary (overall and per model).")
68
+ async def get_eval_summary() -> str:
69
+ return await tools.get_eval_summary(client)
70
+
71
+ @mcp.tool(
72
+ description=(
73
+ "Add a slice switch rule routing from_model to to_model for a team. "
74
+ "Previews the change unless confirm=true is passed, which applies it."
75
+ )
76
+ )
77
+ async def add_rule(
78
+ team: str, from_model: str, to_model: str, confirm: bool = False
79
+ ) -> str:
80
+ return await tools.add_rule(client, team, from_model, to_model, confirm)
81
+
82
+ @mcp.tool(
83
+ description=(
84
+ "Delete a slice switch rule by its id. Previews the deletion unless "
85
+ "confirm=true is passed, which applies it."
86
+ )
87
+ )
88
+ async def delete_rule(rule_id: int, confirm: bool = False) -> str:
89
+ return await tools.delete_rule(client, rule_id, confirm)
90
+
91
+ return mcp
@@ -0,0 +1,323 @@
1
+ """The slice MCP tools: pure ``(SliceClient, args) -> str`` coroutines.
2
+
3
+ This module is deliberately free of the ``mcp`` SDK so the tests can drive it against a
4
+ mocked gateway with nothing else in the way. Each function makes one (or, for a confirmed
5
+ write, one) gateway call through ``SliceClient`` and returns compact, human-readable text,
6
+ never a raw JSON dump, and never an unhandled exception. Every gateway failure is caught
7
+ and rendered by ``_gateway_error`` into a plain sentence.
8
+
9
+ Endpoint map (discovered from the existing FastAPI routes, nothing here is invented):
10
+
11
+ - ``get_spend`` → ``GET /dashboard/teams`` (spend vs budget, warn ratio, cap)
12
+ - ``list_rules`` → ``GET /admin/rules`` (the account's switch rules)
13
+ - ``get_recent_requests`` → ``GET /dashboard/recent?limit=`` (latest requests)
14
+ - ``get_eval_summary`` → ``GET /admin/eval/summary`` (RAGAS pass rate)
15
+ - ``add_rule`` → ``POST /admin/rules`` (confirm handshake)
16
+ - ``delete_rule`` → ``DELETE /admin/rules/{id}`` (confirm handshake)
17
+
18
+ Note (flagged for the report): ``/dashboard/recent`` does not expose ``latency_ms``: the
19
+ column exists in the ``requests`` table but the route's SQL and response omit it. Rather
20
+ than change an existing route, ``get_recent_requests`` reports the fields the endpoint
21
+ does return (model, status, cost, routed-from, cached, time) and simply has no latency to
22
+ show. Nothing was added to the gateway.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from mcp_server.client import (
28
+ GatewayError,
29
+ GatewayUnauthorized,
30
+ GatewayUnreachable,
31
+ SliceClient,
32
+ )
33
+
34
+ # Bounds on the ``limit`` a caller may pass to get_recent_requests. Clamped, not rejected:
35
+ # a request for "too many" should still get an answer. The gateway itself caps at 200.
36
+ RECENT_DEFAULT = 10
37
+ RECENT_MAX = 50
38
+
39
+ GatewayFailure = (GatewayUnreachable, GatewayUnauthorized, GatewayError)
40
+
41
+
42
+ # --- error rendering ---------------------------------------------------------------
43
+
44
+
45
+ def _gateway_error(exc: Exception, client: SliceClient) -> str:
46
+ """One place that turns any gateway failure into a plain, actionable sentence."""
47
+ if isinstance(exc, GatewayUnreachable):
48
+ return (
49
+ f"slice gateway not running at {client.base_url}. "
50
+ "Start the gateway (uvicorn app.main:app) or set SLICE_BASE_URL to point at it."
51
+ )
52
+ if isinstance(exc, GatewayUnauthorized):
53
+ if not client.has_key:
54
+ return (
55
+ "slice gateway returned 401 Unauthorized, but no SLICE_API_KEY is set. "
56
+ "The gateway has auth enabled: set SLICE_API_KEY to your slice key (slk_...)."
57
+ )
58
+ return (
59
+ "slice gateway returned 401 Unauthorized. The slice key in SLICE_API_KEY was "
60
+ "rejected (invalid or revoked): check the value."
61
+ )
62
+ if isinstance(exc, GatewayError):
63
+ detail = f": {exc.message}" if exc.message else ""
64
+ return f"slice gateway returned an error (HTTP {exc.status_code}){detail}."
65
+ return f"slice gateway call failed: {exc}"
66
+
67
+
68
+ # --- formatting helpers ------------------------------------------------------------
69
+
70
+
71
+ def _money(value) -> str:
72
+ """A dollar amount as ``$1.2345``; an em dash for an unknown (null) amount."""
73
+ if value is None:
74
+ return "-"
75
+ try:
76
+ return f"${float(value):.4f}"
77
+ except (TypeError, ValueError):
78
+ return "-"
79
+
80
+
81
+ def _pct(value) -> str:
82
+ if value is None:
83
+ return "-"
84
+ try:
85
+ return f"{float(value) * 100:.1f}%"
86
+ except (TypeError, ValueError):
87
+ return "-"
88
+
89
+
90
+ # --- read tools --------------------------------------------------------------------
91
+
92
+
93
+ async def get_spend(client: SliceClient) -> str:
94
+ """Current-month spend vs budget, the warn ratio, and whether the cap is hit.
95
+
96
+ Reads ``GET /dashboard/teams``, whose ``budget`` block carries the live budget meter
97
+ for the account: recorded spend, the gate-enforced "used" figure, the cap, and dollars
98
+ remaining. The cap is "hit" when nothing remains; the warn threshold is
99
+ ``budget_usd * warn_ratio``.
100
+ """
101
+ try:
102
+ data = await client.request("GET", "/dashboard/teams")
103
+ except GatewayFailure as exc:
104
+ return _gateway_error(exc, client)
105
+ if not isinstance(data, dict):
106
+ return "slice returned an unexpected response for spend."
107
+
108
+ month = data.get("month", "this month")
109
+ budget_cap = data.get("budget_usd")
110
+ # Phase 25: the cap is per account; the dashboard says whether it is the config default.
111
+ cap_note = " (the default cap, set your own in the dashboard's Settings)" if data.get("budget_default") else ""
112
+ warn_ratio = data.get("warn_ratio")
113
+ meter = data.get("budget") or {}
114
+
115
+ used = meter.get("budget_used_usd")
116
+ remaining = meter.get("remaining_usd")
117
+ spend = meter.get("spend_usd")
118
+ source = meter.get("budget_source", "unknown")
119
+
120
+ cap_hit = remaining is not None and float(remaining) <= 0
121
+ warn = False
122
+ if used is not None and budget_cap not in (None, 0) and warn_ratio is not None:
123
+ try:
124
+ warn = float(used) >= float(budget_cap) * float(warn_ratio)
125
+ except (TypeError, ValueError):
126
+ warn = False
127
+
128
+ if cap_hit:
129
+ status = "CAP HIT: budget exhausted, requests are being blocked"
130
+ elif warn:
131
+ status = f"WARNING: over the {_pct(warn_ratio)} warn threshold"
132
+ else:
133
+ status = "OK: under budget"
134
+
135
+ lines = [
136
+ f"slice spend: {month}",
137
+ f" budget used: {_money(used)} of {_money(budget_cap)} (source: {source}){cap_note}",
138
+ f" remaining: {_money(remaining)}",
139
+ f" recorded spend (Postgres): {_money(spend)}",
140
+ f" warn ratio: {_pct(warn_ratio)}",
141
+ f" status: {status}",
142
+ ]
143
+ return "\n".join(lines)
144
+
145
+
146
+ async def list_rules(client: SliceClient) -> str:
147
+ """The account's current switch rules. Reads ``GET /admin/rules``."""
148
+ try:
149
+ data = await client.request("GET", "/admin/rules")
150
+ except GatewayFailure as exc:
151
+ return _gateway_error(exc, client)
152
+ if not isinstance(data, dict):
153
+ return "slice returned an unexpected response for rules."
154
+
155
+ rules = data.get("rules") or []
156
+ if not rules:
157
+ return "No switch rules configured for this account."
158
+
159
+ lines = [f"slice switch rules ({len(rules)}):"]
160
+ for rule in rules:
161
+ rid = rule.get("id", "?")
162
+ team = rule.get("team", "?")
163
+ frm = rule.get("from_model", "?")
164
+ to = rule.get("to_model", "?")
165
+ lines.append(f" [#{rid}] team={team}: {frm} → {to}")
166
+ return "\n".join(lines)
167
+
168
+
169
+ async def get_recent_requests(client: SliceClient, limit: int = RECENT_DEFAULT) -> str:
170
+ """The last N requests (model, status, cost, routed-from, cached, time).
171
+
172
+ Reads ``GET /dashboard/recent?limit=N``. ``limit`` is clamped to [1, 50]. Latency is
173
+ not reported: the endpoint does not expose ``latency_ms`` (see the module note).
174
+ """
175
+ try:
176
+ limit = int(limit)
177
+ except (TypeError, ValueError):
178
+ limit = RECENT_DEFAULT
179
+ limit = max(1, min(limit, RECENT_MAX))
180
+
181
+ try:
182
+ data = await client.request("GET", "/dashboard/recent", params={"limit": limit})
183
+ except GatewayFailure as exc:
184
+ return _gateway_error(exc, client)
185
+ if not isinstance(data, dict):
186
+ return "slice returned an unexpected response for recent requests."
187
+
188
+ requests = data.get("requests") or []
189
+ if not requests:
190
+ return "No requests recorded yet."
191
+
192
+ lines = [f"slice recent requests (latest {len(requests)}):"]
193
+ for req in requests:
194
+ when = req.get("created_at") or "?"
195
+ model = req.get("model") or "?"
196
+ status = req.get("status")
197
+ cost = _money(req.get("cost_usd"))
198
+ routed = req.get("routed_from")
199
+ flags = []
200
+ if routed:
201
+ flags.append(f"routed from {routed}")
202
+ if req.get("cached"):
203
+ flags.append("cached")
204
+ suffix = f" ({', '.join(flags)})" if flags else ""
205
+ lines.append(f" {when} {model} status={status} cost={cost}{suffix}")
206
+ return "\n".join(lines)
207
+
208
+
209
+ async def get_eval_summary(client: SliceClient) -> str:
210
+ """The RAGAS eval pass rate. Reads ``GET /admin/eval/summary``."""
211
+ try:
212
+ data = await client.request("GET", "/admin/eval/summary")
213
+ except GatewayFailure as exc:
214
+ return _gateway_error(exc, client)
215
+ if not isinstance(data, dict):
216
+ return "slice returned an unexpected response for the eval summary."
217
+
218
+ overall = data.get("overall") or {}
219
+ count = overall.get("count", 0)
220
+ passed = overall.get("passed", 0)
221
+ pass_rate = overall.get("pass_rate")
222
+
223
+ if not count:
224
+ return "No eval scores recorded yet (RAGAS pass rate unavailable)."
225
+
226
+ lines = [
227
+ "slice eval summary (RAGAS):",
228
+ f" overall pass rate: {_pct(pass_rate)} ({passed}/{count} passed)",
229
+ ]
230
+ by_model = data.get("by_model") or []
231
+ if by_model:
232
+ lines.append(" by model:")
233
+ for row in by_model:
234
+ model = row.get("model", "?")
235
+ lines.append(
236
+ f" {model}: {_pct(row.get('pass_rate'))} "
237
+ f"({row.get('passed', 0)}/{row.get('count', 0)})"
238
+ )
239
+ return "\n".join(lines)
240
+
241
+
242
+ # --- write tools (two-call confirm handshake) --------------------------------------
243
+
244
+
245
+ def _validate_rule(team: str, from_model: str, to_model: str) -> str | None:
246
+ """Reject a malformed rule up front, before any preview or write. Message or None."""
247
+ for name, value in (("team", team), ("from_model", from_model), ("to_model", to_model)):
248
+ if not isinstance(value, str) or not value.strip():
249
+ return f"Invalid rule: '{name}' is required and must be a non-empty string."
250
+ if from_model.strip() == to_model.strip():
251
+ return "Invalid rule: 'from_model' and 'to_model' must differ."
252
+ return None
253
+
254
+
255
+ async def add_rule(
256
+ client: SliceClient,
257
+ team: str,
258
+ from_model: str,
259
+ to_model: str,
260
+ confirm: bool = False,
261
+ ) -> str:
262
+ """Add a switch rule. Previews unless ``confirm=true``; then POSTs ``/admin/rules``.
263
+
264
+ Inputs are validated before anything else, so a bad rule is rejected without a preview
265
+ or a call. With ``confirm`` false/omitted the tool describes exactly what would change
266
+ and does NOT touch the gateway. Only ``confirm=true`` sends the write.
267
+ """
268
+ error = _validate_rule(team, from_model, to_model)
269
+ if error is not None:
270
+ return error
271
+
272
+ team, from_model, to_model = team.strip(), from_model.strip(), to_model.strip()
273
+ detail = f"team={team}: {from_model} → {to_model}"
274
+
275
+ if not confirm:
276
+ return (
277
+ f"Would add rule: {detail}. "
278
+ "Call again with confirm=true to apply."
279
+ )
280
+
281
+ try:
282
+ data = await client.request(
283
+ "POST",
284
+ "/admin/rules",
285
+ json_body={"team": team, "from_model": from_model, "to_model": to_model},
286
+ )
287
+ except GatewayFailure as exc:
288
+ return _gateway_error(exc, client)
289
+
290
+ rule = data.get("rule") if isinstance(data, dict) else None
291
+ if isinstance(rule, dict) and rule.get("id") is not None:
292
+ return f"Added rule #{rule['id']}: {detail}."
293
+ return f"Added rule: {detail}."
294
+
295
+
296
+ async def delete_rule(client: SliceClient, rule_id: int, confirm: bool = False) -> str:
297
+ """Delete a switch rule by id. Previews unless ``confirm=true``; then DELETEs it.
298
+
299
+ The id is validated (a positive integer) before anything else. With ``confirm``
300
+ false/omitted the tool describes what would be deleted and does NOT touch the gateway.
301
+ Only ``confirm=true`` sends the write.
302
+ """
303
+ try:
304
+ rule_id = int(rule_id)
305
+ except (TypeError, ValueError):
306
+ return "Invalid rule id: it must be an integer."
307
+ if rule_id <= 0:
308
+ return "Invalid rule id: it must be a positive integer."
309
+
310
+ if not confirm:
311
+ return (
312
+ f"Would delete rule #{rule_id}. "
313
+ "Call again with confirm=true to apply."
314
+ )
315
+
316
+ try:
317
+ data = await client.request("DELETE", f"/admin/rules/{rule_id}")
318
+ except GatewayFailure as exc:
319
+ return _gateway_error(exc, client)
320
+
321
+ if isinstance(data, dict) and "deleted" in data:
322
+ return f"Deleted rule #{data['deleted']}."
323
+ return f"Deleted rule #{rule_id}."
@@ -0,0 +1,46 @@
1
+ # Builds the `slice-gateway` distribution: the slice CLI (slice login / init / use) and
2
+ # the standalone slice MCP server (slice-mcp). The wheel ships the slice_cli.py module and
3
+ # the mcp_server package, and depends on typer, httpx, and mcp only. None of the server
4
+ # stack (fastapi, langgraph, redis, torch, ...) is imported or required, and the app/
5
+ # server package is never pulled in. Inside the repo, `python -m app.cli` still works via
6
+ # the app/cli.py shim and `python -m mcp_server` runs the server from source.
7
+ [build-system]
8
+ requires = ["setuptools>=77"]
9
+ build-backend = "setuptools.build_meta"
10
+
11
+ [project]
12
+ name = "slice-gateway"
13
+ version = "0.2.3"
14
+ description = "Command-line client for slice, the self-hosted LLM gateway."
15
+ readme = "PYPI.md"
16
+ requires-python = ">=3.11"
17
+ license = "MIT"
18
+ authors = [{ name = "Jishnu Janardanan" }]
19
+ dependencies = [
20
+ "typer",
21
+ "httpx",
22
+ "mcp>=1.9,<2",
23
+ ]
24
+ classifiers = [
25
+ "Environment :: Console",
26
+ "Intended Audience :: Developers",
27
+ "Programming Language :: Python :: 3",
28
+ "Topic :: Utilities",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://sliceapp.dev"
33
+ Repository = "https://github.com/jjk30/slice"
34
+
35
+ [project.scripts]
36
+ # `slice login`, `slice init`, `slice use <tool>`. See slice_cli.py.
37
+ slice = "slice_cli:main"
38
+ # `slice-mcp`: the stdio MCP server. See mcp_server/__main__.py.
39
+ slice-mcp = "mcp_server.__main__:main"
40
+
41
+ [tool.setuptools]
42
+ # Ship the CLI module and the mcp_server package, and nothing else. Listing py-modules and
43
+ # packages explicitly disables package auto-discovery, so the app/ server package (and
44
+ # every other repo folder) is never pulled into the wheel.
45
+ py-modules = ["slice_cli"]
46
+ packages = ["mcp_server"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slice-gateway
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Command-line client for slice, the self-hosted LLM gateway.
5
5
  Author: Jishnu Janardanan
6
6
  License-Expression: MIT
@@ -15,6 +15,7 @@ Description-Content-Type: text/markdown
15
15
  License-File: LICENSE
16
16
  Requires-Dist: typer
17
17
  Requires-Dist: httpx
18
+ Requires-Dist: mcp<2,>=1.9
18
19
  Dynamic: license-file
19
20
 
20
21
  # slice-gateway
@@ -43,12 +44,24 @@ This package installs the `slice` command. It logs you in through GitHub, then p
43
44
 
44
45
  Run `slice use anthropic`, `slice use openai`, `slice use curl`, or `slice use claude-code` to print the exact lines for your tool.
45
46
 
47
+ ## Claude Code MCP
48
+
49
+ This package also installs `slice-mcp`, a stdio MCP server. Add it to Claude Code with one command:
50
+
51
+ claude mcp add slice -s user -e SLICE_API_KEY=slk_live_... -- slice-mcp
52
+
53
+ It exposes six read and write tools over your slice account: check spend against budget, list recent requests, show the eval pass rate, and list, add, or delete model-routing rules. It talks to the hosted gateway by default; set `SLICE_BASE_URL` for a self-hosted one.
54
+
46
55
  ## The gateway
47
56
 
48
57
  This is only the client. The slice gateway itself, the server that does the routing, caching, budgets, and dashboards, lives on GitHub at https://github.com/jjk30/slice.
49
58
 
50
59
  Homepage: https://sliceapp.dev
51
60
 
61
+ ## What's new in 0.2.3
62
+
63
+ The package now installs `slice-mcp`, the slice MCP server, so `pipx install slice-gateway` gives you both the CLI and the Claude Code integration. See the Claude Code MCP section above.
64
+
52
65
  ## What's new in 0.2.2
53
66
 
54
67
  `slice login` now ends by printing your dashboard link and opening it in the browser.
@@ -3,6 +3,12 @@ MANIFEST.in
3
3
  PYPI.md
4
4
  pyproject.toml
5
5
  slice_cli.py
6
+ mcp_server/__init__.py
7
+ mcp_server/__main__.py
8
+ mcp_server/client.py
9
+ mcp_server/config.py
10
+ mcp_server/server.py
11
+ mcp_server/tools.py
6
12
  slice_gateway.egg-info/PKG-INFO
7
13
  slice_gateway.egg-info/SOURCES.txt
8
14
  slice_gateway.egg-info/dependency_links.txt
@@ -1,2 +1,3 @@
1
1
  [console_scripts]
2
2
  slice = slice_cli:main
3
+ slice-mcp = mcp_server.__main__:main
@@ -0,0 +1,2 @@
1
+ mcp_server
2
+ slice_cli
@@ -1,39 +0,0 @@
1
- # Builds the `slice-gateway` distribution: the slice CLI only (slice login / init / use).
2
- # The wheel ships a single top-level module, slice_cli.py, and depends on typer and httpx
3
- # only. None of the server stack (fastapi, langgraph, redis, torch, ...) is imported or
4
- # required. Inside the repo, `python -m app.cli` still works via the app/cli.py shim.
5
- [build-system]
6
- requires = ["setuptools>=77"]
7
- build-backend = "setuptools.build_meta"
8
-
9
- [project]
10
- name = "slice-gateway"
11
- version = "0.2.2"
12
- description = "Command-line client for slice, the self-hosted LLM gateway."
13
- readme = "PYPI.md"
14
- requires-python = ">=3.11"
15
- license = "MIT"
16
- authors = [{ name = "Jishnu Janardanan" }]
17
- dependencies = [
18
- "typer",
19
- "httpx",
20
- ]
21
- classifiers = [
22
- "Environment :: Console",
23
- "Intended Audience :: Developers",
24
- "Programming Language :: Python :: 3",
25
- "Topic :: Utilities",
26
- ]
27
-
28
- [project.urls]
29
- Homepage = "https://sliceapp.dev"
30
- Repository = "https://github.com/jjk30/slice"
31
-
32
- [project.scripts]
33
- # `slice login`, `slice init`, `slice use <tool>`. See slice_cli.py.
34
- slice = "slice_cli:main"
35
-
36
- [tool.setuptools]
37
- # Ship only the CLI module. Setting py-modules disables package auto-discovery, so the
38
- # app/ server package is never pulled into the wheel.
39
- py-modules = ["slice_cli"]
@@ -1 +0,0 @@
1
- slice_cli
File without changes
File without changes