diff --git a/package.json b/package.json index cfa4212..821206d 100644 --- a/package.json +++ b/package.json @@ -1,65 +1,36 @@ { - "name": "@apexfusion/mcp-server", + "name": "@apexfusion/mcp-server-monorepo", "version": "0.1.0", - "main": "index.js", + "private": true, "type": "module", - "bin": { - "mcp-server": "./build/index.js" - }, + "workspaces": ["packages/*"], "scripts": { "postinstall": "ln -sf ../../../libsodium-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs node_modules/libsodium-wrappers-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs 2>/dev/null || true", - "clean": "rm -rf build", - "build": "tsup && chmod +x build/index.js", - "typecheck": "tsc --noEmit --incremental", - "start": "node build/index.js", + "clean": "rm -rf packages/*/build", + "build": "npm run build --workspace=@apexfusion/vector-mcp-builder", + "typecheck": "tsc --noEmit -p packages/shared && tsc --noEmit -p packages/builder", + "start": "node packages/builder/build/index.js", "test": "npm run test:unit", - "test:unit": "node --import tsx --test test/unit/*.test.ts", - "test:integration": "npm run build && node --import tsx --test test/integration/*.test.ts", - "test:smoke": "npm run build && node --import tsx --test test/smoke/*.test.ts" - }, - "files": [ - "build" - ], - "keywords": [ - "vector", - "mcp", - "blockchain", - "cardano", - "apex-fusion", - "ai-agent", - "utxo" - ], - "author": "Apex Fusion", - "license": "ISC", - "description": "MCP server for Vector - AI agent interface for the Apex Fusion eUTXO L2", - "repository": { - "type": "git", - "url": "https://github.com/Apex-Fusion/mcp-server.git" - }, - "homepage": "https://apex-fusion.github.io/vector-ai-documentation/", - "bugs": { - "url": "https://github.com/Apex-Fusion/mcp-server/issues" - }, - "engines": { - "node": ">=14.0.0" - }, - "private": false, - "publishConfig": { - "registry": "https://registry.npmjs.org/" - }, - "dependencies": { - "@aws-sdk/client-s3": "^3.1027.0", - "@lucid-evolution/lucid": "^0.4.29", - "@modelcontextprotocol/sdk": "^1.4.1", - "@noble/hashes": "^1.7.1", - "cross-fetch": "^4.0.0", - "dotenv": "^16.4.5", - "zod": "^3.24.1" + "test:unit": "node --import tsx --test packages/*/test/*.test.ts", + "test:smoke": "npm run build && node --import tsx --test packages/builder/test/smoke/*.test.ts", + "test:integration": "npm run build && node --import tsx --test packages/builder/test/integration/*.test.ts" }, "devDependencies": { "@types/node": "^22.13.1", "tsup": "^8.5.1", "tsx": "^4.21.0", "typescript": "^5.7.3" + }, + "engines": { + "node": ">=22.0.0" + }, + "license": "ISC", + "author": "Apex Fusion", + "repository": { + "type": "git", + "url": "https://github.com/Apex-Fusion/mcp-server.git" + }, + "bugs": { + "url": "https://github.com/Apex-Fusion/mcp-server/issues" } } diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..7ba6eaf --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,17 @@ +{ + "name": "@apexfusion/vector-mcp-shared", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + "./tx": "./src/tx.ts", + "./types": "./src/types.ts", + "./config": "./src/config.ts", + "./provider": "./src/provider.ts" + }, + "dependencies": { + "@lucid-evolution/lucid": "^0.4.29", + "@noble/hashes": "^1.7.1", + "cross-fetch": "^4.0.0" + } +} diff --git a/packages/shared/src/config.ts b/packages/shared/src/config.ts new file mode 100644 index 0000000..7ccd5fe --- /dev/null +++ b/packages/shared/src/config.ts @@ -0,0 +1,9 @@ +// Vector network endpoints. Defaults target testnet; override via environment. +export const VECTOR_OGMIOS_URL = process.env.VECTOR_OGMIOS_URL || 'https://ogmios.vector.testnet.apexfusion.org'; +export const VECTOR_SUBMIT_URL = process.env.VECTOR_SUBMIT_URL || 'https://submit.vector.testnet.apexfusion.org/api/submit/tx'; +export const VECTOR_KOIOS_URL = process.env.VECTOR_KOIOS_URL || 'https://koios.vector.testnet.apexfusion.org/'; +export const VECTOR_EXPLORER_URL = process.env.VECTOR_EXPLORER_URL || 'https://vector.testnet.apexscan.org'; + +export function explorerTxLink(txHash: string): string { + return `${VECTOR_EXPLORER_URL}/transaction/${txHash}`; +} diff --git a/src/vector/ogmios-provider.ts b/packages/shared/src/provider.ts similarity index 100% rename from src/vector/ogmios-provider.ts rename to packages/shared/src/provider.ts diff --git a/packages/shared/src/tx.ts b/packages/shared/src/tx.ts new file mode 100644 index 0000000..4eeb82d --- /dev/null +++ b/packages/shared/src/tx.ts @@ -0,0 +1,71 @@ +// Pure transaction helpers. No network access — this module is safe to import +// from the signer, which must have no network capability. +import { blake2b } from '@noble/hashes/blake2b'; + +export function lovelaceToAda(lovelace: string | number | bigint): string { + if (lovelace === undefined || lovelace === null) return '0.000000'; + try { + return (Number(BigInt(String(lovelace))) / 1_000_000).toFixed(6); + } catch { + return '0.000000'; + } +} + +export function formatAssetName(name: string): string { + try { + if (/^[0-9a-fA-F]+$/.test(name) && name.length > 0) { + return Buffer.from(name, 'hex').toString('utf8'); + } + return name; + } catch { + return name; + } +} + +// Cardano metadata strings must be <= 64 bytes. Chunk long strings into arrays. +export function metadataStr(s: string): string | string[] { + if (s.length <= 64) return s; + const chunks: string[] = []; + for (let i = 0; i < s.length; i += 64) { + chunks.push(s.slice(i, i + 64)); + } + return chunks; +} + +export function cborUint(n: bigint): Buffer { + if (n < 0n) throw new Error('output_index must be non-negative'); + if (n < 24n) return Buffer.from([Number(n)]); + if (n < 0x100n) return Buffer.from([0x18, Number(n)]); + if (n < 0x10000n) return Buffer.from([0x19, Number(n >> 8n) & 0xff, Number(n) & 0xff]); + if (n < 0x100000000n) { + const b = Buffer.alloc(5); b[0] = 0x1a; b.writeUInt32BE(Number(n), 1); return b; + } + const b = Buffer.alloc(9); b[0] = 0x1b; b.writeBigUInt64BE(n, 1); return b; +} + +export function cborBytes(hex: string): Buffer { + const raw = Buffer.from(hex, 'hex'); + const len = raw.length; + let header: Buffer; + if (len < 24) header = Buffer.from([0x40 | len]); + else if (len < 0x100) header = Buffer.from([0x58, len]); + else if (len < 0x10000) { header = Buffer.alloc(3); header[0] = 0x59; header.writeUInt16BE(len, 1); } + else { header = Buffer.alloc(5); header[0] = 0x5a; header.writeUInt32BE(len, 1); } + return Buffer.concat([header, raw]); +} + +// NFT asset name = blake2b_256(CBOR(OutputReference)). +// agent-registry v2 (Conway) requires INDEFINITE-length CBOR for the constructor +// field array (D8 79 9F … FF), matching Aiken's cbor.serialise. Lucid's Data.to +// emits definite-length arrays (D8 79 82 …) which v2 rejects, so the outer +// constructor is hand-rolled here. +export function deriveNftAssetName(txHash: string, outputIndex: number): string { + const outRefCbor = Buffer.concat([ + Buffer.from([0xd8, 0x79, 0x9f]), + cborBytes(txHash), + cborUint(BigInt(outputIndex)), + Buffer.from([0xff]), + ]); + const hashBytes = blake2b(outRefCbor, { dkLen: 32 }); + return Buffer.from(hashBytes).toString('hex'); +} diff --git a/src/vector/types.ts b/packages/shared/src/types.ts similarity index 100% rename from src/vector/types.ts rename to packages/shared/src/types.ts diff --git a/packages/shared/test/tx.test.ts b/packages/shared/test/tx.test.ts new file mode 100644 index 0000000..fe61fff --- /dev/null +++ b/packages/shared/test/tx.test.ts @@ -0,0 +1,41 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { deriveNftAssetName, lovelaceToAda, formatAssetName, metadataStr } from '../src/tx.ts'; + +const VECTORS: Array<{ tx: string; idx: number; expected: string }> = [ + { tx: '00'.repeat(32), idx: 0, expected: 'a2e5e227858e84f1a8f9b0c1246e6cbc9336d707d43ba43d0e1cb7c51c45f4c9' }, + { tx: '2703182e4d1151a32b7bcfc2362c91278bcac66a49a840d217ae5cb49f8b3649', idx: 0, expected: '24ed834d88890ce35e81a6b9c4f988fad3d8a0284db6f3e641416f44f5e95d75' }, + { tx: 'ff'.repeat(32), idx: 23, expected: 'df229a221c5a84f48c06d5b28a98deb6ea678c369ab166400e88dd3d2bdc7b9f' }, + { tx: 'ab'.repeat(32), idx: 24, expected: '3ddd07517205dd8a745887c33a09da0e4a5d63dca1f151ceeedaeeda0551c52b' }, + { tx: '01'.repeat(32), idx: 256, expected: 'e626a305779e4c1e74b7719d302d0fbc1b722ab33bd52da4c23e6b8cd1bd04ec' }, +]; + +describe('deriveNftAssetName — Conway indefinite-CBOR parity', () => { + for (const { tx, idx, expected } of VECTORS) { + test(`tx=${tx.slice(0, 8)}… idx=${idx}`, () => { + assert.equal(deriveNftAssetName(tx, idx), expected); + }); + } +}); + +describe('lovelaceToAda', () => { + test('converts whole ada', () => assert.equal(lovelaceToAda(2_000_000n), '2.000000')); + test('converts fractional ada', () => assert.equal(lovelaceToAda('1234567'), '1.234567')); + test('returns zero for null', () => assert.equal(lovelaceToAda(null as unknown as bigint), '0.000000')); + test('returns zero for garbage', () => assert.equal(lovelaceToAda('not-a-number'), '0.000000')); +}); + +describe('formatAssetName', () => { + test('decodes hex to utf8', () => assert.equal(formatAssetName('74657374'), 'test')); + test('passes through non-hex unchanged', () => assert.equal(formatAssetName('plain'), 'plain')); + test('passes through empty string', () => assert.equal(formatAssetName(''), '')); +}); + +describe('metadataStr', () => { + test('leaves short strings intact', () => assert.equal(metadataStr('short'), 'short')); + test('chunks strings longer than 64 bytes', () => { + const out = metadataStr('a'.repeat(150)); + assert.ok(Array.isArray(out)); + assert.deepEqual((out as string[]).map(s => s.length), [64, 64, 22]); + }); +}); diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..d9485a5 --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es2020", + "module": "es2020", + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "resolveJsonModule": true + }, + "include": ["src/**/*", "test/**/*"] +}