@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 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
- models = null;
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
- async listModels() {
28
- if (!this.models || Date.now() - this.models.at > MODELS_TTL_MS) {
29
- const list = this.getJson('/v1/models').then((b) => (b.data ?? []).filter((m) => m.type !== 'image'));
30
- this.models = { at: Date.now(), list };
31
- list.catch(() => (this.models = null));
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.models.list;
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 doFetch = mode === 'wallet' ? this.wallet.fetch : fetch;
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.1.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
- return text(`${rows.length} private models, all served in a GPU TEE:\n\n${lines.join('\n')}`);
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.1.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",