diff --git a/.gitignore b/.gitignore index 08b2a42..365c1c3 100644 --- a/.gitignore +++ b/.gitignore @@ -35,7 +35,6 @@ npm-debug.log* # Testing coverage/ .nyc_output/ -tests/.x402-deploy-result.json # Temporary files *.tmp diff --git a/README.md b/README.md index 4abb183..77ce41e 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,18 @@ console.log(`Found ${multiChainResults.length} agents across chains`); const agentSummary = await sdk.getAgent('11155111:123'); // explicit chainId:agentId ``` +### 4b. Create MCP/A2A Clients Directly from URL + +```typescript +// MCP: URL is treated as the direct MCP endpoint +const mcp = sdk.createMCPClient('https://mcp.example.com/mcp'); +const tools = await mcp.listTools(); + +// A2A: URL can be an agent-card URL or base URL (discovery is applied) +const a2a = sdk.createA2AClient('https://a2a.example.com'); +const reply = await a2a.messageA2A('hello'); +``` + ### 4a. Multi-Chain Search ```typescript diff --git a/examples/mcp-demo.ts b/examples/mcp-demo.ts new file mode 100644 index 0000000..cef8d79 --- /dev/null +++ b/examples/mcp-demo.ts @@ -0,0 +1,74 @@ +/** + * MCP demo: list tools, `get_affirmation`, then **`generate_controller_brief`** (may charge ~$0.01 USDC on Base via x402 when the server returns HTTP 402). + * `generate_controller_brief` needs a Delx `session_id`; run **`quick_session`** once and parse the UUID from the reply text. + * On 402, logs **`pay.accepts`** (payment options), then **`pay()`**, then prints the tool result. + * + * npx tsx examples/mcp-demo.ts + * + * Env: RPC_URL / DELX_RPC_URL, PRIVATE_KEY / AGENT_PRIVATE_KEY. + */ +import './_env'; +import { SDK, type X402RequestResult } from '../src/index'; +import type { AgentId } from '../src/models/types.js'; +import type { MCPTool } from '../src/models/mcp.js'; + +const DELX_AGENT_ID = '8453:28350' as AgentId; + +function sessionIdFromQuickSession(result: unknown): string | null { + const text = (result as { content?: { text?: string }[] })?.content?.[0]?.text; + const m = text?.match(/Session ID:\s*([0-9a-f-]{36})/i); + return m?.[1] ?? null; +} + +async function main() { + const pk = process.env.PRIVATE_KEY ?? process.env.AGENT_PRIVATE_KEY; + const sdk = new SDK({ + chainId: 8453, + rpcUrl: process.env.DELX_RPC_URL || process.env.RPC_URL || 'https://mainnet.base.org', + ...(pk?.trim() ? { privateKey: pk.trim() } : {}), + }); + + const agent = await sdk.loadAgent(DELX_AGENT_ID); + + const tools = (await agent.mcp.listTools()) as MCPTool[]; + console.log('Tools:', tools.map((t) => t.name).join(', ')); + + const affRes = (await agent.mcp.call('get_affirmation', {})) as X402RequestResult; + if (affRes.x402Required) { + console.log('get_affirmation:', JSON.stringify(await affRes.x402Payment.pay(), null, 2)); + } else { + console.log('get_affirmation:', JSON.stringify(affRes, null, 2)); + } + + if (!pk?.trim()) { + console.log('Skip paid tool: set PRIVATE_KEY or AGENT_PRIVATE_KEY.'); + return; + } + + const qsRes = (await agent.mcp.call('quick_session', { + agent_id: 'agent0-ts-mcp-demo', + feeling: 'mcp-demo before generate_controller_brief', + })) as X402RequestResult; + const qs = qsRes.x402Required ? await qsRes.x402Payment.pay() : qsRes; + const sessionId = sessionIdFromQuickSession(qs); + if (!sessionId) { + console.log('Could not parse Session ID from quick_session.'); + return; + } + + const briefRes = (await agent.mcp.call('generate_controller_brief', { + session_id: sessionId, + focus: 'x402 demo from agent0-ts', + })) as X402RequestResult; + if (!briefRes.x402Required) { + console.log('generate_controller_brief:', JSON.stringify(briefRes, null, 2)); + return; + } + + const pay = briefRes.x402Payment; + console.log('x402 accepts:', JSON.stringify(pay.accepts, null, 2)); + const paid = await pay.pay(); + console.log('generate_controller_brief:', JSON.stringify(paid, null, 2)); +} + +main().catch(console.error); diff --git a/package.json b/package.json index e7e387f..266021e 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,9 @@ "a2a-server": "node tests/a2a-server/server.mjs", "test:a2a-integration": "RUN_A2A_INTEGRATION=1 jest --testPathPattern=a2a-integration", "test:a2a-anvil": "RUN_A2A_ANVIL=1 jest --testPathPattern=a2a-anvil", + "mcp-server": "node tests/mcp-server/server.mjs", + "test:mcp-integration": "RUN_MCP_INTEGRATION=1 jest --testPathPattern=mcp-integration", + "test:mcp-anvil": "RUN_MCP_ANVIL=1 jest --testPathPattern=mcp-anvil", "forge:build": "forge build", "anvil:node": "anvil --port 8545", "lint": "eslint src", diff --git a/src/core/a2a-summary-client.ts b/src/core/a2a-summary-client.ts index 453d794..52c9e84 100644 --- a/src/core/a2a-summary-client.ts +++ b/src/core/a2a-summary-client.ts @@ -159,3 +159,130 @@ export class A2AClientFromSummary implements A2AClient { ); } } + +/** + * A2A client backed directly by an A2A URL (agent-card URL or base URL). + * Resolves once on first use and delegates to the same low-level A2A functions as Agent. + */ +export class A2AClientFromUrl implements A2AClient { + private _resolved: ResolvedA2A | null = null; + + constructor( + private readonly _sdk: SDKLike, + private readonly _url: string + ) {} + + private async _ensureResolved(): Promise { + if (this._resolved) return this._resolved; + if (!this._url || (!this._url.startsWith('http://') && !this._url.startsWith('https://'))) { + throw new Error('A2A URL must be http or https'); + } + this._resolved = await resolveA2aFromEndpointUrl(this._url); + return this._resolved; + } + + async messageA2A( + content: string | { parts: import('../models/a2a.js').Part[] }, + options?: MessageA2AOptions + ): Promise> { + const resolved = await this._ensureResolved(); + const x402Deps = this._sdk.getX402RequestDeps?.(); + return sendMessage( + { + baseUrl: resolved.baseUrl, + a2aVersion: resolved.a2aVersion, + content, + options, + auth: resolved.auth, + tenant: resolved.tenant, + binding: resolved.binding, + }, + x402Deps + ); + } + + async listTasks( + options?: ListTasksOptions + ): Promise> { + const resolved = await this._ensureResolved(); + const x402Deps = this._sdk.getX402RequestDeps?.(); + return listTasks( + { + baseUrl: resolved.baseUrl, + a2aVersion: resolved.a2aVersion, + options, + auth: resolved.auth, + tenant: resolved.tenant, + }, + x402Deps + ); + } + + async loadTask( + taskId: string, + options?: LoadTaskOptions + ): Promise> { + const resolved = await this._ensureResolved(); + const x402Deps = this._sdk.getX402RequestDeps?.(); + const resolvedAuth = + options?.credential != null && resolved.auth + ? applyCredential(options.credential, resolved.auth) + : undefined; + + const result = await getTask( + resolved.baseUrl, + resolved.a2aVersion, + taskId, + resolvedAuth, + x402Deps, + options?.payment, + resolved.tenant + ); + + if (result.x402Required) { + return { + x402Required: true, + x402Payment: { + ...result.x402Payment, + pay: async (accept?: X402Accept | number) => { + const summary = await result.x402Payment.pay(accept); + return createTaskHandle( + resolved.baseUrl, + resolved.a2aVersion, + summary.taskId, + summary.contextId, + x402Deps, + resolvedAuth, + resolved.tenant + ); + }, + payFirst: result.x402Payment.payFirst + ? async () => { + const summary = await result.x402Payment.payFirst!(); + return createTaskHandle( + resolved.baseUrl, + resolved.a2aVersion, + summary.taskId, + summary.contextId, + x402Deps, + resolvedAuth, + resolved.tenant + ); + } + : undefined, + }, + }; + } + + const summary = result as TaskSummary; + return createTaskHandle( + resolved.baseUrl, + resolved.a2aVersion, + summary.taskId, + summary.contextId, + x402Deps, + resolvedAuth, + resolved.tenant + ); + } +} diff --git a/src/core/agent.ts b/src/core/agent.ts index 7f7a8fc..4d6dede 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -19,6 +19,7 @@ import type { TaskSummary, AgentTask, } from '../models/a2a.js'; +import type { MCPHandle } from '../models/mcp.js'; import { sendMessage as sendMessageA2A, listTasks as listTasksA2A, @@ -42,6 +43,7 @@ import { normalizeEcdsaSignature, recoverTypedDataSigner } from '../utils/signat import { buildErc8004RegistrationJson } from '../utils/registration-json.js'; import { encodeErc8004JsonDataUri } from '../utils/data-uri.js'; import { TransactionHandle } from './transaction-handle.js'; +import { createMCPHandle } from './mcp-client.js'; /** * Agent class for managing individual agents @@ -63,6 +65,7 @@ export class Agent { private _a2aInterfaceResolved = false; /** When set, used as A2A base URL instead of resolving from card (e.g. from discovery). */ private _a2aBaseUrlOverride?: string; + private _mcpHandle?: MCPHandle; constructor(private sdk: SDK, registrationFile: RegistrationFile) { this.registrationFile = registrationFile; @@ -107,6 +110,23 @@ export class Agent { return ep?.value; } + get mcp(): MCPHandle { + if (this._mcpHandle) return this._mcpHandle; + const endpoint = this.mcpEndpoint; + if (!endpoint || (!endpoint.startsWith('http://') && !endpoint.startsWith('https://'))) { + throw new Error('Agent has no MCP endpoint'); + } + const ep = this.registrationFile.endpoints.find((e) => e.type === EndpointType.MCP); + this._mcpHandle = createMCPHandle( + endpoint, + { + protocolVersion: typeof ep?.meta?.version === 'string' ? (ep.meta.version as string) : '2025-06-18', + }, + this.sdk.getX402RequestDeps?.() + ); + return this._mcpHandle; + } + get a2aEndpoint(): string | undefined { const ep = this.registrationFile.endpoints.find((e) => e.type === EndpointType.A2A); return ep?.value; @@ -206,6 +226,7 @@ export class Agent { }; this.registrationFile.endpoints.push(mcpEndpoint); this.registrationFile.updatedAt = Math.floor(Date.now() / 1000); + this._mcpHandle = undefined; return this; } diff --git a/src/core/mcp-client.ts b/src/core/mcp-client.ts new file mode 100644 index 0000000..8d2d9ae --- /dev/null +++ b/src/core/mcp-client.ts @@ -0,0 +1,404 @@ +import type { X402RequestDeps } from './x402-request.js'; +import { requestWithX402 } from './x402-request.js'; +import type { X402RequiredResponse } from './x402-types.js'; +import type { + MCPAuthOptions, + MCPClientOptions, + MCPHandle, + MCPInitializeResult, + MCPMaybePaid, + MCPPrompt, + MCPPromptGetResult, + MCPResource, + MCPResourceTemplate, + MCPTool, +} from '../models/mcp.js'; + +const DEFAULT_PROTOCOL_VERSION = '2025-06-18'; +const SESSION_HEADER = 'Mcp-Session-Id'; + +type JsonRpcRequest = { + jsonrpc: '2.0'; + id?: string | number; + method: string; + params?: Record; +}; + +function isIdentifierSafe(name: string): boolean { + return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name); +} + +function normalizeBearer(credential?: string): string | undefined { + if (!credential) return undefined; + const trimmed = credential.trim(); + if (!trimmed) return undefined; + if (/^Bearer\s+/i.test(trimmed)) return trimmed; + return `Bearer ${trimmed}`; +} + +function parseSseJson(text: string): Record | null { + const lines = text.split('\n'); + for (const line of lines) { + if (!line.startsWith('data:')) continue; + const payload = line.slice(5).trim(); + if (!payload) continue; + try { + return JSON.parse(payload) as Record; + } catch { + continue; + } + } + return null; +} + +function extractJsonRpcBody(text: string, contentType: string): Record { + const trimmed = text.trim(); + if (!trimmed) throw new Error('MCP server returned empty response'); + if (contentType.includes('text/event-stream')) { + const parsed = parseSseJson(trimmed); + if (!parsed) throw new Error('MCP server returned invalid SSE JSON-RPC response'); + return parsed; + } + try { + return JSON.parse(trimmed) as Record; + } catch { + const parsed = parseSseJson(trimmed); + if (parsed) return parsed; + throw new Error('MCP server returned non-JSON response'); + } +} + +function parseJsonRpcResult(data: Record, method: string): T { + if (data.error && typeof data.error === 'object') { + const err = data.error as Record; + throw new Error(`MCP ${method} failed: ${String(err.message ?? err.code ?? 'unknown error')}`); + } + if (!('result' in data)) { + throw new Error(`MCP ${method} failed: missing JSON-RPC result`); + } + return data.result as T; +} + +function castX402(result: X402RequiredResponse): X402RequiredResponse { + return result as X402RequiredResponse; +} + +export class MCPClient implements MCPHandle { + private _initialized = false; + private _sessionId?: string; + private _protocolVersion: string; + /** From last `initialize` result; used to skip prompts/resources RPC when the server did not advertise them. */ + private _serverCaps: Record | undefined; + private _toolsCache: MCPTool[] | null = null; + private _dynamicTools: Record, options?: MCPAuthOptions) => Promise>> = {}; + + constructor( + private readonly _endpoint: string, + private readonly _options: MCPClientOptions = {}, + private readonly _x402Deps?: X402RequestDeps + ) { + this._protocolVersion = _options.protocolVersion ?? DEFAULT_PROTOCOL_VERSION; + this._sessionId = _options.sessionId; + } + + private _baseHeaders(auth?: MCPAuthOptions): Record { + const bearer = normalizeBearer(auth?.credential ?? this._options.credential); + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'MCP-Protocol-Version': this._protocolVersion, + ...(this._options.headers ?? {}), + ...(auth?.headers ?? {}), + ...(bearer ? { Authorization: bearer } : {}), + ...(this._sessionId ? { [SESSION_HEADER]: this._sessionId } : {}), + }; + return headers; + } + + private async _postJsonRpc( + method: string, + params: Record | undefined, + auth?: MCPAuthOptions + ): Promise> { + const body: JsonRpcRequest = { + jsonrpc: '2.0', + id: `${method}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + method, + ...(params ? { params } : {}), + }; + const headers = this._baseHeaders(auth); + + if (this._x402Deps) { + const result = await requestWithX402( + { + url: this._endpoint, + method: 'POST', + headers, + body: JSON.stringify(body), + parseResponse: async (res) => { + const newSession = res.headers.get(SESSION_HEADER); + if (newSession) this._sessionId = newSession; + if (this._sessionId && res.status === 404) { + this._initialized = false; + this._sessionId = undefined; + throw new Error('MCP session expired'); + } + const text = await res.text(); + const data = extractJsonRpcBody(text, res.headers.get('content-type') ?? ''); + return parseJsonRpcResult(data, method); + }, + }, + this._x402Deps + ); + if ('x402Required' in result && result.x402Required) { + return result as X402RequiredResponse; + } + return result as T; + } + + const res = await fetch(this._endpoint, { + method: 'POST', + headers, + body: JSON.stringify(body), + }); + const newSession = res.headers.get(SESSION_HEADER); + if (newSession) this._sessionId = newSession; + if (this._sessionId && res.status === 404) { + this._initialized = false; + this._sessionId = undefined; + throw new Error('MCP session expired'); + } + if (!res.ok) throw new Error(`MCP ${method} failed: HTTP ${res.status}`); + const text = await res.text(); + return parseJsonRpcResult(extractJsonRpcBody(text, res.headers.get('content-type') ?? ''), method); + } + + private async _ensureInitialized(auth?: MCPAuthOptions): Promise | null> { + if (this._initialized) return null; + const initResult = await this._postJsonRpc( + 'initialize', + { + protocolVersion: this._protocolVersion, + capabilities: {}, + clientInfo: this._options.clientInfo ?? { + name: 'agent0-ts', + version: '1.0.0', + }, + }, + auth + ); + if ('x402Required' in initResult && initResult.x402Required) return initResult as X402RequiredResponse; + const initialized = initResult as MCPInitializeResult; + this._protocolVersion = initialized.protocolVersion ?? this._protocolVersion; + const caps = initialized.capabilities; + this._serverCaps = + caps !== undefined && caps !== null && typeof caps === 'object' ? (caps as Record) : undefined; + + const initializedNotif: JsonRpcRequest = { jsonrpc: '2.0', method: 'notifications/initialized' }; + const notifHeaders = this._baseHeaders(auth); + if (this._x402Deps) { + await requestWithX402( + { + url: this._endpoint, + method: 'POST', + headers: notifHeaders, + body: JSON.stringify(initializedNotif), + parseResponse: async (res) => { + if (res.status !== 202 && !res.ok) throw new Error(`MCP initialized notification failed: HTTP ${res.status}`); + return {}; + }, + }, + this._x402Deps + ); + } else { + const res = await fetch(this._endpoint, { + method: 'POST', + headers: notifHeaders, + body: JSON.stringify(initializedNotif), + }); + if (res.status !== 202 && !res.ok) throw new Error(`MCP initialized notification failed: HTTP ${res.status}`); + } + this._initialized = true; + return null; + } + + /** If we parsed server capabilities, only call feature X when that key was present (MCP omits unsupported primitives). */ + private _advertises(feature: 'prompts' | 'resources'): boolean { + if (this._serverCaps === undefined) return true; + return Object.prototype.hasOwnProperty.call(this._serverCaps, feature); + } + + async initialize(options?: MCPAuthOptions): Promise> { + const res = await this._ensureInitialized(options); + if (res && 'x402Required' in res && res.x402Required) return castX402(res); + return { + protocolVersion: this._protocolVersion, + }; + } + + async listTools(options?: MCPAuthOptions): Promise> { + const init = await this._ensureInitialized(options); + if (init && 'x402Required' in init && init.x402Required) return castX402(init); + if (this._toolsCache) return this._toolsCache; + const out: MCPTool[] = []; + let cursor: string | undefined; + do { + const page = await this._postJsonRpc<{ tools?: MCPTool[]; nextCursor?: string }>( + 'tools/list', + cursor ? { cursor } : {}, + options + ); + if ('x402Required' in page && page.x402Required) return castX402(page); + const p = page as { tools?: MCPTool[]; nextCursor?: string }; + out.push(...(p.tools ?? [])); + cursor = p.nextCursor; + } while (cursor); + this._toolsCache = out; + this._rebuildDynamicToolAccess(out); + return out; + } + + private _rebuildDynamicToolAccess(tools: MCPTool[]): void { + this._dynamicTools = {}; + for (const tool of tools) { + const callFn = (args?: Record, options?: MCPAuthOptions) => this.call(tool.name, args, options); + this._dynamicTools[tool.name] = callFn; + } + } + + async call(name: string, args?: Record, options?: MCPAuthOptions): Promise> { + const init = await this._ensureInitialized(options); + if (init && 'x402Required' in init && init.x402Required) return castX402(init); + return this._postJsonRpc('tools/call', { name, arguments: args ?? {} }, options); + } + + readonly prompts = { + list: async (options?: MCPAuthOptions): Promise> => { + const init = await this._ensureInitialized(options); + if (init && 'x402Required' in init && init.x402Required) return castX402(init); + if (!this._advertises('prompts')) return []; + const out: MCPPrompt[] = []; + let cursor: string | undefined; + do { + const page = await this._postJsonRpc<{ prompts?: MCPPrompt[]; nextCursor?: string }>( + 'prompts/list', + cursor ? { cursor } : {}, + options + ); + if ('x402Required' in page && page.x402Required) return castX402(page); + const p = page as { prompts?: MCPPrompt[]; nextCursor?: string }; + out.push(...(p.prompts ?? [])); + cursor = p.nextCursor; + } while (cursor); + return out; + }, + get: async (name: string, args?: Record, options?: MCPAuthOptions): Promise> => { + const init = await this._ensureInitialized(options); + if (init && 'x402Required' in init && init.x402Required) return castX402(init); + if (!this._advertises('prompts')) { + throw new Error('MCP server did not advertise prompts capability'); + } + return this._postJsonRpc('prompts/get', { name, arguments: args ?? {} }, options); + }, + }; + + readonly resources = { + list: async (options?: MCPAuthOptions): Promise> => { + const init = await this._ensureInitialized(options); + if (init && 'x402Required' in init && init.x402Required) return castX402(init); + if (!this._advertises('resources')) return []; + const out: MCPResource[] = []; + let cursor: string | undefined; + do { + const page = await this._postJsonRpc<{ resources?: MCPResource[]; nextCursor?: string }>( + 'resources/list', + cursor ? { cursor } : {}, + options + ); + if ('x402Required' in page && page.x402Required) return castX402(page); + const p = page as { resources?: MCPResource[]; nextCursor?: string }; + out.push(...(p.resources ?? [])); + cursor = p.nextCursor; + } while (cursor); + return out; + }, + read: async ( + uri: string, + options?: MCPAuthOptions + ): Promise }>> => { + const init = await this._ensureInitialized(options); + if (init && 'x402Required' in init && init.x402Required) { + return castX402<{ contents: Array<{ uri: string; [key: string]: unknown }> }>(init); + } + if (!this._advertises('resources')) { + throw new Error('MCP server did not advertise resources capability'); + } + return this._postJsonRpc<{ contents: Array<{ uri: string; [key: string]: unknown }> }>( + 'resources/read', + { uri }, + options + ); + }, + templates: { + list: async (options?: MCPAuthOptions): Promise> => { + const init = await this._ensureInitialized(options); + if (init && 'x402Required' in init && init.x402Required) return castX402(init); + if (!this._advertises('resources')) return []; + const out: MCPResourceTemplate[] = []; + let cursor: string | undefined; + do { + const page = await this._postJsonRpc<{ resourceTemplates?: MCPResourceTemplate[]; nextCursor?: string }>( + 'resources/templates/list', + cursor ? { cursor } : {}, + options + ); + if ('x402Required' in page && page.x402Required) return castX402(page); + const p = page as { resourceTemplates?: MCPResourceTemplate[]; nextCursor?: string }; + out.push(...(p.resourceTemplates ?? [])); + cursor = p.nextCursor; + } while (cursor); + return out; + }, + }, + }; + + get tools(): Record, options?: MCPAuthOptions) => Promise>> { + return this._dynamicTools; + } + + getSessionId(): string | undefined { + return this._sessionId; + } + + setSessionId(sessionId?: string): void { + this._sessionId = sessionId; + if (sessionId) { + this._initialized = true; + } + } + + resetSession(): void { + this._sessionId = undefined; + this._initialized = false; + this._serverCaps = undefined; + this._toolsCache = null; + } +} + +export function createMCPHandle( + endpoint: string, + options: MCPClientOptions = {}, + x402Deps?: X402RequestDeps +): MCPHandle { + const client = new MCPClient(endpoint, options, x402Deps); + return new Proxy(client as unknown as MCPHandle, { + get(target, prop, receiver) { + if (typeof prop === 'string' && isIdentifierSafe(prop) && !(prop in target)) { + return async (args?: Record, options?: MCPAuthOptions) => + target.call(prop, args, options); + } + return Reflect.get(target, prop, receiver); + }, + }); +} + diff --git a/src/core/mcp-summary-client.ts b/src/core/mcp-summary-client.ts new file mode 100644 index 0000000..1a3df8a --- /dev/null +++ b/src/core/mcp-summary-client.ts @@ -0,0 +1,46 @@ +import type { AgentSummary } from '../models/interfaces.js'; +import type { MCPClientOptions, MCPHandle } from '../models/mcp.js'; +import type { X402RequestDeps } from './x402-request.js'; +import { createMCPHandle } from './mcp-client.js'; + +export interface SDKLikeMCP { + getX402RequestDeps?(): X402RequestDeps; +} + +export class MCPClientFromSummary implements MCPHandle { + private _client: MCPHandle | null = null; + + constructor( + private readonly _sdk: SDKLikeMCP, + private readonly _summary: AgentSummary, + private readonly _options: MCPClientOptions = {} + ) {} + + private _ensureClient(): MCPHandle { + if (this._client) return this._client; + const endpoint = this._summary.mcp; + if (!endpoint || (!endpoint.startsWith('http://') && !endpoint.startsWith('https://'))) { + throw new Error('Agent summary has no MCP endpoint'); + } + this._client = createMCPHandle(endpoint, this._options, this._sdk.getX402RequestDeps?.()); + return this._client; + } + + get tools() { + return this._ensureClient().tools; + } + get prompts() { + return this._ensureClient().prompts; + } + get resources() { + return this._ensureClient().resources; + } + listTools = (options?: Parameters[0]) => this._ensureClient().listTools(options); + call = (name: string, args?: Record, options?: Parameters[2]) => + this._ensureClient().call(name, args, options); + getSessionId = () => this._ensureClient().getSessionId(); + setSessionId = (sessionId?: string) => this._ensureClient().setSessionId(sessionId); + resetSession = () => this._ensureClient().resetSession(); + initialize = (options?: Parameters[0]) => this._ensureClient().initialize(options); +} + diff --git a/src/core/sdk.ts b/src/core/sdk.ts index bba81fd..2dd93dc 100644 --- a/src/core/sdk.ts +++ b/src/core/sdk.ts @@ -25,7 +25,10 @@ import { SubgraphClient } from './subgraph-client.js'; import { FeedbackManager } from './feedback-manager.js'; import { AgentIndexer } from './indexer.js'; import { Agent } from './agent.js'; -import { A2AClientFromSummary } from './a2a-summary-client.js'; +import { A2AClientFromSummary, A2AClientFromUrl } from './a2a-summary-client.js'; +import { MCPClientFromSummary } from './mcp-summary-client.js'; +import type { MCPClientOptions } from '../models/mcp.js'; +import { createMCPHandle } from './mcp-client.js'; import type { TransactionHandle } from './transaction-handle.js'; import { DEFAULT_REGISTRIES, @@ -487,13 +490,35 @@ export class SDK { * When given an AgentSummary, returns an A2AClientFromSummary that resolves the agent card from summary.a2a on first use. * Use this to treat agents and summaries interchangeably for A2A. */ - createA2AClient(agentOrSummary: Agent | AgentSummary): Agent | A2AClientFromSummary { + createA2AClient(agentOrSummary: Agent | AgentSummary | string): Agent | A2AClientFromSummary | A2AClientFromUrl { if (agentOrSummary instanceof Agent) { return agentOrSummary; } + if (typeof agentOrSummary === 'string') { + return new A2AClientFromUrl(this, agentOrSummary); + } return new A2AClientFromSummary(this, agentOrSummary); } + /** + * Create an MCP client from a loaded Agent or an AgentSummary. + * - Agent: returns agent.mcp handle + * - AgentSummary: returns summary-backed MCP client, resolved lazily from summary.mcp + */ + createMCPClient( + agentOrSummary: Agent | AgentSummary | string, + options: MCPClientOptions = {} + ): import('../models/mcp.js').MCPHandle { + if (typeof agentOrSummary === 'string') { + return createMCPHandle(agentOrSummary, options, this.getX402RequestDeps()); + } + if (agentOrSummary instanceof Agent) { + if (options.sessionId) agentOrSummary.mcp.setSessionId(options.sessionId); + return agentOrSummary.mcp; + } + return new MCPClientFromSummary(this, agentOrSummary, options); + } + /** * Transfer agent ownership */ diff --git a/src/index.ts b/src/index.ts index f4ac274..cf551b4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,9 @@ export { SDK } from './core/sdk.js'; export type { SDKConfig } from './core/sdk.js'; export { Agent } from './core/agent.js'; export { A2AClientFromSummary } from './core/a2a-summary-client.js'; +export { A2AClientFromUrl } from './core/a2a-summary-client.js'; +export { MCPClient } from './core/mcp-client.js'; +export { MCPClientFromSummary } from './core/mcp-summary-client.js'; export { ViemChainClient } from './core/viem-chain-client.js'; export type { ChainClient, TransactionOptions } from './core/chain-client.js'; export { IPFSClient } from './core/ipfs-client.js'; diff --git a/src/models/index.ts b/src/models/index.ts index 20bebbb..5257fe8 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -7,4 +7,5 @@ export * from './types.js'; export * from './enums.js'; export * from './interfaces.js'; export * from './a2a.js'; +export * from './mcp.js'; diff --git a/src/models/mcp.ts b/src/models/mcp.ts new file mode 100644 index 0000000..2aaa627 --- /dev/null +++ b/src/models/mcp.ts @@ -0,0 +1,116 @@ +import type { X402RequiredResponse } from '../core/x402-types.js'; + +export type MCPProtocolVersion = '2025-06-18' | string; + +export interface MCPClientInfo { + name: string; + title?: string; + version: string; +} + +export interface MCPInitializeResult { + protocolVersion: MCPProtocolVersion; + capabilities?: Record; + serverInfo?: { + name?: string; + title?: string; + version?: string; + }; + instructions?: string; +} + +export interface MCPTool { + name: string; + title?: string; + description?: string; + inputSchema?: Record; + outputSchema?: Record; + annotations?: Record; + [key: string]: unknown; +} + +export interface MCPPrompt { + name: string; + title?: string; + description?: string; + arguments?: Array<{ name: string; description?: string; required?: boolean }>; + [key: string]: unknown; +} + +export interface MCPPromptMessage { + role: 'user' | 'assistant'; + content: Record; +} + +export interface MCPPromptGetResult { + description?: string; + messages: MCPPromptMessage[]; + [key: string]: unknown; +} + +export interface MCPResource { + uri: string; + name: string; + title?: string; + description?: string; + mimeType?: string; + size?: number; + annotations?: Record; + [key: string]: unknown; +} + +export interface MCPResourceTemplate { + uriTemplate: string; + name: string; + title?: string; + description?: string; + mimeType?: string; + annotations?: Record; + [key: string]: unknown; +} + +export interface MCPResourceContent { + uri: string; + mimeType?: string; + text?: string; + blob?: string; + [key: string]: unknown; +} + +export interface MCPAuthOptions { + /** Bearer token (without prefix or full "Bearer ..."). */ + credential?: string; + /** Additional headers merged into MCP requests. */ + headers?: Record; +} + +export interface MCPClientOptions extends MCPAuthOptions { + protocolVersion?: MCPProtocolVersion; + sessionId?: string; + clientInfo?: MCPClientInfo; +} + +export type MCPMaybePaid = T | X402RequiredResponse; + +export interface MCPHandle { + readonly tools: Record, options?: MCPAuthOptions) => Promise>>; + readonly prompts: { + list(options?: MCPAuthOptions): Promise>; + get(name: string, args?: Record, options?: MCPAuthOptions): Promise>; + [key: string]: unknown; + }; + readonly resources: { + list(options?: MCPAuthOptions): Promise>; + read(uri: string, options?: MCPAuthOptions): Promise>; + templates: { + list(options?: MCPAuthOptions): Promise>; + }; + }; + listTools(options?: MCPAuthOptions): Promise>; + call(name: string, args?: Record, options?: MCPAuthOptions): Promise>; + getSessionId(): string | undefined; + setSessionId(sessionId?: string): void; + resetSession(): void; + initialize(options?: MCPAuthOptions): Promise>; +} + diff --git a/tests/.x402-deploy-result.json b/tests/.x402-deploy-result.json new file mode 100644 index 0000000..b64c487 --- /dev/null +++ b/tests/.x402-deploy-result.json @@ -0,0 +1,10 @@ +{ + "token": "0x5fbdb2315678afecb367f032d93f642f64180aa3", + "tokens": [ + "0x5fbdb2315678afecb367f032d93f642f64180aa3", + "0x9fe46736679d2d9a65f0992f2272de9f3c7fa6e0" + ], + "payTo": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "chainId": 31337, + "mintAmount": "1000000000000000000" +} \ No newline at end of file diff --git a/tests/a2a-client.test.ts b/tests/a2a-client.test.ts index 64b5c55..d0ef4ba 100644 --- a/tests/a2a-client.test.ts +++ b/tests/a2a-client.test.ts @@ -23,6 +23,7 @@ import { EndpointType, TrustModel } from '../src/models/enums.js'; import { Agent } from '../src/core/agent.js'; import { SDK } from '../src/core/sdk.js'; import { A2AClientFromSummary } from '../src/core/a2a-summary-client.js'; +import { A2AClientFromUrl } from '../src/core/a2a-summary-client.js'; import type { AgentSummary } from '../src/models/interfaces.js'; function mockResponse(init: { @@ -1711,4 +1712,24 @@ describe('createA2AClient / A2AClientFromSummary', () => { const client = new A2AClientFromSummary(mockSdk, summary); await expect(client.messageA2A('hi')).rejects.toThrow('Agent summary has no A2A endpoint'); }); + + it('createA2AClient(url) returns URL-backed client and resolves on first call', async () => { + const sdk = new SDK({ chainId: 84532, rpcUrl: 'https://base-sepolia.drpc.org' }); + const baseUrl = 'https://a2a.example.com'; + const cardBody = { + supportedInterfaces: [{ url: `${baseUrl}/`, protocolBinding: 'HTTP+JSON', protocolVersion: '0.3' }], + }; + const messageBody = { message: { content: 'OK', contextId: 'c1' } }; + const fetchSpy = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(mockResponse({ status: 200, body: cardBody })) + .mockResolvedValueOnce(mockResponse({ status: 200, body: messageBody })); + + const client = sdk.createA2AClient(baseUrl); + expect(client).toBeInstanceOf(A2AClientFromUrl); + const result = await client.messageA2A('ping'); + expect(fetchSpy).toHaveBeenNthCalledWith(1, `${baseUrl}/.well-known/agent-card.json`, expect.any(Object)); + expect(fetchSpy).toHaveBeenNthCalledWith(2, `${baseUrl}/v1/message:send`, expect.any(Object)); + expect('x402Required' in result).toBe(false); + }); }); diff --git a/tests/mcp-anvil.integration.test.ts b/tests/mcp-anvil.integration.test.ts new file mode 100644 index 0000000..3b9c05e --- /dev/null +++ b/tests/mcp-anvil.integration.test.ts @@ -0,0 +1,169 @@ +import { spawn } from 'child_process'; +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { SDK } from '../src/index.js'; +import type { AgentSummary } from '../src/models/interfaces.js'; + +const ANVIL_PORT = 8547; +const RPC_URL = `http://127.0.0.1:${ANVIL_PORT}`; +const CHAIN_ID = 31337; +const SERVER_PORT = 4043; +const SERVER_PATH = 'tests/mcp-server/server.mjs'; +const DEPLOY_RESULT_PATH = join(process.cwd(), 'tests', '.x402-deploy-result-mcp.json'); +const ANVIL_PK = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; + +let anvil: ReturnType | null = null; +let server: ReturnType | null = null; + +async function waitForRpc(url: string, attempts = 50): Promise { + for (let i = 0; i < attempts; i++) { + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_chainId', params: [] }), + }); + const j = (await res.json()) as { result?: string }; + if (j.result) return; + } catch {} + await new Promise((r) => setTimeout(r, 200)); + } + throw new Error('anvil not ready'); +} + +async function waitForServer(url: string, attempts = 30): Promise { + for (let i = 0; i < attempts; i++) { + try { + const res = await fetch(url.replace('/mcp', '/')); + if (res.ok) return; + } catch {} + await new Promise((r) => setTimeout(r, 200)); + } + throw new Error('mcp server not ready'); +} + +function runForgeBuild(): Promise { + return new Promise((resolve, reject) => { + const child = spawn('forge', ['build'], { cwd: process.cwd(), stdio: 'pipe' }); + let stderr = ''; + child.stderr?.on('data', (d) => { + stderr += d.toString(); + }); + child.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`forge build failed (${code}): ${stderr}`)))); + child.on('error', (e) => reject(new Error(`forge not found: ${e.message}`))); + }); +} + +function runDeploy(): Promise { + return new Promise((resolve, reject) => { + const child = spawn('node', ['scripts/deploy-x402-mock.mjs'], { + cwd: process.cwd(), + env: { ...process.env, RPC_URL, DEPLOY_RESULT_PATH }, + stdio: 'pipe', + }); + let stderr = ''; + child.stderr?.on('data', (d) => { + stderr += d.toString(); + }); + child.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`deploy failed (${code}): ${stderr}`)))); + child.on('error', reject); + }); +} + +interface DeployResult { + token: string; + tokens?: string[]; + payTo: string; + chainId: number; +} + +function readDeployResult(): DeployResult { + return JSON.parse(readFileSync(DEPLOY_RESULT_PATH, 'utf8')) as DeployResult; +} + +const run = process.env.RUN_MCP_ANVIL === '1'; +const describeRun = run ? describe : describe.skip; + +describeRun('MCP anvil integration (real x402 pay)', () => { + const baseUrl = `http://localhost:${SERVER_PORT}/mcp`; + + beforeAll(async () => { + anvil = spawn('anvil', ['--port', String(ANVIL_PORT)], { + cwd: process.cwd(), + env: process.env, + stdio: 'pipe', + }); + await waitForRpc(RPC_URL); + const outPath = join(process.cwd(), 'out', 'MockEIP3009.sol', 'MockEIP3009.json'); + if (!existsSync(outPath)) await runForgeBuild(); + await runDeploy(); + const deploy = readDeployResult(); + const tokenAddresses = deploy.tokens ?? [deploy.token]; + const accepts = tokenAddresses.map((token) => ({ + price: '1000000', + token, + network: String(deploy.chainId), + scheme: 'exact', + destination: deploy.payTo, + })); + server = spawn('node', [SERVER_PATH], { + cwd: process.cwd(), + env: { + ...process.env, + PORT: String(SERVER_PORT), + MCP_402: '1', + MCP_SESSION_REQUIRED: '1', + ACCEPTS_JSON: JSON.stringify(accepts), + }, + stdio: 'pipe', + }); + await waitForServer(baseUrl); + }, 90000); + + afterAll(() => { + if (server) server.kill(); + if (anvil) anvil.kill(); + server = null; + anvil = null; + }); + + it('agent.mcp listTools 402 -> pay() -> success', async () => { + const sdk = new SDK({ chainId: CHAIN_ID, rpcUrl: RPC_URL, privateKey: ANVIL_PK }); + const agent = await sdk.createAgent('MCP Agent', 'Test').setMCP(baseUrl, '2025-06-18', false); + const result = await agent.mcp.listTools(); + expect('x402Required' in result && result.x402Required).toBe(true); + if (!('x402Required' in result) || !result.x402Required) return; + const paid = await result.x402Payment.pay(); + expect('x402Required' in (paid as any)).toBe(false); + }, 25000); + + it('summary-backed client listTools 402 -> pay() -> success', async () => { + const sdk = new SDK({ chainId: CHAIN_ID, rpcUrl: RPC_URL, privateKey: ANVIL_PK }); + const summary: AgentSummary = { + chainId: CHAIN_ID, + agentId: `${CHAIN_ID}:0`, + name: 'MCP Summary Agent', + description: 'Test', + mcp: baseUrl, + owners: [], + operators: [], + supportedTrusts: [], + a2aSkills: [], + mcpTools: [], + mcpPrompts: [], + mcpResources: [], + oasfSkills: [], + oasfDomains: [], + active: true, + x402support: false, + extras: {}, + }; + const client = sdk.createMCPClient(summary); + const result = await client.listTools(); + expect('x402Required' in result && result.x402Required).toBe(true); + if (!('x402Required' in result) || !result.x402Required) return; + const paid = await result.x402Payment.pay(); + expect('x402Required' in (paid as any)).toBe(false); + }, 25000); +}); + diff --git a/tests/mcp-client.test.ts b/tests/mcp-client.test.ts new file mode 100644 index 0000000..982a468 --- /dev/null +++ b/tests/mcp-client.test.ts @@ -0,0 +1,311 @@ +import { MCPClient, createMCPHandle } from '../src/core/mcp-client.js'; +import { MCPClientFromSummary } from '../src/core/mcp-summary-client.js'; +import { SDK } from '../src/core/sdk.js'; +import { Agent } from '../src/core/agent.js'; +import { EndpointType, TrustModel } from '../src/models/enums.js'; +import type { RegistrationFile, AgentSummary } from '../src/models/interfaces.js'; +import type { X402RequestDeps } from '../src/core/x402-request.js'; + +function mockResponse(init: { + status: number; + body?: unknown; + headers?: Record; + contentType?: string; +}): Response { + const bodyStr = init.body !== undefined ? JSON.stringify(init.body) : ''; + const headers = new Headers({ 'content-type': init.contentType ?? 'application/json', ...(init.headers ?? {}) }); + return { + ok: init.status >= 200 && init.status < 300, + status: init.status, + statusText: init.status >= 200 && init.status < 300 ? 'OK' : 'Error', + text: () => Promise.resolve(bodyStr), + json: () => Promise.resolve(init.body ?? {}), + headers, + } as unknown as Response; +} + +describe('MCPClient', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('initializes first, sends initialized notification, then tools/list', async () => { + const fetchSpy = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + mockResponse({ + status: 200, + headers: { 'Mcp-Session-Id': 'sess-1' }, + body: { + jsonrpc: '2.0', + id: '1', + result: { protocolVersion: '2025-06-18', capabilities: { tools: {} } }, + }, + }) + ) + .mockResolvedValueOnce(mockResponse({ status: 202, body: {} })) + .mockResolvedValueOnce( + mockResponse({ + status: 200, + body: { jsonrpc: '2.0', id: '2', result: { tools: [{ name: 'get_weather' }] } }, + }) + ); + + const client = new MCPClient('https://mcp.example.com/mcp'); + const tools = await client.listTools(); + expect('x402Required' in tools).toBe(false); + if ('x402Required' in tools) return; + expect(tools).toEqual([{ name: 'get_weather' }]); + expect(fetchSpy).toHaveBeenCalledTimes(3); + const initializeBody = JSON.parse((fetchSpy.mock.calls[0] as any)[1].body); + expect(initializeBody.method).toBe('initialize'); + const initNotifBody = JSON.parse((fetchSpy.mock.calls[1] as any)[1].body); + expect(initNotifBody.method).toBe('notifications/initialized'); + const toolListBody = JSON.parse((fetchSpy.mock.calls[2] as any)[1].body); + expect(toolListBody.method).toBe('tools/list'); + expect((fetchSpy.mock.calls[2] as any)[1].headers['Mcp-Session-Id']).toBe('sess-1'); + }); + + it('supports tool call via call(name,args)', async () => { + jest + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + mockResponse({ + status: 200, + body: { jsonrpc: '2.0', id: '1', result: { protocolVersion: '2025-06-18' } }, + }) + ) + .mockResolvedValueOnce(mockResponse({ status: 202, body: {} })) + .mockResolvedValueOnce( + mockResponse({ + status: 200, + body: { + jsonrpc: '2.0', + id: '2', + result: { content: [{ type: 'text', text: 'Sunny' }], isError: false }, + }, + }) + ); + + const client = new MCPClient('https://mcp.example.com/mcp'); + const result = (await client.call('weather/get', { location: 'Paris' })) as any; + expect('x402Required' in result).toBe(false); + if ('x402Required' in result) return; + expect(result.content[0].text).toBe('Sunny'); + }); + + it('supports identifier-safe proxy access', async () => { + jest + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + mockResponse({ + status: 200, + body: { jsonrpc: '2.0', id: '1', result: { protocolVersion: '2025-06-18' } }, + }) + ) + .mockResolvedValueOnce(mockResponse({ status: 202, body: {} })) + .mockResolvedValueOnce( + mockResponse({ + status: 200, + body: { jsonrpc: '2.0', id: '2', result: { content: [{ type: 'text', text: 'ok' }] } }, + }) + ); + const handle = createMCPHandle('https://mcp.example.com/mcp'); + const result = await (handle as any).get_weather({ location: 'Rome' }); + expect((result as any).content[0].text).toBe('ok'); + }); + + it('lists and gets prompts', async () => { + jest + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + mockResponse({ status: 200, body: { jsonrpc: '2.0', id: '1', result: { protocolVersion: '2025-06-18' } } }) + ) + .mockResolvedValueOnce(mockResponse({ status: 202, body: {} })) + .mockResolvedValueOnce( + mockResponse({ status: 200, body: { jsonrpc: '2.0', id: '2', result: { prompts: [{ name: 'code_review' }] } } }) + ) + .mockResolvedValueOnce( + mockResponse({ + status: 200, + body: { + jsonrpc: '2.0', + id: '3', + result: { messages: [{ role: 'user', content: { type: 'text', text: 'review this' } }] }, + }, + }) + ); + + const client = new MCPClient('https://mcp.example.com/mcp'); + const prompts = await client.prompts.list(); + expect('x402Required' in prompts).toBe(false); + const promptGet = await client.prompts.get('code_review', { code: 'x' }); + expect('x402Required' in promptGet).toBe(false); + if ('x402Required' in promptGet) return; + expect(promptGet.messages[0]?.role).toBe('user'); + }); + + it('lists and reads resources and templates', async () => { + jest + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + mockResponse({ status: 200, body: { jsonrpc: '2.0', id: '1', result: { protocolVersion: '2025-06-18' } } }) + ) + .mockResolvedValueOnce(mockResponse({ status: 202, body: {} })) + .mockResolvedValueOnce( + mockResponse({ status: 200, body: { jsonrpc: '2.0', id: '2', result: { resources: [{ uri: 'file:///a', name: 'a' }] } } }) + ) + .mockResolvedValueOnce( + mockResponse({ status: 200, body: { jsonrpc: '2.0', id: '3', result: { contents: [{ uri: 'file:///a', text: 'hello' }] } } }) + ) + .mockResolvedValueOnce( + mockResponse({ status: 200, body: { jsonrpc: '2.0', id: '4', result: { resourceTemplates: [{ uriTemplate: 'file:///{path}', name: 'files' }] } } }) + ); + + const client = new MCPClient('https://mcp.example.com/mcp'); + const list = await client.resources.list(); + expect('x402Required' in list).toBe(false); + const read = await client.resources.read('file:///a'); + expect('x402Required' in read).toBe(false); + const templates = await client.resources.templates.list(); + expect('x402Required' in templates).toBe(false); + }); + + it('applies Authorization bearer header from credential', async () => { + const fetchSpy = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + mockResponse({ status: 200, body: { jsonrpc: '2.0', id: '1', result: { protocolVersion: '2025-06-18' } } }) + ) + .mockResolvedValueOnce(mockResponse({ status: 202, body: {} })) + .mockResolvedValueOnce( + mockResponse({ status: 200, body: { jsonrpc: '2.0', id: '2', result: { tools: [] } } }) + ); + const client = new MCPClient('https://mcp.example.com/mcp', { credential: 'token-123' }); + await client.listTools(); + expect((fetchSpy.mock.calls[2] as any)[1].headers['Authorization']).toBe('Bearer token-123'); + }); + + it('handles 402 with x402 deps and pay() retry', async () => { + const accepts = [{ price: '100', token: '0xT', network: '84532', destination: '0xD' }]; + const fetchSpy = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + mockResponse({ status: 402, headers: { 'payment-required': Buffer.from(JSON.stringify({ accepts })).toString('base64') } }) + ) + .mockResolvedValueOnce( + mockResponse({ status: 200, body: { jsonrpc: '2.0', id: '1', result: { protocolVersion: '2025-06-18' } } }) + ) + .mockResolvedValueOnce(mockResponse({ status: 202, body: {} })); + const deps: X402RequestDeps = { + fetch: globalThis.fetch, + buildPayment: async () => Buffer.from(JSON.stringify({ x402Version: 1, payload: { signature: '0x' + 'a'.repeat(130), authorization: {} } })).toString('base64'), + }; + const client = new MCPClient('https://mcp.example.com/mcp', {}, deps); + const init = await client.initialize(); + expect('x402Required' in init && init.x402Required).toBe(true); + if (!('x402Required' in init) || !init.x402Required) return; + await init.x402Payment.pay(); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); +}); + +describe('MCP summary/sdk/agent wiring', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('MCPClientFromSummary throws if summary has no mcp endpoint', async () => { + const summary = { + chainId: 1, + agentId: '1:1', + name: 'x', + description: 'x', + owners: [], + operators: [], + supportedTrusts: [], + a2aSkills: [], + mcpTools: [], + mcpPrompts: [], + mcpResources: [], + oasfSkills: [], + oasfDomains: [], + active: true, + x402support: false, + extras: {}, + } as AgentSummary; + const client = new MCPClientFromSummary({}, summary); + expect(() => client.listTools()).toThrow('Agent summary has no MCP endpoint'); + }); + + it('sdk.createMCPClient(agent) returns agent.mcp handle', async () => { + const sdk = new SDK({ chainId: 84532, rpcUrl: 'https://base-sepolia.drpc.org' }); + const reg: RegistrationFile = { + name: 'x', + description: 'x', + endpoints: [{ type: EndpointType.MCP, value: 'https://mcp.example.com/mcp', meta: { version: '2025-06-18' } }], + trustModels: [TrustModel.REPUTATION], + owners: [], + operators: [], + active: true, + x402support: false, + metadata: {}, + updatedAt: 0, + }; + const agent = new Agent(sdk, reg); + const client = sdk.createMCPClient(agent); + expect(client).toBe(agent.mcp); + }); + + it('sdk.createMCPClient(summary) returns summary-backed client', async () => { + const fetchSpy = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + mockResponse({ status: 200, body: { jsonrpc: '2.0', id: '1', result: { protocolVersion: '2025-06-18' } } }) + ) + .mockResolvedValueOnce(mockResponse({ status: 202, body: {} })) + .mockResolvedValueOnce( + mockResponse({ status: 200, body: { jsonrpc: '2.0', id: '2', result: { tools: [] } } }) + ); + const sdk = new SDK({ chainId: 84532, rpcUrl: 'https://base-sepolia.drpc.org' }); + const summary: AgentSummary = { + chainId: 1, + agentId: '1:1', + name: 'x', + description: 'x', + mcp: 'https://mcp.example.com/mcp', + owners: [], + operators: [], + supportedTrusts: [], + a2aSkills: [], + mcpTools: [], + mcpPrompts: [], + mcpResources: [], + oasfSkills: [], + oasfDomains: [], + active: true, + x402support: false, + extras: {}, + }; + const client = sdk.createMCPClient(summary); + await client.listTools(); + expect(fetchSpy).toHaveBeenCalledTimes(3); + }); + + it('sdk.createMCPClient(url) returns direct MCP handle', async () => { + const fetchSpy = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + mockResponse({ status: 200, body: { jsonrpc: '2.0', id: '1', result: { protocolVersion: '2025-06-18' } } }) + ) + .mockResolvedValueOnce(mockResponse({ status: 202, body: {} })) + .mockResolvedValueOnce( + mockResponse({ status: 200, body: { jsonrpc: '2.0', id: '2', result: { tools: [] } } }) + ); + const sdk = new SDK({ chainId: 84532, rpcUrl: 'https://base-sepolia.drpc.org' }); + const client = sdk.createMCPClient('https://mcp.example.com/mcp'); + await client.listTools(); + expect(fetchSpy).toHaveBeenCalledTimes(3); + }); +}); + diff --git a/tests/mcp-integration.test.ts b/tests/mcp-integration.test.ts new file mode 100644 index 0000000..941d055 --- /dev/null +++ b/tests/mcp-integration.test.ts @@ -0,0 +1,182 @@ +import { spawn } from 'child_process'; +import { SDK } from '../src/core/sdk.js'; +import { Agent } from '../src/core/agent.js'; +import { EndpointType, TrustModel } from '../src/models/enums.js'; +import type { RegistrationFile, AgentSummary } from '../src/models/interfaces.js'; + +const PORT = 4040; +const PORT_AUTH = 4041; +const PORT_402 = 4042; +const SERVER_PATH = 'tests/mcp-server/server.mjs'; +const BASE = `http://localhost:${PORT}/mcp`; +const BASE_AUTH = `http://localhost:${PORT_AUTH}/mcp`; +const BASE_402 = `http://localhost:${PORT_402}/mcp`; +const API_KEY = 'test-secret'; + +const VALID_PAYLOAD_402 = Buffer.from( + JSON.stringify({ + x402Version: 1, + scheme: 'exact', + network: '84532', + payload: { + signature: '0x' + 'a'.repeat(130), + authorization: { + from: '0x1234567890123456789012345678901234567890', + to: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0', + value: '1000000', + validAfter: '0', + validBefore: String(Math.floor(Date.now() / 1000) + 3600), + nonce: '0x' + 'b'.repeat(64), + }, + }, + }) +).toString('base64'); + +async function waitFor(url: string, attempts = 30): Promise { + for (let i = 0; i < attempts; i++) { + try { + const res = await fetch(url.replace('/mcp', '/')); + if (res.ok) return; + } catch {} + await new Promise((r) => setTimeout(r, 200)); + } + throw new Error('server not ready'); +} + +function makeAgent(baseUrl: string, buildPayment?: () => Promise): Agent { + const reg: RegistrationFile = { + name: 'mcp-agent', + description: 'test', + endpoints: [{ type: EndpointType.MCP, value: baseUrl, meta: { version: '2025-06-18' } }], + trustModels: [TrustModel.REPUTATION], + owners: [], + operators: [], + active: true, + x402support: false, + metadata: {}, + updatedAt: 0, + }; + const sdkLike = { + getX402RequestDeps: () => ({ + fetch: globalThis.fetch, + buildPayment: buildPayment ?? (async () => VALID_PAYLOAD_402), + }), + } as unknown as SDK; + return new Agent(sdkLike, reg); +} + +function makeSummaryClient(baseUrl: string): ReturnType { + const sdk = new SDK({ chainId: 84532, rpcUrl: 'https://base-sepolia.drpc.org' }); + const summary: AgentSummary = { + chainId: 1, + agentId: '1:1', + name: 'mcp-summary', + description: 'test', + mcp: baseUrl, + owners: [], + operators: [], + supportedTrusts: [], + a2aSkills: [], + mcpTools: [], + mcpPrompts: [], + mcpResources: [], + oasfSkills: [], + oasfDomains: [], + active: true, + x402support: false, + extras: {}, + }; + return sdk.createMCPClient(summary); +} + +const run = process.env.RUN_MCP_INTEGRATION === '1'; +const describeRun = run ? describe : describe.skip; + +describeRun('MCP integration', () => { + let proc: ReturnType | null = null; + beforeAll(async () => { + proc = spawn('node', [SERVER_PATH], { + env: { ...process.env, PORT: String(PORT), MCP_SESSION_REQUIRED: '1' }, + stdio: 'pipe', + }); + await waitFor(BASE); + }, 15000); + afterAll(() => { + if (proc) proc.kill(); + proc = null; + }); + + it('agent.mcp tools/prompts/resources flow works', async () => { + const agent = makeAgent(BASE); + const tools = await agent.mcp.listTools(); + expect('x402Required' in tools).toBe(false); + const weather = (await agent.mcp.call('get_weather', { location: 'Paris' })) as any; + expect('x402Required' in weather).toBe(false); + if (!('x402Required' in weather)) expect(weather.content[0].text).toContain('Weather'); + const prompts = await agent.mcp.prompts.list(); + expect('x402Required' in prompts).toBe(false); + const prompt = await agent.mcp.prompts.get('code_review', { code: 'x' }); + expect('x402Required' in prompt).toBe(false); + const resources = await agent.mcp.resources.list(); + expect('x402Required' in resources).toBe(false); + const read = await agent.mcp.resources.read('file:///README.md'); + expect('x402Required' in read).toBe(false); + const tpl = await agent.mcp.resources.templates.list(); + expect('x402Required' in tpl).toBe(false); + }, 15000); + + it('summary-backed MCP client works', async () => { + const client = makeSummaryClient(BASE); + const tools = await client.listTools(); + expect('x402Required' in tools).toBe(false); + const out = (await client.call('user-profile/update', { userId: '1' })) as any; + expect('x402Required' in out).toBe(false); + }, 15000); +}); + +describeRun('MCP integration with auth', () => { + let proc: ReturnType | null = null; + beforeAll(async () => { + proc = spawn('node', [SERVER_PATH], { + env: { ...process.env, PORT: String(PORT_AUTH), MCP_AUTH: '1', MCP_EXPECTED_KEY: API_KEY, MCP_SESSION_REQUIRED: '1' }, + stdio: 'pipe', + }); + await waitFor(BASE_AUTH); + }, 15000); + afterAll(() => { + if (proc) proc.kill(); + proc = null; + }); + + it('requires auth and succeeds with bearer credential', async () => { + const agent = makeAgent(BASE_AUTH); + await expect(agent.mcp.listTools()).rejects.toThrow(/401|unauthorized/i); + const ok = await agent.mcp.listTools({ credential: API_KEY }); + expect('x402Required' in ok).toBe(false); + }, 12000); +}); + +describeRun('MCP integration with x402', () => { + let proc: ReturnType | null = null; + beforeAll(async () => { + proc = spawn('node', [SERVER_PATH], { + env: { ...process.env, PORT: String(PORT_402), MCP_402: '1', MCP_SESSION_REQUIRED: '1' }, + stdio: 'pipe', + }); + await waitFor(BASE_402); + }, 15000); + afterAll(() => { + if (proc) proc.kill(); + proc = null; + }); + + it('returns x402Required then pay() succeeds', async () => { + const agent = makeAgent(BASE_402, async () => VALID_PAYLOAD_402); + const result = await agent.mcp.listTools(); + expect('x402Required' in result && result.x402Required).toBe(true); + if (!('x402Required' in result) || !result.x402Required) return; + const paid = await result.x402Payment.pay(); + expect('x402Required' in (paid as any)).toBe(false); + }, 15000); +}); + diff --git a/tests/mcp-server/server.mjs b/tests/mcp-server/server.mjs new file mode 100644 index 0000000..04579a6 --- /dev/null +++ b/tests/mcp-server/server.mjs @@ -0,0 +1,202 @@ +#!/usr/bin/env node +import http from 'http'; + +const PORT = Number(process.env.PORT || 4040); +const MCP_AUTH = process.env.MCP_AUTH === '1'; +const MCP_EXPECTED_KEY = process.env.MCP_EXPECTED_KEY || 'test-secret'; +const MCP_402 = process.env.MCP_402 === '1'; +const MCP_SESSION_REQUIRED = process.env.MCP_SESSION_REQUIRED === '1'; +const MCP_PROTOCOL_VERSION = process.env.MCP_PROTOCOL_VERSION || '2025-06-18'; + +const accepts = (() => { + try { + if (process.env.ACCEPTS_JSON) return JSON.parse(process.env.ACCEPTS_JSON); + } catch {} + return [ + { + price: '1000000', + token: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + network: '84532', + scheme: 'exact', + destination: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0', + }, + ]; +})(); + +let serverSessionId = 'sess-1'; +const tools = [ + { name: 'get_weather', description: 'Weather by location', inputSchema: { type: 'object' } }, + { name: 'user-profile/update', description: 'Update profile', inputSchema: { type: 'object' } }, +]; +const prompts = [{ name: 'code_review', description: 'Review code' }]; +const resources = [{ uri: 'file:///README.md', name: 'README.md', mimeType: 'text/markdown' }]; +const resourceTemplates = [{ uriTemplate: 'file:///{path}', name: 'Project Files', mimeType: 'application/octet-stream' }]; + +function sendJson(res, status, body, headers = {}) { + res.writeHead(status, { 'Content-Type': 'application/json', ...headers }); + res.end(JSON.stringify(body)); +} + +function parsePaymentSignature(sig) { + if (!sig || typeof sig !== 'string') return null; + try { + return JSON.parse(Buffer.from(sig, 'base64').toString('utf8')); + } catch { + return null; + } +} + +function send402(res) { + const payload = Buffer.from(JSON.stringify({ x402Version: 2, accepts }), 'utf8').toString('base64'); + res.writeHead(402, { 'PAYMENT-REQUIRED': payload, 'Content-Type': 'application/json' }); + res.end('{}'); +} + +async function parseBody(req) { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const text = Buffer.concat(chunks).toString('utf8'); + if (!text) return {}; + return JSON.parse(text); +} + +function checkAuth(req) { + if (!MCP_AUTH) return true; + const auth = req.headers.authorization; + const key = req.headers['x-api-key']; + if (typeof auth === 'string' && auth === `Bearer ${MCP_EXPECTED_KEY}`) return true; + if (key === MCP_EXPECTED_KEY) return true; + return false; +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url || '', `http://localhost:${PORT}`); + if (req.method === 'GET' && url.pathname === '/') { + return sendJson(res, 200, { ok: true }); + } + if (url.pathname !== '/mcp') { + return sendJson(res, 404, { error: 'not found' }); + } + if (req.method !== 'POST' && req.method !== 'GET' && req.method !== 'DELETE') { + return sendJson(res, 405, { error: 'method not allowed' }); + } + if (!checkAuth(req)) { + return sendJson( + res, + 401, + { error: 'unauthorized' }, + { 'WWW-Authenticate': 'Bearer realm="mcp", resource_metadata="http://localhost/.well-known/oauth-protected-resource"' } + ); + } + if (req.method === 'GET') { + return sendJson(res, 405, { error: 'sse not supported in test server' }); + } + if (req.method === 'DELETE') { + serverSessionId = `sess-${Date.now()}`; + return sendJson(res, 200, { ok: true }); + } + + if (MCP_402 && !parsePaymentSignature(req.headers['payment-signature'])) { + return send402(res); + } + + const body = await parseBody(req); + const method = body?.method; + const sessionId = req.headers['mcp-session-id']; + const protocolVersion = req.headers['mcp-protocol-version']; + if (method !== 'initialize') { + if (MCP_SESSION_REQUIRED && sessionId !== serverSessionId) { + return sendJson(res, 400, { error: 'missing session id' }); + } + if (MCP_SESSION_REQUIRED && protocolVersion !== MCP_PROTOCOL_VERSION) { + return sendJson(res, 400, { error: 'unsupported protocol header' }); + } + } + + const id = body?.id ?? '1'; + if (method === 'initialize') { + return sendJson( + res, + 200, + { + jsonrpc: '2.0', + id, + result: { + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities: { + tools: { listChanged: true }, + prompts: { listChanged: true }, + resources: { listChanged: true }, + }, + serverInfo: { name: 'mcp-test-server', version: '1.0.0' }, + }, + }, + { 'Mcp-Session-Id': serverSessionId } + ); + } + if (method === 'notifications/initialized') { + res.writeHead(202); + return res.end(); + } + if (method === 'tools/list') { + return sendJson(res, 200, { jsonrpc: '2.0', id, result: { tools } }); + } + if (method === 'tools/call') { + const toolName = body?.params?.name; + if (toolName === 'get_weather') { + return sendJson(res, 200, { + jsonrpc: '2.0', + id, + result: { content: [{ type: 'text', text: 'Weather: Sunny' }], isError: false }, + }); + } + if (toolName === 'user-profile/update') { + return sendJson(res, 200, { + jsonrpc: '2.0', + id, + result: { content: [{ type: 'text', text: 'Profile Updated' }], isError: false }, + }); + } + return sendJson(res, 200, { + jsonrpc: '2.0', + id, + error: { code: -32602, message: `Unknown tool: ${toolName}` }, + }); + } + if (method === 'prompts/list') { + return sendJson(res, 200, { jsonrpc: '2.0', id, result: { prompts } }); + } + if (method === 'prompts/get') { + return sendJson(res, 200, { + jsonrpc: '2.0', + id, + result: { + messages: [{ role: 'user', content: { type: 'text', text: 'Please review this code.' } }], + }, + }); + } + if (method === 'resources/list') { + return sendJson(res, 200, { jsonrpc: '2.0', id, result: { resources } }); + } + if (method === 'resources/read') { + return sendJson(res, 200, { + jsonrpc: '2.0', + id, + result: { contents: [{ uri: 'file:///README.md', mimeType: 'text/markdown', text: '# Hello' }] }, + }); + } + if (method === 'resources/templates/list') { + return sendJson(res, 200, { jsonrpc: '2.0', id, result: { resourceTemplates } }); + } + + return sendJson(res, 200, { + jsonrpc: '2.0', + id, + error: { code: -32601, message: `Method not found: ${method}` }, + }); +}); + +server.listen(PORT, () => { + console.log(`MCP test server on http://localhost:${PORT}/mcp`); +}); +