mirror of
https://github.com/hanzoai/mcp.git
synced 2026-10-15 12:30:12 +00:00
feat(fetch): x402 Payment Required handling + first unified-tool tests (#5)
* feat(fetch): x402 Payment Required handling + first unified-tool tests
Adds HTTP 402 (x402) parsing to the unified fetch tool so AI agents can
surface payment quotes from x402-gated endpoints and replay with X-PAYMENT.
- request / fetch / head detect status === 402 and return a structured
payment_required envelope (parsed accepts[], x402-version, www-authenticate)
- new optional 'payment' input: pre-encoded base64 string sent verbatim, or
a JSON object that is base64-encoded into X-PAYMENT before retry
- adds the first Jest test file for the HIP-0300 unified surface
(test/tools/unified/fetch.test.ts) covering 200, 402, payment object, and
payment string paths against an in-process http.createServer fixture
Also fixes test/setup.ts so the existing test suite actually runs:
- imports 'jest' from @jest/globals (jest is not a global under ESM)
- ESM-safe __dirname via fileURLToPath(import.meta.url)
Net effect: 169 previously-unrunnable tests can now start; 7/7 new fetch
tests pass; remaining failures are pre-existing test bugs unrelated to this
PR (CommonJS require() in ESM mode, missing mocks, etc.).
No new dependencies, no breaking changes to fetch's existing semantics.
* test: anchor setup.ts on process.cwd() to fix Node 18/20 ESM CI
`test/setup.ts` used `fileURLToPath(import.meta.url)` to derive its own
directory, but `ts-jest`'s handling of `import.meta` inside
`setupFilesAfterEnv` is fragile across Node versions — every test suite
failed on Node 18.x and 20.x with TS1343 ("\'import.meta\' meta-property
is only allowed when \'--module\' is es2020/...\"), even though the
root tsconfig and ts-jest's transform tsconfig both set
`module: ESNext`.
Tests always run from the project root, so `path.join(process.cwd(),
'test', ...)` is a stable anchor that doesn't depend on how the setup
file is loaded.
Local: 15/16 suites pass after the fix (2 unrelated shell.test.ts timing
flakes remain).
* fix(ci): suppress TS2307 on optional jsautogui dynamic import
The Node 20 CI job was failing two test suites — test/config/
configuration.test.ts and test/integration/mcp-server.test.ts — with:
src/autogui/adapters/jsautogui.ts:37:37 - error TS2307: Cannot find
module 'jsautogui' or its corresponding type declarations.
while the actual jest run itself reported 326/326 tests passing in the
suites it could load. Root cause: jsautogui is in optionalDependencies
and ships native bindings that npm may skip on some platforms. When
the package (and its bundled .d.ts) isn't present, ts-jest's transform
pass fails the whole suite at type-check time even though the runtime
code path is already guarded by try/catch + AutoGUINotAvailableError.
Adds a localised `// @ts-ignore` on the single dynamic-import line,
with a comment explaining why. The runtime behaviour is unchanged —
if jsautogui isn't installed, the catch arm still throws
AutoGUINotAvailableError as before. Confirmed locally that the
previously-failing test/config/configuration.test.ts now passes both
with jsautogui present and with node_modules/jsautogui removed
(simulating the Ubuntu CI install).
No code logic changed; package.json and package-lock.json untouched.
---------
Co-authored-by: Abhishek Krishna <[email protected]>
This commit is contained in:
committed by
GitHub
co-authored by
Abhishek Krishna
parent
ce4f845528
commit
89bf02f5f7
@@ -33,9 +33,15 @@ export class JSAutoGUIAdapter extends BaseAutoGUIAdapter {
|
||||
|
||||
protected async initializeImplementation(): Promise<void> {
|
||||
try {
|
||||
// Dynamic import since jsautogui is an optional dependency
|
||||
// Dynamic import since jsautogui is an optional dependency. The package
|
||||
// ships native bindings and may not be installed (it is listed under
|
||||
// optionalDependencies in package.json). ts-jest type-checks setup files
|
||||
// and would emit TS2307 here when jsautogui is absent or its types are
|
||||
// missing — suppress that since the runtime failure is already handled
|
||||
// by the surrounding try/catch which throws AutoGUINotAvailableError.
|
||||
// @ts-ignore optional native dependency, may not be installed
|
||||
this.jsautogui = await import('jsautogui');
|
||||
|
||||
|
||||
// Try to get version if available
|
||||
try {
|
||||
this.version = this.jsautogui.version || this.jsautogui.default?.version || 'unknown';
|
||||
|
||||
@@ -21,6 +21,47 @@ function fail(code: string, message: string) {
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify({ ok: false, data: null, error: { code, message }, meta: { tool: 'fetch' } }, null, 2) }], isError: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an HTTP 402 response into the x402 payment-required envelope.
|
||||
* Best-effort: surfaces whatever the server returned (JSON `accepts` array
|
||||
* per x402.org spec, or raw `WWW-Authenticate`/`X-Accept-Payment` headers
|
||||
* for older variants) so the calling agent can decide how to fund the call.
|
||||
*/
|
||||
async function parsePaymentRequired(resp: Response) {
|
||||
const headers = Object.fromEntries(resp.headers.entries());
|
||||
let body: any = null;
|
||||
try {
|
||||
const ct = resp.headers.get('content-type') || '';
|
||||
body = ct.includes('json') ? await resp.json() : await resp.text();
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
const accepts = body && typeof body === 'object' && Array.isArray(body.accepts)
|
||||
? body.accepts
|
||||
: null;
|
||||
return {
|
||||
status: 402,
|
||||
payment_required: {
|
||||
x402_version: headers['x402-version'] || (body && body.x402Version) || null,
|
||||
accepts,
|
||||
www_authenticate: headers['www-authenticate'] || null,
|
||||
raw_body: accepts ? null : body,
|
||||
},
|
||||
headers,
|
||||
};
|
||||
}
|
||||
|
||||
function paymentHeader(payment: any): Record<string, string> {
|
||||
if (!payment) return {};
|
||||
// x402 spec uses base64-encoded JSON in the X-PAYMENT header. Accept either
|
||||
// a pre-encoded string (`payment` is a string) or a JSON object we encode.
|
||||
if (typeof payment === 'string') return { 'X-PAYMENT': payment };
|
||||
if (typeof payment === 'object') {
|
||||
return { 'X-PAYMENT': Buffer.from(JSON.stringify(payment), 'utf8').toString('base64') };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export const fetchTool: Tool = {
|
||||
name: 'fetch',
|
||||
description: 'Network operations: request, fetch, head, download, open, search, crawl',
|
||||
@@ -37,6 +78,7 @@ export const fetchTool: Tool = {
|
||||
query: { type: 'string', description: 'Search query' },
|
||||
depth: { type: 'number', description: 'Crawl depth', default: 2 },
|
||||
limit: { type: 'number', description: 'Max results/pages', default: 10 },
|
||||
payment: { description: 'x402 payment payload — base64 string sent as-is via X-PAYMENT, or a JSON object that will be base64-encoded' },
|
||||
},
|
||||
required: ['action']
|
||||
},
|
||||
@@ -46,11 +88,14 @@ export const fetchTool: Tool = {
|
||||
case 'request': {
|
||||
const opts: RequestInit = {
|
||||
method: args.method || 'GET',
|
||||
headers: args.headers || {},
|
||||
headers: { ...(args.headers || {}), ...paymentHeader(args.payment) },
|
||||
signal: AbortSignal.timeout(args.timeout || 30000),
|
||||
};
|
||||
if (args.body) opts.body = args.body;
|
||||
const resp = await fetch(args.url, opts);
|
||||
if (resp.status === 402) {
|
||||
return envelope(await parsePaymentRequired(resp), 'request');
|
||||
}
|
||||
const contentType = resp.headers.get('content-type') || '';
|
||||
let data: any;
|
||||
if (contentType.includes('json')) {
|
||||
@@ -68,11 +113,14 @@ export const fetchTool: Tool = {
|
||||
case 'fetch': {
|
||||
const opts: RequestInit = {
|
||||
method: args.method || 'GET',
|
||||
headers: args.headers || {},
|
||||
headers: { ...(args.headers || {}), ...paymentHeader(args.payment) },
|
||||
signal: AbortSignal.timeout(args.timeout || 30000),
|
||||
};
|
||||
if (args.body) opts.body = args.body;
|
||||
const resp = await fetch(args.url, opts);
|
||||
if (resp.status === 402) {
|
||||
return envelope(await parsePaymentRequired(resp), 'fetch');
|
||||
}
|
||||
const contentType = resp.headers.get('content-type') || '';
|
||||
let data: any;
|
||||
if (contentType.includes('json')) {
|
||||
@@ -90,9 +138,12 @@ export const fetchTool: Tool = {
|
||||
case 'head': {
|
||||
const resp = await fetch(args.url, {
|
||||
method: 'HEAD',
|
||||
headers: args.headers || {},
|
||||
headers: { ...(args.headers || {}), ...paymentHeader(args.payment) },
|
||||
signal: AbortSignal.timeout(args.timeout || 30000),
|
||||
});
|
||||
if (resp.status === 402) {
|
||||
return envelope(await parsePaymentRequired(resp), 'head');
|
||||
}
|
||||
return envelope({
|
||||
status: resp.status,
|
||||
headers: Object.fromEntries(resp.headers.entries()),
|
||||
|
||||
+9
-3
@@ -4,19 +4,25 @@
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { beforeAll, afterAll, beforeEach, afterEach } from '@jest/globals';
|
||||
import { beforeAll, afterAll, beforeEach, afterEach, jest } from '@jest/globals';
|
||||
|
||||
// Anchor on the project root rather than this file's directory so we don't
|
||||
// need `import.meta.url` here — ts-jest's handling of `import.meta` in
|
||||
// `setupFilesAfterEnv` differs across Node versions and breaks Node 18/20 CI.
|
||||
// Tests always run from the project root, so `process.cwd()` is stable.
|
||||
const TEST_ROOT = path.join(process.cwd(), 'test');
|
||||
|
||||
// Global test timeout
|
||||
jest.setTimeout(10000);
|
||||
|
||||
// Test fixtures directory
|
||||
export const TEST_FIXTURES_DIR = path.join(__dirname, 'fixtures');
|
||||
export const TEST_FIXTURES_DIR = path.join(TEST_ROOT, 'fixtures');
|
||||
|
||||
// Each suite gets a unique temp directory to prevent parallel suite interference.
|
||||
// setupFilesAfterEnv creates a fresh module instance per suite, so this value is
|
||||
// unique per suite even though it's module-scoped.
|
||||
const suiteId = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
export const TEST_TEMP_DIR = path.join(__dirname, 'temp', suiteId);
|
||||
export const TEST_TEMP_DIR = path.join(TEST_ROOT, 'temp', suiteId);
|
||||
|
||||
// Create test directories
|
||||
beforeAll(async () => {
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Tests for the unified `fetch` tool (HIP-0300).
|
||||
*
|
||||
* Covers the standard 200 path plus the new x402 (HTTP 402 Payment Required)
|
||||
* surfacing — the calling agent should receive a structured `payment_required`
|
||||
* envelope on 402, and supplying a `payment` argument should re-issue the
|
||||
* request with the `X-PAYMENT` header.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll } from '@jest/globals';
|
||||
import * as http from 'http';
|
||||
import { AddressInfo } from 'net';
|
||||
import { fetchTool } from '../../../src/tools/unified/fetch.js';
|
||||
|
||||
interface RecordedRequest {
|
||||
method: string;
|
||||
url: string;
|
||||
headers: http.IncomingHttpHeaders;
|
||||
body: string;
|
||||
}
|
||||
|
||||
let server: http.Server;
|
||||
let baseUrl: string;
|
||||
const recorded: RecordedRequest[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
server = http.createServer((req, res) => {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => { body += chunk; });
|
||||
req.on('end', () => {
|
||||
recorded.push({
|
||||
method: req.method || '',
|
||||
url: req.url || '',
|
||||
headers: req.headers,
|
||||
body,
|
||||
});
|
||||
|
||||
// /paid — gates on X-PAYMENT, otherwise 402 with x402 envelope.
|
||||
if (req.url?.startsWith('/paid')) {
|
||||
if (req.headers['x-payment']) {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true, paid: true }));
|
||||
return;
|
||||
}
|
||||
const envelope = {
|
||||
x402Version: 1,
|
||||
accepts: [{
|
||||
scheme: 'exact',
|
||||
network: 'base-sepolia',
|
||||
maxAmountRequired: '1000',
|
||||
asset: '0x036CbD53842c5426634e7929541eC2318f3dCF7e',
|
||||
payTo: '0x0000000000000000000000000000000000000001',
|
||||
resource: 'https://example.test/paid',
|
||||
description: 'kcolbchain test resource',
|
||||
mimeType: 'application/json',
|
||||
}],
|
||||
};
|
||||
res.writeHead(402, {
|
||||
'content-type': 'application/json',
|
||||
'x402-version': '1',
|
||||
'www-authenticate': 'x402',
|
||||
});
|
||||
res.end(JSON.stringify(envelope));
|
||||
return;
|
||||
}
|
||||
|
||||
// /ok — plain 200 JSON.
|
||||
if (req.url?.startsWith('/ok')) {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ hello: 'world' }));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const addr = server.address() as AddressInfo;
|
||||
baseUrl = `http://127.0.0.1:${addr.port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
function parseResult(result: any) {
|
||||
expect(result.content).toHaveLength(1);
|
||||
return JSON.parse(result.content[0].text);
|
||||
}
|
||||
|
||||
describe('fetchTool', () => {
|
||||
test('has expected metadata', () => {
|
||||
expect(fetchTool.name).toBe('fetch');
|
||||
expect(fetchTool.inputSchema.required).toContain('action');
|
||||
});
|
||||
|
||||
test('request returns body for 200 JSON', async () => {
|
||||
const result = await fetchTool.handler({ action: 'request', url: `${baseUrl}/ok` });
|
||||
const parsed = parseResult(result);
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.data.status).toBe(200);
|
||||
expect(parsed.data.body).toEqual({ hello: 'world' });
|
||||
});
|
||||
|
||||
test('head returns headers for 200', async () => {
|
||||
const result = await fetchTool.handler({ action: 'head', url: `${baseUrl}/ok` });
|
||||
const parsed = parseResult(result);
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.data.status).toBe(200);
|
||||
});
|
||||
|
||||
test('request surfaces x402 payment_required envelope on 402', async () => {
|
||||
const result = await fetchTool.handler({ action: 'request', url: `${baseUrl}/paid` });
|
||||
const parsed = parseResult(result);
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.data.status).toBe(402);
|
||||
expect(parsed.data.payment_required).toBeDefined();
|
||||
expect(parsed.data.payment_required.x402_version).toBe('1');
|
||||
expect(parsed.data.payment_required.www_authenticate).toBe('x402');
|
||||
expect(parsed.data.payment_required.accepts).toHaveLength(1);
|
||||
expect(parsed.data.payment_required.accepts[0].network).toBe('base-sepolia');
|
||||
});
|
||||
|
||||
test('fetch surfaces x402 payment_required envelope on 402', async () => {
|
||||
const result = await fetchTool.handler({ action: 'fetch', url: `${baseUrl}/paid` });
|
||||
const parsed = parseResult(result);
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.data.status).toBe(402);
|
||||
expect(parsed.data.payment_required.accepts[0].asset)
|
||||
.toBe('0x036CbD53842c5426634e7929541eC2318f3dCF7e');
|
||||
});
|
||||
|
||||
test('payment object is base64-encoded into X-PAYMENT and unlocks the resource', async () => {
|
||||
const before = recorded.length;
|
||||
const payment = { scheme: 'exact', network: 'base-sepolia', signature: '0xdeadbeef' };
|
||||
const result = await fetchTool.handler({
|
||||
action: 'request',
|
||||
url: `${baseUrl}/paid`,
|
||||
payment,
|
||||
});
|
||||
const parsed = parseResult(result);
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.data.status).toBe(200);
|
||||
expect(parsed.data.body).toEqual({ ok: true, paid: true });
|
||||
|
||||
const last = recorded[recorded.length - 1];
|
||||
const sent = last.headers['x-payment'] as string | undefined;
|
||||
expect(sent).toBeDefined();
|
||||
const decoded = JSON.parse(Buffer.from(sent!, 'base64').toString('utf8'));
|
||||
expect(decoded).toEqual(payment);
|
||||
expect(recorded.length).toBeGreaterThan(before);
|
||||
});
|
||||
|
||||
test('payment string is forwarded verbatim as X-PAYMENT', async () => {
|
||||
const presigned = 'eyJzaWduYXR1cmUiOiJ0ZXN0In0=';
|
||||
const result = await fetchTool.handler({
|
||||
action: 'request',
|
||||
url: `${baseUrl}/paid`,
|
||||
payment: presigned,
|
||||
});
|
||||
const parsed = parseResult(result);
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.data.status).toBe(200);
|
||||
const last = recorded[recorded.length - 1];
|
||||
expect(last.headers['x-payment']).toBe(presigned);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user