Add agent lifecycle tools (update, deregister, transfer), harden registry, and add integration tests

This commit is contained in:
david
2026-03-19 02:17:25 +01:00
parent 74a2e13432
commit 23ead76aba
8 changed files with 890 additions and 69 deletions

44
package-lock.json generated
View File

@@ -21,6 +21,7 @@
"devDependencies": { "devDependencies": {
"@types/node": "^22.13.1", "@types/node": "^22.13.1",
"tsup": "^8.5.1", "tsup": "^8.5.1",
"tsx": "^4.21.0",
"typescript": "^5.7.3" "typescript": "^5.7.3"
}, },
"engines": { "engines": {
@@ -1689,6 +1690,19 @@
"node": ">= 0.4" "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": { "node_modules/gopd": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -2319,6 +2333,16 @@
"node": ">=8" "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": { "node_modules/rollup": {
"version": "4.59.0", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "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": { "node_modules/type-is": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",

View File

@@ -10,7 +10,8 @@
"clean": "rm -rf build", "clean": "rm -rf build",
"build": "tsup && chmod +x build/index.js", "build": "tsup && chmod +x build/index.js",
"typecheck": "tsc --noEmit --incremental", "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": [ "files": [
"build" "build"
@@ -52,6 +53,7 @@
"devDependencies": { "devDependencies": {
"@types/node": "^22.13.1", "@types/node": "^22.13.1",
"tsup": "^8.5.1", "tsup": "^8.5.1",
"tsx": "^4.21.0",
"typescript": "^5.7.3" "typescript": "^5.7.3"
} }
} }

View File

@@ -1,5 +1,5 @@
// @ts-nocheck // @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 // ts-nocheck isolates C (Cardano WASM) imports from tsc's complex type inference
import { z } from "zod"; import { z } from "zod";
@@ -8,6 +8,11 @@ import { OgmiosProvider } from './ogmios-provider.js';
import { safetyLayer } from './safety.js'; import { safetyLayer } from './safety.js';
import { rateLimiter } from './rate-limiter.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) // Env config (mirrors vector.ts)
const VECTOR_OGMIOS_URL = process.env.VECTOR_OGMIOS_URL || 'https://ogmios.vector.testnet.apexfusion.org'; 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'; 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 MIN_AP3X_DEPOSIT = 10_000_000n;
const AGENT_MESSAGE_LABEL = 674; const AGENT_MESSAGE_LABEL = 674;
// Helpers // ─── Helpers ─────────────────────────────────────────────────────────────────
function lovelaceToAda(lovelace) { function lovelaceToAda(lovelace) {
return (Number(BigInt(String(lovelace))) / 1_000_000).toFixed(6); return (Number(BigInt(String(lovelace))) / 1_000_000).toFixed(6);
} }
function explorerTxLink(txHash) { function explorerTxLink(txHash) {
return `${VECTOR_EXPLORER_URL}/transaction/${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)) // Derive NFT asset name = blake2b_256(CBOR(OutputReference))
// Matches Aiken's derive_asset_name (verified via CBOR parity tests in agent-registry repo) // Matches Aiken's derive_asset_name (verified via CBOR parity tests in agent-registry repo)
function deriveNftAssetName(txHash, outputIndex) { function deriveNftAssetName(txHash, outputIndex) {
@@ -36,7 +47,7 @@ function deriveNftAssetName(txHash, outputIndex) {
return Buffer.from(hashBytes).toString('hex'); 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, [ return Data.to(new Constr(0, [
new Constr(0, [vkeyHash]), new Constr(0, [vkeyHash]),
fromText(name), fromText(name),
@@ -44,7 +55,7 @@ function buildAgentDatum(vkeyHash, name, description, capabilities, framework, e
capabilities.map(c => fromText(c)), capabilities.map(c => fromText(c)),
fromText(framework), fromText(framework),
fromText(endpoint), fromText(endpoint),
BigInt(Date.now()), BigInt(registeredAt ?? Date.now()),
])); ]));
} }
@@ -69,31 +80,107 @@ function parseAgentDatum(datumCbor, utxoRef, assets) {
utxoRef, utxoRef,
ownerVkeyHash: vkeyHash, ownerVkeyHash: vkeyHash,
}; };
} catch { } catch (err) {
console.warn(`parseAgentDatum failed for ${utxoRef}:`, err?.message || err);
return null; return null;
} }
} }
async function initLucid(mnemonic: string, accountIndex: number = 0) { // ─── DID & Agent Resolution ──────────────────────────────────────────────────
const provider = new OgmiosProvider({
ogmiosUrl: VECTOR_OGMIOS_URL, function parseDid(agentId) {
submitUrl: VECTOR_SUBMIT_URL, const parts = agentId.split(':');
koiosUrl: VECTOR_KOIOS_URL, 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'); const lucid = await Lucid.new(provider, 'Mainnet');
if (!mnemonic) throw new Error('mnemonic is required'); if (!mnemonic) throw new Error('mnemonic is required');
const trimmed = mnemonic.trim(); const trimmed = mnemonic.trim();
const words = trimmed.split(/\s+/); const words = trimmed.split(/\s+/);
if (words.length !== 15 && words.length !== 24) { const validLengths = [12, 15, 18, 21, 24];
throw new Error(`Invalid mnemonic: Expected 15 or 24 words, got ${words.length}`); 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 }); 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; 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) { export function registerAgentNetworkTools(server) {
// vector_get_agent_profile // vector_get_agent_profile (read-only — no mnemonic needed)
server.tool( server.tool(
"vector_get_agent_profile", "vector_get_agent_profile",
"Get a registered agent's profile from the on-chain registry by DID (did:vector:agent:...)", "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}"), agent_id: z.string().describe("Agent DID: did:vector:agent:{policyId}:{nftAssetName}"),
}, },
async ({ agent_id }) => { async ({ agent_id }) => {
const rateCheck = rateLimiter.check(); const rateLimited = checkRateLimit();
if (!rateCheck.allowed) { if (rateLimited) return rateLimited;
return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] };
}
try { try {
const parts = agent_id.split(':'); const provider = newProvider();
if (parts.length !== 5 || parts[0] !== 'did' || parts[1] !== 'vector' || parts[2] !== 'agent') { const { profile } = await resolveAgentUtxo(provider, agent_id);
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 capList = profile.capabilities.length > 0 const capList = profile.capabilities.length > 0
? profile.capabilities.map(c => `- ${c}`).join('\n') ? profile.capabilities.map(c => `- ${c}`).join('\n')
: '- (none listed)'; : '- (none listed)';
@@ -154,26 +230,21 @@ ${capList}
} }
); );
// vector_discover_agents // vector_discover_agents (read-only — no mnemonic needed)
server.tool( server.tool(
"vector_discover_agents", "vector_discover_agents",
"Discover registered agents in the Vector on-chain registry, optionally filtered by capability or framework", "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')"), 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')"), 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)"), limit: z.number().optional().default(20).describe("Maximum number of agents to return (default: 20)"),
}, },
async ({ mnemonic, capability, framework, limit }) => { async ({ capability, framework, limit }) => {
const rateCheck = rateLimiter.check(); const rateLimited = checkRateLimit();
if (!rateCheck.allowed) { if (rateLimited) return rateLimited;
return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] };
}
try { try {
const lucid = await initLucid(mnemonic); const registryAddress = await getRegistryAddress();
const registryScript = { type: "PlutusV3", script: REGISTRY_SCRIPT_CBOR }; const provider = newProvider();
const registryAddress = lucid.utils.validatorToAddress(registryScript);
const provider = new OgmiosProvider({ ogmiosUrl: VECTOR_OGMIOS_URL, submitUrl: VECTOR_SUBMIT_URL, koiosUrl: VECTOR_KOIOS_URL });
const utxos = await provider.getUtxos(registryAddress); const utxos = await provider.getUtxos(registryAddress);
const profiles = []; const profiles = [];
for (const utxo of utxos) { for (const utxo of utxos) {
@@ -227,11 +298,12 @@ ${capList}
endpoint: z.string().describe("A2A communication endpoint URL (or empty string if not applicable)"), endpoint: z.string().describe("A2A communication endpoint URL (or empty string if not applicable)"),
}, },
async ({ mnemonic, name, description, capabilities, framework, endpoint }) => { async ({ mnemonic, name, description, capabilities, framework, endpoint }) => {
const rateCheck = rateLimiter.check(); const rateLimited = checkRateLimit();
if (!rateCheck.allowed) { if (rateLimited) return rateLimited;
return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] };
}
try { try {
validateEndpoint(endpoint);
validateCapabilities(capabilities);
const safetyCheck = safetyLayer.checkTransaction(Number(MIN_AP3X_DEPOSIT)); const safetyCheck = safetyLayer.checkTransaction(Number(MIN_AP3X_DEPOSIT));
if (!safetyCheck.allowed) throw new Error(`Safety limit exceeded: ${safetyCheck.reason}`); if (!safetyCheck.allowed) throw new Error(`Safety limit exceeded: ${safetyCheck.reason}`);
@@ -252,14 +324,15 @@ ${capList}
const nftUnit = `${REGISTRY_POLICY_ID}${nftAssetName}`; const nftUnit = `${REGISTRY_POLICY_ID}${nftAssetName}`;
const datum = buildAgentDatum(vkeyHash, name, description, capabilities, framework, endpoint); const datum = buildAgentDatum(vkeyHash, name, description, capabilities, framework, endpoint);
const registryScript = { type: "PlutusV3", script: REGISTRY_SCRIPT_CBOR }; 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 registerRedeemer = Data.to(new Constr(0, [new Constr(0, [seedUtxo.txHash, BigInt(seedUtxo.outputIndex)])]));
const tx = await lucid.newTx() const tx = await lucid.newTx()
.collectFrom([seedUtxo]) .collectFrom([seedUtxo])
.mintAssets({ [nftUnit]: 1n }, registerRedeemer) .mintAssets({ [nftUnit]: 1n }, registerRedeemer)
.attachMintingPolicy(registryScript) .attachMintingPolicy(lucidCompat(registryScript))
.payToContract(registryAddress, { inline: datum }, { lovelace: MIN_AP3X_DEPOSIT, [nftUnit]: 1n }) .payToContract(registryAddress, { inline: datum }, { lovelace: MIN_AP3X_DEPOSIT, [nftUnit]: 1n })
.addSigner(walletAddress)
.complete(); .complete();
const signedTx = await tx.sign().complete(); const signedTx = await tx.sign().complete();
const txHash = await signedTx.submit(); 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 // vector_message_agent
server.tool( server.tool(
"vector_message_agent", "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"), mnemonic: z.string().describe("15 or 24-word BIP39 mnemonic for the sending wallet"),
}, },
async ({ agent_id, message_type, payload, mnemonic }) => { async ({ agent_id, message_type, payload, mnemonic }) => {
const rateCheck = rateLimiter.check(); const rateLimited = checkRateLimit();
if (!rateCheck.allowed) { if (rateLimited) return rateLimited;
return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] };
}
try { try {
const parts = agent_id.split(':'); const provider = newProvider();
if (parts.length !== 5 || parts[0] !== 'did' || parts[1] !== 'vector' || parts[2] !== 'agent') { const { profile } = await resolveAgentUtxo(provider, agent_id);
throw new Error('Invalid agent DID format. Expected: did:vector:agent:{policyId}:{nftAssetName}'); if (!profile.ownerVkeyHash) throw new Error('Could not parse agent owner from registry datum');
}
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 lucid = await initLucid(mnemonic); const lucid = await initLucid(mnemonic);
const senderAddress = await lucid.wallet.address(); const senderAddress = await lucid.wallet.address();

View File

@@ -25,6 +25,7 @@ export class OgmiosProvider implements Provider {
private ogmiosUrl: string; private ogmiosUrl: string;
private submitUrl: string; private submitUrl: string;
private koiosUrl: string | undefined; private koiosUrl: string | undefined;
private walletAddress: string | undefined;
constructor(config: OgmiosProviderConfig) { constructor(config: OgmiosProviderConfig) {
// Strip trailing slashes for consistent URL building // Strip trailing slashes for consistent URL building
@@ -33,6 +34,14 @@ export class OgmiosProvider implements Provider {
this.koiosUrl = config.koiosUrl?.replace(/\/+$/, ''); 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 * Send a JSON-RPC request to Ogmios
*/ */
@@ -119,8 +128,15 @@ export class OgmiosProvider implements Provider {
let address: string; let address: string;
if (typeof addressOrCredential === 'string') { if (typeof addressOrCredential === 'string') {
address = addressOrCredential; 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 { } 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] }); const result = await this.rpc('queryLedgerState/utxo', { addresses: [address] });

View File

@@ -159,6 +159,27 @@ export interface AgentMessageResult {
links: { explorer: string }; 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) --- // --- Unsigned TX (transaction-crafter mode) ---
export interface UnsignedTxResult { export interface UnsignedTxResult {

View File

@@ -2,6 +2,18 @@ import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Lucid, fromText, Data, applyDoubleCborEncoding } from 'lucid-cardano'; import { Lucid, fromText, Data, applyDoubleCborEncoding } from 'lucid-cardano';
import type { SpendingValidator } 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 * as dotenv from 'dotenv';
import { resolve } from 'path'; import { resolve } from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
@@ -80,8 +92,9 @@ async function initLucid(mnemonic: string, accountIndex: number = 0) {
const trimmedMnemonic = mnemonic.trim(); const trimmedMnemonic = mnemonic.trim();
const words = trimmedMnemonic.split(/\s+/); const words = trimmedMnemonic.split(/\s+/);
if (words.length !== 15 && words.length !== 24) { const validLengths = [12, 15, 18, 21, 24];
throw new Error(`Invalid mnemonic: Expected 15 or 24 words, got ${words.length}`); 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 }); 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'); 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; return lucid;
} }
@@ -390,9 +406,9 @@ export async function deployContract(
}; };
// @ts-ignore // @ts-ignore
const scriptAddress = lucid.utils.validatorToAddress(validator); const scriptAddress = lucid.utils.validatorToAddress(lucidCompat(validator));
// @ts-ignore // @ts-ignore
const scriptHash = lucid.utils.validatorToScriptHash(validator); const scriptHash = lucid.utils.validatorToScriptHash(lucidCompat(validator));
const datum = initialDatum || Data.void(); const datum = initialDatum || Data.void();
@@ -438,7 +454,7 @@ export async function interactWithContract(
}; };
// @ts-ignore // @ts-ignore
const scriptAddress = lucid.utils.validatorToAddress(validator); const scriptAddress = lucid.utils.validatorToAddress(lucidCompat(validator));
if (action === 'lock') { if (action === 'lock') {
const safetyCheck = safetyLayer.checkTransaction(lovelaceAmount); const safetyCheck = safetyLayer.checkTransaction(lovelaceAmount);
@@ -490,7 +506,7 @@ export async function interactWithContract(
// @ts-ignore // @ts-ignore
let tx = lucid.newTx() let tx = lucid.newTx()
.collectFrom(scriptUtxos, redeemerData) .collectFrom(scriptUtxos, redeemerData)
.attachSpendingValidator(validator) .attachSpendingValidator(lucidCompat(validator))
.addSigner(walletAddress); .addSigner(walletAddress);
try { try {
@@ -501,7 +517,7 @@ export async function interactWithContract(
// @ts-ignore // @ts-ignore
tx = await lucid.newTx() tx = await lucid.newTx()
.collectFrom(scriptUtxos, redeemerData) .collectFrom(scriptUtxos, redeemerData)
.attachSpendingValidator(validator) .attachSpendingValidator(lucidCompat(validator))
.addSigner(walletAddress) .addSigner(walletAddress)
.complete({ nativeUplc: false }); .complete({ nativeUplc: false });
} }

346
test/run.test.ts Normal file
View File

@@ -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');
});
});

65
test/setup.ts Normal file
View File

@@ -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<ServerContext> {
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<void>((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<void> {
try { await ctx.client.close(); } catch {}
ctx.process.kill('SIGTERM');
}
export async function callTool(client: Client, name: string, args: Record<string, unknown> = {}): Promise<string> {
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<void> {
return new Promise(resolve => setTimeout(resolve, seconds * 1000));
}