@offrouter/mcp 0.1.0 → 0.2.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/README.md +3 -1
- package/dist/client.js +86 -29
- package/dist/config.js +3 -0
- package/dist/server.js +66 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -60,12 +60,14 @@ Pick one by setting an environment variable. If you set both, the key is used.
|
|
|
60
60
|
| `OFFROUTER_NETWORK` | both | Only pay on `base` or only on `robinhood` |
|
|
61
61
|
| `OFFROUTER_MAX_TOKENS` | `2048` | Output budget when the agent doesn't set one |
|
|
62
62
|
| `OFFROUTER_E2EE` | `on` | Set to `off` to send prompts to the gateway in the clear |
|
|
63
|
+
| `OFFROUTER_IMAGE_DIR` | a temp folder | Where `generate_image` saves its images |
|
|
63
64
|
| `OFFROUTER_BASE_URL` | `https://offrouter.org` | API to talk to |
|
|
64
65
|
|
|
65
66
|
## Tools
|
|
66
67
|
|
|
67
|
-
- **list_models** lists the models with context length and price per million tokens.
|
|
68
|
+
- **list_models** lists the models with context length and price per million tokens, and the image models with their price per image.
|
|
68
69
|
- **ask** sends a prompt to one model and returns the answer, what it cost, and a link to its signed receipt.
|
|
70
|
+
- **generate_image** makes 1 to 4 images from a text prompt on a private image model (like `flux-2-klein`), priced per image. The images come back inline and are saved as files. Image models don't take end to end encryption yet, so the prompt reaches the gateway in the clear; it's still served in the TEE and nothing is stored. Needs a key or a wallet.
|
|
69
71
|
- **compare** runs the same prompt on 2 to 4 models in parallel, so you can see the answers and costs side by side.
|
|
70
72
|
- **account** shows the key balance and recent spend, the wallet address and cap, or what's left on the demo.
|
|
71
73
|
- **verify_enclave** checks the enclave from your machine right now and shows the key your prompts are encrypted to.
|
package/dist/client.js
CHANGED
|
@@ -18,19 +18,60 @@ export class OffRouter {
|
|
|
18
18
|
config;
|
|
19
19
|
enclave;
|
|
20
20
|
wallet;
|
|
21
|
-
|
|
21
|
+
catalog = null;
|
|
22
22
|
constructor(config) {
|
|
23
23
|
this.config = config;
|
|
24
24
|
this.enclave = new EnclaveCache(config.baseUrl);
|
|
25
25
|
this.wallet = config.mode === 'wallet' ? makeWallet(config) : null;
|
|
26
26
|
}
|
|
27
|
-
|
|
28
|
-
if (!this.
|
|
29
|
-
const list = this.getJson('/v1/models').then((b) => (b.data ?? [])
|
|
30
|
-
this.
|
|
31
|
-
list.catch(() => (this.
|
|
27
|
+
all() {
|
|
28
|
+
if (!this.catalog || Date.now() - this.catalog.at > MODELS_TTL_MS) {
|
|
29
|
+
const list = this.getJson('/v1/models').then((b) => (b.data ?? []));
|
|
30
|
+
this.catalog = { at: Date.now(), list };
|
|
31
|
+
list.catch(() => (this.catalog = null));
|
|
32
32
|
}
|
|
33
|
-
return this.
|
|
33
|
+
return this.catalog.list;
|
|
34
|
+
}
|
|
35
|
+
/** The chat models. Image models are served by generateImage, so they are left out. */
|
|
36
|
+
async listModels() {
|
|
37
|
+
return (await this.all()).filter((m) => m.type !== 'image');
|
|
38
|
+
}
|
|
39
|
+
async listImageModels() {
|
|
40
|
+
return (await this.all()).filter((m) => m.type === 'image');
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Generate images. Image models do not take end to end encryption yet, so the prompt reaches
|
|
44
|
+
* the gateway in the clear; it is still served inside the TEE and nothing is stored.
|
|
45
|
+
*/
|
|
46
|
+
async generateImage(input) {
|
|
47
|
+
const { mode } = this.config;
|
|
48
|
+
if (mode === 'demo')
|
|
49
|
+
throw new ApiError('Image generation needs a key or a wallet: the free demo only serves one chat model. Set OFFROUTER_API_KEY or OFFROUTER_WALLET_KEY.', 402);
|
|
50
|
+
const models = await this.listImageModels();
|
|
51
|
+
const model = input.model ?? models[0]?.id;
|
|
52
|
+
if (!model)
|
|
53
|
+
throw new ApiError('OffRouter has no image model right now.', 404, 'model_not_found');
|
|
54
|
+
const entry = models.find((m) => m.id === model);
|
|
55
|
+
if (!entry)
|
|
56
|
+
throw new ApiError(`There is no image model called "${model}". Image models: ${models.map((m) => m.id).join(', ') || 'none'}.`, 404, 'model_not_found');
|
|
57
|
+
const headers = { 'content-type': 'application/json' };
|
|
58
|
+
if (mode === 'key')
|
|
59
|
+
headers.authorization = `Bearer ${this.config.apiKey}`;
|
|
60
|
+
const body = { model, prompt: input.prompt, n: input.n ?? 1, size: input.size ?? '1024x1024', response_format: 'b64_json' };
|
|
61
|
+
const res = await this.post('/v1/images/generations', headers, body);
|
|
62
|
+
const out = await res.json();
|
|
63
|
+
const images = (out.data ?? [])
|
|
64
|
+
.filter((d) => typeof d?.b64_json === 'string' && d.b64_json)
|
|
65
|
+
.map((d) => ({ data: d.b64_json, mimeType: mimeOf(d.b64_json), ...(d.revised_prompt ? { revisedPrompt: String(d.revised_prompt) } : {}) }));
|
|
66
|
+
const charge = res.headers.get('x-offrouter-charge-usd');
|
|
67
|
+
return {
|
|
68
|
+
model,
|
|
69
|
+
images,
|
|
70
|
+
chargeUsd: charge == null ? null : Number(charge),
|
|
71
|
+
receiptUrl: res.headers.get('x-offrouter-receipt-url'),
|
|
72
|
+
paidWith: mode,
|
|
73
|
+
transaction: mode === 'wallet' ? settlement(res.headers)?.transaction : undefined,
|
|
74
|
+
};
|
|
34
75
|
}
|
|
35
76
|
/** Balance and recent usage of the prepaid key. */
|
|
36
77
|
keyInfo() {
|
|
@@ -102,28 +143,7 @@ export class OffRouter {
|
|
|
102
143
|
Object.assign(headers, session.headers);
|
|
103
144
|
}
|
|
104
145
|
const path = mode === 'demo' ? '/v1/demo/chat' : '/v1/chat/completions';
|
|
105
|
-
const
|
|
106
|
-
let res;
|
|
107
|
-
try {
|
|
108
|
-
res = await doFetch(`${this.config.baseUrl}${path}`, { method: 'POST', headers, body: JSON.stringify(payload) });
|
|
109
|
-
}
|
|
110
|
-
catch (e) {
|
|
111
|
-
const msg = e.message || String(e);
|
|
112
|
-
if (mode === 'wallet' && /spendControls|maxAmountPerPayment/.test(msg)) {
|
|
113
|
-
throw new Error(`Not paid: this call is quoted above the $${this.config.maxUsd} cap per call. Lower max_tokens or raise OFFROUTER_MAX_USD.`);
|
|
114
|
-
}
|
|
115
|
-
if (mode === 'wallet')
|
|
116
|
-
throw new Error(`Payment failed: ${msg.replace(/\.+$/, '')}.`);
|
|
117
|
-
throw e;
|
|
118
|
-
}
|
|
119
|
-
if (!res.ok) {
|
|
120
|
-
const err = await apiError(res);
|
|
121
|
-
if (mode === 'wallet' && res.status === 402) {
|
|
122
|
-
const why = /insufficient_balance/.test(err.message) ? 'it does not hold enough' : err.message.replace(/\.+$/, '');
|
|
123
|
-
throw new ApiError(`The wallet ${this.wallet.address} could not pay: ${why}. Fund it with USDC on Base or USDG on Robinhood Chain.`, 402, err.code);
|
|
124
|
-
}
|
|
125
|
-
throw err;
|
|
126
|
-
}
|
|
146
|
+
const res = await this.post(path, headers, payload);
|
|
127
147
|
if (session && !session.applied(res.headers))
|
|
128
148
|
throw new Error('The enclave did not confirm it encrypted the answer, so it was not read.');
|
|
129
149
|
let text = '';
|
|
@@ -159,6 +179,33 @@ export class OffRouter {
|
|
|
159
179
|
demoRemaining: remaining == null ? undefined : Number(remaining),
|
|
160
180
|
};
|
|
161
181
|
}
|
|
182
|
+
/** POST, paid from the wallet in wallet mode, with payment failures turned into readable errors. */
|
|
183
|
+
async post(path, headers, payload) {
|
|
184
|
+
const { mode } = this.config;
|
|
185
|
+
const doFetch = mode === 'wallet' ? this.wallet.fetch : fetch;
|
|
186
|
+
let res;
|
|
187
|
+
try {
|
|
188
|
+
res = await doFetch(`${this.config.baseUrl}${path}`, { method: 'POST', headers, body: JSON.stringify(payload) });
|
|
189
|
+
}
|
|
190
|
+
catch (e) {
|
|
191
|
+
const msg = e.message || String(e);
|
|
192
|
+
if (mode === 'wallet' && /spendControls|maxAmountPerPayment/.test(msg)) {
|
|
193
|
+
throw new Error(`Not paid: this call is quoted above the $${this.config.maxUsd} cap per call. Lower max_tokens (or n, for images) or raise OFFROUTER_MAX_USD.`);
|
|
194
|
+
}
|
|
195
|
+
if (mode === 'wallet')
|
|
196
|
+
throw new Error(`Payment failed: ${msg.replace(/\.+$/, '')}.`);
|
|
197
|
+
throw e;
|
|
198
|
+
}
|
|
199
|
+
if (!res.ok) {
|
|
200
|
+
const err = await apiError(res);
|
|
201
|
+
if (mode === 'wallet' && res.status === 402) {
|
|
202
|
+
const why = /insufficient_balance/.test(err.message) ? 'it does not hold enough' : err.message.replace(/\.+$/, '');
|
|
203
|
+
throw new ApiError(`The wallet ${this.wallet.address} could not pay: ${why}. Fund it with USDC on Base or USDG on Robinhood Chain.`, 402, err.code);
|
|
204
|
+
}
|
|
205
|
+
throw err;
|
|
206
|
+
}
|
|
207
|
+
return res;
|
|
208
|
+
}
|
|
162
209
|
async getJson(path, headers = {}) {
|
|
163
210
|
const res = await fetch(`${this.config.baseUrl}${path}`, { headers: { accept: 'application/json', ...headers } });
|
|
164
211
|
if (!res.ok)
|
|
@@ -166,6 +213,16 @@ export class OffRouter {
|
|
|
166
213
|
return res.json();
|
|
167
214
|
}
|
|
168
215
|
}
|
|
216
|
+
/** PNG, JPEG or WebP, from the first bytes. */
|
|
217
|
+
function mimeOf(b64) {
|
|
218
|
+
if (b64.startsWith('iVBOR'))
|
|
219
|
+
return 'image/png';
|
|
220
|
+
if (b64.startsWith('/9j/'))
|
|
221
|
+
return 'image/jpeg';
|
|
222
|
+
if (b64.startsWith('UklGR'))
|
|
223
|
+
return 'image/webp';
|
|
224
|
+
return 'image/png';
|
|
225
|
+
}
|
|
169
226
|
async function apiError(res) {
|
|
170
227
|
const body = await res.json().catch(() => null);
|
|
171
228
|
const err = body?.error;
|
package/dist/config.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Everything is configured through environment variables, so it fits in the
|
|
2
2
|
// "env" block of any MCP client config.
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
3
5
|
const off = (v) => !!v && ['0', 'false', 'off', 'no'].includes(v.trim().toLowerCase());
|
|
4
6
|
export function loadConfig(env = process.env) {
|
|
5
7
|
const baseUrl = (env.OFFROUTER_BASE_URL || 'https://offrouter.org').replace(/\/+$/, '');
|
|
@@ -28,5 +30,6 @@ export function loadConfig(env = process.env) {
|
|
|
28
30
|
network: network,
|
|
29
31
|
defaultMaxTokens,
|
|
30
32
|
e2ee: !off(env.OFFROUTER_E2EE),
|
|
33
|
+
imageDir: env.OFFROUTER_IMAGE_DIR?.trim() || join(tmpdir(), 'offrouter-images'),
|
|
31
34
|
};
|
|
32
35
|
}
|
package/dist/server.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
1
3
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
4
|
import { z } from 'zod';
|
|
3
|
-
export const VERSION = '0.
|
|
5
|
+
export const VERSION = '0.2.0';
|
|
4
6
|
const MAX_COMPARE = 4;
|
|
5
7
|
const text = (t) => ({ content: [{ type: 'text', text: t }] });
|
|
6
8
|
const fail = (t) => ({ content: [{ type: 'text', text: t }], isError: true });
|
|
@@ -19,6 +21,18 @@ function footer(r) {
|
|
|
19
21
|
parts.push(`receipt ${r.receiptUrl}`);
|
|
20
22
|
return parts.join(' · ');
|
|
21
23
|
}
|
|
24
|
+
function imageFooter(r) {
|
|
25
|
+
const parts = [r.model, `${r.images.length} image${r.images.length === 1 ? '' : 's'}`];
|
|
26
|
+
if (r.chargeUsd != null)
|
|
27
|
+
parts.push(usd(r.chargeUsd));
|
|
28
|
+
if (r.transaction && !r.transaction.startsWith('dryrun_'))
|
|
29
|
+
parts.push(`paid in tx ${r.transaction}`);
|
|
30
|
+
parts.push('served in a TEE, prompt not end to end encrypted');
|
|
31
|
+
if (r.receiptUrl)
|
|
32
|
+
parts.push(`receipt ${r.receiptUrl}`);
|
|
33
|
+
return parts.join(' · ');
|
|
34
|
+
}
|
|
35
|
+
const EXT = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp' };
|
|
22
36
|
const date = (sec) => (sec ? new Date(sec * 1000).toISOString().slice(0, 16).replace('T', ' ') : 'never');
|
|
23
37
|
/** Where it stands: ready once it passed its evaluation and has run well for a day. */
|
|
24
38
|
function standing(a) {
|
|
@@ -55,6 +69,7 @@ function instructions(api) {
|
|
|
55
69
|
api.config.e2ee ? 'Prompts are encrypted on this machine to a key the enclave proves it holds, so the gateway only sees ciphertext.' : '',
|
|
56
70
|
how,
|
|
57
71
|
'Use list_models to pick a model, then ask for one answer or compare to run the same prompt on several models.',
|
|
72
|
+
'generate_image makes images from a text prompt on a private image model.',
|
|
58
73
|
'list_agents and get_agent show the private agents builders have published on OffRouter Studio.',
|
|
59
74
|
]
|
|
60
75
|
.filter(Boolean)
|
|
@@ -79,13 +94,16 @@ export function createServer(api) {
|
|
|
79
94
|
}
|
|
80
95
|
const q = search?.toLowerCase() ?? '';
|
|
81
96
|
const rows = models.filter((m) => !q || m.id.toLowerCase().includes(q));
|
|
82
|
-
if (!rows.length)
|
|
97
|
+
if (!rows.length && !(await api.listImageModels().catch(() => [])).some((m) => m.id.toLowerCase().includes(q)))
|
|
83
98
|
return text(`No models match "${search}".`);
|
|
84
99
|
const lines = rows.map((m) => `${m.id} | ${Math.round(m.context_length / 1000)}k ctx | ${usd(m.input_usdc_per_mtok)} in / ${usd(m.output_usdc_per_mtok)} out per M tokens` +
|
|
85
100
|
((m.input_modalities ?? []).length > 1 ? ` | reads ${m.input_modalities.slice(1).join(' and ')}` : '') +
|
|
86
101
|
(m.discount_pct ? ` | ${m.discount_pct}% off` : '') +
|
|
87
102
|
(api.config.e2ee && !m.e2ee?.model ? ' | no e2ee' : ''));
|
|
88
|
-
|
|
103
|
+
const images = (await api.listImageModels().catch(() => [])).filter((m) => !q || m.id.toLowerCase().includes(q));
|
|
104
|
+
const imageLines = images.map((m) => `${m.id} | up to ${m.max_images} per call | ${usd(m.usdc_per_image)} per image`);
|
|
105
|
+
return text(`${rows.length} private models, all served in a GPU TEE:\n\n${lines.join('\n')}` +
|
|
106
|
+
(imageLines.length ? `\n\nImage models, for generate_image:\n\n${imageLines.join('\n')}` : ''));
|
|
89
107
|
});
|
|
90
108
|
const chatShape = {
|
|
91
109
|
prompt: z.string().min(1).describe('The user message.'),
|
|
@@ -128,6 +146,51 @@ export function createServer(api) {
|
|
|
128
146
|
const sections = results.map((r, i) => r.status === 'fulfilled' ? `## ${ids[i]}\n\n${r.value.text || '(no text)'}\n\n${footer(r.value)}` : `## ${ids[i]}\n\nError: ${r.reason.message}`);
|
|
129
147
|
return { content: [{ type: 'text', text: sections.join('\n\n---\n\n') }], isError: results.every((r) => r.status === 'rejected') };
|
|
130
148
|
});
|
|
149
|
+
server.registerTool('generate_image', {
|
|
150
|
+
title: 'Generate an image',
|
|
151
|
+
description: 'Generate images from a text prompt on a private OffRouter image model. It runs inside a GPU TEE and nothing is stored. Priced per image (see list_models). ' +
|
|
152
|
+
'The images come back inline and are saved as files, whose paths are returned. Image models do not take end to end encryption yet, so the prompt reaches the gateway in the clear.',
|
|
153
|
+
inputSchema: {
|
|
154
|
+
prompt: z.string().min(1).max(4000).describe('What to draw.'),
|
|
155
|
+
model: z.string().optional().describe('Image model id from list_models, like "flux-2-klein". Defaults to the first one.'),
|
|
156
|
+
n: z.number().int().min(1).max(4).optional().describe('How many images, 1 by default.'),
|
|
157
|
+
size: z
|
|
158
|
+
.string()
|
|
159
|
+
.regex(/^\d{3,4}x\d{3,4}$/)
|
|
160
|
+
.optional()
|
|
161
|
+
.describe('WIDTHxHEIGHT, each side a multiple of 16 from 256 to 2048. Defaults to 1024x1024.'),
|
|
162
|
+
save_dir: z.string().optional().describe('Folder to save the images in. Defaults to OFFROUTER_IMAGE_DIR, or a temp folder.'),
|
|
163
|
+
},
|
|
164
|
+
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
165
|
+
}, async ({ save_dir, ...args }) => {
|
|
166
|
+
try {
|
|
167
|
+
const r = await api.generateImage(args);
|
|
168
|
+
if (!r.images.length)
|
|
169
|
+
return fail(`${r.model} returned no image, so nothing was charged.`);
|
|
170
|
+
const dir = resolve(save_dir ?? api.config.imageDir);
|
|
171
|
+
await mkdir(dir, { recursive: true });
|
|
172
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
173
|
+
const paths = [];
|
|
174
|
+
for (const [i, img] of r.images.entries()) {
|
|
175
|
+
const file = join(dir, `${r.model}-${stamp}${r.images.length > 1 ? `-${i + 1}` : ''}.${EXT[img.mimeType] ?? 'png'}`);
|
|
176
|
+
await writeFile(file, Buffer.from(img.data, 'base64'));
|
|
177
|
+
paths.push(file);
|
|
178
|
+
}
|
|
179
|
+
const revised = r.images.map((img) => img.revisedPrompt).filter(Boolean);
|
|
180
|
+
return {
|
|
181
|
+
content: [
|
|
182
|
+
...r.images.map((img) => ({ type: 'image', data: img.data, mimeType: img.mimeType })),
|
|
183
|
+
{
|
|
184
|
+
type: 'text',
|
|
185
|
+
text: [`Saved to:\n${paths.join('\n')}`, revised.length ? `Revised prompt: ${revised[0]}` : '', imageFooter(r)].filter(Boolean).join('\n\n'),
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
catch (e) {
|
|
191
|
+
return fail(e.message);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
131
194
|
server.registerTool('account', {
|
|
132
195
|
title: 'Account',
|
|
133
196
|
description: 'Show how this server pays for calls: the prepaid key balance and recent spend, the wallet address and per call cap, or what is left on the free demo.',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@offrouter/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "MCP server for OffRouter private inference. Every model runs in a GPU TEE, and your prompts are encrypted to the enclave before they leave your machine.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "OffRouter",
|