Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 23 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -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).
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
33 changes: 25 additions & 8 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,49 @@ 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);
});

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}`);
});
19 changes: 6 additions & 13 deletions src/lib/canonicalize.js
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion src/lib/commandlayerApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
29 changes: 29 additions & 0 deletions src/lib/receiptSchema.js
Original file line number Diff line number Diff line change
@@ -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,
});
59 changes: 9 additions & 50 deletions src/lib/receiptVerifier.js
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions src/tools/getProtocolVersion.js
Original file line number Diff line number Diff line change
@@ -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',
};
}
17 changes: 17 additions & 0 deletions src/tools/validateReceiptSchema.js
Original file line number Diff line number Diff line change
@@ -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,
})),
};
}
Loading
Loading