-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathmcp-integration.test.ts
More file actions
263 lines (219 loc) · 11.4 KB
/
mcp-integration.test.ts
File metadata and controls
263 lines (219 loc) · 11.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
/**
* mcp-integration.test.ts — MCP tool handler integration test against LIVE production.
*
* NO MOCKS. Every tool handler call hits https://api.run402.com for real.
* Uses a pre-funded allowance wallet. Tests that MCP tools routed through
* `@run402/sdk/node` can auto-pay x402 and succeed — the same flow the CLI uses.
*
* Covers:
* - set_tier (x402 payment for prototype tier)
* - provision (SIWX auth + project creation)
* - deploy_function (service_key auth + function deploy)
* - invoke_function (service_key auth + function invocation)
* - generate_image (x402 payment for image generation)
* - bundle_deploy (SIWX auth + full-stack deploy)
* - Cleanup: delete function, delete project
*
* Prerequisites:
* - Set BUYER_PRIVATE_KEY env var (or have it in ~/Developer/run402-private/.env).
* This is an EVM private key with testnet USDC on Base Sepolia.
*
* Run:
* node --test --import tsx mcp-integration.test.ts
*
* Takes ~1-2 minutes. Costs ~$0.13 testnet USDC (tier + image).
*/
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
// ─── Test harness ────────────────────────────────────────────────────────────
const API = "https://api.run402.com";
let tempDir: string;
// ─── State passed between tests ──────────────────────────────────────────────
let projectId: string;
// ─── Setup & teardown ────────────────────────────────────────────────────────
before(async () => {
// Load BUYER_PRIVATE_KEY from env or from sibling run402 repo's .env
let buyerKey = process.env.BUYER_PRIVATE_KEY;
if (!buyerKey) {
const { fileURLToPath } = await import("node:url");
const { dirname } = await import("node:path");
const thisDir = dirname(fileURLToPath(import.meta.url));
const searchPaths = [
join(thisDir, "..", "run402", ".env"),
join(thisDir, "..", "..", "dev", "run402", ".env"),
];
for (const envPath of searchPaths) {
try {
const envContent = readFileSync(envPath, "utf-8");
const match = envContent.match(/BUYER_PRIVATE_KEY=(.+)/);
if (match) { buyerKey = match[1].trim(); break; }
} catch { /* try next */ }
}
}
if (!buyerKey) {
throw new Error("BUYER_PRIVATE_KEY not found. Set env var or ensure ../run402/.env exists.");
}
tempDir = mkdtempSync(join(tmpdir(), "run402-mcp-integ-"));
process.env.RUN402_CONFIG_DIR = tempDir;
process.env.RUN402_API_BASE = API;
// Seed the allowance file with the pre-funded wallet
const { privateKeyToAccount } = await import("viem/accounts");
const account = privateKeyToAccount(buyerKey as `0x${string}`);
writeFileSync(
join(tempDir, "allowance.json"),
JSON.stringify({
address: account.address,
privateKey: buyerKey,
created: new Date().toISOString(),
funded: true,
rail: "x402",
}),
{ mode: 0o600 },
);
// Reset the SDK singleton so it reconstructs with the fresh allowance
// (paid-fetch lives inside each Run402 instance now, not at module scope).
const { _resetSdk } = await import("./src/sdk.js");
_resetSdk();
});
after(() => {
delete process.env.RUN402_CONFIG_DIR;
delete process.env.RUN402_API_BASE;
rmSync(tempDir, { recursive: true, force: true });
});
// ─── Helper ──────────────────────────────────────────────────────────────────
function text(result: { content: Array<{ type: string; text?: string }> }): string {
return result.content
.filter((c) => c.type === "text")
.map((c) => c.text)
.join("\n");
}
// ─── Tests — sequential, MCP tool handlers against live API ──────────────────
describe("MCP integration (live API, no mocks)", { timeout: 180_000 }, () => {
// ── Tier (x402 payment) ─────────────────────────────────────────────
it("set_tier — subscribe/renew prototype via x402 auto-payment", async () => {
const { handleSetTier } = await import("./src/tools/set-tier.js");
const result = await handleSetTier({ tier: "prototype" });
const out = text(result);
// Two valid outcomes:
// 1. Auto-paid → "Tier Subscribed/Renewed/Upgraded"
// 2. 402 informational → tier already active (server may return plain 402
// without x402 protocol headers when wallet already has an active tier)
assert.equal(result.isError, undefined, `Expected no isError, got: ${out}`);
const paid = out.includes("Subscribed") || out.includes("Renewed") || out.includes("Upgraded");
const alreadyActive = out.includes("Payment Required") && out.includes("already active");
assert.ok(paid || alreadyActive, `Expected tier success or already-active 402, got: ${out}`);
});
// ── Provision (SIWX auth) ───────────────────────────────────────────
it("provision — create project with SIWX auth", async () => {
const { handleProvision } = await import("./src/tools/provision.js");
const result = await handleProvision({ tier: "prototype", name: "mcp-integ-test" });
const out = text(result);
assert.equal(result.isError, undefined, `Expected no error, got: ${out}`);
assert.ok(out.includes("Project Provisioned"), `Expected 'Project Provisioned' in: ${out}`);
// Extract project_id from markdown table
const match = out.match(/project_id \| `(prj_[a-zA-Z0-9_]+)`/);
assert.ok(match, `Expected project_id in: ${out}`);
projectId = match![1];
});
// ── Deploy function (service_key auth) ──────────────────────────────
it("deploy_function — deploy a hello-world function", async () => {
const { handleDeployFunction } = await import("./src/tools/deploy-function.js");
const result = await handleDeployFunction({
project_id: projectId,
name: "mcp-hello",
code: `export default async (req) => new Response(JSON.stringify({ hello: "mcp" }), { headers: { "Content-Type": "application/json" } })`,
});
const out = text(result);
assert.equal(result.isError, undefined, `Expected no error, got: ${out}`);
assert.ok(out.includes("Function Deployed"), `Expected 'Function Deployed' in: ${out}`);
assert.ok(out.includes("mcp-hello"), `Expected 'mcp-hello' in: ${out}`);
});
// ── Invoke function ─────────────────────────────────────────────────
it("invoke_function — call the deployed function", async () => {
const { handleInvokeFunction } = await import("./src/tools/invoke-function.js");
// Lambda cold start — retry once after 3s
for (let attempt = 0; attempt < 2; attempt++) {
const result = await handleInvokeFunction({
project_id: projectId,
name: "mcp-hello",
});
const out = text(result);
if (!result.isError && out.includes("Function Response")) {
assert.ok(out.includes("mcp") || out.includes("hello"), `Expected response body in: ${out}`);
return;
}
if (attempt === 0) await new Promise((r) => setTimeout(r, 3000));
}
assert.fail("invoke_function failed after retries");
});
// ── Generate image (x402 payment) ───────────────────────────────────
it("generate_image — generate image via x402 auto-payment", async () => {
const { handleGenerateImage } = await import("./src/tools/generate-image.js");
// Image generation can hit 504 timeouts — retry once
for (let attempt = 0; attempt < 2; attempt++) {
const result = await handleGenerateImage({
prompt: "a tiny blue robot waving hello, pixel art",
aspect: "square",
});
const out = text(result);
if (!result.isError && out.includes("Generated")) {
// Should include an image content block
const imageBlock = result.content.find((c) => c.type === "image");
assert.ok(imageBlock, "Expected image content block in response");
return;
}
// Transient server error (504, 502, 503) — retry once
if (result.isError && (out.includes("504") || out.includes("502") || out.includes("503") || out.includes("timed out"))) {
if (attempt === 0) {
await new Promise((r) => setTimeout(r, 2000));
continue;
}
}
// Payment required (no x402 protocol) — treat as acceptable like set_tier
if (!result.isError && out.includes("Payment Required")) {
assert.ok(true, "generate_image returned 402 informational (x402 payment may not have fired)");
return;
}
// Any other error — fail with details
assert.equal(result.isError, undefined, `Expected no error on attempt ${attempt + 1}, got: ${out}`);
}
assert.fail("generate_image failed after retries (transient server errors)");
});
// ── Bundle deploy (SIWX auth, full-stack) ──────────────────────────
it("bundle_deploy — deploy site + function in one call", async () => {
const { handleBundleDeploy } = await import("./src/tools/bundle-deploy.js");
const result = await handleBundleDeploy({
project_id: projectId,
files: [
{ file: "index.html", data: "<!DOCTYPE html><html><body><h1>MCP Integration Test</h1></body></html>" },
],
functions: [
{
name: "mcp-bundle-fn",
code: `export default async (req) => new Response("bundle ok")`,
},
],
});
const out = text(result);
assert.equal(result.isError, undefined, `Expected no error, got: ${out}`);
assert.ok(out.includes("Bundle Deployed"), `Expected 'Bundle Deployed' in: ${out}`);
assert.ok(out.includes(projectId), `Expected project_id in: ${out}`);
});
// ── Cleanup ─────────────────────────────────────────────────────────
it("cleanup — delete functions", async () => {
const { handleDeleteFunction } = await import("./src/tools/delete-function.js");
// mcp-hello is already deleted by bundle_deploy's stale function cleanup
// (bundle deploy removes functions not in the manifest), so skip it here.
const r2 = await handleDeleteFunction({ project_id: projectId, name: "mcp-bundle-fn" });
assert.equal(r2.isError, undefined, `Expected no error deleting mcp-bundle-fn: ${text(r2)}`);
});
it("cleanup — delete project", async () => {
const { handleDeleteProject } = await import("./src/tools/delete-project.js");
const result = await handleDeleteProject({ project_id: projectId });
const out = text(result);
assert.equal(result.isError, undefined, `Expected no error, got: ${out}`);
});
});