@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lobstack
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,238 @@
1
+ # @lobstack-ai/mcp
2
+
3
+ An MCP server for the [Lobstack](https://www.lobstack.ai) Gateway. One API key
4
+ reaches every major model, and every call comes back with a receipt: which model
5
+ served it, how many tokens, what it cost.
6
+
7
+ Works in Claude Desktop, Claude Code, Cursor, Zed, or anything else that speaks
8
+ the Model Context Protocol over stdio.
9
+
10
+ ## Try it without a key
11
+
12
+ `lobstack_route_preview` is unauthenticated. Install the server with no
13
+ credential at all and an agent can still ask "which model would this prompt go
14
+ to, and what would it cost":
15
+
16
+ ```
17
+ npx -y @lobstack-ai/mcp
18
+ ```
19
+
20
+ Add it to your client using one of the blocks below, leave `env` out, and ask:
21
+
22
+ > Preview how Lobstack would route: "summarise this changelog into three bullets"
23
+
24
+ The other three tools need a key, minted in Console → API keys.
25
+
26
+ ## Install
27
+
28
+ ### Claude Desktop
29
+
30
+ `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS,
31
+ `%APPDATA%\Claude\claude_desktop_config.json` on Windows:
32
+
33
+ ```json
34
+ {
35
+ "mcpServers": {
36
+ "lobstack": {
37
+ "command": "npx",
38
+ "args": ["-y", "@lobstack-ai/mcp"],
39
+ "env": {
40
+ "LOBSTACK_API_KEY": "lsk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
41
+ }
42
+ }
43
+ }
44
+ }
45
+ ```
46
+
47
+ Restart Claude Desktop. The four `lobstack_*` tools appear under the tools menu.
48
+
49
+ ### Cursor
50
+
51
+ `~/.cursor/mcp.json` for every project, or `.cursor/mcp.json` for one:
52
+
53
+ ```json
54
+ {
55
+ "mcpServers": {
56
+ "lobstack": {
57
+ "command": "npx",
58
+ "args": ["-y", "@lobstack-ai/mcp"],
59
+ "env": {
60
+ "LOBSTACK_API_KEY": "lsk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
61
+ }
62
+ }
63
+ }
64
+ }
65
+ ```
66
+
67
+ ### Claude Code
68
+
69
+ ```bash
70
+ claude mcp add lobstack --env LOBSTACK_API_KEY=lsk_live_... -- npx -y @lobstack-ai/mcp
71
+ ```
72
+
73
+ ### Zed
74
+
75
+ In `settings.json`, under `context_servers`:
76
+
77
+ ```json
78
+ {
79
+ "context_servers": {
80
+ "lobstack": {
81
+ "source": "custom",
82
+ "command": "npx",
83
+ "args": ["-y", "@lobstack-ai/mcp"],
84
+ "env": {
85
+ "LOBSTACK_API_KEY": "lsk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
86
+ }
87
+ }
88
+ }
89
+ }
90
+ ```
91
+
92
+ ## Tools
93
+
94
+ ### `lobstack_route_preview`
95
+
96
+ Scores a prompt against the same router the paid path uses and reports the model
97
+ that would serve it, the capability tier, the complexity score, and the
98
+ estimated cost. Runs no inference and spends nothing. **Needs no API key.**
99
+
100
+ | argument | type | notes |
101
+ | --- | --- | --- |
102
+ | `prompt` | string, required | Scored, never sent to a model. Max 8000 characters. |
103
+ | `requested_model` | string | A model to compare against. Defaults to `auto`. |
104
+ | `plan_tier` | string | Plan id, which sets the ceiling the router may reach. |
105
+ | `expected_output_tokens` | integer | Defaults to half the prompt. |
106
+ | `conversation_length` | integer | Messages already in the conversation. |
107
+
108
+ Token counts are estimates — roughly four characters per token. The billed
109
+ figure always comes from the provider's own usage block on the real request, and
110
+ the tool says so in every answer.
111
+
112
+ A `baseline` is returned only when you named a model and the router moved away
113
+ from it. On `auto` it is `null`: there is no model you asked for to compare
114
+ against.
115
+
116
+ ### `lobstack_models`
117
+
118
+ The catalogue: model key, label, tier, provider, context window, and USD per
119
+ million input and output tokens. Optional `tier` and `provider` filters.
120
+
121
+ A model the registry cannot price comes back with `null` prices and renders as
122
+ `—`. It is not free.
123
+
124
+ ### `lobstack_chat`
125
+
126
+ Sends a prompt or a conversation and returns the reply plus the receipt.
127
+
128
+ | argument | type | notes |
129
+ | --- | --- | --- |
130
+ | `prompt` | string | A single user message. Use this **or** `messages`. |
131
+ | `messages` | array | `{ role, content }`, OpenAI-shaped. Use this **or** `prompt`. |
132
+ | `model` | string | Defaults to `auto` — the router picks the cheapest capable model. |
133
+ | `system` | string | Prepended to the conversation. |
134
+ | `max_tokens` | integer | Cap on the reply. |
135
+ | `temperature` | number | Some models do not accept it; the receipt says when it was dropped. |
136
+
137
+ The reply and the receipt come back as two separate content blocks, so whatever
138
+ consumes the answer does not get a price line concatenated onto it. The
139
+ structured result carries:
140
+
141
+ ```json
142
+ {
143
+ "text": "...",
144
+ "model": { "requested": "claude-opus-5", "served": "claude-haiku-4-5", "routed": true },
145
+ "usage": { "prompt_tokens": 400, "completion_tokens": 140, "total_tokens": 540 },
146
+ "receipt": {
147
+ "request_id": "req_...",
148
+ "cost_usd": 0.0011,
149
+ "cost_display": "$0.001100",
150
+ "priced": true,
151
+ "savings": {
152
+ "amount_usd": 0.0044,
153
+ "label": "saved",
154
+ "named": true,
155
+ "baseline_model": "claude-opus-5",
156
+ "baseline_reason": "named"
157
+ }
158
+ },
159
+ "quota": { "meter": "spend", "remaining_usd": 16.75 },
160
+ "dropped_params": []
161
+ }
162
+ ```
163
+
164
+ ### `lobstack_spend`
165
+
166
+ What the organization has spent over `7d`, `14d`, `30d` or `90d`, grouped by
167
+ `day`, `model`, `key` or `agent`, with request counts, tokens, errors and
168
+ latency percentiles. Requires a key holding the `usage:read` scope.
169
+
170
+ It also reports `unpriced_requests` and sets `is_floor`. The endpoint sums an
171
+ unpriced row as zero — the only arithmetic available — so a total that includes
172
+ one is a lower bound, not a total, and this tool says which.
173
+
174
+ It does **not** report a savings total. `/api/v1/usage` does not compute one,
175
+ and adding up savings client-side would mean pricing the org's tokens against a
176
+ copy of the rate card. Savings are reported per call, by `lobstack_chat`, where
177
+ the gateway sends them with the reason attached.
178
+
179
+ ## Two rules about the numbers
180
+
181
+ **A null cost is not zero.** `cost_usd: null` means the gateway could not price
182
+ the call. It renders as `unpriced`, never as `$0.00`. Rendering it as `$0.00`
183
+ writes off a real charge, and that exact substitution ran for three months in
184
+ production.
185
+
186
+ **`baseline_reason` decides what a saving may be called.**
187
+
188
+ - `named` — you asked for a model and got something cheaper. Like-for-like, and
189
+ the only case labelled `saved`.
190
+ - `plan_ceiling` — you sent `auto`, so the comparison is against the most
191
+ expensive model your plan allows. Real, and not something you asked for:
192
+ labelled `vs ceiling`, with the baseline model named next to it.
193
+ - missing — treated as unnamed. A receipt that does not say where its baseline
194
+ came from does not get the flattering reading.
195
+
196
+ ## Configuration
197
+
198
+ | variable | default | notes |
199
+ | --- | --- | --- |
200
+ | `LOBSTACK_API_KEY` | none | Read once at startup. Never logged, never in a tool result. |
201
+ | `LOBSTACK_BASE_URL` | `https://www.lobstack.ai/api/gateway/v1` | For staging and self-hosted deployments. |
202
+
203
+ **Use `www`, not the bare apex.** `lobstack.ai` redirects to `www.lobstack.ai`,
204
+ and [RFC 9110 §15.4](https://www.rfc-editor.org/rfc/rfc9110#section-15.4)
205
+ requires a client to drop `Authorization` across a host change — so the gateway
206
+ answers a perfectly good key with "missing credentials". This server rewrites
207
+ the apex and tells you it did, and refuses to follow any other 3xx rather than
208
+ send a request whose credential has been stripped.
209
+
210
+ ### The key
211
+
212
+ `LOBSTACK_API_KEY` is read from this process's environment and from nowhere
213
+ else. No tool takes a key, a token, or a base URL as an argument: a base URL
214
+ that can arrive as a tool argument is a credential that can be redirected by
215
+ whoever wrote the argument. Error text is scrubbed on the way out, including
216
+ text that came back from upstream.
217
+
218
+ This process holds a live credential for as long as your MCP client runs. The
219
+ dependency list is the MCP SDK, zod, and what those two bring with them.
220
+
221
+ ## Development
222
+
223
+ ```bash
224
+ npm install
225
+ npm run build
226
+ npm test
227
+ ```
228
+
229
+ The tests run a real MCP client against the server over an in-memory transport,
230
+ and the server against a fake gateway over real HTTP. The fake gateway writes
231
+ its SSE stream in two pieces with the cut landing mid-frame, serves a model with
232
+ no price, and refuses any request to `/route-preview` that arrives carrying an
233
+ `Authorization` header — so a client that leaks a credential to a public
234
+ endpoint fails a test rather than shipping.
235
+
236
+ ## Licence
237
+
238
+ MIT
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Where the Gateway is, where the key comes from, and why neither is a tool
3
+ * argument.
4
+ *
5
+ * This process holds a live credential for the whole time the MCP client is
6
+ * running. Two consequences shape this file.
7
+ *
8
+ * The key is read from the environment once, at construction, and never from a
9
+ * tool call. If `base_url` were a tool argument, any agent — or anything that
10
+ * got a sentence into an agent's context — could point a `chat` call at a host
11
+ * of its choosing and the `Authorization` header would follow. So the base URL
12
+ * is process configuration, full stop. `LOBSTACK_BASE_URL` exists for staging
13
+ * and self-hosted deployments; no tool schema in this repo accepts a URL.
14
+ *
15
+ * And the key never leaves. `scrub()` below runs over every error string that
16
+ * makes it into a tool result, because the one place a credential tends to
17
+ * resurface is in somebody else's error message.
18
+ */
19
+ /**
20
+ * The host that answers without a redirect.
21
+ *
22
+ * `lobstack.ai` 307s to `www.lobstack.ai`. RFC 9110 §15.4 requires a client to
23
+ * drop `Authorization` across a host change, so a caller who points at the bare
24
+ * apex gets "missing credentials" back while holding a perfectly good key —
25
+ * which is exactly the failure that made the Gateway look broken for three
26
+ * months. It is corrected, out loud, rather than honoured. See `resolveBase`.
27
+ */
28
+ export declare const DEFAULT_BASE_URL = "https://www.lobstack.ai/api/gateway/v1";
29
+ /** The gateway lives under this prefix; `/api/v1/usage` is its sibling. */
30
+ export declare const GATEWAY_PREFIX = "/api/gateway/v1";
31
+ export declare function looksLikeApiKey(token: string): boolean;
32
+ export declare class ConfigError extends Error {
33
+ readonly hint: string | undefined;
34
+ constructor(message: string, hint?: string);
35
+ }
36
+ export interface ResolvedBase {
37
+ /** Origin only, no trailing slash. Paths are composed from it. */
38
+ origin: string;
39
+ /** True when the apex was rewritten to `www`. Reported, never silent. */
40
+ corrected: boolean;
41
+ }
42
+ /**
43
+ * Normalise a base URL, and refuse the one that silently breaks auth.
44
+ *
45
+ * Accepts either spelling of the same thing — `https://www.lobstack.ai` and
46
+ * `https://www.lobstack.ai/api/gateway/v1` — because the documented default is
47
+ * the full endpoint and people copy what they read. Both reduce to the origin,
48
+ * which `gatewayUrl` and `apiUrl` then compose against.
49
+ */
50
+ export declare function resolveBase(explicit?: string | null): ResolvedBase;
51
+ /** A path under the OpenAI-compatible gateway, e.g. `/chat/completions`. */
52
+ export declare const gatewayUrl: (origin: string, path: string) => string;
53
+ /** A path under the platform API, e.g. `/v1/usage`. */
54
+ export declare const apiUrl: (origin: string, path: string) => string;
55
+ export interface Config {
56
+ base: ResolvedBase;
57
+ /** Null when no credential is configured. The value is never rendered. */
58
+ apiKey: string | null;
59
+ /** True when a key is present but is not shaped like a Lobstack API key. */
60
+ keyShapeUnrecognised: boolean;
61
+ }
62
+ export declare function loadConfig(overrides?: {
63
+ baseUrl?: string | null;
64
+ apiKey?: string | null;
65
+ }): Config;
66
+ /**
67
+ * Remove the credential from a string before anybody reads it.
68
+ *
69
+ * Two passes, because there are two ways a key gets into text. The literal
70
+ * configured value covers an upstream that echoes back what it was sent — and
71
+ * a self-hosted gateway token that does not match the `lsk_` shape. The pattern
72
+ * covers a key that arrived from somewhere else entirely: a pasted config, a
73
+ * provider's error body, a log line quoted into a response.
74
+ */
75
+ export declare function scrub(text: string, apiKey?: string | null): string;
package/dist/config.js ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Where the Gateway is, where the key comes from, and why neither is a tool
3
+ * argument.
4
+ *
5
+ * This process holds a live credential for the whole time the MCP client is
6
+ * running. Two consequences shape this file.
7
+ *
8
+ * The key is read from the environment once, at construction, and never from a
9
+ * tool call. If `base_url` were a tool argument, any agent — or anything that
10
+ * got a sentence into an agent's context — could point a `chat` call at a host
11
+ * of its choosing and the `Authorization` header would follow. So the base URL
12
+ * is process configuration, full stop. `LOBSTACK_BASE_URL` exists for staging
13
+ * and self-hosted deployments; no tool schema in this repo accepts a URL.
14
+ *
15
+ * And the key never leaves. `scrub()` below runs over every error string that
16
+ * makes it into a tool result, because the one place a credential tends to
17
+ * resurface is in somebody else's error message.
18
+ */
19
+ /**
20
+ * The host that answers without a redirect.
21
+ *
22
+ * `lobstack.ai` 307s to `www.lobstack.ai`. RFC 9110 §15.4 requires a client to
23
+ * drop `Authorization` across a host change, so a caller who points at the bare
24
+ * apex gets "missing credentials" back while holding a perfectly good key —
25
+ * which is exactly the failure that made the Gateway look broken for three
26
+ * months. It is corrected, out loud, rather than honoured. See `resolveBase`.
27
+ */
28
+ export const DEFAULT_BASE_URL = "https://www.lobstack.ai/api/gateway/v1";
29
+ /** The apex, and the host it redirects to. */
30
+ const APEX = "lobstack.ai";
31
+ const WWW = "www.lobstack.ai";
32
+ /** The gateway lives under this prefix; `/api/v1/usage` is its sibling. */
33
+ export const GATEWAY_PREFIX = "/api/gateway/v1";
34
+ /**
35
+ * The shape a Lobstack API key has: `lsk_{live,test}_` + 8 hex selector + 48
36
+ * hex secret. See `src/lib/api-keys.ts` in the platform.
37
+ *
38
+ * Used for two things and neither of them is admission control: telling a user
39
+ * their token does not look like an API key, and scrubbing anything key-shaped
40
+ * out of text on its way to a tool result. The Gateway also accepts per-agent
41
+ * gateway tokens and the platform agent secret, so a token that fails this test
42
+ * is still sent — refusing it here would lock out self-hosted deployments.
43
+ */
44
+ const KEY_SHAPE = /lsk_(?:live|test)_[0-9a-f]{8}[0-9a-f]{48}/g;
45
+ export function looksLikeApiKey(token) {
46
+ return new RegExp(`^${KEY_SHAPE.source}$`).test(token.trim());
47
+ }
48
+ export class ConfigError extends Error {
49
+ hint;
50
+ constructor(message, hint) {
51
+ super(message);
52
+ this.name = "ConfigError";
53
+ this.hint = hint;
54
+ }
55
+ }
56
+ /**
57
+ * Normalise a base URL, and refuse the one that silently breaks auth.
58
+ *
59
+ * Accepts either spelling of the same thing — `https://www.lobstack.ai` and
60
+ * `https://www.lobstack.ai/api/gateway/v1` — because the documented default is
61
+ * the full endpoint and people copy what they read. Both reduce to the origin,
62
+ * which `gatewayUrl` and `apiUrl` then compose against.
63
+ */
64
+ export function resolveBase(explicit) {
65
+ const raw = (explicit || process.env.LOBSTACK_BASE_URL || DEFAULT_BASE_URL).trim();
66
+ let url;
67
+ try {
68
+ url = new URL(raw);
69
+ }
70
+ catch {
71
+ throw new ConfigError(`LOBSTACK_BASE_URL is not a URL: ${JSON.stringify(raw)}.`, `Use an absolute URL, e.g. ${DEFAULT_BASE_URL}`);
72
+ }
73
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
74
+ throw new ConfigError(`LOBSTACK_BASE_URL must be http or https, not ${url.protocol.replace(":", "")}.`, `Use an absolute URL, e.g. ${DEFAULT_BASE_URL}`);
75
+ }
76
+ // One known redirect, corrected. Someone else's host is left exactly as
77
+ // given: this is not a policy about other people's domains.
78
+ if (url.hostname === APEX) {
79
+ url.hostname = WWW;
80
+ return { origin: url.origin, corrected: true };
81
+ }
82
+ return { origin: url.origin.replace(/\/+$/, ""), corrected: false };
83
+ }
84
+ /** A path under the OpenAI-compatible gateway, e.g. `/chat/completions`. */
85
+ export const gatewayUrl = (origin, path) => `${origin}${GATEWAY_PREFIX}${path}`;
86
+ /** A path under the platform API, e.g. `/v1/usage`. */
87
+ export const apiUrl = (origin, path) => `${origin}/api${path}`;
88
+ export function loadConfig(overrides = {}) {
89
+ const base = resolveBase(overrides.baseUrl ?? null);
90
+ const raw = (overrides.apiKey ?? process.env.LOBSTACK_API_KEY ?? "").trim();
91
+ return {
92
+ base,
93
+ apiKey: raw || null,
94
+ keyShapeUnrecognised: !!raw && !looksLikeApiKey(raw),
95
+ };
96
+ }
97
+ /**
98
+ * Remove the credential from a string before anybody reads it.
99
+ *
100
+ * Two passes, because there are two ways a key gets into text. The literal
101
+ * configured value covers an upstream that echoes back what it was sent — and
102
+ * a self-hosted gateway token that does not match the `lsk_` shape. The pattern
103
+ * covers a key that arrived from somewhere else entirely: a pasted config, a
104
+ * provider's error body, a log line quoted into a response.
105
+ */
106
+ export function scrub(text, apiKey) {
107
+ let out = text;
108
+ if (apiKey && apiKey.length >= 8)
109
+ out = out.split(apiKey).join("[redacted]");
110
+ return out.replace(KEY_SHAPE, "[redacted]");
111
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Talking to the Gateway.
3
+ *
4
+ * One fetch wrapper, because there is one rule that must never be skipped on
5
+ * any request: do not follow a redirect with a credential attached.
6
+ *
7
+ * A redirect that changes host makes every conforming HTTP client drop
8
+ * `Authorization` (RFC 9110 §15.4), so the Gateway answers a perfectly good key
9
+ * with "missing credentials" and the user goes looking for a problem with their
10
+ * key. `redirect: "manual"` plus a loud failure is the only honest response:
11
+ * the request did not fail because the key is bad, it failed because the key
12
+ * was never sent.
13
+ */
14
+ import { type Config } from "./config.js";
15
+ export declare class GatewayError extends Error {
16
+ readonly hint: string | undefined;
17
+ readonly status: number | undefined;
18
+ readonly requestId: string | undefined;
19
+ constructor(message: string, opts?: {
20
+ hint?: string;
21
+ status?: number;
22
+ requestId?: string;
23
+ });
24
+ }
25
+ /** Identifies this client in the Gateway's traces. Not a credential. */
26
+ export declare const CLIENT_ID = "lobstack-mcp";
27
+ export interface FetchOpts {
28
+ method?: string;
29
+ body?: string;
30
+ /** Send no `Authorization` at all. Used by route_preview, which needs none. */
31
+ anonymous?: boolean;
32
+ signal?: AbortSignal;
33
+ headers?: Record<string, string>;
34
+ }
35
+ export declare function gwFetch(cfg: Config, url: string, opts?: FetchOpts): Promise<Response>;
36
+ /** A readable error out of a non-2xx response, with the key scrubbed out. */
37
+ export declare function errorFrom(cfg: Config, res: Response, hint?: string): Promise<GatewayError>;
38
+ /** Quota, as the Gateway reports it on every response. See `lib/gateway/quota.ts`. */
39
+ export interface Quota {
40
+ meter: string;
41
+ allowance_usd?: number | null;
42
+ spent_usd?: number | null;
43
+ remaining_usd?: number | null;
44
+ limit?: number | null;
45
+ used?: number | null;
46
+ remaining?: number | null;
47
+ credits?: number | null;
48
+ resets_at?: string | null;
49
+ }
50
+ /**
51
+ * Pull the allowance off response headers.
52
+ *
53
+ * Worth doing because it is free: the Gateway sets these on every answer,
54
+ * including the streamed one, so an agent finds out it is close to a 402 on the
55
+ * call before the one that fails rather than after it. There is no key-
56
+ * authenticated endpoint that reports this on its own — `/api/user/allowance`
57
+ * needs a browser session — so this is the only honest place to get it.
58
+ */
59
+ export declare function quotaFromHeaders(h: Headers): Quota | null;
60
+ /** One line describing the allowance, or null when the Gateway did not report one. */
61
+ export declare function describeQuota(q: Quota | null): string | null;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Talking to the Gateway.
3
+ *
4
+ * One fetch wrapper, because there is one rule that must never be skipped on
5
+ * any request: do not follow a redirect with a credential attached.
6
+ *
7
+ * A redirect that changes host makes every conforming HTTP client drop
8
+ * `Authorization` (RFC 9110 §15.4), so the Gateway answers a perfectly good key
9
+ * with "missing credentials" and the user goes looking for a problem with their
10
+ * key. `redirect: "manual"` plus a loud failure is the only honest response:
11
+ * the request did not fail because the key is bad, it failed because the key
12
+ * was never sent.
13
+ */
14
+ import { scrub } from "./config.js";
15
+ export class GatewayError extends Error {
16
+ hint;
17
+ status;
18
+ requestId;
19
+ constructor(message, opts = {}) {
20
+ super(message);
21
+ this.name = "GatewayError";
22
+ this.hint = opts.hint;
23
+ this.status = opts.status;
24
+ this.requestId = opts.requestId;
25
+ }
26
+ }
27
+ /** Identifies this client in the Gateway's traces. Not a credential. */
28
+ export const CLIENT_ID = "lobstack-mcp";
29
+ export async function gwFetch(cfg, url, opts = {}) {
30
+ const headers = {
31
+ Accept: "application/json, text/event-stream",
32
+ "x-lobstack-client": CLIENT_ID,
33
+ ...(opts.headers ?? {}),
34
+ };
35
+ if (opts.body !== undefined)
36
+ headers["Content-Type"] = "application/json";
37
+ if (!opts.anonymous && cfg.apiKey)
38
+ headers.Authorization = `Bearer ${cfg.apiKey}`;
39
+ let res;
40
+ try {
41
+ res = await fetch(url, {
42
+ method: opts.method ?? "GET",
43
+ body: opts.body,
44
+ signal: opts.signal ?? null,
45
+ redirect: "manual",
46
+ headers,
47
+ });
48
+ }
49
+ catch (e) {
50
+ throw new GatewayError(`could not reach the gateway: ${scrub(e instanceof Error ? e.message : String(e), cfg.apiKey)}`, { hint: `Base URL in use: ${cfg.base.origin}` });
51
+ }
52
+ if (res.status >= 300 && res.status < 400) {
53
+ // Not followed, and not quietly.
54
+ const location = res.headers.get("location");
55
+ throw new GatewayError(`the gateway redirected (${res.status}) to ${location || "somewhere else"}; the request was not followed.`, {
56
+ status: res.status,
57
+ hint: "A redirect across hosts strips the Authorization header, so the key would never arrive. " +
58
+ "Set LOBSTACK_BASE_URL to the host that answers directly — https://www.lobstack.ai, never the bare apex.",
59
+ });
60
+ }
61
+ return res;
62
+ }
63
+ /** A readable error out of a non-2xx response, with the key scrubbed out. */
64
+ export async function errorFrom(cfg, res, hint) {
65
+ const requestId = res.headers.get("x-lobstack-request-id") ?? undefined;
66
+ let message = `HTTP ${res.status}`;
67
+ try {
68
+ const body = (await res.json());
69
+ const inner = typeof body?.error === "string" ? body.error : body?.error?.message;
70
+ if (inner)
71
+ message = inner;
72
+ }
73
+ catch {
74
+ /* not JSON */
75
+ }
76
+ return new GatewayError(scrub(message, cfg.apiKey), { status: res.status, requestId, hint });
77
+ }
78
+ const numOrNull = (v) => {
79
+ if (v === null || v.trim() === "")
80
+ return null;
81
+ const n = Number(v);
82
+ return Number.isFinite(n) ? n : null;
83
+ };
84
+ /**
85
+ * Pull the allowance off response headers.
86
+ *
87
+ * Worth doing because it is free: the Gateway sets these on every answer,
88
+ * including the streamed one, so an agent finds out it is close to a 402 on the
89
+ * call before the one that fails rather than after it. There is no key-
90
+ * authenticated endpoint that reports this on its own — `/api/user/allowance`
91
+ * needs a browser session — so this is the only honest place to get it.
92
+ */
93
+ export function quotaFromHeaders(h) {
94
+ const meter = h.get("x-lobstack-quota-meter");
95
+ if (!meter)
96
+ return null;
97
+ const q = { meter };
98
+ if (meter === "spend") {
99
+ q.allowance_usd = numOrNull(h.get("x-lobstack-quota-allowance-usd"));
100
+ q.spent_usd = numOrNull(h.get("x-lobstack-quota-spent-usd"));
101
+ q.remaining_usd = numOrNull(h.get("x-lobstack-quota-remaining-usd"));
102
+ }
103
+ else {
104
+ q.limit = numOrNull(h.get("x-lobstack-quota-limit"));
105
+ q.used = numOrNull(h.get("x-lobstack-quota-used"));
106
+ q.remaining = numOrNull(h.get("x-lobstack-quota-remaining"));
107
+ q.credits = numOrNull(h.get("x-lobstack-quota-credits"));
108
+ }
109
+ q.resets_at = h.get("x-lobstack-quota-resets");
110
+ return q;
111
+ }
112
+ /** One line describing the allowance, or null when the Gateway did not report one. */
113
+ export function describeQuota(q) {
114
+ if (!q)
115
+ return null;
116
+ if (q.meter === "spend") {
117
+ if (q.remaining_usd === null || q.remaining_usd === undefined)
118
+ return null;
119
+ const of = q.allowance_usd != null ? ` of $${q.allowance_usd.toFixed(2)}` : "";
120
+ return ` allowance $${q.remaining_usd.toFixed(4)} remaining${of}`;
121
+ }
122
+ if (q.remaining === null || q.remaining === undefined)
123
+ return null;
124
+ const of = q.limit != null ? ` of ${q.limit}` : "";
125
+ return ` allowance ${q.remaining} requests remaining${of} (${q.meter} meter)`;
126
+ }
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The executable. Stdio transport, and nothing else.
4
+ *
5
+ * Two rules for a stdio MCP server, both easy to break by accident:
6
+ *
7
+ * 1. stdout is the protocol. Anything written to it that is not a JSON-RPC
8
+ * frame corrupts the session. Diagnostics go to stderr.
9
+ * 2. the key is in the environment of this process. It is never printed, not
10
+ * at startup, not in a banner, not in an error. The startup line below
11
+ * says whether a key is present, which is the only part of it anybody
12
+ * needs to debug a config.
13
+ */
14
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The executable. Stdio transport, and nothing else.
4
+ *
5
+ * Two rules for a stdio MCP server, both easy to break by accident:
6
+ *
7
+ * 1. stdout is the protocol. Anything written to it that is not a JSON-RPC
8
+ * frame corrupts the session. Diagnostics go to stderr.
9
+ * 2. the key is in the environment of this process. It is never printed, not
10
+ * at startup, not in a banner, not in an error. The startup line below
11
+ * says whether a key is present, which is the only part of it anybody
12
+ * needs to debug a config.
13
+ */
14
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
15
+ import { createServer, SERVER_NAME, SERVER_VERSION } from "./server.js";
16
+ import { loadConfig } from "./config.js";
17
+ async function main() {
18
+ const cfg = loadConfig();
19
+ const server = createServer();
20
+ await server.connect(new StdioServerTransport());
21
+ process.stderr.write(`${SERVER_NAME} mcp ${SERVER_VERSION} · ${cfg.base.origin}` +
22
+ `${cfg.base.corrected ? " (apex rewritten to www; a redirect would strip the key)" : ""}` +
23
+ ` · key ${cfg.apiKey ? "configured" : "not set — lobstack_route_preview still works"}\n`);
24
+ }
25
+ main().catch((e) => {
26
+ process.stderr.write(`lobstack-mcp failed to start: ${e instanceof Error ? e.message : String(e)}\n`);
27
+ process.exit(1);
28
+ });