🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@otskit/mcp

Package Overview
Dependencies
Maintainers
1
Versions
36
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@otskit/mcp - npm Package Compare versions

Comparing version
0.8.1
to
0.8.2
+344
dist/chunk-CM737OZR.js
import {
writeAtomic
} from "./chunk-PETURIDM.js";
// src/config.ts
import { readFileSync, existsSync, mkdirSync } from "fs";
import { join } from "path";
import { homedir } from "os";
import { z } from "zod";
import { DEFAULT_CALENDARS } from "@otskit/client";
var TRUSTED_CALENDAR_HOSTS = new Set(
DEFAULT_CALENDARS.map((u) => new URL(u).hostname)
);
var httpsAllowlisted = (hosts) => z.string().refine((v) => {
try {
const u = new URL(v);
return u.protocol === "https:" && hosts.has(u.hostname);
} catch {
return false;
}
}, { message: "URL must be https and in the host allowlist" });
var ConfigSchema = z.strictObject({
stamp_enabled: z.boolean(),
preserve_enabled: z.boolean(),
preserve_whitelist: z.array(z.string()),
preserve_max_bytes: z.number().int().positive().max(10 * 1024 ** 3),
preserve_max_files: z.number().int().positive().max(1e5),
scheduler_interval_minutes: z.number().int().min(1).max(1440),
calendar_timeout_ms: z.number().int().min(1e3).max(6e4),
retry_max_attempts: z.number().int().min(1).max(100),
log_file: z.string(),
calendars: z.array(httpsAllowlisted(TRUSTED_CALENDAR_HOSTS)).min(1).max(10)
}).partial();
function getDataDir() {
return process.env.OTS_MCP_DATA_DIR ?? join(homedir(), ".ots-mcp");
}
var DEFAULTS = {
stamp_enabled: true,
preserve_enabled: true,
preserve_whitelist: [],
preserve_max_bytes: 104857600,
preserve_max_files: 1e4,
scheduler_interval_minutes: 30,
calendar_timeout_ms: 1e4,
retry_max_attempts: 20,
log_file: join(getDataDir(), "ots-mcp.log"),
calendars: [...DEFAULT_CALENDARS]
};
function loadConfig() {
const dir = getDataDir();
mkdirSync(dir, { recursive: true });
const configPath = join(dir, "config.json");
if (!existsSync(configPath)) return { ...DEFAULTS };
let raw;
try {
raw = JSON.parse(readFileSync(configPath, "utf8"));
} catch (e) {
process.stderr.write(`[ots-mcp] config parse error, using defaults: ${e}
`);
return { ...DEFAULTS };
}
const parsed = ConfigSchema.safeParse(raw);
if (!parsed.success) {
process.stderr.write(`[ots-mcp] config validation failed, using defaults: ${parsed.error.issues[0]?.message}
`);
return { ...DEFAULTS };
}
return { ...DEFAULTS, ...parsed.data };
}
// src/db/index.ts
import { createRequire } from "module";
import { join as join2 } from "path";
import { mkdirSync as mkdirSync2, statSync } from "fs";
// src/db/schema.ts
function initDb(db) {
db.exec("PRAGMA busy_timeout = 5000");
db.exec("PRAGMA foreign_keys = ON");
runMigrations(db);
}
function runMigrations(db) {
const row = db.get("PRAGMA user_version");
if (row.user_version < 1) migrateTo1(db);
}
function migrateTo1(db) {
db.exec("BEGIN");
try {
db.exec(`
CREATE TABLE IF NOT EXISTS stamps (
id TEXT PRIMARY KEY,
hash TEXT NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
confirmed_at TEXT,
bitcoin_block INTEGER,
bitcoin_time TEXT,
proof_path TEXT,
archive_path TEXT,
last_attempt_at TEXT,
attempt_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
next_retry_at TEXT,
metadata TEXT
);
CREATE INDEX IF NOT EXISTS idx_stamps_hash ON stamps(hash);
CREATE INDEX IF NOT EXISTS idx_stamps_status ON stamps(status);
CREATE TABLE IF NOT EXISTS operations_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stamp_id TEXT NOT NULL REFERENCES stamps(id),
action TEXT NOT NULL,
result TEXT NOT NULL,
error_msg TEXT,
calendar_uri TEXT,
response_time_ms INTEGER,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_oplog_stamp_id ON operations_log(stamp_id);
CREATE INDEX IF NOT EXISTS idx_oplog_created ON operations_log(created_at);
`);
db.exec("PRAGMA user_version = 1");
db.exec("COMMIT");
} catch (e) {
db.exec("ROLLBACK");
throw e;
}
}
// src/db/index.ts
var _db = null;
function getDb() {
if (_db) return _db;
const _require = createRequire(import.meta.url);
const { Database } = _require("node-sqlite3-wasm");
const dir = getDataDir();
mkdirSync2(dir, { recursive: true });
_db = new Database(join2(dir, "db.sqlite"));
initDb(_db);
reconcileOrphans(_db);
return _db;
}
function backupDb(destPath) {
const escaped = destPath.replaceAll("'", "''");
getDb().exec(`VACUUM INTO '${escaped}'`);
}
function reconcileOrphans(db) {
const pending = db.all(
`SELECT id, proof_path FROM stamps WHERE status = 'pending' AND proof_path IS NOT NULL`
);
for (const row of pending) {
try {
statSync(row.proof_path);
} catch {
db.run(
`UPDATE stamps SET status = 'missing_proof', last_error = ? WHERE id = ?`,
["proof file not found at startup", row.id]
);
}
}
}
// src/tools/upgrade-timestamp.ts
import { readFileSync as readFileSync2 } from "fs";
import { OpenTimestampsClient, UpgradeError, DetachedTimestampFile } from "@otskit/client";
// src/db/stamps.ts
function insertStamp(db, params) {
const now = (/* @__PURE__ */ new Date()).toISOString();
db.run(
`INSERT INTO stamps (id, hash, status, created_at, proof_path, archive_path, attempt_count, metadata)
VALUES (?, ?, 'pending', ?, ?, ?, 0, ?)`,
[params.id, params.hash, now, params.proof_path, params.archive_path ?? null, params.metadata ?? null]
);
return getStamp(db, params.id);
}
function getStamp(db, id) {
return db.get("SELECT * FROM stamps WHERE id = ?", [id]) ?? null;
}
function updateStampStatus(db, id, params) {
const fields = [];
const values = [];
const add = (col, val) => {
fields.push(`${col} = ?`);
values.push(val);
};
if (params.status !== void 0) add("status", params.status);
if (params.bitcoin_block !== void 0) add("bitcoin_block", params.bitcoin_block);
if (params.bitcoin_time !== void 0) add("bitcoin_time", params.bitcoin_time);
if (params.confirmed_at !== void 0) add("confirmed_at", params.confirmed_at);
if (params.last_error !== void 0) add("last_error", params.last_error);
if (params.attempt_count !== void 0) add("attempt_count", params.attempt_count);
if (params.last_attempt_at !== void 0) add("last_attempt_at", params.last_attempt_at);
if (params.next_retry_at !== void 0) add("next_retry_at", params.next_retry_at);
if (fields.length === 0) return;
values.push(id);
db.run(`UPDATE stamps SET ${fields.join(", ")} WHERE id = ?`, values);
}
function listStamps(db, params) {
const conds = [];
const vals = [];
if (params.status) {
conds.push("status = ?");
vals.push(params.status);
}
if (params.older_than_hours) {
const cutoff = new Date(Date.now() - params.older_than_hours * 36e5).toISOString();
conds.push("created_at < ?");
vals.push(cutoff);
}
if (params.due_now) {
conds.push("(next_retry_at IS NULL OR next_retry_at <= ?)");
vals.push((/* @__PURE__ */ new Date()).toISOString());
}
const where = conds.length ? `WHERE ${conds.join(" AND ")}` : "";
const countParams = vals.length ? vals : void 0;
const total = db.get(`SELECT COUNT(*) as n FROM stamps ${where}`, countParams).n;
const items = db.all(
`SELECT * FROM stamps ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`,
[...vals, params.limit, params.offset]
);
return { items, total };
}
// src/db/operations-log.ts
function logOperation(db, params) {
db.run(
`INSERT INTO operations_log (stamp_id, action, result, error_msg, calendar_uri, response_time_ms, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
params.stamp_id,
params.action,
params.result,
params.error_msg ?? null,
params.calendar_uri ?? null,
params.response_time_ms ?? null,
(/* @__PURE__ */ new Date()).toISOString()
]
);
}
// src/tools/upgrade-timestamp.ts
function collectAttestations(ts) {
const atts = [...ts.attestations];
for (const branch of ts.branches) {
atts.push(...collectAttestations(branch.stamp));
}
return atts;
}
function checkBitcoinConfirmation(bytes) {
try {
const dtf = DetachedTimestampFile.deserialize(new Uint8Array(bytes));
const attestations = collectAttestations(dtf.timestamp);
const bitcoinAtts = attestations.filter((a) => a.kind === "bitcoin");
if (bitcoinAtts.length === 0) return { confirmed: false };
const block = Math.min(...bitcoinAtts.map((a) => a.height));
return { confirmed: true, block };
} catch {
return { confirmed: false };
}
}
function nextRetryAt(attemptCount) {
const base = Math.min(3e4 * Math.pow(2, attemptCount), 36e5);
const jitter = Math.random() * 0.2 * base;
return new Date(Date.now() + base + jitter).toISOString();
}
async function upgradeTimestamp(input, db, config) {
const record = getStamp(db, input.id);
if (!record) return { error: "not_found", details: `No stamp with id ${input.id}` };
if (!record.proof_path) return { error: "storage_error", details: "No proof_path on record" };
const proofBefore = readFileSync2(record.proof_path);
const client = new OpenTimestampsClient({
calendars: config.calendars,
resilience: {
totalTimeoutMs: config.calendar_timeout_ms,
connectTimeoutMs: Math.min(config.calendar_timeout_ms, 5e3),
retries: { enabled: true, maxAttempts: config.retry_max_attempts, backoff: { strategy: "exponential", initialDelayMs: 500, jitter: "full" } }
}
});
const now = (/* @__PURE__ */ new Date()).toISOString();
const newAttemptCount = record.attempt_count + 1;
const next = nextRetryAt(newAttemptCount);
let upgraded;
try {
upgraded = await client.upgrade(proofBefore);
} catch (e) {
if (e instanceof UpgradeError) {
try {
const v = await client.verify(proofBefore, record.hash);
if (v.status === "verified") {
const bitcoinTime = new Date(v.blockTime * 1e3).toISOString();
updateStampStatus(db, input.id, {
status: "confirmed",
bitcoin_block: v.blockHeight,
bitcoin_time: bitcoinTime,
confirmed_at: now,
last_attempt_at: now,
attempt_count: newAttemptCount
});
logOperation(db, { stamp_id: input.id, action: "upgrade", result: "success" });
return { id: input.id, status: "confirmed", bitcoin_block: v.blockHeight, bitcoin_time: bitcoinTime };
}
} catch {
}
updateStampStatus(db, input.id, { last_attempt_at: now, attempt_count: newAttemptCount, next_retry_at: next });
logOperation(db, { stamp_id: input.id, action: "upgrade", result: "pending" });
return { id: input.id, status: "pending", attempt_count: newAttemptCount, last_attempt_at: now, next_retry_at: next };
}
updateStampStatus(db, input.id, { last_attempt_at: now, attempt_count: newAttemptCount, last_error: String(e), next_retry_at: next });
logOperation(db, { stamp_id: input.id, action: "upgrade", result: "failed", error_msg: String(e) });
return { error: "calendar_error", details: String(e) };
}
writeAtomic(record.proof_path, upgraded);
const { confirmed, block } = checkBitcoinConfirmation(upgraded);
if (confirmed && block !== void 0) {
const bitcoinTime = now;
updateStampStatus(db, input.id, {
status: "confirmed",
bitcoin_block: block,
bitcoin_time: bitcoinTime,
confirmed_at: now,
last_attempt_at: now,
attempt_count: newAttemptCount
});
logOperation(db, { stamp_id: input.id, action: "upgrade", result: "success" });
return { id: input.id, status: "confirmed", bitcoin_block: block, bitcoin_time: bitcoinTime };
}
updateStampStatus(db, input.id, { last_attempt_at: now, attempt_count: newAttemptCount, next_retry_at: next });
logOperation(db, { stamp_id: input.id, action: "upgrade", result: "pending" });
return { id: input.id, status: "pending", attempt_count: newAttemptCount, last_attempt_at: now, next_retry_at: next };
}
export {
getDataDir,
loadConfig,
getDb,
backupDb,
insertStamp,
getStamp,
updateStampStatus,
listStamps,
logOperation,
upgradeTimestamp
};
import {
getDb,
loadConfig,
upgradeTimestamp
} from "./chunk-CM737OZR.js";
// src/tools/watch.ts
var DEFAULT_WATCH_INTERVAL_MINUTES = 30;
var MIN_WATCH_INTERVAL_MINUTES = 15;
var MAX_UPGRADES_PER_TICK = 20;
function normalizeWatchInterval(intervalMinutes = DEFAULT_WATCH_INTERVAL_MINUTES) {
if (!Number.isFinite(intervalMinutes)) return DEFAULT_WATCH_INTERVAL_MINUTES;
return Math.max(MIN_WATCH_INTERVAL_MINUTES, Math.floor(intervalMinutes));
}
async function watchPending(intervalMinutes = DEFAULT_WATCH_INTERVAL_MINUTES) {
const config = loadConfig();
const db = getDb();
const minutes = normalizeWatchInterval(intervalMinutes);
process.stdout.write(`Watching pending stamps and upgrading due proofs every ${minutes} min. Ctrl+C to stop.
`);
async function tick() {
const dueRows = db.all(
`SELECT id FROM stamps
WHERE status = 'pending'
AND (next_retry_at IS NULL OR next_retry_at <= ?)
ORDER BY created_at ASC
LIMIT ?`,
[(/* @__PURE__ */ new Date()).toISOString(), MAX_UPGRADES_PER_TICK]
);
if (dueRows.length > 0) {
process.stdout.write(`${now()} - upgrading ${dueRows.length} due stamp(s)
`);
}
for (const row of dueRows) {
const result = await upgradeTimestamp({ id: row.id }, db, config);
const status = "status" in result ? result.status : `error:${result.error}`;
process.stdout.write(` upgrade ${row.id.slice(0, 8)} -> ${status}
`);
}
const rows = db.all(
`SELECT id, hash, status, attempt_count, bitcoin_block, confirmed_at, next_retry_at
FROM stamps
WHERE status != 'confirmed'
ORDER BY created_at DESC`
);
const confirmed = db.get(`SELECT COUNT(*) as n FROM stamps WHERE status = 'confirmed'`);
process.stdout.write(`${now()} - ${rows.length} pending, ${confirmed.n} confirmed
`);
for (const row of rows) {
const next = row.next_retry_at ? ` next ${row.next_retry_at.replace("T", " ").slice(0, 19)}` : "";
process.stdout.write(` ${row.id.slice(0, 8)} ${row.status} (${row.attempt_count} attempts)${next}
`);
}
if (rows.length === 0) {
process.stdout.write(` (no pending stamps)
`);
}
process.stdout.write("\n");
}
function now() {
return (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19);
}
async function loop() {
try {
await tick();
} catch (e) {
process.stderr.write(`watch error: ${String(e)}
`);
}
setTimeout(loop, minutes * 60 * 1e3);
}
await loop();
}
export {
normalizeWatchInterval,
watchPending
};
// src/utils.ts
import { execFileSync } from "child_process";
import { writeFileSync, renameSync, realpathSync, statSync, createReadStream } from "fs";
import { resolve, sep } from "path";
import { createHash } from "crypto";
import { pipeline } from "stream/promises";
import { Transform } from "stream";
function which(cmd) {
try {
const out = execFileSync(
process.platform === "win32" ? "where" : "which",
[cmd]
).toString().trim();
return out.split("\n")[0] ?? null;
} catch {
return null;
}
}
function writeAtomic(dest, data) {
const tmp = dest + ".tmp";
writeFileSync(tmp, data);
renameSync(tmp, dest);
}
function escapeXml(raw) {
return raw.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
}
function validateFilePath(rawPath, whitelist) {
let canonical;
try {
canonical = realpathSync(rawPath);
} catch (e) {
return { error: "invalid_path", details: String(e?.message ?? e) };
}
if (whitelist.length > 0) {
const allowed = whitelist.some((dir) => {
const root = resolve(dir);
return canonical === root || canonical.startsWith(root + sep);
});
if (!allowed) return { error: "path_not_allowed", details: `${canonical} is outside allowed directories` };
}
let st;
try {
st = statSync(canonical);
} catch (e) {
return { error: "invalid_path", details: String(e?.message ?? e) };
}
if (!st.isFile()) return { error: "not_a_regular_file", details: `${canonical} is not a regular file` };
return { path: canonical };
}
async function hashFileStreaming(filePath, maxBytes) {
const hash = createHash("sha256");
let bytesRead = 0;
const limiter = new Transform({
transform(chunk, _enc, cb) {
bytesRead += chunk.length;
if (bytesRead > maxBytes) cb(new Error(`file_too_large: exceeds ${maxBytes} bytes`));
else cb(null, chunk);
}
});
await pipeline(createReadStream(filePath), limiter, hash);
return hash.digest("hex");
}
export {
which,
writeAtomic,
escapeXml,
validateFilePath,
hashFileStreaming
};
import {
getDataDir,
getStamp,
insertStamp,
listStamps,
logOperation,
updateStampStatus
} from "./chunk-CM737OZR.js";
import {
writeAtomic
} from "./chunk-PETURIDM.js";
// src/tools/create-timestamp.ts
import { mkdirSync, unlinkSync } from "fs";
import { join } from "path";
import { randomUUID } from "crypto";
import { OpenTimestampsClient } from "@otskit/client";
var HEX64 = /^[0-9a-f]{64}$/i;
async function createTimestamp(input, db, config) {
if (!HEX64.test(input.hash)) {
return { error: "invalid_hash", details: "hash must be 64 hex characters (SHA-256)" };
}
const normalizedHash = input.hash.toLowerCase();
const client = new OpenTimestampsClient({
calendars: config.calendars,
resilience: {
totalTimeoutMs: config.calendar_timeout_ms,
connectTimeoutMs: Math.min(config.calendar_timeout_ms, 5e3),
retries: { enabled: true, maxAttempts: config.retry_max_attempts, backoff: { strategy: "exponential", initialDelayMs: 500, jitter: "full" } }
}
});
const t0 = Date.now();
let proofBuffer;
try {
proofBuffer = await client.stamp(normalizedHash);
} catch (e) {
return { error: "calendar_error", details: String(e) };
}
const responseTimeMs = Date.now() - t0;
const id = randomUUID();
const proofDir = join(getDataDir(), "proofs");
mkdirSync(proofDir, { recursive: true });
const proofPath = join(proofDir, `${id}.ots`);
try {
writeAtomic(proofPath, proofBuffer);
} catch (e) {
return { error: "storage_error", details: String(e) };
}
let record;
db.exec("BEGIN");
try {
record = insertStamp(db, { id, hash: normalizedHash, proof_path: proofPath });
logOperation(db, { stamp_id: id, action: "stamp", result: "success", response_time_ms: responseTimeMs });
db.exec("COMMIT");
} catch (e) {
db.exec("ROLLBACK");
try {
unlinkSync(proofPath);
} catch {
}
return { error: "storage_error", details: String(e) };
}
return {
id: record.id,
hash: record.hash,
status: "pending",
calendars: config.calendars,
created_at: record.created_at
};
}
// src/tools/verify-timestamp.ts
import { readFileSync } from "fs";
import { OpenTimestampsClient as OpenTimestampsClient2 } from "@otskit/client";
async function verifyTimestamp(input, db, config) {
const record = getStamp(db, input.id);
if (!record) return { error: "not_found", details: `No stamp with id ${input.id}` };
if (!record.proof_path) return { error: "storage_error", details: "No proof_path on record" };
let proofBytes;
try {
proofBytes = readFileSync(record.proof_path);
} catch (e) {
return { error: "storage_error", details: String(e) };
}
const client = new OpenTimestampsClient2({
calendars: config.calendars,
resilience: {
totalTimeoutMs: config.calendar_timeout_ms,
connectTimeoutMs: Math.min(config.calendar_timeout_ms, 5e3),
retries: { enabled: true, maxAttempts: config.retry_max_attempts, backoff: { strategy: "exponential", initialDelayMs: 500, jitter: "full" } }
}
});
let result;
try {
result = await client.verify(proofBytes, record.hash);
} catch (e) {
logOperation(db, { stamp_id: input.id, action: "verify", result: "failed", error_msg: String(e) });
return { status: "network_error", hash: record.hash, details: String(e) };
}
switch (result.status) {
case "pending":
logOperation(db, { stamp_id: input.id, action: "verify", result: "pending" });
return { status: "pending", hash: record.hash, calendars: config.calendars };
case "invalid":
logOperation(db, { stamp_id: input.id, action: "verify", result: "failed", error_msg: result.reason });
return { status: "invalid", hash: record.hash, reason: result.reason };
case "network_error":
logOperation(db, { stamp_id: input.id, action: "verify", result: "failed", error_msg: result.reason });
return { status: "network_error", hash: record.hash, details: result.reason };
case "verified": {
const bitcoinTime = new Date(result.blockTime * 1e3).toISOString();
const now = (/* @__PURE__ */ new Date()).toISOString();
updateStampStatus(db, input.id, {
status: "confirmed",
bitcoin_block: result.blockHeight,
bitcoin_time: bitcoinTime,
confirmed_at: now
});
logOperation(db, { stamp_id: input.id, action: "verify", result: "success" });
return {
status: "confirmed",
hash: record.hash,
bitcoin_block: result.blockHeight,
bitcoin_time: bitcoinTime
};
}
/* c8 ignore next 4 */
default: {
const _exhaustive = result;
return { status: "unknown", hash: record.hash };
}
}
}
// src/tools/list-pending.ts
function toPublic({ attempt_count: _a, last_attempt_at: _b, next_retry_at: _c, proof_path: _d, archive_path: _e, ...rest }) {
return rest;
}
function listPending(input, db, _config) {
const result = listStamps(db, {
status: input.status ?? "pending",
limit: Math.min(input.limit ?? 50, 200),
offset: input.offset ?? 0,
older_than_hours: input.older_than_hours,
due_now: input.due_now
});
return { items: result.items.map(toPublic), total: result.total };
}
export {
createTimestamp,
verifyTimestamp,
listPending
};
import {
createTimestamp,
listPending,
verifyTimestamp
} from "./chunk-X5C5H6FS.js";
import {
backupDb,
getDb,
loadConfig,
upgradeTimestamp
} from "./chunk-CM737OZR.js";
import "./chunk-PETURIDM.js";
// src/cli.ts
async function runCli(command, args) {
const config = loadConfig();
const db = getDb();
switch (command) {
case "stamp": {
const hash = args[0];
if (!hash) {
process.stderr.write("Usage: ots-mcp stamp <sha256-hash>\n");
process.exit(1);
}
const result = await createTimestamp({ hash }, db, config);
if ("error" in result) {
process.stderr.write(`Error: ${result.error} \u2014 ${result.details}
`);
process.exit(1);
}
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
break;
}
case "upgrade": {
const id = args[0];
if (!id) {
process.stderr.write("Usage: ots-mcp upgrade <id>\n");
process.exit(1);
}
const result = await upgradeTimestamp({ id }, db, config);
if ("error" in result) {
process.stderr.write(`Error: ${result.error} \u2014 ${result.details}
`);
process.exit(1);
}
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
break;
}
case "verify": {
const id = args[0];
if (!id) {
process.stderr.write("Usage: ots-mcp verify <id>\n");
process.exit(1);
}
const result = await verifyTimestamp({ id }, db, config);
if ("error" in result) {
process.stderr.write(`Error: ${result.error} \u2014 ${result.details}
`);
process.exit(1);
}
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
break;
}
case "list": {
const status = args[0] ?? "pending";
const result = listPending({ status }, db, config);
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
break;
}
case "check-pending": {
const { items } = listPending({ status: "pending", limit: 200, due_now: true }, db, config);
process.stderr.write(`Processing ${items.length} pending stamps...
`);
for (const record of items) {
const result = await upgradeTimestamp({ id: record.id }, db, config);
const statusStr = "status" in result ? result.status : `error:${result.error}`;
process.stderr.write(`${record.id.slice(0, 8)}: ${statusStr}
`);
}
process.exit(0);
}
case "backup": {
const dest = args[0] ?? `ots-mcp-backup-${Date.now()}.sqlite`;
backupDb(dest);
process.stdout.write(`Backup saved to ${dest}
`);
break;
}
case "scheduler": {
const { runScheduler } = await import("./scheduler-DS5AJ6JO.js");
await runScheduler(args);
break;
}
}
}
export {
runCli
};
import {
escapeXml,
which
} from "./chunk-PETURIDM.js";
// src/scheduler/install.ts
import { execFileSync } from "child_process";
import { mkdtempSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
async function installScheduler(args) {
const intervalIdx = args.indexOf("--interval");
const parsedInterval = intervalIdx !== -1 ? Number.parseInt(args[intervalIdx + 1] ?? "30", 10) : 30;
const interval = Math.max(1, Math.min(1440, Number.isFinite(parsedInterval) ? parsedInterval : 30));
const bin = which("ots-mcp") ?? process.argv[1];
if (process.platform === "win32") {
const workDir = mkdtempSync(join(tmpdir(), "ots-mcp-"));
const xmlPath = join(workDir, "task.xml");
try {
writeFileSync(xmlPath, `<?xml version="1.0"?>
<Task xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<Triggers><TimeTrigger>
<Repetition><Interval>PT${interval}M</Interval><StopAtDurationEnd>false</StopAtDurationEnd></Repetition>
<StartBoundary>2020-01-01T00:00:00</StartBoundary><Enabled>true</Enabled>
</TimeTrigger></Triggers>
<Actions><Exec>
<Command>${escapeXml(bin)}</Command>
<Arguments>check-pending</Arguments>
</Exec></Actions>
</Task>`);
execFileSync("schtasks", ["/create", "/tn", "ots-mcp-check-pending", "/xml", xmlPath, "/f"]);
} finally {
rmSync(workDir, { recursive: true, force: true });
}
process.stdout.write(`Scheduler installed: runs every ${interval} minutes
`);
} else {
process.stdout.write(`Add to crontab (run: crontab -e):
`);
process.stdout.write(`*/${interval} * * * * "${bin}" check-pending
`);
}
}
export {
installScheduler
};
// src/scheduler/index.ts
async function runScheduler(args) {
const [sub, ...rest] = args;
switch (sub) {
case "install": {
const { installScheduler } = await import("./install-375QBG75.js");
await installScheduler(rest);
break;
}
case "remove": {
const { removeScheduler } = await import("./remove-BGFQRDX3.js");
await removeScheduler();
break;
}
case "status": {
const { statusScheduler } = await import("./status-M6A2RG7G.js");
await statusScheduler();
break;
}
default:
process.stderr.write("Usage: ots-mcp scheduler install [--interval N] | remove | status\n");
process.exit(1);
}
}
export {
runScheduler
};
import {
createTimestamp,
listPending,
verifyTimestamp
} from "./chunk-X5C5H6FS.js";
import {
normalizeWatchInterval
} from "./chunk-OO26D46G.js";
import {
getDb,
getStamp,
loadConfig,
upgradeTimestamp
} from "./chunk-CM737OZR.js";
import {
hashFileStreaming,
validateFilePath
} from "./chunk-PETURIDM.js";
// src/server.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
// src/tools/inspect-timestamp.ts
import { readFileSync, statSync } from "fs";
import { DetachedTimestampFile } from "@otskit/client";
function inspectTimestamp(input, db, _config) {
const record = getStamp(db, input.id);
if (!record) return { error: "not_found", details: `No stamp with id ${input.id}` };
if (!record.proof_path) return { error: "proof_missing", details: "No proof file on record" };
let proofBytes;
let proofSize;
try {
proofSize = statSync(record.proof_path).size;
proofBytes = readFileSync(record.proof_path);
} catch {
return { error: "proof_missing", details: `Cannot read proof: ${record.proof_path}` };
}
let calendarAttestations = 0;
let bitcoinAttestations = 0;
let bitcoinBlock = null;
try {
const proof = DetachedTimestampFile.deserialize(new Uint8Array(proofBytes));
const attestations = proof.timestamp.getAttestations();
bitcoinAttestations = attestations.filter((a) => a.kind === "bitcoin").length;
calendarAttestations = attestations.filter((a) => a.kind !== "bitcoin").length;
if (bitcoinAttestations > 0) {
const blocks = attestations.filter((a) => a.kind === "bitcoin").map((a) => a.height);
bitcoinBlock = blocks.length > 0 ? Math.min(...blocks) : null;
}
} catch {
}
return {
id: record.id,
hash: record.hash,
status: record.status,
created_at: record.created_at,
proof_exists: true,
proof_size_bytes: proofSize,
calendar_attestations: calendarAttestations,
bitcoin_attestations: bitcoinAttestations,
bitcoin_confirmed: bitcoinAttestations > 0,
bitcoin_block: bitcoinBlock
};
}
// src/tools/watch-window.ts
import { exec } from "child_process";
function openWatchWindow(intervalMinutes) {
const minutes = normalizeWatchInterval(intervalMinutes);
const cmd = `start powershell.exe -NoExit -Command "ots-mcp watch ${minutes}"`;
let errorMsg;
exec(cmd, { shell: "cmd" }, (err) => {
if (err) errorMsg = err.message;
});
return { opened: true, interval_minutes: minutes, ...errorMsg ? { error: errorMsg } : {} };
}
// src/tools/stamp-file.ts
async function stampFile(input, db, config) {
const v = validateFilePath(input.path, config.preserve_whitelist);
if ("error" in v) return v;
let hash;
try {
hash = await hashFileStreaming(v.path, config.preserve_max_bytes);
} catch (e) {
if (String(e?.message).startsWith("file_too_large")) {
return { error: "file_too_large", details: e.message };
}
throw e;
}
return createTimestamp({ hash }, db, config);
}
// src/tools/hash-file.ts
async function hashFileTool(input, config) {
const v = validateFilePath(input.path, config.preserve_whitelist);
if ("error" in v) return v;
try {
const hash = await hashFileStreaming(v.path, config.preserve_max_bytes);
return { hash };
} catch (e) {
if (String(e?.message).startsWith("file_too_large")) {
return { error: "file_too_large", details: e.message };
}
throw e;
}
}
// src/tool-definitions.ts
var TOOL_DEFINITIONS = [
{
name: "create_timestamp",
description: "Creates a verifiable Bitcoin timestamp for a SHA-256 hash using the OpenTimestamps protocol. Submits the hash to four public OTS calendars (alice.btc, bob.btc, finney, catallaxy) and stores a pending proof locally. Returns a stamp ID to track confirmation status. Confirmation typically takes ~60 minutes but can take several hours during network congestion.",
inputSchema: {
type: "object",
properties: { hash: { type: "string", description: "SHA-256 hex digest (64 chars)" } },
required: ["hash"]
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true
}
},
{
name: "upgrade_timestamp",
description: "Attempts to upgrade a pending OpenTimestamps proof by fetching the latest merkle tree from the calendars. If Bitcoin has included the timestamp, the proof becomes confirmed and the bitcoin_block is recorded. Safe to call repeatedly \u2014 if not yet confirmed, it schedules the next retry automatically.",
inputSchema: {
type: "object",
properties: { id: { type: "string", description: "UUID from the stamp record" } },
required: ["id"]
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true
}
},
{
name: "verify_timestamp",
description: "Verifies a timestamp proof against the Bitcoin blockchain via an Esplora API. Proves that a specific hash existed before a given Bitcoin block height. Does NOT affirm document authorship, content truth, or legal validity \u2014 it only provides a cryptographic proof of existence at a point in time.",
inputSchema: {
type: "object",
properties: { id: { type: "string", description: "UUID from the stamp record" } },
required: ["id"]
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true
}
},
{
name: "inspect_timestamp",
description: "Reads a stored proof file from disk without any network calls. Returns proof metadata including size, number of calendar attestations (pending promises from OTS servers) and Bitcoin attestations (actual confirmed blocks). A stamp is only truly confirmed when bitcoin_attestations > 0 and bitcoin_confirmed is true \u2014 calendar_attestations alone do not prove Bitcoin confirmation.",
inputSchema: {
type: "object",
properties: { id: { type: "string", description: "UUID from the stamp record" } },
required: ["id"]
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false
}
},
{
name: "list_pending",
description: "Lists stamp records from the local database with their current status, retry count, and next scheduled upgrade time. Filter by status (pending, confirmed, failed), page through results, or find stamps older than N hours. Use this to monitor the state of all timestamped hashes.",
inputSchema: {
type: "object",
properties: {
status: { type: "string", enum: ["pending", "confirmed", "failed", "timeout"] },
limit: { type: "number", maximum: 200 },
offset: { type: "number" },
older_than_hours: { type: "number" }
}
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false
}
},
{
name: "hash_file",
description: "Computes the SHA-256 hash of a local file and returns it as a 64-character hex string. Purely local \u2014 no network calls, no data stored. Use this to get the hash before calling create_timestamp, or to verify the integrity of a file independently.",
inputSchema: {
type: "object",
properties: { path: { type: "string", description: "Absolute path to the file" } },
required: ["path"]
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false
}
},
{
name: "stamp_file",
description: "Convenience tool that hashes a local file and stamps it on Bitcoin in one step. Computes the SHA-256 of the file, then submits it to four public OTS calendars. The file contents are never sent externally \u2014 only the hash is. Returns a stamp ID for tracking confirmation.",
inputSchema: {
type: "object",
properties: { path: { type: "string", description: "Absolute path to the file to stamp" } },
required: ["path"]
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true
}
},
{
name: "watch",
description: "Opens a new terminal window that continuously monitors pending stamps and attempts due upgrades at each interval. Useful for long-running monitoring sessions after stamping. The window remains open so the user can watch confirmation progress in real time. Minimum interval is 15 minutes to avoid hammering OTS calendars.",
inputSchema: {
type: "object",
properties: {
interval_minutes: { type: "number", description: "Polling interval in minutes (default: 30, minimum: 15)" }
}
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true
}
}
];
// src/schemas.ts
import { z } from "zod";
function parse(schema, args) {
const r = schema.safeParse(args ?? {});
if (!r.success) {
const msg = r.error.issues[0]?.message ?? "invalid input";
throw new Error(`invalid_params: ${msg}`);
}
return r.data;
}
var HashInput = z.strictObject({ hash: z.string() });
var IdInput = z.strictObject({ id: z.string().min(1) });
var PathInput = z.strictObject({ path: z.string().min(1).max(4096) });
var ListInput = z.strictObject({
status: z.enum(["pending", "confirmed", "failed", "timeout", "missing_proof"]).optional(),
limit: z.number().int().min(1).max(200).optional(),
offset: z.number().int().min(0).optional(),
older_than_hours: z.number().positive().optional(),
due_now: z.boolean().optional()
});
var WatchInput = z.strictObject({
interval_minutes: z.number().int().min(15).max(1440).optional()
});
// src/feature-gate.ts
var STAMP_TOOLS = /* @__PURE__ */ new Set([
"create_timestamp",
"upgrade_timestamp",
"verify_timestamp",
"inspect_timestamp",
"list_pending",
"stamp_file",
"hash_file",
"watch"
]);
var PRESERVE_TOOLS = /* @__PURE__ */ new Set(["stamp_file"]);
function featureDisabledError(name, config) {
if (STAMP_TOOLS.has(name) && !config.stamp_enabled) return { error: "feature_disabled", feature: "stamp" };
if (PRESERVE_TOOLS.has(name) && !config.preserve_enabled) return { error: "feature_disabled", feature: "preserve" };
return null;
}
// src/server.ts
async function runServer() {
let config = null;
const getConfig = () => {
if (!config) {
config = loadConfig();
}
return config;
};
const server = new Server(
{ name: "ots-mcp", version: "0.1.0" },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: TOOL_DEFINITIONS
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
const db = getDb();
const config2 = getConfig();
const gate = featureDisabledError(name, config2);
if (gate) return { content: [{ type: "text", text: JSON.stringify(gate) }], isError: true };
try {
let result;
switch (name) {
case "create_timestamp":
result = await createTimestamp(parse(HashInput, args), db, config2);
break;
case "upgrade_timestamp":
result = await upgradeTimestamp(parse(IdInput, args), db, config2);
break;
case "verify_timestamp":
result = await verifyTimestamp(parse(IdInput, args), db, config2);
break;
case "inspect_timestamp":
result = inspectTimestamp(parse(IdInput, args), db, config2);
break;
case "list_pending":
result = listPending(parse(ListInput, args), db, config2);
break;
case "hash_file":
result = await hashFileTool(parse(PathInput, args), config2);
break;
case "stamp_file":
result = await stampFile(parse(PathInput, args), db, config2);
break;
case "watch":
result = openWatchWindow(parse(WatchInput, args).interval_minutes);
break;
default:
return { content: [{ type: "text", text: JSON.stringify({ error: "unknown_tool", tool: name }) }], isError: true };
}
const isError = Boolean(result && typeof result === "object" && "error" in result);
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], isError };
} catch (e) {
const details = String(e);
const code = details.includes("invalid_params") ? "invalid_params" : "internal_error";
return { content: [{ type: "text", text: JSON.stringify({ error: code, details }) }], isError: true };
}
});
const exit = () => {
try {
getDb().close();
} catch {
}
process.exit(0);
};
process.stdin.on("close", exit);
process.on("SIGTERM", exit);
process.on("SIGINT", exit);
const transport = new StdioServerTransport();
await server.connect(transport);
}
export {
runServer
};
import {
normalizeWatchInterval,
watchPending
} from "./chunk-OO26D46G.js";
import "./chunk-CM737OZR.js";
import "./chunk-PETURIDM.js";
export {
normalizeWatchInterval,
watchPending
};
+7
-6

@@ -23,3 +23,3 @@ #!/usr/bin/env node

case "serve": {
const { runServer } = await import("./server-5ECBWC53.js");
const { runServer } = await import("./server-AZKD7PQJ.js");
await runServer();

@@ -53,8 +53,9 @@ break;

case "watch": {
const { normalizeWatchInterval, watchPending } = await import("./watch-TJ3HPEHH.js");
const parsed = args[0] ? parseInt(args[0], 10) : NaN;
const interval = normalizeWatchInterval(isNaN(parsed) ? void 0 : parsed);
if (args[0] && (isNaN(parsed) || parsed < 15))
const { normalizeWatchInterval, watchPending } = await import("./watch-U54QJOMV.js");
const parsed = args[0] ? Number.parseInt(args[0], 10) : Number.NaN;
const interval = normalizeWatchInterval(Number.isNaN(parsed) ? void 0 : parsed);
if (args[0] && (Number.isNaN(parsed) || parsed < 15)) {
process.stderr.write(`Invalid interval "${args[0]}", using ${interval} min
`);
}
await watchPending(interval);

@@ -70,3 +71,3 @@ break;

case "scheduler": {
const { runCli } = await import("./cli-CJKURFLC.js");
const { runCli } = await import("./cli-CANM7A2I.js");
await runCli(command, args);

@@ -73,0 +74,0 @@ break;

{
"name": "@otskit/mcp",
"mcpName": "io.github.AlexAlves87/otskit-mcp",
"version": "0.8.1",
"version": "0.8.2",
"license": "MIT",

@@ -6,0 +6,0 @@ "description": "OpenTimestamps MCP server — stamp, upgrade, verify via AI agents",

@@ -15,3 +15,2 @@ <p align="center">

[![License](https://img.shields.io/npm/l/@otskit/mcp)](LICENSE)
[![Glama](https://glama.ai/mcp/servers/OTSkit/OTSkit-MCP/badges/score.svg)](https://glama.ai/mcp/servers/OTSkit/OTSkit-MCP)
[![smithery badge](https://smithery.ai/badge/otskit/otskit-mcp)](https://smithery.ai/servers/otskit/otskit-mcp)

@@ -23,4 +22,8 @@

> **Note on confirmation times:** After stamping, a proof is `pending` until Bitcoin confirms it. Confirmations typically arrive within **10–60 minutes**, but can take **several hours** during network congestion. Use `ots-mcp watch` or `upgrade_timestamp` to monitor. A pending proof is not a failed proof.
<a href="https://glama.ai/mcp/servers/@OTSkit/OTSkit-MCP">
<img width="380" height="200" src="https://glama.ai/mcp/servers/@OTSkit/OTSkit-MCP/badge" alt="OTSkit MCP server on Glama — security, license and quality rating plus download count" />
</a>
> **Note on confirmation times:** After stamping, a proof is `pending` until Bitcoin confirms it. Confirmations typically arrive within **~60 minutes**, but can take **several hours** during network congestion. Use `ots-mcp watch` or `upgrade_timestamp` to monitor. A pending proof is not a failed proof.
## Install

@@ -27,0 +30,0 @@

import {
getDb,
loadConfig,
upgradeTimestamp
} from "./chunk-PLEDJI67.js";
// src/tools/watch.ts
var DEFAULT_WATCH_INTERVAL_MINUTES = 30;
var MIN_WATCH_INTERVAL_MINUTES = 15;
var MAX_UPGRADES_PER_TICK = 20;
function normalizeWatchInterval(intervalMinutes = DEFAULT_WATCH_INTERVAL_MINUTES) {
if (!Number.isFinite(intervalMinutes)) return DEFAULT_WATCH_INTERVAL_MINUTES;
return Math.max(MIN_WATCH_INTERVAL_MINUTES, Math.floor(intervalMinutes));
}
async function watchPending(intervalMinutes = DEFAULT_WATCH_INTERVAL_MINUTES) {
const config = loadConfig();
const db = getDb();
const minutes = normalizeWatchInterval(intervalMinutes);
process.stdout.write(`Watching pending stamps and upgrading due proofs every ${minutes} min. Ctrl+C to stop.
`);
async function tick() {
const dueRows = db.all(
`SELECT id FROM stamps
WHERE status = 'pending'
AND (next_retry_at IS NULL OR next_retry_at <= ?)
ORDER BY created_at ASC
LIMIT ?`,
[(/* @__PURE__ */ new Date()).toISOString(), MAX_UPGRADES_PER_TICK]
);
if (dueRows.length > 0) {
process.stdout.write(`${now()} - upgrading ${dueRows.length} due stamp(s)
`);
}
for (const row of dueRows) {
const result = await upgradeTimestamp({ id: row.id }, db, config);
const status = "status" in result ? result.status : `error:${result.error}`;
process.stdout.write(` upgrade ${row.id.slice(0, 8)} -> ${status}
`);
}
const rows = db.all(
`SELECT id, hash, status, attempt_count, bitcoin_block, confirmed_at, next_retry_at
FROM stamps
WHERE status != 'confirmed'
ORDER BY created_at DESC`
);
const confirmed = db.get(`SELECT COUNT(*) as n FROM stamps WHERE status = 'confirmed'`);
process.stdout.write(`${now()} - ${rows.length} pending, ${confirmed.n} confirmed
`);
for (const row of rows) {
const next = row.next_retry_at ? ` next ${row.next_retry_at.replace("T", " ").slice(0, 19)}` : "";
process.stdout.write(` ${row.id.slice(0, 8)} ${row.status} (${row.attempt_count} attempts)${next}
`);
}
if (rows.length === 0) {
process.stdout.write(` (no pending stamps)
`);
}
process.stdout.write("\n");
}
function now() {
return (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19);
}
async function loop() {
try {
await tick();
} catch (e) {
process.stderr.write(`watch error: ${String(e)}
`);
}
setTimeout(loop, minutes * 60 * 1e3);
}
await loop();
}
export {
normalizeWatchInterval,
watchPending
};
// src/utils.ts
import { execFileSync } from "child_process";
import { writeFileSync, renameSync, realpathSync, statSync, createReadStream } from "fs";
import { resolve, sep } from "path";
import { createHash } from "crypto";
import { pipeline } from "stream/promises";
import { Transform } from "stream";
function which(cmd) {
try {
const out = execFileSync(
process.platform === "win32" ? "where" : "which",
[cmd]
).toString().trim();
return out.split("\n")[0] ?? null;
} catch {
return null;
}
}
function writeAtomic(dest, data) {
const tmp = dest + ".tmp";
writeFileSync(tmp, data);
renameSync(tmp, dest);
}
function escapeXml(raw) {
return raw.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
}
function validateFilePath(rawPath, whitelist) {
let canonical;
try {
canonical = realpathSync(rawPath);
} catch (e) {
return { error: "invalid_path", details: String(e?.message ?? e) };
}
if (whitelist.length > 0) {
const allowed = whitelist.some((dir) => {
const root = resolve(dir);
return canonical === root || canonical.startsWith(root + sep);
});
if (!allowed) return { error: "path_not_allowed", details: `${canonical} is outside allowed directories` };
}
let st;
try {
st = statSync(canonical);
} catch (e) {
return { error: "invalid_path", details: String(e?.message ?? e) };
}
if (!st.isFile()) return { error: "not_a_regular_file", details: `${canonical} is not a regular file` };
return { path: canonical };
}
async function hashFileStreaming(filePath, maxBytes) {
const hash = createHash("sha256");
let bytesRead = 0;
const limiter = new Transform({
transform(chunk, _enc, cb) {
bytesRead += chunk.length;
if (bytesRead > maxBytes) cb(new Error(`file_too_large: exceeds ${maxBytes} bytes`));
else cb(null, chunk);
}
});
await pipeline(createReadStream(filePath), limiter, hash);
return hash.digest("hex");
}
export {
which,
writeAtomic,
escapeXml,
validateFilePath,
hashFileStreaming
};
import {
writeAtomic
} from "./chunk-IB2AYNP4.js";
// src/config.ts
import { readFileSync, existsSync, mkdirSync } from "fs";
import { join } from "path";
import { homedir } from "os";
import { z } from "zod";
import { DEFAULT_CALENDARS } from "@otskit/client";
var TRUSTED_CALENDAR_HOSTS = new Set(
DEFAULT_CALENDARS.map((u) => new URL(u).hostname)
);
var httpsAllowlisted = (hosts) => z.string().refine((v) => {
try {
const u = new URL(v);
return u.protocol === "https:" && hosts.has(u.hostname);
} catch {
return false;
}
}, { message: "URL must be https and in the host allowlist" });
var ConfigSchema = z.strictObject({
stamp_enabled: z.boolean(),
preserve_enabled: z.boolean(),
preserve_whitelist: z.array(z.string()),
preserve_max_bytes: z.number().int().positive().max(10 * 1024 ** 3),
preserve_max_files: z.number().int().positive().max(1e5),
scheduler_interval_minutes: z.number().int().min(1).max(1440),
calendar_timeout_ms: z.number().int().min(1e3).max(6e4),
retry_max_attempts: z.number().int().min(1).max(100),
log_file: z.string(),
calendars: z.array(httpsAllowlisted(TRUSTED_CALENDAR_HOSTS)).min(1).max(10)
}).partial();
function getDataDir() {
return process.env.OTS_MCP_DATA_DIR ?? join(homedir(), ".ots-mcp");
}
var DEFAULTS = {
stamp_enabled: true,
preserve_enabled: true,
preserve_whitelist: [],
preserve_max_bytes: 104857600,
preserve_max_files: 1e4,
scheduler_interval_minutes: 30,
calendar_timeout_ms: 1e4,
retry_max_attempts: 20,
log_file: join(getDataDir(), "ots-mcp.log"),
calendars: [...DEFAULT_CALENDARS]
};
function loadConfig() {
const dir = getDataDir();
mkdirSync(dir, { recursive: true });
const configPath = join(dir, "config.json");
if (!existsSync(configPath)) return { ...DEFAULTS };
let raw;
try {
raw = JSON.parse(readFileSync(configPath, "utf8"));
} catch (e) {
process.stderr.write(`[ots-mcp] config parse error, using defaults: ${e}
`);
return { ...DEFAULTS };
}
const parsed = ConfigSchema.safeParse(raw);
if (!parsed.success) {
process.stderr.write(`[ots-mcp] config validation failed, using defaults: ${parsed.error.issues[0]?.message}
`);
return { ...DEFAULTS };
}
return { ...DEFAULTS, ...parsed.data };
}
// src/db/index.ts
import { createRequire } from "module";
import { join as join2 } from "path";
import { mkdirSync as mkdirSync2, statSync } from "fs";
// src/db/schema.ts
function initDb(db) {
db.exec("PRAGMA busy_timeout = 5000");
db.exec("PRAGMA foreign_keys = ON");
runMigrations(db);
}
function runMigrations(db) {
const row = db.get("PRAGMA user_version");
if (row.user_version < 1) migrateTo1(db);
}
function migrateTo1(db) {
db.exec("BEGIN");
try {
db.exec(`
CREATE TABLE IF NOT EXISTS stamps (
id TEXT PRIMARY KEY,
hash TEXT NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
confirmed_at TEXT,
bitcoin_block INTEGER,
bitcoin_time TEXT,
proof_path TEXT,
archive_path TEXT,
last_attempt_at TEXT,
attempt_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
next_retry_at TEXT,
metadata TEXT
);
CREATE INDEX IF NOT EXISTS idx_stamps_hash ON stamps(hash);
CREATE INDEX IF NOT EXISTS idx_stamps_status ON stamps(status);
CREATE TABLE IF NOT EXISTS operations_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stamp_id TEXT NOT NULL REFERENCES stamps(id),
action TEXT NOT NULL,
result TEXT NOT NULL,
error_msg TEXT,
calendar_uri TEXT,
response_time_ms INTEGER,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_oplog_stamp_id ON operations_log(stamp_id);
CREATE INDEX IF NOT EXISTS idx_oplog_created ON operations_log(created_at);
`);
db.exec("PRAGMA user_version = 1");
db.exec("COMMIT");
} catch (e) {
db.exec("ROLLBACK");
throw e;
}
}
// src/db/index.ts
var _db = null;
function getDb() {
if (_db) return _db;
const _require = createRequire(import.meta.url);
const { Database } = _require("node-sqlite3-wasm");
const dir = getDataDir();
mkdirSync2(dir, { recursive: true });
_db = new Database(join2(dir, "db.sqlite"));
initDb(_db);
reconcileOrphans(_db);
return _db;
}
function backupDb(destPath) {
const escaped = destPath.replace(/'/g, "''");
getDb().exec(`VACUUM INTO '${escaped}'`);
}
function reconcileOrphans(db) {
const pending = db.all(
`SELECT id, proof_path FROM stamps WHERE status = 'pending' AND proof_path IS NOT NULL`
);
for (const row of pending) {
try {
statSync(row.proof_path);
} catch {
db.run(
`UPDATE stamps SET status = 'missing_proof', last_error = ? WHERE id = ?`,
["proof file not found at startup", row.id]
);
}
}
}
// src/tools/upgrade-timestamp.ts
import { readFileSync as readFileSync2 } from "fs";
import { OpenTimestampsClient, UpgradeError } from "@otskit/client";
import { DetachedTimestampFile } from "@otskit/client";
// src/db/stamps.ts
function insertStamp(db, params) {
const now = (/* @__PURE__ */ new Date()).toISOString();
db.run(
`INSERT INTO stamps (id, hash, status, created_at, proof_path, archive_path, attempt_count, metadata)
VALUES (?, ?, 'pending', ?, ?, ?, 0, ?)`,
[params.id, params.hash, now, params.proof_path, params.archive_path ?? null, params.metadata ?? null]
);
return getStamp(db, params.id);
}
function getStamp(db, id) {
return db.get("SELECT * FROM stamps WHERE id = ?", [id]) ?? null;
}
function updateStampStatus(db, id, params) {
const fields = [];
const values = [];
const add = (col, val) => {
fields.push(`${col} = ?`);
values.push(val);
};
if (params.status !== void 0) add("status", params.status);
if (params.bitcoin_block !== void 0) add("bitcoin_block", params.bitcoin_block);
if (params.bitcoin_time !== void 0) add("bitcoin_time", params.bitcoin_time);
if (params.confirmed_at !== void 0) add("confirmed_at", params.confirmed_at);
if (params.last_error !== void 0) add("last_error", params.last_error);
if (params.attempt_count !== void 0) add("attempt_count", params.attempt_count);
if (params.last_attempt_at !== void 0) add("last_attempt_at", params.last_attempt_at);
if (params.next_retry_at !== void 0) add("next_retry_at", params.next_retry_at);
if (fields.length === 0) return;
values.push(id);
db.run(`UPDATE stamps SET ${fields.join(", ")} WHERE id = ?`, values);
}
function listStamps(db, params) {
const conds = [];
const vals = [];
if (params.status) {
conds.push("status = ?");
vals.push(params.status);
}
if (params.older_than_hours) {
const cutoff = new Date(Date.now() - params.older_than_hours * 36e5).toISOString();
conds.push("created_at < ?");
vals.push(cutoff);
}
if (params.due_now) {
conds.push("(next_retry_at IS NULL OR next_retry_at <= ?)");
vals.push((/* @__PURE__ */ new Date()).toISOString());
}
const where = conds.length ? `WHERE ${conds.join(" AND ")}` : "";
const countParams = vals.length ? vals : void 0;
const total = db.get(`SELECT COUNT(*) as n FROM stamps ${where}`, countParams).n;
const items = db.all(
`SELECT * FROM stamps ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`,
[...vals, params.limit, params.offset]
);
return { items, total };
}
// src/db/operations-log.ts
function logOperation(db, params) {
db.run(
`INSERT INTO operations_log (stamp_id, action, result, error_msg, calendar_uri, response_time_ms, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
params.stamp_id,
params.action,
params.result,
params.error_msg ?? null,
params.calendar_uri ?? null,
params.response_time_ms ?? null,
(/* @__PURE__ */ new Date()).toISOString()
]
);
}
// src/tools/upgrade-timestamp.ts
function collectAttestations(ts) {
const atts = [...ts.attestations];
for (const branch of ts.branches) {
atts.push(...collectAttestations(branch.stamp));
}
return atts;
}
function checkBitcoinConfirmation(bytes) {
try {
const dtf = DetachedTimestampFile.deserialize(new Uint8Array(bytes));
const attestations = collectAttestations(dtf.timestamp);
const bitcoinAtts = attestations.filter((a) => a.kind === "bitcoin");
if (bitcoinAtts.length === 0) return { confirmed: false };
const block = Math.min(...bitcoinAtts.map((a) => a.height));
return { confirmed: true, block };
} catch {
return { confirmed: false };
}
}
function nextRetryAt(attemptCount) {
const base = Math.min(3e4 * Math.pow(2, attemptCount), 36e5);
const jitter = Math.random() * 0.2 * base;
return new Date(Date.now() + base + jitter).toISOString();
}
async function upgradeTimestamp(input, db, config) {
const record = getStamp(db, input.id);
if (!record) return { error: "not_found", details: `No stamp with id ${input.id}` };
if (!record.proof_path) return { error: "storage_error", details: "No proof_path on record" };
const proofBefore = readFileSync2(record.proof_path);
const client = new OpenTimestampsClient({
calendars: config.calendars,
resilience: {
totalTimeoutMs: config.calendar_timeout_ms,
connectTimeoutMs: Math.min(config.calendar_timeout_ms, 5e3),
retries: { enabled: true, maxAttempts: config.retry_max_attempts, backoff: { strategy: "exponential", initialDelayMs: 500, jitter: "full" } }
}
});
const now = (/* @__PURE__ */ new Date()).toISOString();
const newAttemptCount = record.attempt_count + 1;
const next = nextRetryAt(newAttemptCount);
let upgraded;
try {
upgraded = await client.upgrade(proofBefore);
} catch (e) {
if (e instanceof UpgradeError) {
try {
const v = await client.verify(proofBefore, record.hash);
if (v.status === "verified") {
const bitcoinTime = new Date(v.blockTime * 1e3).toISOString();
updateStampStatus(db, input.id, {
status: "confirmed",
bitcoin_block: v.blockHeight,
bitcoin_time: bitcoinTime,
confirmed_at: now,
last_attempt_at: now,
attempt_count: newAttemptCount
});
logOperation(db, { stamp_id: input.id, action: "upgrade", result: "success" });
return { id: input.id, status: "confirmed", bitcoin_block: v.blockHeight, bitcoin_time: bitcoinTime };
}
} catch {
}
updateStampStatus(db, input.id, { last_attempt_at: now, attempt_count: newAttemptCount, next_retry_at: next });
logOperation(db, { stamp_id: input.id, action: "upgrade", result: "pending" });
return { id: input.id, status: "pending", attempt_count: newAttemptCount, last_attempt_at: now, next_retry_at: next };
}
updateStampStatus(db, input.id, { last_attempt_at: now, attempt_count: newAttemptCount, last_error: String(e), next_retry_at: next });
logOperation(db, { stamp_id: input.id, action: "upgrade", result: "failed", error_msg: String(e) });
return { error: "calendar_error", details: String(e) };
}
writeAtomic(record.proof_path, upgraded);
const { confirmed, block } = checkBitcoinConfirmation(upgraded);
if (confirmed && block !== void 0) {
const bitcoinTime = now;
updateStampStatus(db, input.id, {
status: "confirmed",
bitcoin_block: block,
bitcoin_time: bitcoinTime,
confirmed_at: now,
last_attempt_at: now,
attempt_count: newAttemptCount
});
logOperation(db, { stamp_id: input.id, action: "upgrade", result: "success" });
return { id: input.id, status: "confirmed", bitcoin_block: block, bitcoin_time: bitcoinTime };
}
updateStampStatus(db, input.id, { last_attempt_at: now, attempt_count: newAttemptCount, next_retry_at: next });
logOperation(db, { stamp_id: input.id, action: "upgrade", result: "pending" });
return { id: input.id, status: "pending", attempt_count: newAttemptCount, last_attempt_at: now, next_retry_at: next };
}
export {
getDataDir,
loadConfig,
getDb,
backupDb,
insertStamp,
getStamp,
updateStampStatus,
listStamps,
logOperation,
upgradeTimestamp
};
import {
getDataDir,
getStamp,
insertStamp,
listStamps,
logOperation,
updateStampStatus
} from "./chunk-PLEDJI67.js";
import {
writeAtomic
} from "./chunk-IB2AYNP4.js";
// src/tools/create-timestamp.ts
import { mkdirSync, unlinkSync } from "fs";
import { join } from "path";
import { randomUUID } from "crypto";
import { OpenTimestampsClient } from "@otskit/client";
var HEX64 = /^[0-9a-f]{64}$/i;
async function createTimestamp(input, db, config) {
if (!HEX64.test(input.hash)) {
return { error: "invalid_hash", details: "hash must be 64 hex characters (SHA-256)" };
}
const normalizedHash = input.hash.toLowerCase();
const client = new OpenTimestampsClient({
calendars: config.calendars,
resilience: {
totalTimeoutMs: config.calendar_timeout_ms,
connectTimeoutMs: Math.min(config.calendar_timeout_ms, 5e3),
retries: { enabled: true, maxAttempts: config.retry_max_attempts, backoff: { strategy: "exponential", initialDelayMs: 500, jitter: "full" } }
}
});
const t0 = Date.now();
let proofBuffer;
try {
proofBuffer = await client.stamp(normalizedHash);
} catch (e) {
return { error: "calendar_error", details: String(e) };
}
const responseTimeMs = Date.now() - t0;
const id = randomUUID();
const proofDir = join(getDataDir(), "proofs");
mkdirSync(proofDir, { recursive: true });
const proofPath = join(proofDir, `${id}.ots`);
try {
writeAtomic(proofPath, proofBuffer);
} catch (e) {
return { error: "storage_error", details: String(e) };
}
let record;
db.exec("BEGIN");
try {
record = insertStamp(db, { id, hash: normalizedHash, proof_path: proofPath });
logOperation(db, { stamp_id: id, action: "stamp", result: "success", response_time_ms: responseTimeMs });
db.exec("COMMIT");
} catch (e) {
db.exec("ROLLBACK");
try {
unlinkSync(proofPath);
} catch {
}
return { error: "storage_error", details: String(e) };
}
return {
id: record.id,
hash: record.hash,
status: "pending",
calendars: config.calendars,
created_at: record.created_at
};
}
// src/tools/verify-timestamp.ts
import { readFileSync } from "fs";
import { OpenTimestampsClient as OpenTimestampsClient2 } from "@otskit/client";
async function verifyTimestamp(input, db, config) {
const record = getStamp(db, input.id);
if (!record) return { error: "not_found", details: `No stamp with id ${input.id}` };
if (!record.proof_path) return { error: "storage_error", details: "No proof_path on record" };
let proofBytes;
try {
proofBytes = readFileSync(record.proof_path);
} catch (e) {
return { error: "storage_error", details: String(e) };
}
const client = new OpenTimestampsClient2({
calendars: config.calendars,
resilience: {
totalTimeoutMs: config.calendar_timeout_ms,
connectTimeoutMs: Math.min(config.calendar_timeout_ms, 5e3),
retries: { enabled: true, maxAttempts: config.retry_max_attempts, backoff: { strategy: "exponential", initialDelayMs: 500, jitter: "full" } }
}
});
let result;
try {
result = await client.verify(proofBytes, record.hash);
} catch (e) {
logOperation(db, { stamp_id: input.id, action: "verify", result: "failed", error_msg: String(e) });
return { status: "network_error", hash: record.hash, details: String(e) };
}
switch (result.status) {
case "pending":
logOperation(db, { stamp_id: input.id, action: "verify", result: "pending" });
return { status: "pending", hash: record.hash, calendars: config.calendars };
case "invalid":
logOperation(db, { stamp_id: input.id, action: "verify", result: "failed", error_msg: result.reason });
return { status: "invalid", hash: record.hash, reason: result.reason };
case "network_error":
logOperation(db, { stamp_id: input.id, action: "verify", result: "failed", error_msg: result.reason });
return { status: "network_error", hash: record.hash, details: result.reason };
case "verified": {
const bitcoinTime = new Date(result.blockTime * 1e3).toISOString();
const now = (/* @__PURE__ */ new Date()).toISOString();
updateStampStatus(db, input.id, {
status: "confirmed",
bitcoin_block: result.blockHeight,
bitcoin_time: bitcoinTime,
confirmed_at: now
});
logOperation(db, { stamp_id: input.id, action: "verify", result: "success" });
return {
status: "confirmed",
hash: record.hash,
bitcoin_block: result.blockHeight,
bitcoin_time: bitcoinTime
};
}
/* c8 ignore next 4 */
default: {
const _exhaustive = result;
return { status: "unknown", hash: record.hash };
}
}
}
// src/tools/list-pending.ts
function toPublic({ attempt_count: _a, last_attempt_at: _b, next_retry_at: _c, proof_path: _d, archive_path: _e, ...rest }) {
return rest;
}
function listPending(input, db, _config) {
const result = listStamps(db, {
status: input.status ?? "pending",
limit: Math.min(input.limit ?? 50, 200),
offset: input.offset ?? 0,
older_than_hours: input.older_than_hours,
due_now: input.due_now
});
return { items: result.items.map(toPublic), total: result.total };
}
export {
createTimestamp,
verifyTimestamp,
listPending
};
import {
createTimestamp,
listPending,
verifyTimestamp
} from "./chunk-TDZK25BZ.js";
import {
backupDb,
getDb,
loadConfig,
upgradeTimestamp
} from "./chunk-PLEDJI67.js";
import "./chunk-IB2AYNP4.js";
// src/cli.ts
async function runCli(command, args) {
const config = loadConfig();
const db = getDb();
switch (command) {
case "stamp": {
const hash = args[0];
if (!hash) {
process.stderr.write("Usage: ots-mcp stamp <sha256-hash>\n");
process.exit(1);
}
const result = await createTimestamp({ hash }, db, config);
if ("error" in result) {
process.stderr.write(`Error: ${result.error} \u2014 ${result.details}
`);
process.exit(1);
}
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
break;
}
case "upgrade": {
const id = args[0];
if (!id) {
process.stderr.write("Usage: ots-mcp upgrade <id>\n");
process.exit(1);
}
const result = await upgradeTimestamp({ id }, db, config);
if ("error" in result) {
process.stderr.write(`Error: ${result.error} \u2014 ${result.details}
`);
process.exit(1);
}
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
break;
}
case "verify": {
const id = args[0];
if (!id) {
process.stderr.write("Usage: ots-mcp verify <id>\n");
process.exit(1);
}
const result = await verifyTimestamp({ id }, db, config);
if ("error" in result) {
process.stderr.write(`Error: ${result.error} \u2014 ${result.details}
`);
process.exit(1);
}
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
break;
}
case "list": {
const status = args[0] ?? "pending";
const result = listPending({ status }, db, config);
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
break;
}
case "check-pending": {
const { items } = listPending({ status: "pending", limit: 200, due_now: true }, db, config);
process.stderr.write(`Processing ${items.length} pending stamps...
`);
for (const record of items) {
const result = await upgradeTimestamp({ id: record.id }, db, config);
const statusStr = "status" in result ? result.status : `error:${result.error}`;
process.stderr.write(`${record.id.slice(0, 8)}: ${statusStr}
`);
}
process.exit(0);
}
case "backup": {
const dest = args[0] ?? `ots-mcp-backup-${Date.now()}.sqlite`;
backupDb(dest);
process.stdout.write(`Backup saved to ${dest}
`);
break;
}
case "scheduler": {
const { runScheduler } = await import("./scheduler-B4HFMIOK.js");
await runScheduler(args);
break;
}
}
}
export {
runCli
};
import {
escapeXml,
which
} from "./chunk-IB2AYNP4.js";
// src/scheduler/install.ts
import { execFileSync } from "child_process";
import { mkdtempSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
async function installScheduler(args) {
const intervalIdx = args.indexOf("--interval");
const parsedInterval = intervalIdx !== -1 ? parseInt(args[intervalIdx + 1] ?? "30") : 30;
const interval = Math.max(1, Math.min(1440, Number.isFinite(parsedInterval) ? parsedInterval : 30));
const bin = which("ots-mcp") ?? process.argv[1];
if (process.platform === "win32") {
const workDir = mkdtempSync(join(tmpdir(), "ots-mcp-"));
const xmlPath = join(workDir, "task.xml");
try {
writeFileSync(xmlPath, `<?xml version="1.0"?>
<Task xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<Triggers><TimeTrigger>
<Repetition><Interval>PT${interval}M</Interval><StopAtDurationEnd>false</StopAtDurationEnd></Repetition>
<StartBoundary>2020-01-01T00:00:00</StartBoundary><Enabled>true</Enabled>
</TimeTrigger></Triggers>
<Actions><Exec>
<Command>${escapeXml(bin)}</Command>
<Arguments>check-pending</Arguments>
</Exec></Actions>
</Task>`);
execFileSync("schtasks", ["/create", "/tn", "ots-mcp-check-pending", "/xml", xmlPath, "/f"]);
} finally {
rmSync(workDir, { recursive: true, force: true });
}
process.stdout.write(`Scheduler installed: runs every ${interval} minutes
`);
} else {
process.stdout.write(`Add to crontab (run: crontab -e):
`);
process.stdout.write(`*/${interval} * * * * "${bin}" check-pending
`);
}
}
export {
installScheduler
};
// src/scheduler/index.ts
async function runScheduler(args) {
const [sub, ...rest] = args;
switch (sub) {
case "install": {
const { installScheduler } = await import("./install-A7BHHPUP.js");
await installScheduler(rest);
break;
}
case "remove": {
const { removeScheduler } = await import("./remove-BGFQRDX3.js");
await removeScheduler();
break;
}
case "status": {
const { statusScheduler } = await import("./status-M6A2RG7G.js");
await statusScheduler();
break;
}
default:
process.stderr.write("Usage: ots-mcp scheduler install [--interval N] | remove | status\n");
process.exit(1);
}
}
export {
runScheduler
};
import {
createTimestamp,
listPending,
verifyTimestamp
} from "./chunk-TDZK25BZ.js";
import {
normalizeWatchInterval
} from "./chunk-7JZDZK3N.js";
import {
getDb,
getStamp,
loadConfig,
upgradeTimestamp
} from "./chunk-PLEDJI67.js";
import {
hashFileStreaming,
validateFilePath
} from "./chunk-IB2AYNP4.js";
// src/server.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
// src/tools/inspect-timestamp.ts
import { readFileSync, statSync } from "fs";
import { DetachedTimestampFile } from "@otskit/client";
function inspectTimestamp(input, db, _config) {
const record = getStamp(db, input.id);
if (!record) return { error: "not_found", details: `No stamp with id ${input.id}` };
if (!record.proof_path) return { error: "proof_missing", details: "No proof file on record" };
let proofBytes;
let proofSize;
try {
proofSize = statSync(record.proof_path).size;
proofBytes = readFileSync(record.proof_path);
} catch {
return { error: "proof_missing", details: `Cannot read proof: ${record.proof_path}` };
}
let calendarAttestations = 0;
let bitcoinAttestations = 0;
let bitcoinBlock = null;
try {
const proof = DetachedTimestampFile.deserialize(new Uint8Array(proofBytes));
const attestations = proof.timestamp.getAttestations();
bitcoinAttestations = attestations.filter((a) => a.kind === "bitcoin").length;
calendarAttestations = attestations.filter((a) => a.kind !== "bitcoin").length;
if (bitcoinAttestations > 0) {
const blocks = attestations.filter((a) => a.kind === "bitcoin").map((a) => a.height);
bitcoinBlock = blocks.length > 0 ? Math.min(...blocks) : null;
}
} catch {
}
return {
id: record.id,
hash: record.hash,
status: record.status,
created_at: record.created_at,
proof_exists: true,
proof_size_bytes: proofSize,
calendar_attestations: calendarAttestations,
bitcoin_attestations: bitcoinAttestations,
bitcoin_confirmed: bitcoinAttestations > 0,
bitcoin_block: bitcoinBlock
};
}
// src/tools/watch-window.ts
import { exec } from "child_process";
function openWatchWindow(intervalMinutes) {
const minutes = normalizeWatchInterval(intervalMinutes);
const cmd = `start powershell.exe -NoExit -Command "ots-mcp watch ${minutes}"`;
let errorMsg;
exec(cmd, { shell: "cmd" }, (err) => {
if (err) errorMsg = err.message;
});
return { opened: true, interval_minutes: minutes, ...errorMsg ? { error: errorMsg } : {} };
}
// src/tools/stamp-file.ts
async function stampFile(input, db, config) {
const v = validateFilePath(input.path, config.preserve_whitelist);
if ("error" in v) return v;
let hash;
try {
hash = await hashFileStreaming(v.path, config.preserve_max_bytes);
} catch (e) {
if (String(e?.message).startsWith("file_too_large")) return { error: "file_too_large", details: e.message };
throw e;
}
return createTimestamp({ hash }, db, config);
}
// src/tools/hash-file.ts
async function hashFileTool(input, config) {
const v = validateFilePath(input.path, config.preserve_whitelist);
if ("error" in v) return v;
try {
const hash = await hashFileStreaming(v.path, config.preserve_max_bytes);
return { hash };
} catch (e) {
if (String(e?.message).startsWith("file_too_large")) return { error: "file_too_large", details: e.message };
throw e;
}
}
// src/tool-definitions.ts
var TOOL_DEFINITIONS = [
{
name: "create_timestamp",
description: "Creates a verifiable Bitcoin timestamp for a SHA-256 hash using the OpenTimestamps protocol. Submits the hash to four public OTS calendars (alice.btc, bob.btc, finney, catallaxy) and stores a pending proof locally. Returns a stamp ID to track confirmation status. Confirmation typically takes ~60 minutes but can take several hours during network congestion.",
inputSchema: {
type: "object",
properties: { hash: { type: "string", description: "SHA-256 hex digest (64 chars)" } },
required: ["hash"]
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true
}
},
{
name: "upgrade_timestamp",
description: "Attempts to upgrade a pending OpenTimestamps proof by fetching the latest merkle tree from the calendars. If Bitcoin has included the timestamp, the proof becomes confirmed and the bitcoin_block is recorded. Safe to call repeatedly \u2014 if not yet confirmed, it schedules the next retry automatically.",
inputSchema: {
type: "object",
properties: { id: { type: "string", description: "UUID from the stamp record" } },
required: ["id"]
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true
}
},
{
name: "verify_timestamp",
description: "Verifies a timestamp proof against the Bitcoin blockchain via an Esplora API. Proves that a specific hash existed before a given Bitcoin block height. Does NOT affirm document authorship, content truth, or legal validity \u2014 it only provides a cryptographic proof of existence at a point in time.",
inputSchema: {
type: "object",
properties: { id: { type: "string", description: "UUID from the stamp record" } },
required: ["id"]
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true
}
},
{
name: "inspect_timestamp",
description: "Reads a stored proof file from disk without any network calls. Returns proof metadata including size, number of calendar attestations (pending promises from OTS servers) and Bitcoin attestations (actual confirmed blocks). A stamp is only truly confirmed when bitcoin_attestations > 0 and bitcoin_confirmed is true \u2014 calendar_attestations alone do not prove Bitcoin confirmation.",
inputSchema: {
type: "object",
properties: { id: { type: "string", description: "UUID from the stamp record" } },
required: ["id"]
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false
}
},
{
name: "list_pending",
description: "Lists stamp records from the local database with their current status, retry count, and next scheduled upgrade time. Filter by status (pending, confirmed, failed), page through results, or find stamps older than N hours. Use this to monitor the state of all timestamped hashes.",
inputSchema: {
type: "object",
properties: {
status: { type: "string", enum: ["pending", "confirmed", "failed", "timeout"] },
limit: { type: "number", maximum: 200 },
offset: { type: "number" },
older_than_hours: { type: "number" }
}
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false
}
},
{
name: "hash_file",
description: "Computes the SHA-256 hash of a local file and returns it as a 64-character hex string. Purely local \u2014 no network calls, no data stored. Use this to get the hash before calling create_timestamp, or to verify the integrity of a file independently.",
inputSchema: {
type: "object",
properties: { path: { type: "string", description: "Absolute path to the file" } },
required: ["path"]
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false
}
},
{
name: "stamp_file",
description: "Convenience tool that hashes a local file and stamps it on Bitcoin in one step. Computes the SHA-256 of the file, then submits it to four public OTS calendars. The file contents are never sent externally \u2014 only the hash is. Returns a stamp ID for tracking confirmation.",
inputSchema: {
type: "object",
properties: { path: { type: "string", description: "Absolute path to the file to stamp" } },
required: ["path"]
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true
}
},
{
name: "watch",
description: "Opens a new terminal window that continuously monitors pending stamps and attempts due upgrades at each interval. Useful for long-running monitoring sessions after stamping. The window remains open so the user can watch confirmation progress in real time. Minimum interval is 15 minutes to avoid hammering OTS calendars.",
inputSchema: {
type: "object",
properties: {
interval_minutes: { type: "number", description: "Polling interval in minutes (default: 30, minimum: 15)" }
}
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true
}
}
];
// src/schemas.ts
import { z } from "zod";
function parse(schema, args) {
const r = schema.safeParse(args ?? {});
if (!r.success) {
const msg = r.error.issues[0]?.message ?? "invalid input";
throw new Error(`invalid_params: ${msg}`);
}
return r.data;
}
var HashInput = z.strictObject({ hash: z.string() });
var IdInput = z.strictObject({ id: z.string().min(1) });
var PathInput = z.strictObject({ path: z.string().min(1).max(4096) });
var ListInput = z.strictObject({
status: z.enum(["pending", "confirmed", "failed", "timeout", "missing_proof"]).optional(),
limit: z.number().int().min(1).max(200).optional(),
offset: z.number().int().min(0).optional(),
older_than_hours: z.number().positive().optional(),
due_now: z.boolean().optional()
});
var WatchInput = z.strictObject({
interval_minutes: z.number().int().min(15).max(1440).optional()
});
// src/feature-gate.ts
var STAMP_TOOLS = /* @__PURE__ */ new Set([
"create_timestamp",
"upgrade_timestamp",
"verify_timestamp",
"inspect_timestamp",
"list_pending",
"stamp_file",
"hash_file",
"watch"
]);
var PRESERVE_TOOLS = /* @__PURE__ */ new Set(["stamp_file"]);
function featureDisabledError(name, config) {
if (STAMP_TOOLS.has(name) && !config.stamp_enabled) return { error: "feature_disabled", feature: "stamp" };
if (PRESERVE_TOOLS.has(name) && !config.preserve_enabled) return { error: "feature_disabled", feature: "preserve" };
return null;
}
// src/server.ts
async function runServer() {
let config = null;
const getConfig = () => {
if (!config) config = loadConfig();
return config;
};
const server = new Server(
{ name: "ots-mcp", version: "0.1.0" },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: TOOL_DEFINITIONS
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
const db = getDb();
const config2 = getConfig();
const gate = featureDisabledError(name, config2);
if (gate) return { content: [{ type: "text", text: JSON.stringify(gate) }], isError: true };
try {
let result;
switch (name) {
case "create_timestamp":
result = await createTimestamp(parse(HashInput, args), db, config2);
break;
case "upgrade_timestamp":
result = await upgradeTimestamp(parse(IdInput, args), db, config2);
break;
case "verify_timestamp":
result = await verifyTimestamp(parse(IdInput, args), db, config2);
break;
case "inspect_timestamp":
result = inspectTimestamp(parse(IdInput, args), db, config2);
break;
case "list_pending":
result = listPending(parse(ListInput, args), db, config2);
break;
case "hash_file":
result = await hashFileTool(parse(PathInput, args), config2);
break;
case "stamp_file":
result = await stampFile(parse(PathInput, args), db, config2);
break;
case "watch":
result = openWatchWindow(parse(WatchInput, args).interval_minutes);
break;
default:
return { content: [{ type: "text", text: JSON.stringify({ error: "unknown_tool", tool: name }) }], isError: true };
}
const isError = Boolean(result && typeof result === "object" && "error" in result);
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], isError };
} catch (e) {
const details = String(e);
const code = details.includes("invalid_params") ? "invalid_params" : "internal_error";
return { content: [{ type: "text", text: JSON.stringify({ error: code, details }) }], isError: true };
}
});
const exit = () => {
try {
getDb().close();
} catch {
}
process.exit(0);
};
process.stdin.on("close", exit);
process.on("SIGTERM", exit);
process.on("SIGINT", exit);
const transport = new StdioServerTransport();
await server.connect(transport);
}
export {
runServer
};
import {
normalizeWatchInterval,
watchPending
} from "./chunk-7JZDZK3N.js";
import "./chunk-PLEDJI67.js";
import "./chunk-IB2AYNP4.js";
export {
normalizeWatchInterval,
watchPending
};