Add on-chain agent registry tools and rate limiter
This commit is contained in:
376
src/vector/agent-network.ts
Normal file
376
src/vector/agent-network.ts
Normal file
@@ -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`,
|
||||
}],
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
27
src/vector/rate-limiter.ts
Normal file
27
src/vector/rate-limiter.ts
Normal file
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user