@lobstack-ai/mcp 0.1.0

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.
@@ -0,0 +1,49 @@
1
+ /**
2
+ * lobstack_spend — what this organization has spent, over a range.
3
+ *
4
+ * `GET /api/v1/usage` is org-scoped and takes either a browser session or an
5
+ * API key holding the `usage:read` scope. It is a sibling of the gateway
6
+ * prefix, not under it, which is why the base URL here is kept as an origin
7
+ * and paths are composed rather than concatenated onto a gateway URL.
8
+ *
9
+ * TWO THINGS THIS TOOL REPORTS THAT THE ENDPOINT BURIES
10
+ *
11
+ * `unpriced_requests` — rows the meter could not price. The endpoint's
12
+ * `cost_usd` sums a NULL as zero, which is the only arithmetic available and
13
+ * not the only truth: a total built partly from unpriced rows is a FLOOR. A
14
+ * reader not told how many were unpriced reads it as exact, which is the same
15
+ * mistake as a $0.00 receipt, one aggregation up.
16
+ *
17
+ * `truncated` — the endpoint pages to a cap. When it binds, the sums are a
18
+ * floor for a second, independent reason.
19
+ *
20
+ * WHAT THIS TOOL DOES NOT REPORT
21
+ *
22
+ * A savings total. `/api/v1/usage` does not compute one — its summary carries
23
+ * requests, tokens, cost, error rate and latency percentiles, and nothing else.
24
+ * Adding up per-call savings client-side would require the baselines, which are
25
+ * not in this response, and printing a number derived from a rate card we hold
26
+ * a copy of is the failure mode this whole product argues against. Savings are
27
+ * reported per call, by lobstack_chat, where the Gateway sends them with the
28
+ * reason attached.
29
+ */
30
+ import { z } from "zod";
31
+ import type { Config } from "../config.js";
32
+ import { type ToolResult } from "./shared.js";
33
+ export declare const spendInput: {
34
+ range: z.ZodOptional<z.ZodEnum<["7d", "14d", "30d", "90d"]>>;
35
+ group_by: z.ZodOptional<z.ZodEnum<["day", "model", "key", "agent"]>>;
36
+ };
37
+ export declare const spendOutput: {
38
+ enabled: z.ZodBoolean;
39
+ range: z.ZodString;
40
+ group_by: z.ZodString;
41
+ summary: z.ZodNullable<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
42
+ groups: z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>, "many">;
43
+ is_floor: z.ZodBoolean;
44
+ message: z.ZodNullable<z.ZodString>;
45
+ };
46
+ export declare function runSpend(cfg: Config, args: {
47
+ range?: string;
48
+ group_by?: string;
49
+ }): Promise<ToolResult>;
@@ -0,0 +1,121 @@
1
+ /**
2
+ * lobstack_spend — what this organization has spent, over a range.
3
+ *
4
+ * `GET /api/v1/usage` is org-scoped and takes either a browser session or an
5
+ * API key holding the `usage:read` scope. It is a sibling of the gateway
6
+ * prefix, not under it, which is why the base URL here is kept as an origin
7
+ * and paths are composed rather than concatenated onto a gateway URL.
8
+ *
9
+ * TWO THINGS THIS TOOL REPORTS THAT THE ENDPOINT BURIES
10
+ *
11
+ * `unpriced_requests` — rows the meter could not price. The endpoint's
12
+ * `cost_usd` sums a NULL as zero, which is the only arithmetic available and
13
+ * not the only truth: a total built partly from unpriced rows is a FLOOR. A
14
+ * reader not told how many were unpriced reads it as exact, which is the same
15
+ * mistake as a $0.00 receipt, one aggregation up.
16
+ *
17
+ * `truncated` — the endpoint pages to a cap. When it binds, the sums are a
18
+ * floor for a second, independent reason.
19
+ *
20
+ * WHAT THIS TOOL DOES NOT REPORT
21
+ *
22
+ * A savings total. `/api/v1/usage` does not compute one — its summary carries
23
+ * requests, tokens, cost, error rate and latency percentiles, and nothing else.
24
+ * Adding up per-call savings client-side would require the baselines, which are
25
+ * not in this response, and printing a number derived from a rate card we hold
26
+ * a copy of is the failure mode this whole product argues against. Savings are
27
+ * reported per call, by lobstack_chat, where the Gateway sends them with the
28
+ * reason attached.
29
+ */
30
+ import { z } from "zod";
31
+ import { apiUrl } from "../config.js";
32
+ import { errorFrom, gwFetch } from "../gateway.js";
33
+ import { money } from "../receipt.js";
34
+ import { baseNote, fromThrown, ok, requireKey } from "./shared.js";
35
+ const RANGES = ["7d", "14d", "30d", "90d"];
36
+ const GROUPS = ["day", "model", "key", "agent"];
37
+ export const spendInput = {
38
+ range: z.enum(RANGES).optional().describe("How far back to look. Defaults to 7d."),
39
+ group_by: z.enum(GROUPS).optional().describe("How to break the total down. Defaults to model."),
40
+ };
41
+ export const spendOutput = {
42
+ enabled: z.boolean().describe("False when request tracing is not enabled on this deployment."),
43
+ range: z.string(),
44
+ group_by: z.string(),
45
+ summary: z.record(z.unknown()).nullable(),
46
+ groups: z.array(z.record(z.unknown())),
47
+ is_floor: z
48
+ .boolean()
49
+ .describe("True when some rows were unpriced or the row cap bound, so the totals are a lower bound, not a total."),
50
+ message: z.string().nullable(),
51
+ };
52
+ const pad = (s, n) => (s.length >= n ? s : s + " ".repeat(n - s.length));
53
+ const padStart = (s, n) => (s.length >= n ? s : " ".repeat(n - s.length) + s);
54
+ export async function runSpend(cfg, args) {
55
+ const missing = requireKey(cfg);
56
+ if (missing)
57
+ return missing;
58
+ const range = args.range ?? "7d";
59
+ const groupBy = args.group_by ?? "model";
60
+ try {
61
+ const res = await gwFetch(cfg, `${apiUrl(cfg.base.origin, "/v1/usage")}?range=${encodeURIComponent(range)}&group_by=${encodeURIComponent(groupBy)}`);
62
+ if (!res.ok) {
63
+ throw await errorFrom(cfg, res, res.status === 403
64
+ ? 'This key needs the "usage:read" scope. Mint one in Console → API keys; a key with only "inference" can spend but cannot read the ledger.'
65
+ : undefined);
66
+ }
67
+ const b = (await res.json());
68
+ if (b.enabled === false) {
69
+ // Not an error. "Tracing is not on here" and "you spent nothing" are
70
+ // different sentences and only the first one is true.
71
+ return ok([b.message ?? "Request tracing is not enabled on this deployment, so there is nothing to report."], {
72
+ enabled: false,
73
+ range,
74
+ group_by: groupBy,
75
+ summary: null,
76
+ groups: [],
77
+ is_floor: false,
78
+ message: b.message ?? null,
79
+ });
80
+ }
81
+ const s = b.summary ?? {};
82
+ const unpriced = s.unpriced_requests ?? 0;
83
+ const truncated = b.truncated === true;
84
+ const isFloor = unpriced > 0 || truncated;
85
+ const groups = b.groups ?? [];
86
+ const head = `Last ${b.range ?? range} · ${s.requests ?? 0} request${s.requests === 1 ? "" : "s"} · ` +
87
+ `${money(typeof s.cost_usd === "number" ? s.cost_usd : null)}` +
88
+ (s.total_tokens ? ` · ${s.total_tokens.toLocaleString("en-US")} tokens` : "") +
89
+ (s.errors ? ` · ${s.errors} error${s.errors === 1 ? "" : "s"}` : "");
90
+ const w = Math.max(3, ...groups.map((g) => String(g.key ?? "").length));
91
+ const table = groups.length
92
+ ? [
93
+ "",
94
+ `${pad(String(groupBy).toUpperCase(), w)} ${padStart("REQS", 6)} ${padStart("COST", 11)}`,
95
+ ...groups.map((g) => `${pad(String(g.key ?? ""), w)} ${padStart(String(g.requests ?? 0), 6)} ` +
96
+ `${padStart(money(typeof g.cost_usd === "number" ? g.cost_usd : null), 11)}` +
97
+ (g.unpriced_requests ? ` (${g.unpriced_requests} unpriced)` : "")),
98
+ ].join("\n")
99
+ : "\nNo requests in this window.";
100
+ const notes = [
101
+ unpriced
102
+ ? `${unpriced} request${unpriced === 1 ? "" : "s"} could not be priced. Those rows sum as zero, so the total above is a floor, not a total.`
103
+ : null,
104
+ truncated ? "The row cap bound on this range, so older requests are not counted here." : null,
105
+ typeof s.p95_latency_ms === "number" ? `p50 ${s.p50_latency_ms ?? "—"}ms · p95 ${s.p95_latency_ms}ms` : null,
106
+ baseNote(cfg),
107
+ ].filter((n) => n !== null);
108
+ return ok([head + table + (notes.length ? "\n\n" + notes.join("\n") : "")], {
109
+ enabled: true,
110
+ range: b.range ?? range,
111
+ group_by: b.group_by ?? groupBy,
112
+ summary: (b.summary ?? null),
113
+ groups: groups,
114
+ is_floor: isFloor,
115
+ message: null,
116
+ });
117
+ }
118
+ catch (e) {
119
+ return fromThrown(cfg, e);
120
+ }
121
+ }
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@lobstack-ai/mcp",
3
+ "version": "0.1.0",
4
+ "description": "The Lobstack Gateway as an MCP server: thirty models behind one key, and what each call cost.",
5
+ "type": "module",
6
+ "bin": {
7
+ "lobstack-mcp": "./dist/index.js"
8
+ },
9
+ "main": "./dist/server.js",
10
+ "types": "./dist/server.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/server.d.ts",
14
+ "import": "./dist/server.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "engines": {
23
+ "node": ">=20"
24
+ },
25
+ "scripts": {
26
+ "build": "rm -rf dist && tsc -p tsconfig.json",
27
+ "typecheck": "tsc -p tsconfig.json --noEmit",
28
+ "test": "npm run build && node --test test/*.test.mjs",
29
+ "prepublishOnly": "npm run build && node --test test/*.test.mjs",
30
+ "start": "node dist/index.js"
31
+ },
32
+ "keywords": [
33
+ "mcp",
34
+ "model-context-protocol",
35
+ "llm",
36
+ "gateway",
37
+ "openai",
38
+ "anthropic",
39
+ "claude",
40
+ "cursor",
41
+ "lobstack"
42
+ ],
43
+ "license": "MIT",
44
+ "author": "Lobstack",
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/Lobstack-ai/lobstack-mcp.git"
48
+ },
49
+ "bugs": {
50
+ "url": "https://github.com/Lobstack-ai/lobstack-mcp/issues"
51
+ },
52
+ "homepage": "https://github.com/Lobstack-ai/lobstack-mcp#readme",
53
+ "dependencies": {
54
+ "@modelcontextprotocol/sdk": "^1.30.0",
55
+ "zod": "^3.25.76"
56
+ },
57
+ "devDependencies": {
58
+ "@types/node": "^22.15.0",
59
+ "typescript": "^5.9.2"
60
+ },
61
+ "publishConfig": {
62
+ "access": "public",
63
+ "provenance": true
64
+ }
65
+ }