From 23ead76abaa0bba81d816859da332e9fc2b8a754 Mon Sep 17 00:00:00 2001 From: david Date: Thu, 19 Mar 2026 02:17:25 +0100 Subject: [PATCH] Add agent lifecycle tools (update, deregister, transfer), harden registry, and add integration tests --- package-lock.json | 44 ++++ package.json | 4 +- src/vector/agent-network.ts | 431 +++++++++++++++++++++++++++++----- src/vector/ogmios-provider.ts | 18 +- src/vector/types.ts | 21 ++ src/vector/vector.ts | 30 ++- test/run.test.ts | 346 +++++++++++++++++++++++++++ test/setup.ts | 65 +++++ 8 files changed, 890 insertions(+), 69 deletions(-) create mode 100644 test/run.test.ts create mode 100644 test/setup.ts diff --git a/package-lock.json b/package-lock.json index cb0b1d2..f27de7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "devDependencies": { "@types/node": "^22.13.1", "tsup": "^8.5.1", + "tsx": "^4.21.0", "typescript": "^5.7.3" }, "engines": { @@ -1689,6 +1690,19 @@ "node": ">= 0.4" } }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2319,6 +2333,16 @@ "node": ">=8" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -2710,6 +2734,26 @@ } } }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", diff --git a/package.json b/package.json index 57806be..539d84a 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "clean": "rm -rf build", "build": "tsup && chmod +x build/index.js", "typecheck": "tsc --noEmit --incremental", - "start": "node build/index.js" + "start": "node build/index.js", + "test": "npm run build && node --import tsx --test test/run.test.ts" }, "files": [ "build" @@ -52,6 +53,7 @@ "devDependencies": { "@types/node": "^22.13.1", "tsup": "^8.5.1", + "tsx": "^4.21.0", "typescript": "^5.7.3" } } diff --git a/src/vector/agent-network.ts b/src/vector/agent-network.ts index 29edccc..b3d1e7f 100644 --- a/src/vector/agent-network.ts +++ b/src/vector/agent-network.ts @@ -1,5 +1,5 @@ // @ts-nocheck -// Agent network tools: register, discover, message, get_profile +// Agent network tools: register, discover, message, get_profile, update, deregister, transfer // ts-nocheck isolates C (Cardano WASM) imports from tsc's complex type inference import { z } from "zod"; @@ -8,6 +8,11 @@ import { OgmiosProvider } from './ogmios-provider.js'; import { safetyLayer } from './safety.js'; import { rateLimiter } from './rate-limiter.js'; +// Lucid v0.10.x lacks PlutusV3 — downcast to PlutusV2 for hashing (same algorithm) +function lucidCompat(validator) { + return validator.type === 'PlutusV3' ? { ...validator, type: 'PlutusV2' } : validator; +} + // Env config (mirrors vector.ts) const VECTOR_OGMIOS_URL = process.env.VECTOR_OGMIOS_URL || 'https://ogmios.vector.testnet.apexfusion.org'; const VECTOR_SUBMIT_URL = process.env.VECTOR_SUBMIT_URL || 'https://submit.vector.testnet.apexfusion.org/api/submit/tx'; @@ -20,14 +25,20 @@ const REGISTRY_POLICY_ID = "5dd5118943d5aa7329696181252a6565a27dbf2c6de92b02a6aa const MIN_AP3X_DEPOSIT = 10_000_000n; const AGENT_MESSAGE_LABEL = 674; -// Helpers +// ─── Helpers ───────────────────────────────────────────────────────────────── + function lovelaceToAda(lovelace) { return (Number(BigInt(String(lovelace))) / 1_000_000).toFixed(6); } + function explorerTxLink(txHash) { return `${VECTOR_EXPLORER_URL}/transaction/${txHash}`; } +function newProvider() { + return new OgmiosProvider({ ogmiosUrl: VECTOR_OGMIOS_URL, submitUrl: VECTOR_SUBMIT_URL, koiosUrl: VECTOR_KOIOS_URL }); +} + // Derive NFT asset name = blake2b_256(CBOR(OutputReference)) // Matches Aiken's derive_asset_name (verified via CBOR parity tests in agent-registry repo) function deriveNftAssetName(txHash, outputIndex) { @@ -36,7 +47,7 @@ function deriveNftAssetName(txHash, outputIndex) { return Buffer.from(hashBytes).toString('hex'); } -function buildAgentDatum(vkeyHash, name, description, capabilities, framework, endpoint) { +function buildAgentDatum(vkeyHash, name, description, capabilities, framework, endpoint, registeredAt) { return Data.to(new Constr(0, [ new Constr(0, [vkeyHash]), fromText(name), @@ -44,7 +55,7 @@ function buildAgentDatum(vkeyHash, name, description, capabilities, framework, e capabilities.map(c => fromText(c)), fromText(framework), fromText(endpoint), - BigInt(Date.now()), + BigInt(registeredAt ?? Date.now()), ])); } @@ -69,31 +80,107 @@ function parseAgentDatum(datumCbor, utxoRef, assets) { utxoRef, ownerVkeyHash: vkeyHash, }; - } catch { + } catch (err) { + console.warn(`parseAgentDatum failed for ${utxoRef}:`, err?.message || err); return null; } } -async function initLucid(mnemonic: string, accountIndex: number = 0) { - const provider = new OgmiosProvider({ - ogmiosUrl: VECTOR_OGMIOS_URL, - submitUrl: VECTOR_SUBMIT_URL, - koiosUrl: VECTOR_KOIOS_URL, - }); +// ─── DID & Agent Resolution ────────────────────────────────────────────────── + +function parseDid(agentId) { + const parts = agentId.split(':'); + if (parts.length !== 5 || parts[0] !== 'did' || parts[1] !== 'vector' || parts[2] !== 'agent') { + throw new Error('Invalid agent DID format. Expected: did:vector:agent:{policyId}:{nftAssetName}'); + } + if (!/^[a-f0-9]+$/.test(parts[3]) || !/^[a-f0-9]+$/.test(parts[4])) { + throw new Error('Invalid agent DID: policyId and assetName must be hex strings.'); + } + return { policyId: parts[3], assetName: parts[4], unit: `${parts[3]}${parts[4]}` }; +} + +async function resolveAgentUtxo(provider, agentId) { + const { unit } = parseDid(agentId); + const utxo = await provider.getUtxoByUnit(unit); + if (!utxo) throw new Error(`Agent not found: no UTxO holds NFT ${unit}. The agent may not exist or may have deregistered.`); + if (!utxo.datum) throw new Error('Registry UTxO found but has no inline datum.'); + const profile = parseAgentDatum(utxo.datum, `${utxo.txHash}#${utxo.outputIndex}`, utxo.assets); + if (!profile) throw new Error('Could not parse agent datum. The on-chain data may be malformed.'); + return { profile, utxo, nftUnit: unit }; +} + +function verifyOwnership(profile, walletVkeyHash) { + if (profile.ownerVkeyHash !== walletVkeyHash) { + throw new Error('Ownership check failed: your wallet does not own this agent. The agent\'s owner verification key does not match your wallet\'s payment key.'); + } +} + +// ─── Validation ────────────────────────────────────────────────────────────── + +function validateEndpoint(endpoint) { + if (!endpoint) return; // empty string is allowed + try { + new URL(endpoint); + } catch { + throw new Error(`Invalid endpoint URL: "${endpoint}". Must be a valid URL (e.g. https://example.com/api) or empty string.`); + } +} + +function validateCapabilities(capabilities) { + for (const cap of capabilities) { + if (typeof cap !== 'string' || cap.trim().length === 0) { + throw new Error('Each capability must be a non-empty string.'); + } + } +} + +// ─── Lucid Init ────────────────────────────────────────────────────────────── + +async function initLucid(mnemonic, accountIndex = 0) { + const provider = newProvider(); const lucid = await Lucid.new(provider, 'Mainnet'); if (!mnemonic) throw new Error('mnemonic is required'); const trimmed = mnemonic.trim(); const words = trimmed.split(/\s+/); - if (words.length !== 15 && words.length !== 24) { - throw new Error(`Invalid mnemonic: Expected 15 or 24 words, got ${words.length}`); + const validLengths = [12, 15, 18, 21, 24]; + if (!validLengths.includes(words.length)) { + throw new Error(`Invalid mnemonic: Expected 12, 15, 18, 21 or 24 words, got ${words.length}`); } lucid.selectWalletFromSeed(trimmed, { accountIndex }); + + // Store the full wallet address so credential-based UTxO lookups resolve correctly + const address = await lucid.wallet.address(); + provider.setWalletAddress(address); + return lucid; } +// Cached registry address (derived from script hash, doesn't need a wallet) +let _registryAddress = null; +async function getRegistryAddress() { + if (_registryAddress) return _registryAddress; + const provider = newProvider(); + const lucid = await Lucid.new(provider, 'Mainnet'); + const registryScript = { type: 'PlutusV3', script: REGISTRY_SCRIPT_CBOR }; + _registryAddress = lucid.utils.validatorToAddress(lucidCompat(registryScript)); + return _registryAddress; +} + +// ─── Rate limit wrapper ────────────────────────────────────────────────────── + +function checkRateLimit() { + const rateCheck = rateLimiter.check(); + if (!rateCheck.allowed) { + return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] }; + } + return null; +} + +// ─── Tool Registration ─────────────────────────────────────────────────────── + export function registerAgentNetworkTools(server) { - // vector_get_agent_profile + // vector_get_agent_profile (read-only — no mnemonic needed) server.tool( "vector_get_agent_profile", "Get a registered agent's profile from the on-chain registry by DID (did:vector:agent:...)", @@ -101,22 +188,11 @@ export function registerAgentNetworkTools(server) { agent_id: z.string().describe("Agent DID: did:vector:agent:{policyId}:{nftAssetName}"), }, async ({ agent_id }) => { - const rateCheck = rateLimiter.check(); - if (!rateCheck.allowed) { - return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] }; - } + const rateLimited = checkRateLimit(); + if (rateLimited) return rateLimited; try { - const parts = agent_id.split(':'); - if (parts.length !== 5 || parts[0] !== 'did' || parts[1] !== 'vector' || parts[2] !== 'agent') { - throw new Error('Invalid agent DID format. Expected: did:vector:agent:{policyId}:{nftAssetName}'); - } - const unit = `${parts[3]}${parts[4]}`; - const provider = new OgmiosProvider({ ogmiosUrl: VECTOR_OGMIOS_URL, submitUrl: VECTOR_SUBMIT_URL, koiosUrl: VECTOR_KOIOS_URL }); - const utxo = await provider.getUtxoByUnit(unit); - if (!utxo) throw new Error(`Agent not found: no UTxO holds NFT ${unit}. The agent may not exist or may have deregistered.`); - if (!utxo.datum) throw new Error(`Registry UTxO found but has no inline datum.`); - const profile = parseAgentDatum(utxo.datum, `${utxo.txHash}#${utxo.outputIndex}`, utxo.assets); - if (!profile) throw new Error(`Could not parse agent datum.`); + const provider = newProvider(); + const { profile } = await resolveAgentUtxo(provider, agent_id); const capList = profile.capabilities.length > 0 ? profile.capabilities.map(c => `- ${c}`).join('\n') : '- (none listed)'; @@ -154,26 +230,21 @@ ${capList} } ); - // vector_discover_agents + // vector_discover_agents (read-only — no mnemonic needed) server.tool( "vector_discover_agents", "Discover registered agents in the Vector on-chain registry, optionally filtered by capability or framework", { - mnemonic: z.string().describe("15 or 24-word BIP39 mnemonic (used to derive the registry address)"), capability: z.string().optional().describe("Filter by capability tag (e.g. 'investing', 'research')"), framework: z.string().optional().describe("Filter by framework (e.g. 'OpenClaw', 'LangChain', 'CrewAI')"), limit: z.number().optional().default(20).describe("Maximum number of agents to return (default: 20)"), }, - async ({ mnemonic, capability, framework, limit }) => { - const rateCheck = rateLimiter.check(); - if (!rateCheck.allowed) { - return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] }; - } + async ({ capability, framework, limit }) => { + const rateLimited = checkRateLimit(); + if (rateLimited) return rateLimited; try { - const lucid = await initLucid(mnemonic); - const registryScript = { type: "PlutusV3", script: REGISTRY_SCRIPT_CBOR }; - const registryAddress = lucid.utils.validatorToAddress(registryScript); - const provider = new OgmiosProvider({ ogmiosUrl: VECTOR_OGMIOS_URL, submitUrl: VECTOR_SUBMIT_URL, koiosUrl: VECTOR_KOIOS_URL }); + const registryAddress = await getRegistryAddress(); + const provider = newProvider(); const utxos = await provider.getUtxos(registryAddress); const profiles = []; for (const utxo of utxos) { @@ -227,11 +298,12 @@ ${capList} endpoint: z.string().describe("A2A communication endpoint URL (or empty string if not applicable)"), }, async ({ mnemonic, name, description, capabilities, framework, endpoint }) => { - const rateCheck = rateLimiter.check(); - if (!rateCheck.allowed) { - return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] }; - } + const rateLimited = checkRateLimit(); + if (rateLimited) return rateLimited; try { + validateEndpoint(endpoint); + validateCapabilities(capabilities); + const safetyCheck = safetyLayer.checkTransaction(Number(MIN_AP3X_DEPOSIT)); if (!safetyCheck.allowed) throw new Error(`Safety limit exceeded: ${safetyCheck.reason}`); @@ -252,14 +324,15 @@ ${capList} const nftUnit = `${REGISTRY_POLICY_ID}${nftAssetName}`; const datum = buildAgentDatum(vkeyHash, name, description, capabilities, framework, endpoint); const registryScript = { type: "PlutusV3", script: REGISTRY_SCRIPT_CBOR }; - const registryAddress = lucid.utils.validatorToAddress(registryScript); + const registryAddress = lucid.utils.validatorToAddress(lucidCompat(registryScript)); const registerRedeemer = Data.to(new Constr(0, [new Constr(0, [seedUtxo.txHash, BigInt(seedUtxo.outputIndex)])])); const tx = await lucid.newTx() .collectFrom([seedUtxo]) .mintAssets({ [nftUnit]: 1n }, registerRedeemer) - .attachMintingPolicy(registryScript) + .attachMintingPolicy(lucidCompat(registryScript)) .payToContract(registryAddress, { inline: datum }, { lovelace: MIN_AP3X_DEPOSIT, [nftUnit]: 1n }) + .addSigner(walletAddress) .complete(); const signedTx = await tx.sign().complete(); const txHash = await signedTx.submit(); @@ -300,6 +373,253 @@ Save your Agent DID — you'll need it to update, deregister, or let other agent } ); + // vector_update_agent + server.tool( + "vector_update_agent", + "Update a registered agent's profile fields (name, description, capabilities, framework, endpoint). Only the specified fields are changed; others are preserved.", + { + mnemonic: z.string().describe("15 or 24-word BIP39 mnemonic for the agent's owner wallet"), + agent_id: z.string().describe("Agent DID to update: did:vector:agent:{policyId}:{nftAssetName}"), + name: z.string().min(1).max(64).optional().describe("New agent name"), + description: z.string().max(256).optional().describe("New description"), + capabilities: z.array(z.string()).optional().describe("New capability tags (replaces existing list)"), + framework: z.string().optional().describe("New framework identifier"), + endpoint: z.string().optional().describe("New A2A endpoint URL (or empty string to clear)"), + }, + async ({ mnemonic, agent_id, name, description, capabilities, framework, endpoint }) => { + const rateLimited = checkRateLimit(); + if (rateLimited) return rateLimited; + try { + if (name === undefined && description === undefined && capabilities === undefined && framework === undefined && endpoint === undefined) { + throw new Error('At least one field must be provided to update (name, description, capabilities, framework, or endpoint).'); + } + if (endpoint !== undefined) validateEndpoint(endpoint); + if (capabilities !== undefined) validateCapabilities(capabilities); + + const lucid = await initLucid(mnemonic); + const walletAddress = await lucid.wallet.address(); + const vkeyHash = lucid.utils.getAddressDetails(walletAddress).paymentCredential?.hash; + if (!vkeyHash) throw new Error('Cannot derive payment key hash from wallet address'); + + const provider = newProvider(); + const { profile, utxo, nftUnit } = await resolveAgentUtxo(provider, agent_id); + verifyOwnership(profile, vkeyHash); + + // Merge: use new values where provided, keep old values otherwise + const newName = name ?? profile.name; + const newDesc = description ?? profile.description; + const newCaps = capabilities ?? profile.capabilities; + const newFramework = framework ?? profile.framework; + const newEndpoint = endpoint ?? profile.endpoint; + + const newDatum = buildAgentDatum(vkeyHash, newName, newDesc, newCaps, newFramework, newEndpoint, profile.registeredAt); + const registryScript = { type: 'PlutusV3', script: REGISTRY_SCRIPT_CBOR }; + const registryAddress = lucid.utils.validatorToAddress(lucidCompat(registryScript)); + const spendRedeemer = Data.to(new Constr(0, [])); // Update + + const tx = await lucid.newTx() + .collectFrom([utxo], spendRedeemer) + .attachSpendingValidator(lucidCompat(registryScript)) + .payToContract(registryAddress, { inline: newDatum }, { lovelace: MIN_AP3X_DEPOSIT, [nftUnit]: 1n }) + .addSigner(walletAddress) + .complete(); + const signedTx = await tx.sign().complete(); + const txHash = await signedTx.submit(); + safetyLayer.recordTransaction(txHash, 0, registryAddress); + + const updatedFields = []; + if (name !== undefined) updatedFields.push('name'); + if (description !== undefined) updatedFields.push('description'); + if (capabilities !== undefined) updatedFields.push('capabilities'); + if (framework !== undefined) updatedFields.push('framework'); + if (endpoint !== undefined) updatedFields.push('endpoint'); + + return { + content: [{ + type: "text", + text: `# Agent Updated Successfully + +**Agent DID:** ${agent_id} +**Updated fields:** ${updatedFields.join(', ')} +**Transaction:** ${txHash} + +[View on Explorer](${explorerTxLink(txHash)}) + +The agent's profile has been updated on-chain. The identity NFT and deposit are unchanged.`, + }], + }; + } catch (err) { + return { + content: [{ + type: "text", + text: `Failed to update agent: ${err.message} + +**Troubleshooting Tips:** +1. Verify the agent DID is correct +2. Your wallet must be the agent's current owner +3. Ensure wallet has enough ADA for transaction fees +4. Check spend limits with vector_get_spend_limits`, + }], + }; + } + } + ); + + // vector_deregister_agent + server.tool( + "vector_deregister_agent", + "Deregister an agent from the Vector registry. Burns the identity NFT and returns the 10 AP3X deposit to your wallet.", + { + mnemonic: z.string().describe("15 or 24-word BIP39 mnemonic for the agent's owner wallet"), + agent_id: z.string().describe("Agent DID to deregister: did:vector:agent:{policyId}:{nftAssetName}"), + }, + async ({ mnemonic, agent_id }) => { + const rateLimited = checkRateLimit(); + if (rateLimited) return rateLimited; + try { + const lucid = await initLucid(mnemonic); + const walletAddress = await lucid.wallet.address(); + const vkeyHash = lucid.utils.getAddressDetails(walletAddress).paymentCredential?.hash; + if (!vkeyHash) throw new Error('Cannot derive payment key hash from wallet address'); + + const provider = newProvider(); + const { profile, utxo, nftUnit } = await resolveAgentUtxo(provider, agent_id); + verifyOwnership(profile, vkeyHash); + + const registryScript = { type: 'PlutusV3', script: REGISTRY_SCRIPT_CBOR }; + const spendRedeemer = Data.to(new Constr(1, [])); // Deregister + const mintRedeemer = Data.to(new Constr(1, [])); // Burn + + const tx = await lucid.newTx() + .collectFrom([utxo], spendRedeemer) + .attachSpendingValidator(lucidCompat(registryScript)) + .mintAssets({ [nftUnit]: -1n }, mintRedeemer) + .attachMintingPolicy(lucidCompat(registryScript)) + .addSigner(walletAddress) + .complete(); + const signedTx = await tx.sign().complete(); + const txHash = await signedTx.submit(); + safetyLayer.recordTransaction(txHash, 0, walletAddress); + + return { + content: [{ + type: "text", + text: `# Agent Deregistered Successfully + +**Agent DID:** ${agent_id} +**Name:** ${profile.name} +**Deposit returned:** ${lovelaceToAda(MIN_AP3X_DEPOSIT)} AP3X +**Transaction:** ${txHash} + +[View on Explorer](${explorerTxLink(txHash)}) + +The agent's identity NFT has been burned and the 10 AP3X deposit has been returned to your wallet.`, + }], + }; + } catch (err) { + return { + content: [{ + type: "text", + text: `Failed to deregister agent: ${err.message} + +**Troubleshooting Tips:** +1. Verify the agent DID is correct +2. Your wallet must be the agent's current owner +3. Ensure wallet has enough ADA for transaction fees +4. Use vector_get_agent_profile to check the agent's current state`, + }], + }; + } + } + ); + + // vector_transfer_agent + server.tool( + "vector_transfer_agent", + "Transfer agent ownership to a new address. The new owner must have a verification key credential (not a script address).", + { + mnemonic: z.string().describe("15 or 24-word BIP39 mnemonic for the current owner's wallet"), + agent_id: z.string().describe("Agent DID to transfer: did:vector:agent:{policyId}:{nftAssetName}"), + new_owner_address: z.string().describe("Bech32 address of the new owner (must be a verification key address, not a script address)"), + }, + async ({ mnemonic, agent_id, new_owner_address }) => { + const rateLimited = checkRateLimit(); + if (rateLimited) return rateLimited; + try { + const lucid = await initLucid(mnemonic); + const walletAddress = await lucid.wallet.address(); + const vkeyHash = lucid.utils.getAddressDetails(walletAddress).paymentCredential?.hash; + if (!vkeyHash) throw new Error('Cannot derive payment key hash from wallet address'); + + // Validate new owner address + let newOwnerDetails; + try { + newOwnerDetails = lucid.utils.getAddressDetails(new_owner_address); + } catch { + throw new Error(`Invalid new owner address: "${new_owner_address}". Must be a valid bech32 address.`); + } + if (newOwnerDetails.paymentCredential?.type !== 'Key') { + throw new Error('New owner address must be a verification key credential, not a script address. The on-chain contract rejects script credentials as owners.'); + } + const newOwnerVkeyHash = newOwnerDetails.paymentCredential.hash; + + const provider = newProvider(); + const { profile, utxo, nftUnit } = await resolveAgentUtxo(provider, agent_id); + verifyOwnership(profile, vkeyHash); + + // Build datum with new owner, everything else preserved + const newDatum = buildAgentDatum( + newOwnerVkeyHash, profile.name, profile.description, + profile.capabilities, profile.framework, profile.endpoint, + profile.registeredAt + ); + const registryScript = { type: 'PlutusV3', script: REGISTRY_SCRIPT_CBOR }; + const registryAddress = lucid.utils.validatorToAddress(lucidCompat(registryScript)); + const spendRedeemer = Data.to(new Constr(0, [])); // Update (transfer uses Update redeemer) + + const tx = await lucid.newTx() + .collectFrom([utxo], spendRedeemer) + .attachSpendingValidator(lucidCompat(registryScript)) + .payToContract(registryAddress, { inline: newDatum }, { lovelace: MIN_AP3X_DEPOSIT, [nftUnit]: 1n }) + .addSigner(walletAddress) + .complete(); + const signedTx = await tx.sign().complete(); + const txHash = await signedTx.submit(); + safetyLayer.recordTransaction(txHash, 0, registryAddress); + + return { + content: [{ + type: "text", + text: `# Agent Transferred Successfully + +**Agent DID:** ${agent_id} +**Name:** ${profile.name} +**Previous owner:** ${walletAddress} +**New owner:** ${new_owner_address} +**Transaction:** ${txHash} + +[View on Explorer](${explorerTxLink(txHash)}) + +Ownership has been transferred. The new owner can now update, transfer, or deregister this agent.`, + }], + }; + } catch (err) { + return { + content: [{ + type: "text", + text: `Failed to transfer agent: ${err.message} + +**Troubleshooting Tips:** +1. Verify the agent DID is correct +2. Your wallet must be the agent's current owner +3. The new owner address must be a verification key address (not a script) +4. Ensure wallet has enough ADA for transaction fees`, + }], + }; + } + } + ); + // vector_message_agent server.tool( "vector_message_agent", @@ -311,21 +631,12 @@ Save your Agent DID — you'll need it to update, deregister, or let other agent mnemonic: z.string().describe("15 or 24-word BIP39 mnemonic for the sending wallet"), }, async ({ agent_id, message_type, payload, mnemonic }) => { - const rateCheck = rateLimiter.check(); - if (!rateCheck.allowed) { - return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] }; - } + const rateLimited = checkRateLimit(); + if (rateLimited) return rateLimited; try { - const parts = agent_id.split(':'); - if (parts.length !== 5 || parts[0] !== 'did' || parts[1] !== 'vector' || parts[2] !== 'agent') { - throw new Error('Invalid agent DID format. Expected: did:vector:agent:{policyId}:{nftAssetName}'); - } - const unit = `${parts[3]}${parts[4]}`; - const provider = new OgmiosProvider({ ogmiosUrl: VECTOR_OGMIOS_URL, submitUrl: VECTOR_SUBMIT_URL, koiosUrl: VECTOR_KOIOS_URL }); - const utxo = await provider.getUtxoByUnit(unit); - if (!utxo || !utxo.datum) throw new Error(`Agent not found: ${agent_id}. The agent may not be registered or may have deregistered.`); - const profile = parseAgentDatum(utxo.datum, `${utxo.txHash}#${utxo.outputIndex}`, utxo.assets); - if (!profile?.ownerVkeyHash) throw new Error('Could not parse agent owner from registry datum'); + const provider = newProvider(); + const { profile } = await resolveAgentUtxo(provider, agent_id); + if (!profile.ownerVkeyHash) throw new Error('Could not parse agent owner from registry datum'); const lucid = await initLucid(mnemonic); const senderAddress = await lucid.wallet.address(); diff --git a/src/vector/ogmios-provider.ts b/src/vector/ogmios-provider.ts index ee0e834..a87bd14 100644 --- a/src/vector/ogmios-provider.ts +++ b/src/vector/ogmios-provider.ts @@ -25,6 +25,7 @@ export class OgmiosProvider implements Provider { private ogmiosUrl: string; private submitUrl: string; private koiosUrl: string | undefined; + private walletAddress: string | undefined; constructor(config: OgmiosProviderConfig) { // Strip trailing slashes for consistent URL building @@ -33,6 +34,14 @@ export class OgmiosProvider implements Provider { this.koiosUrl = config.koiosUrl?.replace(/\/+$/, ''); } + /** + * Store the wallet's full bech32 address so credential-based lookups + * (used internally by Lucid during tx building) can resolve to it. + */ + setWalletAddress(address: string): void { + this.walletAddress = address; + } + /** * Send a JSON-RPC request to Ogmios */ @@ -119,8 +128,15 @@ export class OgmiosProvider implements Provider { let address: string; if (typeof addressOrCredential === 'string') { address = addressOrCredential; + } else if (this.walletAddress) { + // Lucid's wallet internally calls getUtxos(paymentCredential) during tx building. + // Ogmios requires a full bech32 address, so use the stored wallet address. + address = this.walletAddress; } else { - throw new Error('Credential-based UTxO lookup not supported via Ogmios. Use an address string.'); + throw new Error( + 'Credential-based UTxO lookup requires a wallet address. ' + + 'Call provider.setWalletAddress(addr) after selecting a wallet.' + ); } const result = await this.rpc('queryLedgerState/utxo', { addresses: [address] }); diff --git a/src/vector/types.ts b/src/vector/types.ts index 5879eba..a63baf5 100644 --- a/src/vector/types.ts +++ b/src/vector/types.ts @@ -159,6 +159,27 @@ export interface AgentMessageResult { links: { explorer: string }; } +export interface AgentDeregistrationResult { + agentId: string; + txHash: string; + depositReturned: string; + links: { explorer: string }; +} + +export interface AgentUpdateResult { + agentId: string; + txHash: string; + updatedFields: string[]; + links: { explorer: string }; +} + +export interface AgentTransferResult { + agentId: string; + txHash: string; + newOwnerAddress: string; + links: { explorer: string }; +} + // --- Unsigned TX (transaction-crafter mode) --- export interface UnsignedTxResult { diff --git a/src/vector/vector.ts b/src/vector/vector.ts index cb6c7f8..d1a920c 100644 --- a/src/vector/vector.ts +++ b/src/vector/vector.ts @@ -2,6 +2,18 @@ import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { Lucid, fromText, Data, applyDoubleCborEncoding } from 'lucid-cardano'; import type { SpendingValidator } from 'lucid-cardano'; + +/** + * Lucid v0.10.x lacks PlutusV3 support. This helper downcasts PlutusV3 to + * PlutusV2 for hashing/address derivation (same blake2b-224 algorithm). + * Use this when passing validators to any Lucid method. + */ +function lucidCompat(validator: SpendingValidator): SpendingValidator { + if ((validator.type as string) === 'PlutusV3') { + return { ...validator, type: 'PlutusV2' as const }; + } + return validator; +} import * as dotenv from 'dotenv'; import { resolve } from 'path'; import { fileURLToPath } from 'url'; @@ -80,8 +92,9 @@ async function initLucid(mnemonic: string, accountIndex: number = 0) { const trimmedMnemonic = mnemonic.trim(); const words = trimmedMnemonic.split(/\s+/); - if (words.length !== 15 && words.length !== 24) { - throw new Error(`Invalid mnemonic: Expected 15 or 24 words, got ${words.length}`); + const validLengths = [12, 15, 18, 21, 24]; + if (!validLengths.includes(words.length)) { + throw new Error(`Invalid mnemonic: Expected 12, 15, 18, 21 or 24 words, got ${words.length}`); } lucid.selectWalletFromSeed(trimmedMnemonic, { accountIndex }); @@ -91,6 +104,9 @@ async function initLucid(mnemonic: string, accountIndex: number = 0) { throw new Error('Failed to derive address from mnemonic'); } + // Store the full wallet address so credential-based UTxO lookups resolve correctly + provider.setWalletAddress(address); + return lucid; } @@ -390,9 +406,9 @@ export async function deployContract( }; // @ts-ignore - const scriptAddress = lucid.utils.validatorToAddress(validator); + const scriptAddress = lucid.utils.validatorToAddress(lucidCompat(validator)); // @ts-ignore - const scriptHash = lucid.utils.validatorToScriptHash(validator); + const scriptHash = lucid.utils.validatorToScriptHash(lucidCompat(validator)); const datum = initialDatum || Data.void(); @@ -438,7 +454,7 @@ export async function interactWithContract( }; // @ts-ignore - const scriptAddress = lucid.utils.validatorToAddress(validator); + const scriptAddress = lucid.utils.validatorToAddress(lucidCompat(validator)); if (action === 'lock') { const safetyCheck = safetyLayer.checkTransaction(lovelaceAmount); @@ -490,7 +506,7 @@ export async function interactWithContract( // @ts-ignore let tx = lucid.newTx() .collectFrom(scriptUtxos, redeemerData) - .attachSpendingValidator(validator) + .attachSpendingValidator(lucidCompat(validator)) .addSigner(walletAddress); try { @@ -501,7 +517,7 @@ export async function interactWithContract( // @ts-ignore tx = await lucid.newTx() .collectFrom(scriptUtxos, redeemerData) - .attachSpendingValidator(validator) + .attachSpendingValidator(lucidCompat(validator)) .addSigner(walletAddress) .complete({ nativeUplc: false }); } diff --git a/test/run.test.ts b/test/run.test.ts new file mode 100644 index 0000000..706a06d --- /dev/null +++ b/test/run.test.ts @@ -0,0 +1,346 @@ +import { describe, test, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { startServer, stopServer, callTool, getMnemonic, wait, ServerContext } from './setup.ts'; + +// Always-succeeds PlutusV3 validator (accepts any datum/redeemer/context) +const ALWAYS_SUCCEEDS_V3 = '49480100002221200101'; + +let ctx: ServerContext; +let mnemonic: string; +let walletAddress: string; +let walletHasAda = false; +let agentDid: string | null = null; + +before(async () => { + mnemonic = getMnemonic(); + console.log('Starting MCP server...'); + ctx = await startServer(); + console.log(`MCP server running on port ${ctx.port}`); +}); + +after(async () => { + console.log('Stopping MCP server...'); + await stopServer(ctx); +}); + +// Helper: assert tool returned a non-empty response (didn't crash) +function assertResponded(text: string, toolName: string) { + assert.ok(text.length > 0, `${toolName} should return a non-empty response`); + assert.ok(!text.includes('Rate limit exceeded'), `${toolName} should not be rate limited`); +} + +// Helper: assert tool succeeded (for funded wallet) or returned a known error (unfunded) +function assertSuccessOrKnownError(text: string, successPattern: RegExp, toolName: string) { + assertResponded(text, toolName); + const isSuccess = successPattern.test(text); + const isKnownError = text.includes('Failed') || text.includes('Credential-based UTxO') + || text.includes('insufficient') || text.includes('No UTxOs in wallet') + || text.includes('Dry run failed') || text.includes('No variant matched'); + assert.ok( + isSuccess || isKnownError, + `${toolName}: expected success or known error, got: ${text.substring(0, 200)}` + ); + if (isSuccess) console.log(` ✓ ${toolName} succeeded`); + else console.log(` ⚠ ${toolName} returned known error (wallet may be unfunded)`); +} + +// ─── Wallet Tools ─────────────────────────────────────────────────────────── + +describe('Wallet Tools', () => { + test('vector_get_address', { timeout: 120_000 }, async () => { + const text = await callTool(ctx.client, 'vector_get_address', { mnemonic }); + console.log(text); + assert.match(text, /addr1/, 'Should contain a Vector address'); + assert.match(text, /ADA Balance:/, 'Should show ADA balance'); + assert.match(text, /UTXO Count:/, 'Should show UTXO count'); + + const addrMatch = text.match(/(addr1[a-z0-9]+)/); + assert.ok(addrMatch, 'Should be able to extract address'); + walletAddress = addrMatch![1]; + + // Check if wallet is funded + const balanceMatch = text.match(/ADA Balance:\s*([\d.]+)/); + if (balanceMatch && parseFloat(balanceMatch[1]) > 0) { + walletHasAda = true; + console.log(`Wallet funded: ${balanceMatch[1]} ADA`); + } else { + console.log('Wallet has 0 ADA - transaction tests will verify error handling'); + } + }); + + test('vector_get_balance', { timeout: 120_000 }, async () => { + assert.ok(walletAddress, 'Wallet address should be set from previous test'); + const text = await callTool(ctx.client, 'vector_get_balance', { address: walletAddress }); + console.log(text); + assert.match(text, /ADA Balance:/, 'Should show ADA balance'); + assert.ok(text.includes(walletAddress), 'Should contain the queried address'); + }); + + test('vector_get_utxos', { timeout: 120_000 }, async () => { + const text = await callTool(ctx.client, 'vector_get_utxos', { mnemonic }); + console.log(text); + assert.ok( + text.includes('Total:') || text.includes('No UTxOs found'), + 'Should show UTxO count or no-UTxO message' + ); + }); +}); + +// ─── History & Limits ─────────────────────────────────────────────────────── + +describe('History & Limits', () => { + test('vector_get_spend_limits', { timeout: 120_000 }, async () => { + const text = await callTool(ctx.client, 'vector_get_spend_limits', {}); + console.log(text); + assert.match(text, /Per-Transaction Limit:/, 'Should show per-tx limit'); + assert.match(text, /Daily Limit:/, 'Should show daily limit'); + assert.match(text, /Daily Remaining:/, 'Should show daily remaining'); + }); + + test('vector_get_transaction_history', { timeout: 120_000 }, async () => { + const text = await callTool(ctx.client, 'vector_get_transaction_history', { mnemonic, limit: 5 }); + console.log(text); + assert.ok( + text.includes('Transaction History') || text.includes('No transactions found') + || text.includes('Failed to get transaction history'), + 'Should show history, no-transactions message, or error' + ); + }); +}); + +// ─── Transaction Tools ────────────────────────────────────────────────────── + +describe('Transaction Tools', () => { + test('vector_dry_run', { timeout: 120_000 }, async () => { + assert.ok(walletAddress, 'Wallet address required'); + const text = await callTool(ctx.client, 'vector_dry_run', { + outputs: [{ address: walletAddress, lovelace: 2_000_000 }], + mnemonic, + }); + console.log(text); + assertSuccessOrKnownError(text, /Valid:.*\nEstimated Fee:/, 'vector_dry_run'); + }); + + test('vector_build_transaction (unsigned)', { timeout: 120_000 }, async () => { + assert.ok(walletAddress, 'Wallet address required'); + const text = await callTool(ctx.client, 'vector_build_transaction', { + outputs: [{ address: walletAddress, lovelace: 2_000_000 }], + mnemonic, + submit: false, + }); + console.log(text); + assertSuccessOrKnownError(text, /CBOR/, 'vector_build_transaction (unsigned)'); + }); + + test('vector_build_transaction (submit)', { timeout: 120_000 }, async () => { + assert.ok(walletAddress, 'Wallet address required'); + const text = await callTool(ctx.client, 'vector_build_transaction', { + outputs: [{ address: walletAddress, lovelace: 2_000_000 }], + mnemonic, + submit: true, + }); + console.log(text); + assertSuccessOrKnownError(text, /Transaction Hash:/, 'vector_build_transaction (submit)'); + }); + + test('vector_send_apex', { timeout: 120_000 }, async () => { + assert.ok(walletAddress, 'Wallet address required'); + // Wait for previous tx UTxOs to settle + if (walletHasAda) { + console.log('Waiting 30s for UTxOs to settle...'); + await wait(30); + } + const text = await callTool(ctx.client, 'vector_send_apex', { + recipientAddress: walletAddress, + amount: 2, + mnemonic, + }); + console.log(text); + assertSuccessOrKnownError(text, /Transaction Hash:/, 'vector_send_apex'); + }); + + test('vector_send_tokens', { timeout: 120_000 }, async () => { + assert.ok(walletAddress, 'Wallet address required'); + // Wait for send_apex UTxOs to settle + if (walletHasAda) { + console.log('Waiting 30s for UTxOs to settle...'); + await wait(30); + } + const text = await callTool(ctx.client, 'vector_send_tokens', { + recipientAddress: walletAddress, + policyId: 'a'.repeat(56), + assetName: 'test', + amount: '1', + mnemonic, + }); + console.log(text); + assertSuccessOrKnownError(text, /Transaction Hash:/, 'vector_send_tokens'); + }); +}); + +// ─── Smart Contract Tools ─────────────────────────────────────────────────── + +describe('Smart Contract Tools', () => { + test('vector_deploy_contract', { timeout: 120_000 }, async () => { + if (walletHasAda) { + console.log('Waiting 30s for UTxOs to settle before deploy...'); + await wait(30); + } + const text = await callTool(ctx.client, 'vector_deploy_contract', { + scriptCbor: ALWAYS_SUCCEEDS_V3, + scriptType: 'PlutusV3', + mnemonic, + }); + console.log(text); + assertSuccessOrKnownError(text, /Script Address:/, 'vector_deploy_contract'); + }); + + test('vector_interact_contract (lock)', { timeout: 120_000 }, async () => { + if (walletHasAda) { + console.log('Waiting 30s for deploy tx to confirm...'); + await wait(30); + } + const text = await callTool(ctx.client, 'vector_interact_contract', { + scriptCbor: ALWAYS_SUCCEEDS_V3, + scriptType: 'PlutusV3', + action: 'lock', + mnemonic, + datum: 'd87980', + lovelaceAmount: 2_000_000, + }); + console.log(text); + assertSuccessOrKnownError(text, /Transaction Hash:/, 'vector_interact_contract (lock)'); + }); + + test('vector_interact_contract (spend)', { timeout: 120_000 }, async () => { + if (walletHasAda) { + console.log('Waiting 30s for lock tx to confirm...'); + await wait(30); + } + const text = await callTool(ctx.client, 'vector_interact_contract', { + scriptCbor: ALWAYS_SUCCEEDS_V3, + scriptType: 'PlutusV3', + action: 'spend', + mnemonic, + redeemer: 'd87980', + }); + console.log(text); + assertSuccessOrKnownError(text, /Transaction Hash:/, 'vector_interact_contract (spend)'); + }); +}); + +// ─── Agent Network Tools ──────────────────────────────────────────────────── + +describe('Agent Network Tools', () => { + test('vector_register_agent', { timeout: 120_000 }, async () => { + if (walletHasAda) { + console.log('Waiting 30s for UTxOs to settle before register...'); + await wait(30); + } + const timestamp = Date.now(); + const text = await callTool(ctx.client, 'vector_register_agent', { + mnemonic, + name: `TestAgent-${timestamp}`, + description: 'Integration test agent', + capabilities: ['testing'], + framework: 'custom', + endpoint: '', + }); + console.log(text); + assertSuccessOrKnownError(text, /Agent DID:/, 'vector_register_agent'); + + const didMatch = text.match(/(did:vector:agent:[a-f0-9]+:[a-f0-9]+)/); + if (didMatch) { + agentDid = didMatch[1]; + console.log(`Registered agent DID: ${agentDid}`); + } + }); + + test('vector_discover_agents (no mnemonic)', { timeout: 120_000 }, async () => { + if (walletHasAda && agentDid) { + console.log('Waiting 30s for agent registration to confirm...'); + await wait(30); + } + const text = await callTool(ctx.client, 'vector_discover_agents', {}); + console.log(text); + assertSuccessOrKnownError(text, /Agent Discovery|Found|No agents found/, 'vector_discover_agents'); + }); + + test('vector_get_agent_profile', { timeout: 120_000 }, async () => { + // Use a known DID format even if registration failed, to test the tool responds + const testDid = agentDid || `did:vector:agent:${ALWAYS_SUCCEEDS_V3}:${'a'.repeat(64)}`; + const text = await callTool(ctx.client, 'vector_get_agent_profile', { agent_id: testDid }); + console.log(text); + assertSuccessOrKnownError(text, /Agent Profile/, 'vector_get_agent_profile'); + }); + + test('vector_update_agent', { timeout: 120_000 }, async () => { + if (!agentDid) { + console.log('Skipping update — no agent registered'); + return; + } + if (walletHasAda) { + console.log('Waiting 30s for UTxOs to settle before update...'); + await wait(30); + } + const text = await callTool(ctx.client, 'vector_update_agent', { + mnemonic, + agent_id: agentDid, + description: 'Updated integration test agent', + }); + console.log(text); + assertSuccessOrKnownError(text, /Agent Updated|Updated fields/, 'vector_update_agent'); + }); + + test('vector_transfer_agent (to self)', { timeout: 120_000 }, async () => { + if (!agentDid || !walletAddress) { + console.log('Skipping transfer — no agent registered or no wallet address'); + return; + } + if (walletHasAda) { + console.log('Waiting 30s for UTxOs to settle before transfer...'); + await wait(30); + } + const text = await callTool(ctx.client, 'vector_transfer_agent', { + mnemonic, + agent_id: agentDid, + new_owner_address: walletAddress, + }); + console.log(text); + assertSuccessOrKnownError(text, /Agent Transferred|Transfer/, 'vector_transfer_agent'); + }); + + test('vector_message_agent', { timeout: 120_000 }, async () => { + // Use a known DID format even if registration failed, to test the tool responds + const testDid = agentDid || `did:vector:agent:${ALWAYS_SUCCEEDS_V3}:${'a'.repeat(64)}`; + if (walletHasAda && agentDid) { + console.log('Waiting 30s for UTxOs to settle before message...'); + await wait(30); + } + const text = await callTool(ctx.client, 'vector_message_agent', { + agent_id: testDid, + message_type: 'inquiry', + payload: 'integration test ping', + mnemonic, + }); + console.log(text); + assertSuccessOrKnownError(text, /Message Sent/, 'vector_message_agent'); + }); + + test('vector_deregister_agent', { timeout: 120_000 }, async () => { + if (!agentDid) { + console.log('Skipping deregister — no agent registered'); + return; + } + if (walletHasAda) { + console.log('Waiting 30s for UTxOs to settle before deregister...'); + await wait(30); + } + const text = await callTool(ctx.client, 'vector_deregister_agent', { + mnemonic, + agent_id: agentDid, + }); + console.log(text); + assertSuccessOrKnownError(text, /Agent Deregistered|deposit returned/, 'vector_deregister_agent'); + }); +}); diff --git a/test/setup.ts b/test/setup.ts new file mode 100644 index 0000000..0651be1 --- /dev/null +++ b/test/setup.ts @@ -0,0 +1,65 @@ +import { spawn, ChildProcess } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; + +const PROJECT_ROOT = resolve(import.meta.dirname!, '..'); + +export interface ServerContext { + client: Client; + process: ChildProcess; + port: number; +} + +export function getMnemonic(): string { + const mnemonic = readFileSync(resolve(PROJECT_ROOT, 'mnemonic.txt'), 'utf-8').trim(); + const wordCount = mnemonic.split(/\s+/).length; + const validLengths = [12, 15, 18, 21, 24]; + if (!validLengths.includes(wordCount)) { + throw new Error(`mnemonic.txt has ${wordCount} words, expected one of: ${validLengths.join(', ')}`); + } + return mnemonic; +} + +export async function startServer(): Promise { + const port = 3100 + Math.floor(Math.random() * 900); + const child = spawn('node', ['build/index.js'], { + cwd: PROJECT_ROOT, + env: { ...process.env, PORT: String(port) }, + stdio: ['ignore', 'ignore', 'pipe'], + }); + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Server start timeout (15s)')), 15_000); + child.stderr!.on('data', (chunk: Buffer) => { + if (chunk.toString().includes('listening on port')) { + clearTimeout(timeout); + resolve(); + } + }); + child.on('error', (err) => { clearTimeout(timeout); reject(err); }); + child.on('exit', (code) => { clearTimeout(timeout); reject(new Error(`Server exited with code ${code}`)); }); + }); + + const transport = new SSEClientTransport(new URL(`http://localhost:${port}/sse`)); + const client = new Client({ name: 'integration-test', version: '1.0.0' }); + await client.connect(transport); + + return { client, process: child, port }; +} + +export async function stopServer(ctx: ServerContext): Promise { + try { await ctx.client.close(); } catch {} + ctx.process.kill('SIGTERM'); +} + +export async function callTool(client: Client, name: string, args: Record = {}): Promise { + const result = await client.callTool({ name, arguments: args }); + const content = result.content as Array<{ type: string; text?: string }>; + return content?.[0]?.text ?? ''; +} + +export function wait(seconds: number): Promise { + return new Promise(resolve => setTimeout(resolve, seconds * 1000)); +}