diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..157d260 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# CommandLayer Runtime URL (default: https://runtime.commandlayer.org) +# COMMANDLAYER_RUNTIME_URL=https://runtime.commandlayer.org + +# Override the verify path on the runtime (default: /verify) +# Only change this if routing through a proxy that remaps the path. +# COMMANDLAYER_VERIFY_PATH=/verify + +# Ethereum RPC URL for ENS resolution (default: https://eth.llamarpc.com) +# ETHEREUM_RPC_URL=https://eth.llamarpc.com + +# HTTP port for the MCP server (default: 3000) +# PORT=3000 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..689bf4b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + - run: npm ci + - run: npm test diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2a5c34b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [1.1.0] - 2026-05-12 + +### Breaking Changes + +- `verify_receipt` now validates receipt structure via Zod before proxying to the runtime. Receipts using pre-v1.1.0 field names (`signature_alg`, `key_id`, `signer`, `canonicalization`) are rejected at the MCP layer with a structured error. +- Default verify path corrected from `/api/verify` to `/verify` — this matches the runtime server's actual endpoint. Override with `COMMANDLAYER_VERIFY_PATH` if needed. + +### Added + +- **`get_protocol_version` tool** — returns the protocol version, signing specification, canonicalization method, proof field names, and schema host URL. +- **`validate_receipt_schema` tool** — validates a receipt's structure against the v1.1.0 schema without performing cryptographic verification. Returns structured field-level errors. Intended for development and debugging. +- `src/lib/receiptSchema.js` — shared Zod schema for the v1.1.0 receipt format, imported by both `verify_receipt` input validation and `validate_receipt_schema`. +- `test/tools.test.js` — unit tests for all tool handlers (no network required). +- `.env.example` documenting all environment variables. +- `SECURITY.md` documenting the security model and known limitations. +- CI workflow (`.github/workflows/ci.yml`) running `npm test` on every push and PR. + +### Changed + +- Tool definitions are now declared at module level so Zod schemas and handler references are evaluated once at startup rather than on every request. +- `src/lib/receiptVerifier.js` — dead code removed. This file implemented a 4th incompatible receipt format (`receipt.payload / .signer / .hash / .signature`) that was never called by any tool. +- `src/lib/canonicalize.js` — removed (was only imported by the now-dead `receiptVerifier.js`). Will be deleted once `@commandlayer/runtime-core` is published to npm. +- Health endpoint now includes `version` in the response body. +- Version bumped to `1.1.0`. + +## [1.0.0] - 2026-03-01 + +Initial release. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..9e8b5c9 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +## Reporting a Vulnerability + +Please report security vulnerabilities to **security@commandlayer.org**. Do not open a public GitHub issue. + +You will receive a response within 48 hours. If the issue is confirmed we will release a patch as soon as possible. + +## MCP Server Security Model + +**verify_receipt** validates receipt structure via Zod before proxying to the configured runtime (`COMMANDLAYER_RUNTIME_URL`). Cryptographic verification — Ed25519 signature check and ENS-based public key resolution — is performed by the runtime, not this server. + +**resolve_agent** performs live ENS resolution. Results depend on ENS being accessible and the ENS records being accurate. Consider the ENS trust model before using in high-assurance contexts. + +**validate_receipt_schema** validates structure only — it performs no cryptographic verification and must not be used as a security gate. + +**Rate limiting** is not built into this server. Deploy behind a reverse proxy or API gateway that enforces rate limits. `verify_receipt` and `resolve_agent` trigger network calls (runtime HTTP and ENS RPC) and are the most expensive endpoints. + +**Input validation** — all tool inputs are validated via Zod schemas before processing. Unknown fields in the receipt proof are rejected (`additionalProperties`-equivalent via Zod `.strict()` semantics on the proof object). + +## Known Limitations + +**No key revocation.** ENS-based public keys have no expiration or revocation mechanism. A compromised signing key validates all past receipts forever. This is a known protocol-level limitation documented in the [CommandLayer Protocol](https://commandlayer.org/protocol). diff --git a/package.json b/package.json index 413a95e..0ae6e4c 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "name": "@commandlayer/mcp-server", - "version": "1.0.0", + "version": "1.1.0", "description": "Trust & Verification v1 MCP server for CommandLayer", "type": "module", "main": "src/index.js", "scripts": { - "start": "node src/index.js" + "start": "node src/index.js", + "test": "node --test test/tools.test.js" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", diff --git a/src/index.js b/src/index.js index 903f276..794c5c5 100644 --- a/src/index.js +++ b/src/index.js @@ -3,26 +3,43 @@ import cors from 'cors'; import { z } from 'zod'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { receiptSchema } from './lib/receiptSchema.js'; import { discoverAction } from './tools/discoverAction.js'; import { getActionSchema } from './tools/getActionSchema.js'; import { verifyReceipt } from './tools/verifyReceipt.js'; import { resolveAgent } from './tools/resolveAgent.js'; +import { getProtocolVersion } from './tools/getProtocolVersion.js'; +import { validateReceiptSchema } from './tools/validateReceiptSchema.js'; + +const PROTOCOL_VERSION = '1.1.0'; + +// Tool definitions declared once at module level so schemas and handler +// references are evaluated only on startup, not per request. +const TOOL_DEFS = [ + ['discover_action', { capability: z.string().optional() }, discoverAction], + ['get_action_schema', { action: z.string() }, getActionSchema], + ['verify_receipt', { receipt: receiptSchema }, verifyReceipt], + ['resolve_agent', { agent: z.string() }, resolveAgent], + ['get_protocol_version', {}, getProtocolVersion], + ['validate_receipt_schema',{ receipt: z.unknown() }, validateReceiptSchema], +]; const app = express(); app.use(cors()); app.use(express.json({ limit: '1mb' })); app.get('/health', (_req, res) => { - res.json({ ok: true, service: 'commandlayer-mcp-server' }); + res.json({ ok: true, service: 'commandlayer-mcp-server', version: PROTOCOL_VERSION }); }); app.post('/mcp', async (req, res) => { - const server = new McpServer({ name: 'commandlayer-mcp-server', version: '1.0.0' }); - server.tool('discover_action', { capability: z.string().optional() }, discoverAction); - server.tool('get_action_schema', { action: z.string() }, getActionSchema); - server.tool('verify_receipt', { receipt: z.any() }, verifyReceipt); - server.tool('resolve_agent', { agent: z.string() }, resolveAgent); - + // A new McpServer is created per request to avoid transport collision under + // concurrent requests (server.connect() replaces the active transport). + // Tool defs are defined at module level so this is cheap — just reference binding. + const server = new McpServer({ name: 'commandlayer-mcp-server', version: PROTOCOL_VERSION }); + for (const [name, schema, handler] of TOOL_DEFS) { + server.tool(name, schema, handler); + } const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); await server.connect(transport); await transport.handleRequest(req, res, req.body); @@ -30,5 +47,5 @@ app.post('/mcp', async (req, res) => { const port = process.env.PORT || 3000; app.listen(port, () => { - console.log(`commandlayer-mcp-server listening on ${port}`); + console.log(`commandlayer-mcp-server v${PROTOCOL_VERSION} listening on ${port}`); }); diff --git a/src/lib/canonicalize.js b/src/lib/canonicalize.js index 29f2164..ef2c88c 100644 --- a/src/lib/canonicalize.js +++ b/src/lib/canonicalize.js @@ -1,13 +1,6 @@ -export function canonicalize(value) { - if (value === null || typeof value !== 'object') { - return JSON.stringify(value); - } - - if (Array.isArray(value)) { - return `[${value.map((item) => canonicalize(item)).join(',')}]`; - } - - const keys = Object.keys(value).sort(); - const entries = keys.map((key) => `${JSON.stringify(key)}:${canonicalize(value[key])}`); - return `{${entries.join(',')}}`; -} +// REMOVED — this was a duplicate canonicalize implementation only imported by +// receiptVerifier.js, which has been removed. The single canonical +// implementation lives in @commandlayer/runtime-core (src/canonical.ts). +// +// This file will be deleted once @commandlayer/runtime-core is published to npm +// and added as a dependency. diff --git a/src/lib/commandlayerApi.js b/src/lib/commandlayerApi.js index 18a7a5c..6f6866d 100644 --- a/src/lib/commandlayerApi.js +++ b/src/lib/commandlayerApi.js @@ -43,5 +43,7 @@ export async function postToRuntime(path, payload) { } export async function verifyReceiptOnRuntime(receipt) { - return postToRuntime(process.env.COMMANDLAYER_VERIFY_PATH || '/api/verify', { receipt }); + // Default path is /verify — matches the runtime server's POST /verify endpoint. + // Override with COMMANDLAYER_VERIFY_PATH if routing through a proxy. + return postToRuntime(process.env.COMMANDLAYER_VERIFY_PATH || '/verify', { receipt }); } diff --git a/src/lib/receiptSchema.js b/src/lib/receiptSchema.js new file mode 100644 index 0000000..bc8687e --- /dev/null +++ b/src/lib/receiptSchema.js @@ -0,0 +1,29 @@ +import { z } from 'zod'; + +export const proofSchema = z.object({ + canonical: z.literal('json.sorted_keys.v1'), + alg: z.enum(['ed25519']), + signature: z.string().min(16).regex(/^[A-Za-z0-9+/=]+$/), + kid: z.string().min(1), + signer_id: z.string().min(1), +}); + +// CommandLayer receipt schema v1.1.0 +// Signing spec: Ed25519(UTF8(canonicalize(payload))) — raw canonical bytes, no pre-hash +export const receiptSchema = z.object({ + version: z.literal('1.0.0'), + family: z.literal('trust-verification'), + signer: z.string().min(1), + verb: z.string().min(1), + ts: z.string(), + input: z.unknown(), + output: z.unknown(), + execution: z.object({ + status: z.enum(['ok', 'error']), + duration_ms: z.number().int().nonnegative(), + started_at: z.string(), + completed_at: z.string(), + error: z.string().optional(), + }), + proof: proofSchema, +}); diff --git a/src/lib/receiptVerifier.js b/src/lib/receiptVerifier.js index 317ef07..582d429 100644 --- a/src/lib/receiptVerifier.js +++ b/src/lib/receiptVerifier.js @@ -1,50 +1,9 @@ -import { createHash, createPublicKey, verify as verifySignature } from 'node:crypto'; -import { canonicalize } from './canonicalize.js'; -import { resolveTextRecord } from './ensResolver.js'; - -function decodeEd25519Key(record) { - if (!record || !record.startsWith('ed25519:')) return null; - return Buffer.from(record.slice('ed25519:'.length), 'base64'); -} - -export async function verifyReceiptLocally(receiptInput) { - const checks = { schema: false, hash: false, signature: false, signer: false, canonicalization: false }; - const receipt = typeof receiptInput === 'string' ? JSON.parse(receiptInput) : receiptInput; - - const signer = receipt?.signer; - const signature = receipt?.signature; - const kid = receipt?.kid || receipt?.metadata?.kid; - const claimedHash = receipt?.hash || receipt?.metadata?.proof?.hash; - const payload = receipt?.payload; - - if (!receipt || typeof receipt !== 'object') return { status: 'INVALID', checks, reason: 'invalid_receipt' }; - checks.schema = true; - if (!signer || !signature || !claimedHash || !payload) return { status: 'INVALID', checks, reason: 'missing_required_fields' }; - - checks.signer = true; - const canonical = canonicalize(payload); - checks.canonicalization = true; - - const computedHash = createHash('sha256').update(canonical).digest('hex'); - checks.hash = computedHash.toLowerCase() === String(claimedHash).toLowerCase(); - - const keyed = kid ? await resolveTextRecord(signer, `cl.sig.pub.${kid}`) : null; - const fallback = await resolveTextRecord(signer, 'cl.sig.pub'); - const keyText = keyed || fallback; - const pubRaw = decodeEd25519Key(keyText); - if (!pubRaw) return { status: 'INVALID', checks, reason: 'missing_or_invalid_pubkey' }; - - const spkiPrefix = Buffer.from('302a300506032b6570032100', 'hex'); - const keyObject = createPublicKey({ key: Buffer.concat([spkiPrefix, pubRaw]), format: 'der', type: 'spki' }); - const sigBuf = Buffer.from(signature, 'base64'); - checks.signature = verifySignature(null, Buffer.from(canonical), keyObject, sigBuf); - - return { - status: checks.hash && checks.signature ? 'VALID' : 'INVALID', - checks, - signer, - kid: kid ?? null, - canonicalization: 'json.sorted_keys.v1', - hash: { claimed: claimedHash, computed: computedHash } - }; -} +// REMOVED — this file implemented a 4th incompatible receipt format +// (receipt.payload / receipt.signer / receipt.hash / receipt.signature) that +// was never used anywhere in the codebase. The verifyReceipt tool proxies to +// the runtime via commandlayerApi.js. +// +// When @commandlayer/runtime-core is published to npm, local verification +// should use its verifyReceiptSignature() export instead. +// +// Do not add new code here. This file will be deleted in a future cleanup. diff --git a/src/tools/getProtocolVersion.js b/src/tools/getProtocolVersion.js new file mode 100644 index 0000000..de396fc --- /dev/null +++ b/src/tools/getProtocolVersion.js @@ -0,0 +1,12 @@ +export async function getProtocolVersion() { + return { + status: 'ok', + protocol_version: '1.1.0', + signing_spec: 'Ed25519(UTF8(canonicalize(payload)))', + canonicalization: 'json.sorted_keys.v1', + receipt_format: 'commandlayer-receipt-v1.1', + proof_fields: ['canonical', 'alg', 'signature', 'kid', 'signer_id'], + schema_host: 'https://commandlayer.org/schemas', + runtime_url: 'https://runtime.commandlayer.org', + }; +} diff --git a/src/tools/validateReceiptSchema.js b/src/tools/validateReceiptSchema.js new file mode 100644 index 0000000..722a1ac --- /dev/null +++ b/src/tools/validateReceiptSchema.js @@ -0,0 +1,17 @@ +import { receiptSchema } from '../lib/receiptSchema.js'; + +export async function validateReceiptSchema({ receipt }) { + const result = receiptSchema.safeParse(receipt); + if (result.success) { + return { status: 'ok', valid: true }; + } + return { + status: 'ok', + valid: false, + errors: result.error.issues.map((issue) => ({ + path: issue.path.join('.') || '(root)', + message: issue.message, + code: issue.code, + })), + }; +} diff --git a/test/tools.test.js b/test/tools.test.js new file mode 100644 index 0000000..38ec986 --- /dev/null +++ b/test/tools.test.js @@ -0,0 +1,113 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { discoverAction } from '../src/tools/discoverAction.js'; +import { getActionSchema } from '../src/tools/getActionSchema.js'; +import { getProtocolVersion } from '../src/tools/getProtocolVersion.js'; +import { validateReceiptSchema } from '../src/tools/validateReceiptSchema.js'; + +function makeValidReceipt(overrides = {}) { + const now = new Date().toISOString(); + return { + version: '1.0.0', + family: 'trust-verification', + signer: 'verifyagent.eth', + verb: 'verify', + ts: now, + input: { challenge: 'abc' }, + output: { approved: true }, + execution: { status: 'ok', duration_ms: 10, started_at: now, completed_at: now }, + proof: { + canonical: 'json.sorted_keys.v1', + alg: 'ed25519', + signature: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + kid: 'kid-1', + signer_id: 'verifyagent.eth', + }, + ...overrides, + }; +} + +test('discoverAction returns all actions when no capability given', async () => { + const result = await discoverAction({}); + assert.equal(result.status, 'ok'); + assert.ok(Array.isArray(result.actions)); + assert.ok(result.actions.length > 0); +}); + +test('discoverAction filters by capability', async () => { + const result = await discoverAction({ capability: 'verify' }); + assert.equal(result.status, 'ok'); + assert.equal(result.actions.length, 1); + assert.equal(result.actions[0].action, 'verify'); +}); + +test('discoverAction returns not_found for unknown capability', async () => { + const result = await discoverAction({ capability: 'unknown-action' }); + assert.equal(result.status, 'not_found'); +}); + +test('getActionSchema returns schema for known action', async () => { + const result = await getActionSchema({ action: 'verify' }); + assert.equal(result.status, 'ok'); + assert.equal(result.schema.action, 'verify'); + assert.equal(result.schema.family, 'trust-verification'); +}); + +test('getActionSchema returns not_found for unknown action', async () => { + const result = await getActionSchema({ action: 'nonexistent' }); + assert.equal(result.status, 'not_found'); +}); + +test('getProtocolVersion returns version and signing spec', async () => { + const result = await getProtocolVersion(); + assert.equal(result.status, 'ok'); + assert.equal(result.protocol_version, '1.1.0'); + assert.ok(result.signing_spec.includes('Ed25519')); + assert.equal(result.canonicalization, 'json.sorted_keys.v1'); + assert.ok(Array.isArray(result.proof_fields)); + assert.ok(result.proof_fields.includes('kid')); + assert.ok(result.proof_fields.includes('signer_id')); +}); + +test('validateReceiptSchema accepts valid v1.1.0 receipt', async () => { + const result = await validateReceiptSchema({ receipt: makeValidReceipt() }); + assert.equal(result.valid, true); + assert.equal(result.status, 'ok'); +}); + +test('validateReceiptSchema rejects receipt with missing proof', async () => { + const { proof: _proof, ...noProof } = makeValidReceipt(); + const result = await validateReceiptSchema({ receipt: noProof }); + assert.equal(result.valid, false); + assert.ok(result.errors.some((e) => e.path === 'proof')); +}); + +test('validateReceiptSchema rejects old v1.0.x field names', async () => { + const receipt = makeValidReceipt({ + proof: { + canonicalization: 'json.sorted_keys.v1', // old: should be canonical + signature_alg: 'ed25519', // old: should be alg + signature: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + key_id: 'kid-1', // old: should be kid + signer: 'verifyagent.eth', // old: should be signer_id + }, + }); + const result = await validateReceiptSchema({ receipt }); + assert.equal(result.valid, false); +}); + +test('validateReceiptSchema rejects unsupported alg', async () => { + const result = await validateReceiptSchema({ + receipt: makeValidReceipt({ proof: { ...makeValidReceipt().proof, alg: 'rsa-pkcs1v15' } }), + }); + assert.equal(result.valid, false); + assert.ok(result.errors.some((e) => e.path === 'proof.alg')); +}); + +test('validateReceiptSchema rejects wrong canonicalization method', async () => { + const result = await validateReceiptSchema({ + receipt: makeValidReceipt({ proof: { ...makeValidReceipt().proof, canonical: 'json.unsorted.v1' } }), + }); + assert.equal(result.valid, false); + assert.ok(result.errors.some((e) => e.path === 'proof.canonical')); +});