ouro-mcp 1.0.0 → 1.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.
Files changed (3) hide show
  1. package/README.md +4 -3
  2. package/dist/index.js +115 -8
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -31,7 +31,7 @@ Add to your MCP client config (works with Cursor, Claude Code, Claude Desktop, V
31
31
  }
32
32
  ```
33
33
 
34
- > **Claude Code** also supports: `claude mcp add ouro -- npx -y ouro-mcp`
34
+ > **Claude Code CLI:** `claude mcp add ouro --transport stdio -e WALLET_PRIVATE_KEY=0x... -- npx -y ouro-mcp`
35
35
 
36
36
  ## Usage Examples
37
37
 
@@ -64,10 +64,11 @@ User: Run a Python script that computes the first 1000 primes
64
64
  ← { "job_id": "def456", "price_usdc": "0.03", "status": "pending" }
65
65
  ```
66
66
 
67
- ### Poll for results
67
+ ### Wait for results
68
68
 
69
69
  ```
70
70
  → get_job_status(job_id: "abc123")
71
+ # Streams SSE events internally, returns when the job finishes
71
72
 
72
73
  ← { "status": "completed", "output": "[2, 3, 5, 7, 11, ...]", "runtime_seconds": 4.2 }
73
74
  ```
@@ -97,7 +98,7 @@ Submit a compute job and pay automatically. Returns `job_id` when accepted.
97
98
 
98
99
  ### `get_job_status`
99
100
 
100
- Check the status of a submitted job. Returns output when completed.
101
+ Check the status of a submitted job. Uses SSE streaming to wait for the job to finish — call it once and it returns when the job reaches a terminal state (`completed` or `failed`). No manual polling needed.
101
102
 
102
103
  | Parameter | Type | Required | Description |
103
104
  |-----------|------|----------|-------------|
package/dist/index.js CHANGED
@@ -82,7 +82,7 @@ Payment is automatic — your wallet signs USDC payments locally.
82
82
 
83
83
  Typical flow:
84
84
  1. run_job(script="echo hello") → { job_id, price }
85
- 2. get_job_status(job_id) → poll until status is "completed"
85
+ 2. get_job_status(job_id) → waits for completion and returns result
86
86
 
87
87
  Submission modes:
88
88
  - script: single shell script (simplest)
@@ -107,6 +107,7 @@ server.tool("run_job", "Submit a compute job and pay automatically. Returns job_
107
107
  cpus: z.number().int().min(1).max(8).default(1).describe("CPU cores (1-8)"),
108
108
  time_limit_min: z.number().int().min(1).default(1).describe("Max runtime in minutes"),
109
109
  builder_code: z.string().optional().describe("Builder code for ERC-8021 attribution"),
110
+ webhook_url: z.string().url().optional().describe("URL to receive a POST notification when the job completes or fails"),
110
111
  }, async (params) => {
111
112
  // Validate: exactly one of script or files
112
113
  if (params.script && params.files) {
@@ -128,6 +129,8 @@ server.tool("run_job", "Submit a compute job and pay automatically. Returns job_
128
129
  const headers = { "Content-Type": "application/json" };
129
130
  if (params.builder_code)
130
131
  headers["X-BUILDER-CODE"] = params.builder_code;
132
+ if (params.webhook_url)
133
+ body.webhook_url = params.webhook_url;
131
134
  try {
132
135
  const res = await apiFetch("/api/compute/submit", {
133
136
  method: "POST",
@@ -147,22 +150,126 @@ server.tool("run_job", "Submit a compute job and pay automatically. Returns job_
147
150
  }
148
151
  });
149
152
  // ---------------------------------------------------------------------------
153
+ // Polling fallback (used when SSE is unavailable)
154
+ // ---------------------------------------------------------------------------
155
+ async function pollUntilDone(jobId) {
156
+ const maxPolls = 780; // 65 min at 5s intervals (matches SSE timeout)
157
+ for (let i = 0; i < maxPolls; i++) {
158
+ try {
159
+ const res = await apiFetch(`/api/jobs/${jobId}`);
160
+ if (!res.ok) {
161
+ if (res.status === 404) {
162
+ const text = await res.text();
163
+ return { content: [{ type: "text", text: errorText(res.status, text) }] };
164
+ }
165
+ await new Promise((r) => setTimeout(r, 5_000));
166
+ continue;
167
+ }
168
+ const job = await res.json();
169
+ if (job.status === "completed" || job.status === "failed") {
170
+ return { content: [{ type: "text", text: JSON.stringify(job, null, 2) }] };
171
+ }
172
+ }
173
+ catch {
174
+ // Network error — continue polling
175
+ }
176
+ await new Promise((r) => setTimeout(r, 5_000));
177
+ }
178
+ return { content: [{ type: "text", text: `Job ${jobId} still running after 65 minutes` }] };
179
+ }
180
+ // ---------------------------------------------------------------------------
150
181
  // Tool: get_job_status
151
182
  // ---------------------------------------------------------------------------
152
- server.tool("get_job_status", "Check the status of a job. Returns output when completed.", {
183
+ server.tool("get_job_status", "Check the status of a job. Returns immediately if already done, otherwise waits for completion and returns the final result with output.", {
153
184
  job_id: z.string().describe("Job ID to check"),
154
185
  }, async (params) => {
186
+ // Step 1: Check current status — job may already be done
155
187
  try {
156
- const res = await apiFetch(`/api/jobs/${params.job_id}`);
157
- const text = await res.text();
158
- if (!res.ok) {
159
- return { content: [{ type: "text", text: errorText(res.status, text) }] };
188
+ const statusRes = await apiFetch(`/api/jobs/${params.job_id}`);
189
+ if (!statusRes.ok) {
190
+ const text = await statusRes.text();
191
+ return { content: [{ type: "text", text: errorText(statusRes.status, text) }] };
160
192
  }
161
- return { content: [{ type: "text", text }] };
193
+ const job = await statusRes.json();
194
+ if (job.status === "completed" || job.status === "failed") {
195
+ return { content: [{ type: "text", text: JSON.stringify(job, null, 2) }] };
196
+ }
197
+ console.error(`Job ${params.job_id.slice(0, 8)} is ${job.status}, streaming events...`);
162
198
  }
163
199
  catch (err) {
164
200
  const msg = err instanceof Error ? err.message : String(err);
165
- return { content: [{ type: "text", text: msg }] };
201
+ return { content: [{ type: "text", text: `Status check failed: ${msg}` }] };
202
+ }
203
+ // Step 2: Connect to SSE for live events
204
+ try {
205
+ const sseRes = await apiFetch(`/api/jobs/${params.job_id}/events`, {
206
+ timeout: 3_900_000, // 65 minutes
207
+ });
208
+ if (!sseRes.ok) {
209
+ console.error(`SSE unavailable (${sseRes.status}), falling back to polling`);
210
+ return await pollUntilDone(params.job_id);
211
+ }
212
+ const reader = sseRes.body?.getReader();
213
+ if (!reader) {
214
+ console.error("SSE stream body unavailable, falling back to polling");
215
+ return await pollUntilDone(params.job_id);
216
+ }
217
+ const decoder = new TextDecoder();
218
+ let buffer = "";
219
+ while (true) {
220
+ const { done, value } = await reader.read();
221
+ if (done)
222
+ break;
223
+ buffer += decoder.decode(value, { stream: true });
224
+ const lines = buffer.split("\n");
225
+ buffer = lines.pop() || "";
226
+ let terminalMessage = null;
227
+ for (const rawLine of lines) {
228
+ const line = rawLine.trim();
229
+ if (!line.startsWith("data: "))
230
+ continue;
231
+ try {
232
+ const event = JSON.parse(line.slice(6));
233
+ if (event.type === "job" && event.message) {
234
+ const msg = event.message.toLowerCase();
235
+ if (msg.includes("completed") || msg.includes("failed")) {
236
+ terminalMessage = event.message;
237
+ break;
238
+ }
239
+ }
240
+ }
241
+ catch {
242
+ // Skip malformed SSE lines
243
+ }
244
+ }
245
+ if (terminalMessage) {
246
+ console.error(`Job ${params.job_id.slice(0, 8)}: ${terminalMessage}`);
247
+ try {
248
+ reader.cancel();
249
+ }
250
+ catch { /* best effort stream cleanup */ }
251
+ const finalRes = await apiFetch(`/api/jobs/${params.job_id}`);
252
+ const finalText = await finalRes.text();
253
+ return { content: [{ type: "text", text: finalText }] };
254
+ }
255
+ }
256
+ // Stream ended without terminal event — fetch current status
257
+ console.error("SSE stream ended, fetching final status");
258
+ const fallbackRes = await apiFetch(`/api/jobs/${params.job_id}`);
259
+ const fallbackText = await fallbackRes.text();
260
+ return { content: [{ type: "text", text: fallbackText }] };
261
+ }
262
+ catch (err) {
263
+ const msg = err instanceof Error ? err.message : String(err);
264
+ console.error(`SSE error: ${msg}, fetching current status`);
265
+ try {
266
+ const fallbackRes = await apiFetch(`/api/jobs/${params.job_id}`);
267
+ const fallbackText = await fallbackRes.text();
268
+ return { content: [{ type: "text", text: fallbackText }] };
269
+ }
270
+ catch {
271
+ return { content: [{ type: "text", text: `Job status unavailable: ${msg}` }] };
272
+ }
166
273
  }
167
274
  });
168
275
  // ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "ouro-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Run HPC compute from any AI agent — pay with USDC via x402 on Base",
5
5
  "bin": {
6
- "ouro-mcp": "./dist/index.js"
6
+ "ouro-mcp": "dist/index.js"
7
7
  },
8
8
  "type": "module",
9
9
  "scripts": {