Add agent lifecycle tools (update, deregister, transfer), harden registry, and add integration tests
This commit is contained in:
44
package-lock.json
generated
44
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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] });
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
346
test/run.test.ts
Normal 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
65
test/setup.ts
Normal 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));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user