@keyflint/wrap 0.0.0-stage → 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 KeyFlint Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,50 @@
1
- # Temporary Holding Version
1
+ # @keyflint/wrap
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Sell access to an existing stdio MCP server without touching its code. `keyflint-wrap` starts your
4
+ server, relays JSON-RPC in both directions, and gates every `tools/call`:
5
+
6
+ 1. Offline Ed25519 license check (`keyflint-verify`), including features, node and domain locks.
7
+ 2. For metered tools, `POST /api/v1/quota/deduct` on your KeyFlint gateway.
8
+ 3. If the server answers with a JSON-RPC error or `isError: true`, the credits are refunded.
9
+
10
+ Refused calls never reach your server; the client gets the standard KeyFlint errors
11
+ (`-32060` license, `-32061` feature, `-32062` quota, `-32063` gateway). `initialize`, `tools/list`
12
+ and every other message pass through untouched.
13
+
14
+ ```bash
15
+ export KEYFLINT_LICENSE="LIC-..." # the buyer's key
16
+ export KEYFLINT_PUBLIC_KEYS="k1=<hex>" # your issuer public key(s)
17
+ export KEYFLINT_URL="https://licenses.example.com" # needed for metered tools
18
+
19
+ npx @keyflint/wrap --credits 0 --tool summarize=5 -- python server.py
20
+ ```
21
+
22
+ | Option | Meaning |
23
+ |---|---|
24
+ | `--credits N` | Credits per call for tools without `--tool` (default `1` when `KEYFLINT_URL` is set, else `0`). `0` means license check only. |
25
+ | `--tool NAME=N` | Credits for one tool. Repeatable. |
26
+ | `--free A,B` | Tools that skip KeyFlint entirely. |
27
+ | `--features A,B` | Features the license must include. |
28
+ | `--offline-grace N` | Calls (0-5) allowed while the gateway is unreachable; their debits are replayed later and never refunded. |
29
+ | `--event-type TYPE` | `event_type` sent to the gateway (default `mcp:<tool>`). |
30
+
31
+ Environment variables are the same as `keyflint-verify` (`KEYFLINT_LICENSE`, `KEYFLINT_PUBLIC_KEYS`,
32
+ `KEYFLINT_URL`, `KEYFLINT_CHECKOUT_URL`, `KEYFLINT_MACHINE_ID`, `KEYFLINT_DOMAIN`).
33
+
34
+ ## Claude Desktop (.mcpb)
35
+
36
+ [`examples/mcpb-template`](../../examples/mcpb-template) packages a server behind the wrapper as an
37
+ MCP Bundle. The buyer pastes the license key once at install time; it is declared `sensitive`, so the
38
+ host masks it and stores it securely, then passes it to the wrapper as `KEYFLINT_LICENSE`.
39
+
40
+ ## Limits
41
+
42
+ - stdio transport only (newline-delimited JSON-RPC). For HTTP servers use `keyflint-verify/mcp` in code.
43
+ - A gated `tools/call` is forwarded after its check, so it can reach the server after messages the
44
+ client sent later. JSON-RPC does not order requests, and MCP clients do not rely on it.
45
+ - JSON-RPC batches (removed in MCP 2025-06-18) may not contain `tools/call`; such entries are refused.
46
+ - The wrapper protects honest buyers' keys and quota, not a server the buyer can edit: anyone with
47
+ the server's source can run it without the wrapper. Keep paid logic behind the gateway or on your
48
+ own host if that matters.
49
+
50
+ MIT licensed.
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs, wrap, UsageError } from "../src/wrap.js";
3
+
4
+ const USAGE = `Usage: keyflint-wrap [options] -- <command> [args...]
5
+
6
+ Gates every tools/call of a stdio MCP server with a KeyFlint license.
7
+
8
+ Options:
9
+ --credits N credits per tool call (default 1 with KEYFLINT_URL, else 0 = license check only)
10
+ --tool NAME=N credits for one tool (repeatable; 0 = license check only)
11
+ --free A,B tools that skip KeyFlint entirely
12
+ --features A,B features the license must include
13
+ --offline-grace N calls allowed while the gateway is unreachable (0-5, default 0)
14
+ --event-type TYPE event_type sent to the gateway (default mcp:<tool>)
15
+
16
+ Environment: KEYFLINT_LICENSE, KEYFLINT_PUBLIC_KEYS, KEYFLINT_URL, KEYFLINT_CHECKOUT_URL,
17
+ KEYFLINT_MACHINE_ID, KEYFLINT_DOMAIN (same as keyflint-verify).
18
+ `;
19
+
20
+ const argv = process.argv.slice(2);
21
+ if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
22
+ process.stderr.write(USAGE);
23
+ process.exit(argv.length === 0 ? 2 : 0);
24
+ }
25
+ let opts;
26
+ try {
27
+ opts = parseArgs(argv);
28
+ } catch (e) {
29
+ if (!(e instanceof UsageError)) throw e;
30
+ process.stderr.write(`keyflint-wrap: ${e.message}\n\n${USAGE}`);
31
+ process.exit(2);
32
+ }
33
+ const code = await wrap(opts, { forwardSignals: true });
34
+ // stdin may still be open when the server exits on its own; flush and leave with its code.
35
+ process.stdout.write("", () => process.exit(code));
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "@keyflint/wrap",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Put KeyFlint license checks and credit metering in front of any stdio MCP server, with no code changes.",
5
+ "license": "MIT",
6
+ "author": "ArceeDev",
7
+ "homepage": "https://keyflint.dev/",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/ArceeDev/keyflint.git",
11
+ "directory": "sdks/js-wrap"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/ArceeDev/keyflint/issues"
15
+ },
16
+ "type": "module",
17
+ "main": "./src/wrap.js",
18
+ "exports": {
19
+ ".": "./src/wrap.js",
20
+ "./package.json": "./package.json"
21
+ },
22
+ "bin": {
23
+ "keyflint-wrap": "bin/keyflint-wrap.js"
24
+ },
25
+ "files": [
26
+ "bin",
27
+ "src",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "engines": {
32
+ "node": ">=18.4"
33
+ },
34
+ "keywords": [
35
+ "keyflint",
36
+ "license",
37
+ "licensing",
38
+ "mcp",
39
+ "model-context-protocol",
40
+ "metering",
41
+ "proxy"
42
+ ],
43
+ "scripts": {
44
+ "test": "node --test test/*.test.mjs"
45
+ },
46
+ "dependencies": {
47
+ "keyflint-verify": "^1.2.0"
48
+ },
49
+ "publishConfig": {
50
+ "access": "public"
51
+ }
52
+ }
package/src/wrap.js ADDED
@@ -0,0 +1,206 @@
1
+ /**
2
+ * @keyflint/wrap — puts KeyFlint license checks and credit metering in front of any stdio MCP server.
3
+ *
4
+ * npx @keyflint/wrap --tool summarize=5 -- python server.py
5
+ *
6
+ * The wrapper spawns the server, relays newline-delimited JSON-RPC in both directions, and gates
7
+ * every `tools/call` request: offline license check, then POST /api/v1/quota/deduct for metered
8
+ * tools. Failures are answered by the wrapper with the KeyFlint JSON-RPC errors (-32060..-32063)
9
+ * and never reach the server. Calls the server answers with an error (or `isError: true`) are
10
+ * refunded. Every other message passes through untouched.
11
+ */
12
+
13
+ import { spawn } from "node:child_process";
14
+ import { createInterface } from "node:readline";
15
+ import { createKeyFlint, KeyFlintMcpError } from "keyflint-verify/mcp";
16
+
17
+ const INTERNAL_ERROR = -32603;
18
+
19
+ /** Parses argv (without node and script) into wrapper options and the server command. */
20
+ export function parseArgs(argv, env = process.env) {
21
+ const sep = argv.indexOf("--");
22
+ if (sep === -1 || sep === argv.length - 1) throw new UsageError("missing server command after --");
23
+ const flags = argv.slice(0, sep);
24
+ const opts = {
25
+ command: argv[sep + 1],
26
+ args: argv.slice(sep + 2),
27
+ defaultCredits: undefined,
28
+ tools: new Map(),
29
+ free: new Set(),
30
+ features: [],
31
+ offlineGraceCalls: 0,
32
+ eventType: undefined,
33
+ };
34
+ const value = (i, name) => {
35
+ if (i + 1 >= flags.length) throw new UsageError(`${name} needs a value`);
36
+ return flags[i + 1];
37
+ };
38
+ for (let i = 0; i < flags.length; i++) {
39
+ const f = flags[i];
40
+ if (f === "--credits") opts.defaultCredits = credits(value(i++, f), f);
41
+ else if (f === "--tool") {
42
+ const raw = value(i++, f);
43
+ const eq = raw.lastIndexOf("=");
44
+ if (eq <= 0) throw new UsageError(`--tool expects name=credits, got "${raw}"`);
45
+ opts.tools.set(raw.slice(0, eq), credits(raw.slice(eq + 1), f));
46
+ } else if (f === "--free") for (const t of list(value(i++, f))) opts.free.add(t);
47
+ else if (f === "--features") opts.features.push(...list(value(i++, f)));
48
+ else if (f === "--offline-grace") {
49
+ opts.offlineGraceCalls = credits(value(i++, f), f);
50
+ if (opts.offlineGraceCalls > 5) throw new UsageError("--offline-grace is capped at 5");
51
+ }
52
+ else if (f === "--event-type") opts.eventType = value(i++, f);
53
+ else throw new UsageError(`unknown option ${f}`);
54
+ }
55
+ // Without a gateway nothing can be deducted, so the default is a license-only check.
56
+ if (opts.defaultCredits === undefined) opts.defaultCredits = gatewayConfigured(env) ? 1 : 0;
57
+ return opts;
58
+ }
59
+
60
+ export class UsageError extends Error {}
61
+
62
+ function credits(raw, flag) {
63
+ const n = Number(raw);
64
+ if (!Number.isInteger(n) || n < 0) throw new UsageError(`${flag}: "${raw}" is not a non-negative integer`);
65
+ return n;
66
+ }
67
+
68
+ function list(raw) {
69
+ return raw.split(",").map((s) => s.trim()).filter(Boolean);
70
+ }
71
+
72
+ function gatewayConfigured(env) {
73
+ return Boolean((env.KEYFLINT_URL ?? env.KEYFORGE_URL ?? "").trim());
74
+ }
75
+
76
+ /**
77
+ * Starts the server and relays messages. Resolves with the server's exit code.
78
+ * `io` lets tests swap stdin/stdout; `kf` lets tests inject a configured KeyFlint instance.
79
+ */
80
+ export function wrap(opts, io = {}) {
81
+ const input = io.stdin ?? process.stdin;
82
+ const output = io.stdout ?? process.stdout;
83
+ const kf = io.kf ?? createKeyFlint();
84
+ const child = spawn(opts.command, opts.args, {
85
+ stdio: ["pipe", "pipe", "inherit"],
86
+ env: io.env ?? process.env,
87
+ shell: false,
88
+ });
89
+
90
+ const waiting = new Map(); // JSON-RPC id (as JSON) -> resolve(response)
91
+ const gates = new Map(); // tool name -> gated forwarder
92
+ let childGone = false;
93
+ if (io.forwardSignals) {
94
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(sig, () => child.kill(sig));
95
+ }
96
+
97
+ const send = (msg) => output.write(JSON.stringify(msg) + "\n");
98
+ const toChild = (line) => {
99
+ if (!childGone) child.stdin.write(line + "\n");
100
+ };
101
+
102
+ function gateFor(name) {
103
+ let gate = gates.get(name);
104
+ if (gate) return gate;
105
+ const forward = async (msg) => {
106
+ const response = await new Promise((resolve, reject) => {
107
+ if (childGone) return reject(new Error("MCP server exited"));
108
+ waiting.set(JSON.stringify(msg.id), resolve);
109
+ toChild(JSON.stringify(msg));
110
+ });
111
+ // Refund (metered) when the server reports a failure; the response is relayed as-is.
112
+ return { isError: Boolean(response.error || response.result?.isError === true), response };
113
+ };
114
+ const n = opts.tools.get(name) ?? opts.defaultCredits;
115
+ if (n === 0) gate = kf.requireLicense(forward, { features: opts.features });
116
+ else {
117
+ const m = { features: opts.features, offlineGraceCalls: opts.offlineGraceCalls };
118
+ m.eventType = opts.eventType ?? `mcp:${name}`;
119
+ gate = kf.metered(n, forward, m);
120
+ }
121
+ gates.set(name, gate);
122
+ return gate;
123
+ }
124
+
125
+ async function handleToolCall(msg) {
126
+ const name = typeof msg.params?.name === "string" ? msg.params.name : "";
127
+ if (opts.free.has(name)) return toChild(JSON.stringify(msg));
128
+ try {
129
+ const { response } = await gateFor(name)(msg);
130
+ output.write(response.__raw + "\n");
131
+ } catch (e) {
132
+ if (e instanceof KeyFlintMcpError || (e && typeof e.code === "number" && e.data)) {
133
+ send({ jsonrpc: "2.0", id: msg.id, error: { code: e.code, message: e.message, data: e.data } });
134
+ } else {
135
+ send({ jsonrpc: "2.0", id: msg.id, error: { code: INTERNAL_ERROR, message: String(e?.message ?? e) } });
136
+ }
137
+ }
138
+ }
139
+
140
+ // client -> server
141
+ const fromClient = createInterface({ input, crlfDelay: Infinity });
142
+ fromClient.on("line", (line) => {
143
+ if (!line.trim()) return;
144
+ let msg;
145
+ try {
146
+ msg = JSON.parse(line);
147
+ } catch {
148
+ return toChild(line); // not ours to judge; the server answers parse errors
149
+ }
150
+ if (Array.isArray(msg)) {
151
+ // JSON-RPC batches (dropped in MCP 2025-06-18): never let a tools/call through ungated.
152
+ const calls = msg.filter(isToolCall);
153
+ const rest = msg.filter((m) => !isToolCall(m));
154
+ if (calls.length) {
155
+ send(calls.map((m) => ({ jsonrpc: "2.0", id: m.id, error: { code: INTERNAL_ERROR, message: "keyflint-wrap: batched tools/call is not supported" } })));
156
+ }
157
+ if (rest.length) toChild(JSON.stringify(rest));
158
+ return;
159
+ }
160
+ if (isToolCall(msg)) handleToolCall(msg);
161
+ else toChild(line);
162
+ });
163
+ fromClient.on("close", () => {
164
+ if (!childGone) child.stdin.end();
165
+ });
166
+
167
+ // server -> client
168
+ const fromServer = createInterface({ input: child.stdout, crlfDelay: Infinity });
169
+ fromServer.on("line", (line) => {
170
+ let msg;
171
+ try {
172
+ msg = JSON.parse(line);
173
+ } catch {
174
+ return output.write(line + "\n");
175
+ }
176
+ const key = msg && !Array.isArray(msg) && "id" in msg && !("method" in msg) ? JSON.stringify(msg.id) : undefined;
177
+ const resolve = key !== undefined ? waiting.get(key) : undefined;
178
+ if (resolve) {
179
+ waiting.delete(key);
180
+ Object.defineProperty(msg, "__raw", { value: line, enumerable: false });
181
+ resolve(msg);
182
+ } else output.write(line + "\n");
183
+ });
184
+
185
+ return new Promise((resolve) => {
186
+ const finish = (code) => {
187
+ if (childGone) return;
188
+ childGone = true;
189
+ for (const [id, done] of waiting) {
190
+ const msg = { jsonrpc: "2.0", id: JSON.parse(id), error: { code: INTERNAL_ERROR, message: "MCP server exited" } };
191
+ done(Object.defineProperty(msg, "__raw", { value: JSON.stringify(msg) }));
192
+ }
193
+ waiting.clear();
194
+ resolve(code);
195
+ };
196
+ child.on("error", (e) => {
197
+ process.stderr.write(`keyflint-wrap: cannot start ${opts.command}: ${e.message}\n`);
198
+ finish(127);
199
+ });
200
+ child.on("exit", (code, signal) => finish(code ?? (signal ? 128 : 1)));
201
+ });
202
+ }
203
+
204
+ function isToolCall(m) {
205
+ return m && typeof m === "object" && m.method === "tools/call" && "id" in m;
206
+ }