checksum-mcp 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,3 +19,22 @@ node dist/index.js
19
19
  ```
20
20
 
21
21
  Hashing runs locally with the built in crypto module.
22
+
23
+ <!-- paywall -->
24
+ ## Premium tools
25
+
26
+ These tools need a license key:
27
+
28
+ * `verify_checksum`
29
+ * `directory_manifest`
30
+
31
+ Buy a key at https://mcp-marketplace.io/server/io-github-mrfentmen-checksum-mcp and set it in your MCP client config:
32
+
33
+ ```json
34
+ "env": { "MCP_LICENSE_KEY": "mcp_live_..." }
35
+ ```
36
+
37
+ The key is checked against MCP Marketplace, cached for 24 hours, and keeps
38
+ working offline once one check has succeeded. Every other tool on this server
39
+ stays free.
40
+ <!-- /paywall -->
package/dist/api.js CHANGED
@@ -23,3 +23,116 @@ export async function hashFile(args) {
23
23
  const data = readFileSync(path);
24
24
  return `${a}: ${createHash(a).update(data).digest("hex")} (${data.length} bytes)`;
25
25
  }
26
+ // ---- muxA tools:
27
+ import { createHmac } from "node:crypto";
28
+ import { readdirSync, statSync } from "node:fs";
29
+ import { join, relative, sep } from "node:path";
30
+ function digest(algorithm, data) {
31
+ return createHash(algorithm).update(data).digest("hex");
32
+ }
33
+ function parseExpected(expected) {
34
+ const raw = (expected ?? "").trim().toLowerCase().replace(/[\s-]/g, "");
35
+ const withPrefix = /^([a-z0-9]{3,10}):([a-f0-9]{8,128})$/.exec(raw);
36
+ if (withPrefix)
37
+ return { algorithm: withPrefix[1], hex: withPrefix[2] };
38
+ const bare = /^([a-f0-9]{8,128})$/.exec(raw);
39
+ if (bare)
40
+ return { hex: bare[1] };
41
+ throw new HashError("Expected digest should look like sha256:abc123... or a bare hex digest of 8 to 128 characters.");
42
+ }
43
+ function timingSafeEqual(a, b) {
44
+ if (a.length !== b.length)
45
+ return false;
46
+ let diff = 0;
47
+ for (let i = 0; i < a.length; i += 1)
48
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
49
+ return diff === 0;
50
+ }
51
+ export async function verifyChecksum(args) {
52
+ const expectedRaw = (args.expected ?? "").trim();
53
+ if (!expectedRaw)
54
+ throw new HashError("Provide the expected digest to check against.");
55
+ const parsed = parseExpected(expectedRaw);
56
+ const algorithm = parsed.algorithm ?? algo(args.algorithm ?? "sha256");
57
+ const hasPath = Boolean((args.path ?? "").trim());
58
+ const hasText = typeof args.text === "string" && args.text.length > 0;
59
+ if (hasPath === hasText) {
60
+ throw new HashError("Provide exactly one of path (verify a file) or text (verify a string).");
61
+ }
62
+ const target = hasPath ? String(args.path).trim() : "";
63
+ const actual = hasPath ? digest(algorithm, readFileSync(target)) : digest(algorithm, args.text);
64
+ const ok = timingSafeEqual(actual, parsed.hex);
65
+ const what = hasPath ? `${target} (${readFileSync(target).length} bytes)` : `${args.text.length} character string`;
66
+ return [
67
+ `${ok ? "MATCH" : "MISMATCH"}: ${algorithm} digest of ${what}`,
68
+ ` expected ${parsed.hex}`,
69
+ ` actual ${actual}`,
70
+ ok ? " The file or string is intact." : " Content differs from the expected digest.",
71
+ ].join("\n");
72
+ }
73
+ export async function directoryManifest(args) {
74
+ const root = (args.path ?? "").trim();
75
+ if (!root)
76
+ throw new HashError("Provide a directory path.");
77
+ let stat;
78
+ try {
79
+ stat = statSync(root);
80
+ }
81
+ catch {
82
+ throw new HashError(`Cannot read path ${root}.`);
83
+ }
84
+ if (!stat.isDirectory())
85
+ throw new HashError(`${root} is a file, not a directory. Use hash_file to hash a single file.`);
86
+ const algorithm = algo(args.algorithm ?? "sha256");
87
+ const limit = Math.max(1, Math.min(Math.round(Number(args.limit ?? 100) || 100), 500));
88
+ const exclude = (args.exclude ?? "").trim();
89
+ const skip = (p) => {
90
+ if (!exclude)
91
+ return false;
92
+ const e = exclude.toLowerCase();
93
+ const lower = p.toLowerCase();
94
+ return lower.includes(e) || lower.split(sep).some((seg) => seg === e || seg.startsWith(`${e}.`) || seg.endsWith(e));
95
+ };
96
+ const entries = readdirSync(root, { recursive: true, withFileTypes: true })
97
+ .filter((d) => (d.isFile() || d.isSymbolicLink()) && !skip(join(d.parentPath ?? d.path ?? root, d.name)))
98
+ .map((d) => {
99
+ const full = join(d.parentPath ?? d.path ?? root, d.name);
100
+ return { full, rel: relative(root, full), data: readFileSync(full) };
101
+ })
102
+ .filter((e) => e.data.length > 0)
103
+ .sort((a, b) => a.rel.localeCompare(b.rel));
104
+ if (!entries.length) {
105
+ return `${root} contains no readable non-empty files${exclude ? ` after excluding "${exclude}"` : ""}.`;
106
+ }
107
+ const rows = entries.map((e) => ({ rel: e.rel.split(sep).join("/"), size: e.data.length, hex: digest(algorithm, e.data) }));
108
+ const totalBytes = rows.reduce((a, r) => a + r.size, 0);
109
+ const manifestText = rows.map((r) => `${r.hex} ${r.rel}`).join("\n");
110
+ const manifestHash = digest(algorithm, manifestText);
111
+ return [
112
+ `Directory manifest for ${root}: ${rows.length} file(s), ${countBytes(totalBytes)}, ${algorithm}.`,
113
+ ...rows.slice(0, limit).map((r) => `${r.hex} ${r.size.toLocaleString("en-US")} B ${r.rel}`),
114
+ rows.length > limit ? `... plus ${rows.length - limit} more files not listed` : "",
115
+ `manifest digest (${algorithm} of the sorted "digest path" listing): ${manifestHash}`,
116
+ exclude ? `Excluded paths matching "${exclude}".` : "",
117
+ ]
118
+ .filter(Boolean)
119
+ .join("\n");
120
+ }
121
+ function countBytes(n) {
122
+ return n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB` : `${(n / 1024).toFixed(1)} kB`;
123
+ }
124
+ export async function hmacDigest(args) {
125
+ const message = args.message ?? "";
126
+ if (!message)
127
+ throw new HashError("Provide the message to authenticate.");
128
+ const key = args.key ?? "";
129
+ if (!key)
130
+ throw new HashError("Provide the shared secret key.");
131
+ const algorithm = algo(args.algorithm ?? "sha256");
132
+ const requested = (args.encoding ?? "hex").trim().toLowerCase();
133
+ if (requested !== "hex" && requested !== "base64")
134
+ return "Unknown encoding, use hex or base64.";
135
+ const enc = requested;
136
+ const mac = createHmac(algorithm, key).update(message, "utf8").digest(enc);
137
+ return `${algorithm} HMAC (${enc}): ${mac}`;
138
+ }
@@ -0,0 +1,79 @@
1
+ // Premium tools on this server need a license key bought from MCP Marketplace.
2
+ // Buyers put the key in their MCP client config as MCP_LICENSE_KEY. Every tool
3
+ // that is not listed in PREMIUM keeps working without a key.
4
+ const SLUG = "checksum-mcp";
5
+ // Tools that need a key. Everything else is free.
6
+ const PREMIUM = new Set([
7
+ "verify_checksum",
8
+ "directory_manifest",
9
+ ]);
10
+ const BUY_URL = `https://mcp-marketplace.io/server/io-github-mrfentmen-${SLUG}`;
11
+ // The license check calls the marketplace's verify endpoint directly instead of
12
+ // using @mcp_marketplace/license. That SDK sends no `apikey` header, so every
13
+ // check it makes is rejected by Supabase before it reaches the function. The
14
+ // publishable key below is public by design - it ships in mcp-marketplace.io's
15
+ // own JavaScript and only ever reaches their licence check.
16
+ const DEFAULT_VERIFY_URL = "https://virupvwhtkpkjsiskckg.supabase.co/functions/v1/verify-key";
17
+ const PUBLISHABLE_KEY = "sb_publishable_BuqlW96Ke8C_zzJG-LQv1Q_YHi_r_4h";
18
+ const OK_CACHE_MS = 60 * 60 * 1000;
19
+ const BAD_CACHE_MS = 60 * 1000;
20
+ const seen = new Map();
21
+ const REASONS = {
22
+ missing_key: "no key is set",
23
+ invalid_format: "that key is not a valid MCP Marketplace key",
24
+ not_found: "that key was not found",
25
+ revoked: "that key has been revoked",
26
+ rotated: "that key was rotated, use your new one",
27
+ expired: "that key has expired, renew it",
28
+ rate_limited: "there were too many checks just now, try again shortly",
29
+ network_error: "the license server could not be reached",
30
+ };
31
+ function blocked(tool, reason) {
32
+ const why = REASONS[reason] ?? `the license server answered "${reason}"`;
33
+ return `"${tool}" needs a license key, but ${why}. ` +
34
+ `Set MCP_LICENSE_KEY in your MCP client config. Get a key: ${BUY_URL}`;
35
+ }
36
+ async function verify(key) {
37
+ const res = await fetch(process.env.MCP_LICENSE_VERIFY_URL || DEFAULT_VERIFY_URL, {
38
+ method: "POST",
39
+ headers: {
40
+ apikey: PUBLISHABLE_KEY,
41
+ Authorization: `Bearer ${PUBLISHABLE_KEY}`,
42
+ "Content-Type": "application/json",
43
+ },
44
+ body: JSON.stringify({ key, slug: SLUG }),
45
+ // an unusable key answers 400 with a JSON body, so read the body either way
46
+ signal: AbortSignal.timeout(15000),
47
+ });
48
+ const data = (await res.json());
49
+ if (typeof data?.valid !== "boolean")
50
+ return { valid: false, reason: "unexpected_response" };
51
+ return data;
52
+ }
53
+ /**
54
+ * Returns null when the caller may run the tool, or a short message telling
55
+ * them how to get a key.
56
+ *
57
+ * A good key is remembered for an hour, so a busy session does not hit the
58
+ * license server on every call and keeps working through a brief outage.
59
+ */
60
+ export async function premiumRequired(tool) {
61
+ if (!PREMIUM.has(tool))
62
+ return null;
63
+ const key = process.env.MCP_LICENSE_KEY;
64
+ if (!key)
65
+ return blocked(tool, "missing_key");
66
+ const hit = seen.get(key);
67
+ if (hit && Date.now() - hit.at < (hit.valid ? OK_CACHE_MS : BAD_CACHE_MS)) {
68
+ return hit.valid ? null : blocked(tool, hit.reason ?? "invalid");
69
+ }
70
+ let result;
71
+ try {
72
+ result = await verify(key);
73
+ }
74
+ catch {
75
+ result = { valid: false, reason: "network_error" };
76
+ }
77
+ seen.set(key, { ...result, at: Date.now() });
78
+ return result.valid ? null : blocked(tool, result.reason ?? "invalid");
79
+ }
package/dist/server.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
3
  import { hashFile } from "./api.js";
4
- import { hashText } from "./api.js";
4
+ import { hashText, verifyChecksum, directoryManifest, hmacDigest } from "./api.js";
5
+ import { premiumRequired } from "./license.js";
5
6
  const text = (value) => ({ content: [{ type: "text", text: value }] });
6
7
  const textError = (t) => ({ content: [{ type: "text", text: t }], isError: true });
7
8
  const READ_ONLY = { readOnlyHint: true, openWorldHint: true };
@@ -34,5 +35,54 @@ export function createServer() {
34
35
  return textError(error(e));
35
36
  }
36
37
  });
38
+ server.registerTool("verify_checksum", {
39
+ title: "Verify checksum",
40
+ description: "Check a file or string against an expected digest and report MATCH or MISMATCH, accepting either a bare hex value or an algorithm-prefixed digest.",
41
+ inputSchema: z.object({ path: z.string().describe("File to verify.").optional(), text: z.string().describe("String to verify.").optional(), expected: z.string().describe("Expected digest, bare hex or algo:hex."), algorithm: z.string().describe("sha256, sha512, sha1 or md5.").optional() }),
42
+ annotations: READ_ONLY,
43
+ }, async (args) => {
44
+ // ---- paywall: verify_checksum ----
45
+ const paywallMessage = await premiumRequired("verify_checksum");
46
+ if (paywallMessage)
47
+ return { content: [{ type: "text", text: paywallMessage }], isError: true };
48
+ // ---- paywall: end ----
49
+ try {
50
+ return text(await verifyChecksum(args));
51
+ }
52
+ catch (e) {
53
+ return textError(e instanceof Error ? `Error: ${e.message}` : String(e));
54
+ }
55
+ });
56
+ server.registerTool("directory_manifest", {
57
+ title: "Directory manifest",
58
+ description: "Hash every file in a directory, list each path with its size and digest, and return a single digest over the sorted manifest so a tree can be verified later.",
59
+ inputSchema: z.object({ path: z.string().describe("Directory to walk."), algorithm: z.string().describe("sha256, sha512, sha1 or md5.").optional(), limit: z.number().describe("Max files listed.").optional(), exclude: z.string().describe("Skip paths containing this text, e.g. node_modules.").optional() }),
60
+ annotations: READ_ONLY,
61
+ }, async (args) => {
62
+ // ---- paywall: directory_manifest ----
63
+ const paywallMessage = await premiumRequired("directory_manifest");
64
+ if (paywallMessage)
65
+ return { content: [{ type: "text", text: paywallMessage }], isError: true };
66
+ // ---- paywall: end ----
67
+ try {
68
+ return text(await directoryManifest(args));
69
+ }
70
+ catch (e) {
71
+ return textError(e instanceof Error ? `Error: ${e.message}` : String(e));
72
+ }
73
+ });
74
+ server.registerTool("hmac_digest", {
75
+ title: "HMAC digest",
76
+ description: "Keyed HMAC of a message using sha256, sha512, sha1 or md5, in hex or base64, for webhook and API signature verification.",
77
+ inputSchema: z.object({ message: z.string().describe("Message to authenticate."), key: z.string().describe("Shared secret."), encoding: z.string().describe("hex or base64.").optional(), algorithm: z.string().describe("sha256, sha512, sha1 or md5.").optional() }),
78
+ annotations: READ_ONLY,
79
+ }, async (args) => {
80
+ try {
81
+ return text(await hmacDigest(args));
82
+ }
83
+ catch (e) {
84
+ return textError(e instanceof Error ? `Error: ${e.message}` : String(e));
85
+ }
86
+ });
37
87
  return server;
38
88
  }
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.0",
2
+ "version": "1.0.2",
3
3
  "type": "module",
4
4
  "repository": {
5
5
  "type": "git",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/mrfentmen/checksum-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "1.0.0",
9
+ "version": "1.0.1",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "checksum-mcp",
14
- "version": "1.0.0",
14
+ "version": "1.0.1",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  }