feat(signer): MCP server with get_address, decode, sign, get_spend_limits
stdio transport only - no HTTP listener, no Provider, no outbound network.
Wires together decode.ts/policy.ts/sign.ts/audit.ts/keysource.ts (Tasks 2-6)
behind four tools: vector_signer_get_address, vector_signer_decode_transaction,
vector_signer_sign, vector_signer_get_spend_limits.
Two corrections needed beyond the brief's given code to reach its own stated
"pass 9, fail 0" outcome, both verified necessary by reproducing the failure
first:
- packages/signer/tsup.config.ts: target bumped es2020 -> es2022. index.ts's
top-level `await server.connect(...)` is ES2022 syntax; esbuild hard-errors
("Top-level await is not available in the configured target environment")
under the package's original es2020 target rather than attempting an unsafe
downlevel transform. Scoped to the signer package only - the builder never
uses top-level await and its own tsup.config.ts is untouched.
- tools.ts's decode_transaction response text: "(not signed)" and "would be
signed" both contain the substring "signed", which collides with this same
smoke test's own `!/signed/i.test(text)` check on every call, including the
refused case. Reworded to "(read-only)" / "would be ALLOWED" - same meaning,
no collision. Fixed the response text rather than loosening the test, which
stays exactly as given.
Brief's own smoke test (9 tests) passes verbatim. Workspace-wide: typecheck 0,
test:unit 180/180 (no regressions), builder test:smoke still 26/23/0.
The audit-append-failure handling required by the task (a produced signature
must never be released if its audit record fails to write) is not yet in this
commit - see the follow-up commit.
This commit is contained in:
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@@ -43,6 +43,11 @@ jobs:
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=12288
|
||||
|
||||
- name: Signer smoke test
|
||||
run: npm run test:smoke:signer
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=12288
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"test": "npm run test:unit",
|
||||
"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:smoke:signer": "npm run build && node --import tsx --test packages/signer/test/smoke/*.test.ts",
|
||||
"test:integration": "npm run build && node --import tsx --test packages/builder/test/integration/*.test.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
63
packages/signer/src/config.ts
Normal file
63
packages/signer/src/config.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// Signer configuration, entirely from the environment. No network settings
|
||||
// exist here by design — the signer has no endpoints to configure.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { MnemonicKeySource, PrivateKeyKeySource, type KeySource } from './keysource.js';
|
||||
import type { SpendLimits } from './policy.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export interface SignerConfig {
|
||||
keySource: KeySource;
|
||||
limits: SpendLimits;
|
||||
auditLogPath: string;
|
||||
}
|
||||
|
||||
function repoRootDefault(): string {
|
||||
// From packages/signer/build/index.js, three levels up is the repo root.
|
||||
return resolve(__dirname, '../../..');
|
||||
}
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): SignerConfig {
|
||||
const keySource = resolveKeySource(env);
|
||||
|
||||
return {
|
||||
keySource,
|
||||
limits: {
|
||||
perTxLovelace: BigInt(env.VECTOR_SIGNER_SPEND_LIMIT_PER_TX ?? '100000000'),
|
||||
dailyLovelace: BigInt(env.VECTOR_SIGNER_SPEND_LIMIT_DAILY ?? '500000000'),
|
||||
},
|
||||
auditLogPath: env.VECTOR_SIGNER_AUDIT_LOG_PATH
|
||||
? resolve(env.VECTOR_SIGNER_AUDIT_LOG_PATH)
|
||||
: resolve(repoRootDefault(), 'vector-signer-audit-log.json'),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveKeySource(env: NodeJS.ProcessEnv): KeySource {
|
||||
const accountIndex = Number(env.VECTOR_SIGNER_ACCOUNT_INDEX ?? '0');
|
||||
|
||||
if (env.VECTOR_SIGNER_PRIVATE_KEY && env.VECTOR_SIGNER_ADDRESS) {
|
||||
return new PrivateKeyKeySource(env.VECTOR_SIGNER_PRIVATE_KEY, env.VECTOR_SIGNER_ADDRESS);
|
||||
}
|
||||
if (env.VECTOR_SIGNER_MNEMONIC) {
|
||||
return new MnemonicKeySource(env.VECTOR_SIGNER_MNEMONIC, accountIndex);
|
||||
}
|
||||
if (env.VECTOR_SIGNER_MNEMONIC_FILE) {
|
||||
let contents: string;
|
||||
try {
|
||||
contents = readFileSync(env.VECTOR_SIGNER_MNEMONIC_FILE, 'utf8');
|
||||
} catch (err) {
|
||||
// Reference the source, never the contents.
|
||||
throw new Error(
|
||||
`Could not read VECTOR_SIGNER_MNEMONIC_FILE (${env.VECTOR_SIGNER_MNEMONIC_FILE}): ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
return new MnemonicKeySource(contents, accountIndex);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'No key material configured. Set one of: VECTOR_SIGNER_MNEMONIC_FILE (recommended), ' +
|
||||
'VECTOR_SIGNER_MNEMONIC, or VECTOR_SIGNER_PRIVATE_KEY together with VECTOR_SIGNER_ADDRESS.'
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,19 @@
|
||||
// Local signing companion for the Vector MCP server.
|
||||
// Speaks stdio only: no HTTP listener, no outbound network. See test/boundary.test.ts.
|
||||
console.error('vector-mcp-signer: not yet implemented');
|
||||
//
|
||||
// Speaks stdio only: no HTTP listener, no outbound network, no Provider.
|
||||
// The signing key never leaves this process. See test/boundary.test.ts.
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { loadConfig } from './config.js';
|
||||
import { registerSignerTools } from './tools.js';
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
const server = new McpServer({ name: 'vector-mcp-signer', version: '0.1.0' });
|
||||
registerSignerTools(server, config);
|
||||
|
||||
// stdout is the MCP channel — every diagnostic must go to stderr.
|
||||
console.error(`vector-mcp-signer ready. Key source: ${config.keySource.describe()}`);
|
||||
console.error(`Audit log: ${config.auditLogPath}`);
|
||||
|
||||
await server.connect(new StdioServerTransport());
|
||||
|
||||
139
packages/signer/src/tools.ts
Normal file
139
packages/signer/src/tools.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { z } from 'zod/v4';
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { lovelaceToAda } from '@apexfusion/vector-mcp-shared/tx';
|
||||
import { decodeTransaction } from './decode.js';
|
||||
import { evaluate } from './policy.js';
|
||||
import { signTransaction } from './sign.js';
|
||||
import { AuditLog } from './audit.js';
|
||||
import type { SignerConfig } from './config.js';
|
||||
|
||||
const txCborShape = { txCbor: z.string().describe('Hex-encoded CBOR of an unsigned transaction') };
|
||||
|
||||
function text(body: string) {
|
||||
return { content: [{ type: 'text' as const, text: body }] };
|
||||
}
|
||||
|
||||
export function registerSignerTools(server: McpServer, config: SignerConfig): void {
|
||||
const identity = config.keySource.load();
|
||||
const audit = new AuditLog(config.auditLogPath);
|
||||
const ownAddresses = [identity.address];
|
||||
|
||||
server.tool(
|
||||
'vector_signer_get_address',
|
||||
'Get this signer\'s wallet address. Pass it to the builder as changeAddress when building a transaction.',
|
||||
{},
|
||||
async () => text(`Signer address: ${identity.address}\nKey source: ${config.keySource.describe()}`)
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'vector_signer_decode_transaction',
|
||||
'Decode an unsigned transaction and report what it would move, without signing it. Use this to show a human what is about to be signed.',
|
||||
txCborShape,
|
||||
async ({ txCbor }) => {
|
||||
try {
|
||||
const tx = decodeTransaction(txCbor);
|
||||
const decision = evaluate(tx, ownAddresses, config.limits, audit.committedTodayLovelace());
|
||||
const rows = tx.outputs
|
||||
.map((o, i) => {
|
||||
const mine = o.address === identity.address;
|
||||
const assets = o.assets.length ? ` + ${o.assets.length} asset(s)` : '';
|
||||
return ` ${i}. ${mine ? '[change] ' : '[outgoing]'} ${lovelaceToAda(o.lovelace)} AP3X${assets}\n ${o.address}`;
|
||||
})
|
||||
.join('\n');
|
||||
return text(
|
||||
// "(read-only)", not "(not signed)": the latter still contains the
|
||||
// substring "signed", which collides with this smoke test's own
|
||||
// `!/signed/i.test(text)` check (verified empirically — see the
|
||||
// task report). "would be ALLOWED" below is the same fix applied
|
||||
// to the policy line, which otherwise would have read "would be
|
||||
// signed" whenever a decoded transaction happened to be in-policy.
|
||||
`# Transaction preview (read-only)\n\n` +
|
||||
`Hash: ${tx.txHashHex}\nInputs: ${tx.inputCount}\nFee: ${lovelaceToAda(tx.fee)} AP3X\n` +
|
||||
`Minted assets: ${tx.mintedAssetCount}\n\nOutputs:\n${rows || ' (none)'}\n\n` +
|
||||
`**Net outflow: ${lovelaceToAda(decision.netOutflowLovelace)} AP3X** (outgoing outputs + fee; change excluded)\n` +
|
||||
`Assets leaving: ${decision.assetMovements.length}\n\n` +
|
||||
`Policy: ${decision.allowed ? 'would be ALLOWED' : `would be REFUSED - ${decision.reason}`}`
|
||||
);
|
||||
} catch (err) {
|
||||
return text(`Could not decode the transaction: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'vector_signer_sign',
|
||||
'Decode, policy-check, and sign an unsigned transaction. Returns signed CBOR to submit via the builder. Refuses if the transaction violates spend policy.',
|
||||
txCborShape,
|
||||
async ({ txCbor }) => {
|
||||
let tx;
|
||||
try {
|
||||
tx = decodeTransaction(txCbor);
|
||||
} catch (err) {
|
||||
// Fail closed: never sign bytes that were not fully understood.
|
||||
return text(`REFUSED. ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
const decision = evaluate(tx, ownAddresses, config.limits, audit.committedTodayLovelace());
|
||||
|
||||
if (!decision.allowed) {
|
||||
audit.append({
|
||||
timestamp: new Date().toISOString(),
|
||||
txHash: tx.txHashHex,
|
||||
decision: 'refused',
|
||||
netOutflowLovelace: decision.netOutflowLovelace.toString(),
|
||||
reason: decision.reason,
|
||||
assetMovements: decision.assetMovements.length,
|
||||
});
|
||||
return text(`REFUSED. ${decision.reason}`);
|
||||
}
|
||||
|
||||
let signed;
|
||||
try {
|
||||
signed = signTransaction(txCbor, identity.privateKeyBech32);
|
||||
} catch (err) {
|
||||
return text(`REFUSED. Signing failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
audit.append({
|
||||
timestamp: new Date().toISOString(),
|
||||
txHash: signed.txHashHex,
|
||||
decision: 'signed',
|
||||
netOutflowLovelace: decision.netOutflowLovelace.toString(),
|
||||
assetMovements: decision.assetMovements.length,
|
||||
});
|
||||
|
||||
return text(
|
||||
`# Signed\n\nHash: ${signed.txHashHex}\n` +
|
||||
`Net outflow: ${lovelaceToAda(decision.netOutflowLovelace)} AP3X\n\n` +
|
||||
`Signed CBOR:\n\`\`\`\n${signed.signedCborHex}\n\`\`\`\n\n` +
|
||||
`Submit it with the builder's vector_submit_transaction. Nothing has been submitted yet.`
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'vector_signer_get_spend_limits',
|
||||
'Report this signer\'s spend limits, how much of today\'s budget is committed, and recent signing decisions.',
|
||||
{},
|
||||
async () => {
|
||||
const committed = audit.committedTodayLovelace();
|
||||
const remaining = config.limits.dailyLovelace - committed;
|
||||
const recent = audit
|
||||
.recent(5)
|
||||
.map((e) => ` ${e.timestamp} ${e.decision.toUpperCase()} ${lovelaceToAda(BigInt(e.netOutflowLovelace))} AP3X ${e.txHash.slice(0, 16)}...${e.reason ? ` (${e.reason})` : ''}`)
|
||||
.join('\n');
|
||||
return text(
|
||||
`# Signer spend limits\n\n` +
|
||||
`Per transaction: ${lovelaceToAda(config.limits.perTxLovelace)} AP3X\n` +
|
||||
`Daily: ${lovelaceToAda(config.limits.dailyLovelace)} AP3X\n` +
|
||||
`Committed today: ${lovelaceToAda(committed)} AP3X\n` +
|
||||
`Remaining today: ${lovelaceToAda(remaining > 0n ? remaining : 0n)} AP3X\n\n` +
|
||||
`Audit log: ${config.auditLogPath}\n\n` +
|
||||
(recent ? `Recent decisions:\n${recent}` : 'No decisions recorded yet.') +
|
||||
`\n\nNote: the daily total counts signed transactions at signing time. The signer has no ` +
|
||||
`network access and cannot observe whether a submit later succeeded, so a failed submit ` +
|
||||
`still counts against the budget.`
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
114
packages/signer/test/smoke/signer-tools.test.ts
Normal file
114
packages/signer/test/smoke/signer-tools.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, test, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
|
||||
const TEST_MNEMONIC =
|
||||
'test walk nut penalty hip pave soap entry language right filter choice';
|
||||
|
||||
const FIXTURE_CBOR =
|
||||
'84a300d9010281825820e17a2ebab8eae3850f959290041b3bef3f3597584f8cf4a728e14777dddb8c3200018282581d71ab1aad52c4774e5da9f2c0fa1a4d07220a0bdd57ee3dce9be860dac61a002dc6c0825839013fd872a48d71872cf76528bb41dd2a5775e2c532c21662a47c6cebf44b6be5f33456f28523ed04d1aaad158541176425378d8ed8fce53f881a3b6a7221021a0002911fa0f5f6';
|
||||
|
||||
const EXPECTED_TOOLS = [
|
||||
'vector_signer_decode_transaction',
|
||||
'vector_signer_get_address',
|
||||
'vector_signer_get_spend_limits',
|
||||
'vector_signer_sign',
|
||||
];
|
||||
|
||||
let client: Client;
|
||||
let dir: string;
|
||||
|
||||
async function callText(name: string, args: Record<string, unknown> = {}): Promise<string> {
|
||||
const r = await client.callTool({ name, arguments: args });
|
||||
const c = r.content as Array<{ type: string; text?: string }>;
|
||||
return c?.[0]?.text ?? '';
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'signer-smoke-'));
|
||||
const mnemonicFile = join(dir, 'mnemonic.txt');
|
||||
writeFileSync(mnemonicFile, TEST_MNEMONIC, { mode: 0o600 });
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command: 'node',
|
||||
args: ['packages/signer/build/index.js'],
|
||||
env: {
|
||||
...process.env,
|
||||
VECTOR_SIGNER_MNEMONIC_FILE: mnemonicFile,
|
||||
VECTOR_SIGNER_AUDIT_LOG_PATH: join(dir, 'audit.json'),
|
||||
VECTOR_SIGNER_SPEND_LIMIT_PER_TX: '100000000',
|
||||
VECTOR_SIGNER_SPEND_LIMIT_DAILY: '500000000',
|
||||
},
|
||||
});
|
||||
client = new Client({ name: 'signer-smoke', version: '1.0.0' });
|
||||
await client.connect(transport);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
try { await client.close(); } catch {}
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('signer MCP server over stdio', () => {
|
||||
test('exposes exactly the four expected tools', async () => {
|
||||
const { tools } = await client.listTools();
|
||||
assert.deepStrictEqual(tools.map((t) => t.name).sort(), EXPECTED_TOOLS);
|
||||
});
|
||||
|
||||
test('no tool accepts a mnemonic or private key parameter', async () => {
|
||||
const { tools } = await client.listTools();
|
||||
for (const t of tools) {
|
||||
const props = Object.keys((t.inputSchema as any).properties ?? {});
|
||||
for (const p of props) {
|
||||
assert.ok(!/mnemonic|private_?key|seed/i.test(p), `${t.name} exposes key material as parameter "${p}"`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('get_address returns the derived addr1 address', async () => {
|
||||
assert.match(await callText('vector_signer_get_address'), /addr1[0-9a-z]+/);
|
||||
});
|
||||
|
||||
test('decode_transaction reports the net outflow without signing', async () => {
|
||||
const text = await callText('vector_signer_decode_transaction', { txCbor: FIXTURE_CBOR });
|
||||
assert.match(text, /Net outflow/i);
|
||||
assert.ok(!/signed/i.test(text), 'decode must not sign');
|
||||
});
|
||||
|
||||
// The fixture was built for a DIFFERENT wallet, so for this test key both of
|
||||
// its outputs are foreign: net outflow is ~1000 AP3X, far over the 100 AP3X
|
||||
// per-transaction limit. Refusal is the correct outcome and the security-
|
||||
// relevant path, so assert it precisely rather than settling for "responded".
|
||||
test('sign refuses a transaction whose net outflow exceeds the per-transaction limit', async () => {
|
||||
const text = await callText('vector_signer_sign', { txCbor: FIXTURE_CBOR });
|
||||
assert.match(text, /REFUSED/);
|
||||
assert.match(text, /per-transaction limit/i);
|
||||
});
|
||||
|
||||
test('a refusal returns no signed CBOR', async () => {
|
||||
const text = await callText('vector_signer_sign', { txCbor: FIXTURE_CBOR });
|
||||
assert.ok(!/Signed CBOR/i.test(text), 'a refused transaction must not return signed bytes');
|
||||
});
|
||||
|
||||
test('a refusal is recorded in the audit log but consumes no daily budget', async () => {
|
||||
await callText('vector_signer_sign', { txCbor: FIXTURE_CBOR });
|
||||
const limits = await callText('vector_signer_get_spend_limits');
|
||||
assert.match(limits, /REFUSED/, 'the refusal should appear in recent decisions');
|
||||
assert.match(limits, /Committed today:\s*0\.000000 AP3X/, 'a refusal must not consume budget');
|
||||
});
|
||||
|
||||
test('get_spend_limits reports the configured limits', async () => {
|
||||
const text = await callText('vector_signer_get_spend_limits');
|
||||
assert.match(text, /100\.000000/);
|
||||
assert.match(text, /500\.000000/);
|
||||
});
|
||||
|
||||
test('sign rejects malformed CBOR', async () => {
|
||||
const text = await callText('vector_signer_sign', { txCbor: 'not-hex' });
|
||||
assert.match(text, /could not be decoded|refus/i);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,16 @@ import { defineConfig } from "tsup";
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["esm"],
|
||||
target: "es2020",
|
||||
// es2022, not es2020 (Task 1's original value): index.ts uses a top-level
|
||||
// `await server.connect(...)` (brief's own given code, Task 7), and
|
||||
// top-level await is an ES2022 language feature — esbuild hard-errors
|
||||
// ("Top-level await is not available in the configured target environment")
|
||||
// rather than attempting an unsafe downlevel transform, unlike most other
|
||||
// modern syntax. Node 22 (this package's engines.node floor) natively
|
||||
// supports it. Scoped to the signer package only; the builder's own
|
||||
// tsup.config.ts is untouched because vector.ts/index.ts never uses
|
||||
// top-level await and has no need for the bump.
|
||||
target: "es2022",
|
||||
outDir: "build",
|
||||
clean: true,
|
||||
splitting: false,
|
||||
|
||||
Reference in New Issue
Block a user