@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 +21 -0
- package/README.md +238 -0
- package/dist/config.d.ts +75 -0
- package/dist/config.js +111 -0
- package/dist/gateway.d.ts +61 -0
- package/dist/gateway.js +126 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +28 -0
- package/dist/receipt.d.ts +84 -0
- package/dist/receipt.js +122 -0
- package/dist/server.d.ts +44 -0
- package/dist/server.js +106 -0
- package/dist/sse.d.ts +50 -0
- package/dist/sse.js +112 -0
- package/dist/tools/chat.d.ts +133 -0
- package/dist/tools/chat.js +177 -0
- package/dist/tools/models.d.ts +56 -0
- package/dist/tools/models.js +90 -0
- package/dist/tools/route-preview.d.ts +75 -0
- package/dist/tools/route-preview.js +155 -0
- package/dist/tools/shared.d.ts +38 -0
- package/dist/tools/shared.js +64 -0
- package/dist/tools/spend.d.ts +49 -0
- package/dist/tools/spend.js +121 -0
- package/package.json +65 -0
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
|
package/dist/config.d.ts
ADDED
|
@@ -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;
|
package/dist/gateway.js
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
});
|