diff --git a/src/vector/agent-network.ts b/src/vector/agent-network.ts new file mode 100644 index 0000000..afe0fcd --- /dev/null +++ b/src/vector/agent-network.ts @@ -0,0 +1,376 @@ +// @ts-nocheck +// Agent network tools: register, discover, message, get_profile +// ts-nocheck isolates C (Cardano WASM) imports from tsc's complex type inference + +import { z } from "zod"; +import { Lucid, fromText, toText, Data, Constr, C } from 'lucid-cardano'; +import { OgmiosProvider } from './ogmios-provider.js'; +import { safetyLayer } from './safety.js'; +import { rateLimiter } from './rate-limiter.js'; + +// 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'; +const VECTOR_KOIOS_URL = process.env.VECTOR_KOIOS_URL || 'https://koios.vector.testnet.apexfusion.org/'; +const VECTOR_EXPLORER_URL = process.env.VECTOR_EXPLORER_URL || 'https://vector.testnet.apexscan.org'; +const VECTOR_MNEMONIC = process.env.VECTOR_MNEMONIC || ''; +const VECTOR_ACCOUNT_INDEX = parseInt(process.env.VECTOR_ACCOUNT_INDEX || '0'); + +// Registry constants +const REGISTRY_SCRIPT_CBOR = "59058101010029800aba2aba1aba0aab9faab9eaab9dab9a48888889660033001300337540112232330010010032259800800c52845660026006601400314a3133002002300b001401480424600e60106010003223232330010010042259800800c00e2646644b30013372200e00515980099b8f0070028800c01900944cc014014c03c0110091bae3008001375660120026016002804852f5bded8c1230073008300830083008001918039804000cdc3a4005370e90002444444453001300f0089807004496600266e1d2004300b3754600e60186ea80062946294100a4888c8cc88cc008008004896600200300389919912cc004cdc8803801456600266e3c01c00a20030064049133005005301800440486eb8c044004dd69809000980a000a02433009004003148001222232980098079baa00194c004006910100a44100400d301300548896600260120071323322598009806000c4c9660026036003132598009807180b9baa0018991919912cc004c08000e0111640746eb4c074004dd7180e801180e800980c1baa0018b202c301a0018b2030301637540091598009806800c566002602c6ea801200516405d16405080a0566002601460266ea800a2646644b30013301337586034602e6ea802c8cdd7980d980c1baa0010048acc004cc89660020030028acc004c074006264b30013371e6eb8c064004016260226eb4c0680062941018180e000c00901a203414a0660226eacc040c05cdd500580244cc04cdd61809180b9baa00b25980099baf301b301837540020051598009805800c566002601f30013756602060306ea800600b003402913371290406d620498039bab30103018375400314a080b229410164528202c8a50405514a080a8dca1bb30013374a90001980b99ba548008cc05cdd480125eb80cc05d300103d87a80004bd70180b980a1baa002899912cc004006005159800980d000c4cdc39bad301630190014800600480b90170a503300e3756601a60286ea802000501218099baa002375c602c60266ea80122b3001300a003899199119912cc004c0380062b30013018375400d0028b20328acc004c03c0062b30013018375400d0028b20328b202c40582b3001300c3015375400319800980c980b1baa001912cc004c038c05cdd5000c4c8c8cc004004dd6180e980f180f180f180f180f180f180f180f180d1baa0042259800800c528456600266e3cdd7180f000801c528c4cc008008c07c0050192038375c603660306ea80062941016488c8cc00400400c896600200314c0103d87a80008992cc004c010006266e9520003301d0014bd7044cc00c00cc07c009019180e800a0369192cc004c03cc05cdd5000c4dd7180d980c1baa0018b202c301a301737540029111192cc004c044c068dd5004c4c8c8c9660026600e0246042603c6ea80222660346eb0c064c078dd500912cc004cdd79811180f9baa0010048acc004c0480062b3001301698009bab3017301f37540030038012022899b89482036b1024c038dd5980b980f9baa0018a50407514a080ea294101d4528203832598009811000c4c966002602c6eb4c07c00626eb8c0780062c80e8c0840062c80f8cc05cc9660026028603a6ea800626eacc058c078dd5180b180f1baa3021301e37540031640706600a6eb0c080c074dd5008919baf3021301e37540020140026006002660026eb0c078c06cdd5007803c5660026600801e603c60366ea801626644b30010018014566002604200313370e6eb4c074c0800052001801203c40782940cc054dd5980a180d9baa00f3002330013758603c60366ea803c01e294101920322232598009809980e1baa00189810180e9baa3015301d37546040603a6ea80062c80d8cc0100088cdd79810180e9baa001002459014180a9baa00430170013017301800130133754009164044808860246026008452689b2b200201"; +const REGISTRY_POLICY_ID = "5dd5118943d5aa7329696181252a6565a27dbf2c6de92b02a6aae361"; +const MIN_AP3X_DEPOSIT = 10_000_000n; +const AGENT_MESSAGE_LABEL = 674; + +// Helpers +function lovelaceToAda(lovelace) { + return (Number(BigInt(String(lovelace))) / 1_000_000).toFixed(6); +} +function explorerTxLink(txHash) { + return `${VECTOR_EXPLORER_URL}/transaction/${txHash}`; +} + +// 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) { + const outRefCbor = Data.to(new Constr(0, [txHash, BigInt(outputIndex)])); + const hashBytes = C.hash_blake2b256(Buffer.from(outRefCbor, 'hex')); + return Buffer.from(hashBytes).toString('hex'); +} + +function buildAgentDatum(vkeyHash, name, description, capabilities, framework, endpoint) { + return Data.to(new Constr(0, [ + new Constr(0, [vkeyHash]), + fromText(name), + fromText(description), + capabilities.map(c => fromText(c)), + fromText(framework), + fromText(endpoint), + BigInt(Date.now()), + ])); +} + +function parseAgentDatum(datumCbor, utxoRef, assets) { + try { + const c = Data.from(datumCbor); + if (Number(c.index) !== 0) return null; + const ownerCred = c.fields[0]; + const vkeyHash = ownerCred.fields[0]; + const nftUnit = Object.keys(assets).find( + u => u.startsWith(REGISTRY_POLICY_ID) && assets[u] === 1n + ); + const nftAssetName = nftUnit ? nftUnit.slice(REGISTRY_POLICY_ID.length) : ''; + return { + agentId: `did:vector:agent:${REGISTRY_POLICY_ID}:${nftAssetName}`, + name: toText(c.fields[1]), + description: toText(c.fields[2]), + capabilities: c.fields[3].map(toText), + framework: toText(c.fields[4]), + endpoint: toText(c.fields[5]), + registeredAt: Number(c.fields[6]), + utxoRef, + ownerVkeyHash: vkeyHash, + }; + } catch { + return null; + } +} + +async function initLucid() { + const provider = new OgmiosProvider({ + ogmiosUrl: VECTOR_OGMIOS_URL, + submitUrl: VECTOR_SUBMIT_URL, + koiosUrl: VECTOR_KOIOS_URL, + }); + const lucid = await Lucid.new(provider, 'Mainnet'); + if (!VECTOR_MNEMONIC) throw new Error('VECTOR_MNEMONIC is required'); + const trimmed = VECTOR_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}`); + } + lucid.selectWalletFromSeed(trimmed, { accountIndex: VECTOR_ACCOUNT_INDEX }); + return lucid; +} + +export function registerAgentNetworkTools(server) { + + // vector_get_agent_profile + server.tool( + "vector_get_agent_profile", + "Get a registered agent's profile from the on-chain registry by DID (did:vector:agent:...)", + { + 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.` }] }; + } + 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 capList = profile.capabilities.length > 0 + ? profile.capabilities.map(c => `- ${c}`).join('\n') + : '- (none listed)'; + return { + content: [{ + type: "text", + text: `# Agent Profile + +**DID:** ${profile.agentId} +**Name:** ${profile.name} +**Description:** ${profile.description} + +**Capabilities:** +${capList} + +**Framework:** ${profile.framework || '(not specified)'} +**Endpoint:** ${profile.endpoint || '(not specified)'} +**Registered:** ${new Date(profile.registeredAt).toISOString()} +**Registry UTxO:** ${profile.utxoRef}`, + }], + }; + } catch (err) { + return { + content: [{ + type: "text", + text: `Failed to get agent profile: ${err.message} + +**Troubleshooting Tips:** +1. Verify the DID format: did:vector:agent:{policyId}:{nftAssetName} +2. The agent may not be registered or may have deregistered +3. Check that Koios is reachable at ${VECTOR_KOIOS_URL}`, + }], + }; + } + } + ); + + // vector_discover_agents + server.tool( + "vector_discover_agents", + "Discover registered agents in the Vector on-chain registry, optionally filtered by capability or framework", + { + 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 ({ capability, framework, limit }) => { + const rateCheck = rateLimiter.check(); + if (!rateCheck.allowed) { + return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] }; + } + try { + const lucid = await initLucid(); + 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 utxos = await provider.getUtxos(registryAddress); + const profiles = []; + for (const utxo of utxos) { + if (!utxo.datum) continue; + const profile = parseAgentDatum(utxo.datum, `${utxo.txHash}#${utxo.outputIndex}`, utxo.assets); + if (!profile) continue; + if (capability && !profile.capabilities.some(c => c.toLowerCase().includes(capability.toLowerCase()))) continue; + if (framework && profile.framework.toLowerCase() !== framework.toLowerCase()) continue; + profiles.push(profile); + if (profiles.length >= (limit ?? 20)) break; + } + if (profiles.length === 0) { + const filterDesc = [capability ? `capability="${capability}"` : '', framework ? `framework="${framework}"` : ''].filter(Boolean).join(', '); + return { content: [{ type: "text", text: `No agents found${filterDesc ? ` matching ${filterDesc}` : ''} in the registry.\n\nTotal UTxOs at registry: ${utxos.length}` }] }; + } + const agentList = profiles.map((p, i) => + `### ${i + 1}. ${p.name}\n**DID:** ${p.agentId}\n**Description:** ${p.description}\n**Capabilities:** ${p.capabilities.join(', ') || '(none)'}\n**Framework:** ${p.framework || '(not specified)'}\n**Endpoint:** ${p.endpoint || '(not specified)'}\n**Registered:** ${new Date(p.registeredAt).toISOString()}` + ).join('\n\n'); + return { + content: [{ + type: "text", + text: `# Agent Discovery Results\n\nFound **${profiles.length}** agent${profiles.length !== 1 ? 's' : ''}${capability ? ` with capability "${capability}"` : ''}${framework ? ` using framework "${framework}"` : ''}:\n\n${agentList}`, + }], + }; + } catch (err) { + return { + content: [{ + type: "text", + text: `Failed to discover agents: ${err.message} + +**Troubleshooting Tips:** +1. Check that Ogmios is reachable at ${VECTOR_OGMIOS_URL} +2. The registry may be empty — no agents have registered yet +3. Try without filters to list all agents`, + }], + }; + } + } + ); + + // vector_register_agent + server.tool( + "vector_register_agent", + "Register this agent in the Vector on-chain agent registry. Mints a soulbound identity NFT and locks a 10 AP3X deposit.", + { + name: z.string().min(1).max(64).describe("Agent name (e.g. 'TradingBot', 'ResearchAgent')"), + description: z.string().max(256).describe("Short description of the agent's purpose"), + capabilities: z.array(z.string()).describe("List of capability tags (e.g. ['investing', 'research', 'environmental'])"), + framework: z.string().describe("Framework used (e.g. 'OpenClaw', 'LangChain', 'CrewAI', 'custom')"), + endpoint: z.string().describe("A2A communication endpoint URL (or empty string if not applicable)"), + }, + async ({ 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.` }] }; + } + try { + const safetyCheck = safetyLayer.checkTransaction(Number(MIN_AP3X_DEPOSIT)); + if (!safetyCheck.allowed) throw new Error(`Safety limit exceeded: ${safetyCheck.reason}`); + + const lucid = await initLucid(); + const walletAddress = await lucid.wallet.address(); + const addressDetails = lucid.utils.getAddressDetails(walletAddress); + const vkeyHash = addressDetails.paymentCredential?.hash; + if (!vkeyHash) throw new Error('Cannot derive payment key hash from wallet address'); + + const utxos = await lucid.wallet.getUtxos(); + const seedUtxo = utxos.find(u => { + const keys = Object.keys(u.assets); + return keys.length === 1 && keys[0] === 'lovelace' && u.assets['lovelace'] >= MIN_AP3X_DEPOSIT + 2_000_000n; + }) || utxos[0]; + if (!seedUtxo) throw new Error('No UTxOs in wallet. Please fund the wallet first.'); + + const nftAssetName = deriveNftAssetName(seedUtxo.txHash, seedUtxo.outputIndex); + 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 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) + .payToContract(registryAddress, { inline: datum }, { lovelace: MIN_AP3X_DEPOSIT, [nftUnit]: 1n }) + .complete(); + const signedTx = await tx.sign().complete(); + const txHash = await signedTx.submit(); + safetyLayer.recordTransaction(txHash, Number(MIN_AP3X_DEPOSIT), registryAddress); + + const agentId = `did:vector:agent:${REGISTRY_POLICY_ID}:${nftAssetName}`; + return { + content: [{ + type: "text", + text: `# Agent Registered Successfully + +**Agent DID:** ${agentId} +**Name:** ${name} +**NFT Asset Name:** ${nftAssetName} +**Registry Address:** ${registryAddress} +**Transaction:** ${txHash} +**Deposit:** ${lovelaceToAda(MIN_AP3X_DEPOSIT)} AP3X (returned on deregistration) + +[View on Explorer](${explorerTxLink(txHash)}) + +Save your Agent DID — you'll need it to update, deregister, or let other agents discover you.`, + }], + }; + } catch (err) { + return { + content: [{ + type: "text", + text: `Failed to register agent: ${err.message} + +**Troubleshooting Tips:** +1. Ensure wallet has at least 12 AP3X (10 deposit + fees) +2. Check spend limits with vector_get_spend_limits +3. Each wallet can register multiple agents (different seed UTxOs) +4. Verify Ogmios endpoint is reachable at ${VECTOR_OGMIOS_URL}`, + }], + }; + } + } + ); + + // vector_message_agent + server.tool( + "vector_message_agent", + "Send an on-chain message to a registered Vector agent via TX metadata (label 674). Delivers 2 ADA to the agent's owner address.", + { + agent_id: z.string().describe("Recipient agent DID: did:vector:agent:{policyId}:{nftAssetName}"), + message_type: z.enum(["inquiry", "proposal", "result"]).describe("Type of message"), + payload: z.string().max(512).describe("Message payload (string, max 512 chars)"), + }, + async ({ agent_id, message_type, payload }) => { + const rateCheck = rateLimiter.check(); + if (!rateCheck.allowed) { + return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] }; + } + 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 lucid = await initLucid(); + const senderAddress = await lucid.wallet.address(); + const recipientAddress = lucid.utils.credentialToAddress({ type: 'Key', hash: profile.ownerVkeyHash }); + const minAda = 2_000_000n; + const safetyCheck = safetyLayer.checkTransaction(Number(minAda)); + if (!safetyCheck.allowed) throw new Error(`Safety limit exceeded: ${safetyCheck.reason}`); + + const tx = await lucid.newTx() + .payToAddress(recipientAddress, { lovelace: minAda }) + .attachMetadata(AGENT_MESSAGE_LABEL, { msg: ['a2a'], from: senderAddress, to: agent_id, type: message_type, payload }) + .complete(); + const signedTx = await tx.sign().complete(); + const txHash = await signedTx.submit(); + safetyLayer.recordTransaction(txHash, Number(minAda), recipientAddress); + + return { + content: [{ + type: "text", + text: `# Message Sent + +**To:** ${profile.name} (${agent_id}) +**Type:** ${message_type} +**Payload:** ${payload} +**Recipient Address:** ${recipientAddress} +**Transaction:** ${txHash} + +[View on Explorer](${explorerTxLink(txHash)}) + +The message is recorded on-chain in TX metadata label 674.`, + }], + }; + } catch (err) { + return { + content: [{ + type: "text", + text: `Failed to send message: ${err.message} + +**Troubleshooting Tips:** +1. Verify the agent DID is correct: did:vector:agent:{policyId}:{nftAssetName} +2. Ensure wallet has at least 3 ADA (2 ADA delivery + fees) +3. Use vector_get_agent_profile to verify the agent exists first +4. Check spend limits with vector_get_spend_limits`, + }], + }; + } + } + ); +} diff --git a/src/vector/rate-limiter.ts b/src/vector/rate-limiter.ts new file mode 100644 index 0000000..7d32428 --- /dev/null +++ b/src/vector/rate-limiter.ts @@ -0,0 +1,27 @@ +// Sliding-window rate limiter for Vector MCP tools + +export class RateLimiter { + private calls: number[] = []; + private readonly windowMs: number; + private readonly maxCalls: number; + + constructor(perMinute = 60) { + this.windowMs = 60_000; + this.maxCalls = perMinute; + } + + check(): { allowed: boolean; retryAfterMs?: number } { + const now = Date.now(); + // Evict calls outside the window + this.calls = this.calls.filter(t => now - t < this.windowMs); + if (this.calls.length >= this.maxCalls) { + const oldest = this.calls[0]; + return { allowed: false, retryAfterMs: this.windowMs - (now - oldest) }; + } + this.calls.push(now); + return { allowed: true }; + } +} + +const RATE_LIMIT_PER_MINUTE = parseInt(process.env.VECTOR_RATE_LIMIT_PER_MINUTE || '60'); +export const rateLimiter = new RateLimiter(RATE_LIMIT_PER_MINUTE); diff --git a/src/vector/types.ts b/src/vector/types.ts index a36a05f..5879eba 100644 --- a/src/vector/types.ts +++ b/src/vector/types.ts @@ -130,3 +130,39 @@ export interface VectorInteractContractResult { explorer: string; }; } + +// --- Agent Network --- + +export interface AgentProfile { + agentId: string; // did:vector:agent:{policyId}:{nftAssetName} + name: string; + description: string; + capabilities: string[]; + framework: string; + endpoint: string; + registeredAt: number; // POSIX ms timestamp + utxoRef?: string; // tx_hash#index of registry UTxO + ownerVkeyHash?: string; // owner verification key hash (hex) +} + +export interface AgentRegistrationResult { + agentId: string; + nftAssetName: string; + txHash: string; + links: { explorer: string }; +} + +export interface AgentMessageResult { + txHash: string; + recipientAddress: string; + messageType: string; + links: { explorer: string }; +} + +// --- Unsigned TX (transaction-crafter mode) --- + +export interface UnsignedTxResult { + txCbor: string; + fee: string; + feeAda: string; +} diff --git a/src/vector/vector.ts b/src/vector/vector.ts index 2b8e792..d29fe16 100644 --- a/src/vector/vector.ts +++ b/src/vector/vector.ts @@ -8,6 +8,8 @@ import { fileURLToPath } from 'url'; import { existsSync } from 'fs'; import { OgmiosProvider } from './ogmios-provider.js'; import { safetyLayer } from './safety.js'; +import { rateLimiter } from './rate-limiter.js'; +import { registerAgentNetworkTools } from './agent-network.js'; import type { VectorToken, VectorWalletInfo, @@ -528,6 +530,10 @@ export function registerVectorTools(server: McpServer) { address: z.string().describe("Vector address to check (addr1...)"), }, async ({ address }) => { + const rateCheck = rateLimiter.check(); + if (!rateCheck.allowed) { + return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] }; + } try { const provider = new OgmiosProvider({ ogmiosUrl: VECTOR_OGMIOS_URL, @@ -596,6 +602,10 @@ ${tokens.length > 0 ? `Token Holdings (${tokens.length}):\n${tokenList}` : 'No t "Get the agent's Vector wallet address, balance, and token holdings", {}, async () => { + const rateCheck = rateLimiter.check(); + if (!rateCheck.allowed) { + return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] }; + } try { const walletInfo = await getWalletInfo(); @@ -641,6 +651,10 @@ ${walletInfo.tokens.length > 0 ? `## Token Holdings (${walletInfo.tokens.length} address: z.string().optional().describe("Vector address to query UTxOs for. If omitted, uses the agent's wallet."), }, async ({ address }) => { + const rateCheck = rateLimiter.check(); + if (!rateCheck.allowed) { + return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] }; + } try { let utxos; let queryAddress: string; @@ -656,7 +670,7 @@ ${walletInfo.tokens.length > 0 ? `## Token Holdings (${walletInfo.tokens.length} } else { const lucid = await initLucid(); queryAddress = await lucid.wallet.address(); - utxos = await lucid.wallet.getUtxos(); + utxos = await lucid.utxosAt(queryAddress); } if (utxos.length === 0) { @@ -696,20 +710,59 @@ ${utxoList}`, } ); - // vector_send_apex — Send APEX with safety limits + // vector_send_apex — Send APEX with safety limits (or craft unsigned TX) // @ts-ignore: MCP SDK deep type instantiation server.tool( "vector_send_apex", - "Send ADA from the agent's wallet to a recipient address (respects spend limits)", + "Send ADA from the agent's wallet to a recipient address. Set unsigned_only=true to return unsigned CBOR without submitting (transaction-crafter mode).", { recipientAddress: z.string().describe("Recipient Vector address (addr1...)"), amount: z.number().min(1).describe("Amount of ADA to send"), metadata: z.string().optional().describe("Optional transaction metadata in JSON format"), + unsigned_only: z.boolean().optional().default(false).describe("If true, return unsigned TX CBOR without signing or submitting"), }, - async ({ recipientAddress, amount, metadata }) => { + async ({ recipientAddress, amount, metadata, unsigned_only }) => { + const rateCheck = rateLimiter.check(); + if (!rateCheck.allowed) { + return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] }; + } try { - const result = await sendAda(recipientAddress, amount, metadata); + if (unsigned_only) { + const lovelaceAmount = BigInt(Math.floor(amount * 1_000_000)); + const lucid = await initLucid(); + // @ts-ignore + let txBuilder = lucid.newTx().payToAddress(recipientAddress, { lovelace: lovelaceAmount }); + if (metadata) { + const parsedMetadata = typeof metadata === 'string' ? JSON.parse(metadata) : metadata; + // @ts-ignore + txBuilder = txBuilder.attachMetadata(674, parsedMetadata); + } + // @ts-ignore + const tx = await txBuilder.complete(); + // @ts-ignore + const fee = tx.fee; + // @ts-ignore + const txCbor = tx.toString(); + return { + content: [{ + type: "text", + text: `# Unsigned Transaction (Transaction-Crafter Mode) +**Amount:** ${amount} ADA (${lovelaceAmount} lovelace) +**To:** ${recipientAddress} +**Estimated Fee:** ${lovelaceToAda(fee)} ADA + +**Unsigned TX CBOR:** +\`\`\` +${txCbor} +\`\`\` + +This transaction has NOT been submitted. Sign and submit it separately.`, + }], + }; + } + + const result = await sendAda(recipientAddress, amount, metadata); return { content: [{ type: "text", @@ -741,22 +794,65 @@ Amount: ${result.amount} ADA } ); - // vector_send_tokens — Send native tokens with safety limits + // vector_send_tokens — Send native tokens with safety limits (or craft unsigned TX) // @ts-ignore: MCP SDK deep type instantiation server.tool( "vector_send_tokens", - "Send Vector native tokens from the agent's wallet to a recipient address", + "Send Vector native tokens from the agent's wallet to a recipient address. Set unsigned_only=true to return unsigned CBOR without submitting.", { recipientAddress: z.string().describe("Recipient Vector address (addr1...)"), policyId: z.string().describe("Token policy ID"), assetName: z.string().describe("Asset name (can be empty for policy-only tokens)"), amount: z.string().describe("Amount of tokens to send"), adaAmount: z.number().optional().describe("Optional ADA to include (uses minimum required if not specified)"), + unsigned_only: z.boolean().optional().default(false).describe("If true, return unsigned TX CBOR without signing or submitting"), }, - async ({ recipientAddress, policyId, assetName, amount, adaAmount }) => { + async ({ recipientAddress, policyId, assetName, amount, adaAmount, unsigned_only }) => { + const rateCheck = rateLimiter.check(); + if (!rateCheck.allowed) { + return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] }; + } try { - const result = await sendTokens(recipientAddress, policyId, assetName, amount, adaAmount); + if (unsigned_only) { + const lucid = await initLucid(); + let assetNameHex = assetName; + if (assetName && !/^[0-9a-fA-F]+$/.test(assetName)) { + assetNameHex = fromText(assetName); + } + const unit = `${policyId}${assetNameHex}`; + const outputLovelace = adaAmount + ? BigInt(Math.floor(adaAmount * 1_000_000)) + : BigInt(2_000_000); + // @ts-ignore + const tx = await lucid.newTx() + .payToAddress(recipientAddress, { lovelace: outputLovelace, [unit]: BigInt(amount) }) + .complete(); + // @ts-ignore + const fee = tx.fee; + // @ts-ignore + const txCbor = tx.toString(); + return { + content: [{ + type: "text", + text: `# Unsigned Token Transaction (Transaction-Crafter Mode) +**Token:** ${formatAssetName(assetNameHex) || policyId.substring(0,8) + '...'} +**Amount:** ${amount} +**To:** ${recipientAddress} +**Included ADA:** ${lovelaceToAda(outputLovelace)} ADA +**Estimated Fee:** ${lovelaceToAda(fee)} ADA + +**Unsigned TX CBOR:** +\`\`\` +${txCbor} +\`\`\` + +This transaction has NOT been submitted. Sign and submit it separately.`, + }], + }; + } + + const result = await sendTokens(recipientAddress, policyId, assetName, amount, adaAmount); return { content: [{ type: "text", @@ -801,6 +897,10 @@ Included ADA: ${result.ada} ADA "Check current spend limits and remaining daily budget", {}, async () => { + const rateCheck = rateLimiter.check(); + if (!rateCheck.allowed) { + return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] }; + } try { const status = safetyLayer.getSpendStatus(); const log = safetyLayer.getAuditLog(); @@ -850,6 +950,10 @@ ${log.length > 0 ? `## Recent Transactions (last ${Math.min(5, log.length)}):\n$ submit: z.boolean().optional().describe("If true, sign and submit the transaction. If false/omitted, return unsigned CBOR for review."), }, async ({ outputs, metadata, submit }) => { + const rateCheck = rateLimiter.check(); + if (!rateCheck.allowed) { + return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] }; + } try { const result = await buildTransaction(outputs, metadata, submit); @@ -916,6 +1020,10 @@ Use vector_dry_run with this CBOR to simulate, or call vector_build_transaction metadata: z.string().optional().describe("Optional JSON metadata when building from outputs"), }, async ({ txCbor, outputs, metadata }) => { + const rateCheck = rateLimiter.check(); + if (!rateCheck.allowed) { + return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] }; + } try { let cborToEvaluate = txCbor; let feeFromBuild: string | null = null; @@ -1039,6 +1147,10 @@ No transaction was submitted to the network.`, offset: z.number().min(0).optional().describe("Offset for pagination (default: 0)"), }, async ({ address, limit, offset }) => { + const rateCheck = rateLimiter.check(); + if (!rateCheck.allowed) { + return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] }; + } try { let queryAddress = address; if (!queryAddress) { @@ -1109,6 +1221,10 @@ ${txList} lovelaceAmount: z.number().optional().describe("ADA to lock at the script address in lovelace (default: 2,000,000 = 2 ADA)"), }, async ({ scriptCbor, scriptType, initialDatum, lovelaceAmount }) => { + const rateCheck = rateLimiter.check(); + if (!rateCheck.allowed) { + return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] }; + } try { const result = await deployContract( scriptCbor, @@ -1169,6 +1285,10 @@ Funds locked at the script address. Use vector_interact_contract to interact wit assets: z.record(z.string()).optional().describe("Additional native assets for lock action: { 'policyId+assetNameHex': 'quantity' }"), }, async ({ scriptCbor, scriptType, action, redeemer, datum, lovelaceAmount, utxoRef, assets }) => { + const rateCheck = rateLimiter.check(); + if (!rateCheck.allowed) { + return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] }; + } try { const result = await interactWithContract( scriptCbor, @@ -1215,4 +1335,8 @@ Funds ${actionVerb} the script address. } } ); + + // Agent network tools (register, discover, message, profile) live in agent-network.ts + // to isolate C (Cardano WASM) imports from tsc's complex type inference + registerAgentNetworkTools(server); }