@powforge/captcha-mcp 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/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # @powforge/captcha-mcp
2
+
3
+ **Charge AI agents per-call without accounts.** PoW solve = free tier. Lightning payment = paid tier.
4
+
5
+ OpenAI's Sora API does not let you charge per call. Anthropic's billing does not pass through to your tools. If you ship an MCP server today and an autonomous agent finds it, you eat the bill.
6
+
7
+ This is the gate. Three tools over stdio. Stdlib only.
8
+
9
+ ## Quickstart
10
+
11
+ ```bash
12
+ npx -y @powforge/captcha-mcp
13
+ ```
14
+
15
+ That is it. No install, no config, no API key. The server starts on stdio and waits for an MCP client.
16
+
17
+ To wire it into Claude Code, Cursor, or any MCP-compatible host, add to your config:
18
+
19
+ ```json
20
+ {
21
+ "mcpServers": {
22
+ "powforge-captcha": {
23
+ "command": "npx",
24
+ "args": ["-y", "@powforge/captcha-mcp"]
25
+ }
26
+ }
27
+ }
28
+ ```
29
+
30
+ Or run `npx @powforge/captcha-mcp --install` to print the config block.
31
+
32
+ ## What it does
33
+
34
+ Wraps the PowForge pow-captcha service ([captcha.powforge.dev](https://captcha.powforge.dev)) as three MCP tools:
35
+
36
+ | Tool | Purpose |
37
+ |-------------|-------------------------------------------------------------------------|
38
+ | `challenge` | Request a fresh proof-of-work puzzle. Returns `{id, salt, difficulty, signature}`. |
39
+ | `verify` | Submit a solved nonce. Returns a 5-minute HMAC-signed access token. |
40
+ | `status` | Server health, lifetime stats, L402 endpoint metadata. |
41
+
42
+ The free tier costs the agent ~5-10 seconds of CPU time (SHA-256, default 14 leading zero bits). The paid tier costs 3 sats over Lightning via L402 (RFC 7235 + bolt11 invoice in `WWW-Authenticate`).
43
+
44
+ ## Why this and not OAuth, API keys, or Stripe
45
+
46
+ | Approach | Per-call cost | Account required | Agent-friendly |
47
+ |-------------------|---------------|------------------|-----------------|
48
+ | API keys | $0 | yes | no |
49
+ | OAuth | $0 | yes | no |
50
+ | Stripe metering | high overhead | yes | no |
51
+ | **PoW + L402** | seconds or 3 sats | **no** | **yes** |
52
+
53
+ Agents do not have email addresses. They do not click confirmation links. They do not enter credit cards. PoW + Lightning is the only auth primitive that works for fully autonomous callers.
54
+
55
+ ## Configuration
56
+
57
+ Set `CAPTCHA_URL` to point at a different captcha backend. Default is `http://localhost:3077` so you can run the full stack locally for development. Production deployments point it at `https://captcha.powforge.dev`.
58
+
59
+ ```bash
60
+ CAPTCHA_URL=https://captcha.powforge.dev npx @powforge/captcha-mcp
61
+ ```
62
+
63
+ ## Local development
64
+
65
+ Clone the [captcha widget repo](https://www.npmjs.com/package/@powforge/captcha) or run the public service. The MCP server only needs HTTP access to the captcha endpoints listed under `status`.
66
+
67
+ ```bash
68
+ git clone https://github.com/zekebuilds-lab/captcha-mcp
69
+ cd captcha-mcp
70
+ node src/server.js
71
+ ```
72
+
73
+ It prints `ready` to stderr and waits for JSON-RPC on stdin.
74
+
75
+ Smoke-test the protocol manually:
76
+
77
+ ```bash
78
+ echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}' | node src/server.js
79
+ ```
80
+
81
+ You should see a JSON response with `serverInfo: { name: "@powforge/captcha-mcp", version: "0.1.0" }`.
82
+
83
+ ## Token verification from your own backend
84
+
85
+ When an agent submits a token to your service, verify it without trusting the agent:
86
+
87
+ ```bash
88
+ curl -X POST https://captcha.powforge.dev/api/token/verify \
89
+ -H "Content-Type: application/json" \
90
+ -d '{"token":"<token-from-verify-tool>"}'
91
+ ```
92
+
93
+ Returns `{valid: true, method, issued_at, expires_at}` or `{valid: false, reason}`.
94
+
95
+ ## Related packages
96
+
97
+ - [`@powforge/captcha`](https://www.npmjs.com/package/@powforge/captcha) — the browser widget for the same service.
98
+ - [`@powforge/mcp-l402-gate`](https://www.npmjs.com/package/@powforge/mcp-l402-gate) — Express middleware to gate any MCP server with L402 + Depth-of-Identity scoring.
99
+ - [`@powforge/mcp-identity`](https://www.npmjs.com/package/@powforge/mcp-identity) — agent reputation oracle. Pair with this gate for first-call abuse protection.
100
+
101
+ ## License
102
+
103
+ MIT
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@powforge/captcha-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server that turns PowForge pow-captcha into agent auth. Charge AI agents per-call without accounts: PoW solve = free tier, Lightning payment = paid tier. Three tools: challenge, verify, status. Stdio transport, stdlib only — no MCP SDK dependency.",
5
+ "keywords": [
6
+ "mcp",
7
+ "model-context-protocol",
8
+ "agent-auth",
9
+ "captcha",
10
+ "proof-of-work",
11
+ "lightning",
12
+ "l402",
13
+ "rate-limiting",
14
+ "anti-bot",
15
+ "powforge"
16
+ ],
17
+ "main": "src/index.js",
18
+ "bin": {
19
+ "captcha-mcp": "src/server.js"
20
+ },
21
+ "scripts": {
22
+ "test": "node --test tests/server.test.js",
23
+ "start": "node src/server.js"
24
+ },
25
+ "license": "MIT",
26
+ "author": "PowForge",
27
+ "homepage": "https://powforge.dev/captcha",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "https://github.com/zekebuilds-lab/captcha-mcp"
31
+ },
32
+ "bugs": {
33
+ "url": "https://powforge.dev"
34
+ },
35
+ "files": [
36
+ "src/",
37
+ "README.md"
38
+ ],
39
+ "engines": {
40
+ "node": ">=18"
41
+ }
42
+ }
package/src/index.js ADDED
@@ -0,0 +1,242 @@
1
+ /**
2
+ * @powforge/captcha-mcp — MCP server module
3
+ *
4
+ * Wraps the PowForge pow-captcha HTTP service as three MCP tools:
5
+ * - challenge() → request a fresh PoW challenge
6
+ * - verify({salt, nonce, signature, id, algo, difficulty}) → verify a solution, return token
7
+ * - status() → server health + current config
8
+ *
9
+ * Free tier: solve the PoW (SHA-256, ~14 leading zero bits, ~5-10s in browser JS).
10
+ * Paid tier: skip the PoW via L402 Lightning payment (3 sats, RFC 7235 + LN).
11
+ *
12
+ * Tools are exported as plain async functions so they can be tested without
13
+ * spinning up the stdio transport.
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ const DEFAULT_CAPTCHA_URL = 'http://localhost:3077';
19
+
20
+ /**
21
+ * Resolve the base URL of the pow-captcha HTTP service. Override via
22
+ * CAPTCHA_URL env var. Defaults to the canonical local-dev port (3077).
23
+ * In production, point this at https://captcha.powforge.dev.
24
+ */
25
+ function captchaUrl() {
26
+ const raw = process.env.CAPTCHA_URL || DEFAULT_CAPTCHA_URL;
27
+ return raw.replace(/\/+$/, '');
28
+ }
29
+
30
+ /**
31
+ * Tool: challenge
32
+ *
33
+ * Input shape: {} (no arguments)
34
+ *
35
+ * Output shape:
36
+ * {
37
+ * id: string, // unique challenge id (signed by server)
38
+ * salt: string, // hex salt for the PoW
39
+ * difficulty: number, // leading zero bits required (e.g. 14)
40
+ * algo: 'sha256', // hash algorithm
41
+ * signature: string, // server-issued HMAC binding the challenge to the id
42
+ * instructions: string // how to solve and return the nonce
43
+ * }
44
+ *
45
+ * Output shape on error:
46
+ * { error: string, hint?: string }
47
+ */
48
+ async function challenge(_input, opts = {}) {
49
+ const fetchImpl = opts.fetchImpl || globalThis.fetch;
50
+ if (!fetchImpl) {
51
+ return { error: 'fetch_unavailable', hint: 'Node 18+ required, or pass fetchImpl in opts.' };
52
+ }
53
+ try {
54
+ const r = await fetchImpl(`${captchaUrl()}/api/challenge`, { method: 'GET' });
55
+ if (!r.ok) return { error: 'challenge_http_error', status: r.status };
56
+ const ch = await r.json();
57
+ return {
58
+ id: ch.id,
59
+ salt: ch.salt,
60
+ difficulty: ch.difficulty,
61
+ algo: ch.algo || 'sha256',
62
+ signature: ch.signature,
63
+ instructions:
64
+ 'Find a nonce string such that ' +
65
+ 'SHA-256(salt + nonce) has at least `difficulty` leading zero bits. ' +
66
+ 'Then call the verify tool with {salt, nonce, id, signature, algo, difficulty}. ' +
67
+ 'Returns a 5-min HMAC token your server can accept as proof-of-human or proof-of-agent. ' +
68
+ 'Or skip the PoW by paying the L402 invoice at POST ' + captchaUrl() + '/l402/skip.',
69
+ };
70
+ } catch (e) {
71
+ return { error: 'challenge_request_failed', hint: e.message };
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Tool: verify
77
+ *
78
+ * Input shape:
79
+ * {
80
+ * salt: string,
81
+ * nonce: string, // string-encoded nonce that solves the PoW
82
+ * id: string, // challenge id from the challenge tool
83
+ * signature: string, // signature from the challenge tool
84
+ * algo?: 'sha256',
85
+ * difficulty?: number // defaults to challenge difficulty
86
+ * }
87
+ *
88
+ * Output shape on success:
89
+ * { valid: true, token: string, method: 'pow' | 'sha256', expires_in_sec: 300 }
90
+ *
91
+ * Output shape on failure:
92
+ * { valid: false, reason: string }
93
+ */
94
+ async function verify(input, opts = {}) {
95
+ const fetchImpl = opts.fetchImpl || globalThis.fetch;
96
+ if (!fetchImpl) {
97
+ return { error: 'fetch_unavailable', hint: 'Node 18+ required, or pass fetchImpl in opts.' };
98
+ }
99
+ if (!input || typeof input !== 'object') return { error: 'input_required' };
100
+ const { salt, nonce, id, signature, algo, difficulty } = input;
101
+ if (!salt || typeof salt !== 'string') return { error: 'salt_required' };
102
+ if (typeof nonce === 'undefined' || nonce === null) return { error: 'nonce_required' };
103
+ if (!id || typeof id !== 'string') return { error: 'id_required' };
104
+ if (!signature || typeof signature !== 'string') return { error: 'signature_required' };
105
+
106
+ const body = {
107
+ salt,
108
+ nonce: String(nonce),
109
+ id,
110
+ signature,
111
+ algo: algo || 'sha256',
112
+ difficulty: typeof difficulty === 'number' ? difficulty : undefined,
113
+ };
114
+
115
+ try {
116
+ const r = await fetchImpl(`${captchaUrl()}/api/verify`, {
117
+ method: 'POST',
118
+ headers: { 'Content-Type': 'application/json' },
119
+ body: JSON.stringify(body),
120
+ });
121
+ const result = await r.json();
122
+ if (result.valid) {
123
+ return {
124
+ valid: true,
125
+ token: result.token,
126
+ method: result.method || 'pow',
127
+ expires_in_sec: 300,
128
+ verify_endpoint: `${captchaUrl()}/api/token/verify`,
129
+ hint: 'POST {token} to verify_endpoint to confirm validity from your own backend.',
130
+ };
131
+ }
132
+ return { valid: false, reason: result.reason || 'verification_failed' };
133
+ } catch (e) {
134
+ return { error: 'verify_request_failed', hint: e.message };
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Tool: status
140
+ *
141
+ * Input shape: {} (no arguments)
142
+ *
143
+ * Output shape:
144
+ * {
145
+ * ok: true,
146
+ * captcha_url: string,
147
+ * stats: { pow_solves: number, ln_skips: number, challenges_issued: number },
148
+ * l402: { scope: string, price_sats: number, endpoint: string }
149
+ * }
150
+ *
151
+ * Output shape on failure:
152
+ * { ok: false, error: string }
153
+ */
154
+ async function status(_input, opts = {}) {
155
+ const fetchImpl = opts.fetchImpl || globalThis.fetch;
156
+ if (!fetchImpl) {
157
+ return { ok: false, error: 'fetch_unavailable', hint: 'Node 18+ required, or pass fetchImpl in opts.' };
158
+ }
159
+ const url = captchaUrl();
160
+ try {
161
+ const [statsR, l402R] = await Promise.all([
162
+ fetchImpl(`${url}/api/stats`).then((r) => (r.ok ? r.json() : null)).catch(() => null),
163
+ fetchImpl(`${url}/l402/info`).then((r) => (r.ok ? r.json() : null)).catch(() => null),
164
+ ]);
165
+ if (!statsR) return { ok: false, error: 'captcha_unreachable', captcha_url: url };
166
+
167
+ const ep = (l402R && l402R.endpoints && l402R.endpoints[0]) || null;
168
+ return {
169
+ ok: true,
170
+ captcha_url: url,
171
+ stats: statsR,
172
+ l402: ep
173
+ ? {
174
+ scope: ep.scope,
175
+ price_sats: ep.price_sats,
176
+ endpoint: `${url}${ep.path}`,
177
+ note: 'POST with no auth to receive a 402 + macaroon + bolt11 invoice. Pay, then re-POST with Authorization: L402 <macaroon>:<preimage_hex>.',
178
+ }
179
+ : null,
180
+ };
181
+ } catch (e) {
182
+ return { ok: false, error: 'status_request_failed', hint: e.message, captcha_url: url };
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Tool registry for the MCP server. Each entry is { name, description,
188
+ * inputSchema, handler } so the stdio server can advertise them via
189
+ * tools/list and route tools/call.
190
+ */
191
+ const TOOLS = [
192
+ {
193
+ name: 'challenge',
194
+ description:
195
+ 'Request a fresh PoW challenge from the PowForge captcha service. Returns {id, salt, difficulty, signature, instructions}. The agent must find a nonce such that SHA-256(salt + nonce) has at least `difficulty` leading zero bits, then call the verify tool. Free tier — no payment required.',
196
+ inputSchema: {
197
+ type: 'object',
198
+ properties: {},
199
+ additionalProperties: false,
200
+ },
201
+ handler: challenge,
202
+ },
203
+ {
204
+ name: 'verify',
205
+ description:
206
+ 'Verify a PoW solution. Input: {salt, nonce, id, signature, algo?, difficulty?} from a prior challenge call plus the nonce the agent computed. Returns a 5-minute HMAC-signed token on success, or {valid: false, reason} on failure. Tokens can be re-verified server-side via POST /api/token/verify.',
207
+ inputSchema: {
208
+ type: 'object',
209
+ properties: {
210
+ salt: { type: 'string', description: 'Hex salt from the challenge response' },
211
+ nonce: { type: 'string', description: 'Nonce string the agent computed' },
212
+ id: { type: 'string', description: 'Challenge id from the challenge response' },
213
+ signature: { type: 'string', description: 'HMAC signature from the challenge response' },
214
+ algo: { type: 'string', description: "Hash algorithm. Default: 'sha256'" },
215
+ difficulty: { type: 'number', description: 'Leading zero bits required. Default: matches challenge.' },
216
+ },
217
+ required: ['salt', 'nonce', 'id', 'signature'],
218
+ additionalProperties: false,
219
+ },
220
+ handler: verify,
221
+ },
222
+ {
223
+ name: 'status',
224
+ description:
225
+ 'Return PowForge captcha server health, lifetime stats (pow_solves, ln_skips, challenges_issued), and L402 endpoint metadata (scope, price_sats, paid endpoint URL). Use this to discover the Lightning skip price before paying, or as a liveness check.',
226
+ inputSchema: {
227
+ type: 'object',
228
+ properties: {},
229
+ additionalProperties: false,
230
+ },
231
+ handler: status,
232
+ },
233
+ ];
234
+
235
+ module.exports = {
236
+ TOOLS,
237
+ challenge,
238
+ verify,
239
+ status,
240
+ captchaUrl,
241
+ DEFAULT_CAPTCHA_URL,
242
+ };
package/src/server.js ADDED
@@ -0,0 +1,220 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @powforge/captcha-mcp — MCP stdio server entrypoint.
4
+ *
5
+ * This is the npx-installable binary. Implements the Model Context Protocol
6
+ * over stdio using line-delimited JSON-RPC 2.0. No SDK dependency — Node 18+
7
+ * stdlib only.
8
+ *
9
+ * Designed to be referenced from a Claude Code / Cursor / Continue MCP config:
10
+ *
11
+ * {
12
+ * "mcpServers": {
13
+ * "powforge-captcha": {
14
+ * "command": "npx",
15
+ * "args": ["-y", "@powforge/captcha-mcp"]
16
+ * }
17
+ * }
18
+ * }
19
+ *
20
+ * Optional environment variables:
21
+ * CAPTCHA_URL override the captcha base URL (default: http://localhost:3077)
22
+ *
23
+ * Why stdlib only:
24
+ * - Smaller install footprint (`npx -y` is faster).
25
+ * - No transitive deps to audit.
26
+ * - The MCP protocol is simple line-delimited JSON-RPC 2.0; the SDK is
27
+ * ~200 lines of glue we can replicate in <100 lines here.
28
+ */
29
+
30
+ 'use strict';
31
+
32
+ const { TOOLS } = require('./index.js');
33
+
34
+ const PROTOCOL_VERSION = '2024-11-05';
35
+ const SERVER_INFO = {
36
+ name: '@powforge/captcha-mcp',
37
+ version: '0.1.0',
38
+ };
39
+
40
+ // --install: print a ready-to-paste MCP config block. We don't write to the
41
+ // user's config — config paths vary across editors and that's their decision.
42
+ if (process.argv.includes('--install') || process.argv.includes('-i')) {
43
+ const block = {
44
+ mcpServers: {
45
+ 'powforge-captcha': {
46
+ command: 'npx',
47
+ args: ['-y', '@powforge/captcha-mcp'],
48
+ },
49
+ },
50
+ };
51
+ // eslint-disable-next-line no-console
52
+ console.log('Add this block to your MCP config (e.g. ~/.config/Claude/claude_desktop_config.json):\n');
53
+ // eslint-disable-next-line no-console
54
+ console.log(JSON.stringify(block, null, 2));
55
+ // eslint-disable-next-line no-console
56
+ console.log('\nThen restart your MCP client. The three tools (challenge, verify, status) will appear automatically.');
57
+ process.exit(0);
58
+ }
59
+
60
+ // --version: print version and exit. Useful for CI smoke tests.
61
+ if (process.argv.includes('--version') || process.argv.includes('-v')) {
62
+ // eslint-disable-next-line no-console
63
+ console.log(SERVER_INFO.version);
64
+ process.exit(0);
65
+ }
66
+
67
+ /**
68
+ * Send a JSON-RPC response on stdout. Stdout MUST stay strictly JSON-RPC —
69
+ * any stray writes to stdout will corrupt the framing. Diagnostics go to
70
+ * stderr.
71
+ */
72
+ function send(msg) {
73
+ process.stdout.write(JSON.stringify(msg) + '\n');
74
+ }
75
+
76
+ function makeError(id, code, message, data) {
77
+ const err = { code, message };
78
+ if (data !== undefined) err.data = data;
79
+ return { jsonrpc: '2.0', id, error: err };
80
+ }
81
+
82
+ function makeResult(id, result) {
83
+ return { jsonrpc: '2.0', id, result };
84
+ }
85
+
86
+ /**
87
+ * Handle one incoming JSON-RPC request and return a response (or null for
88
+ * notifications). Methods implemented:
89
+ * - initialize
90
+ * - notifications/initialized (notification, no response)
91
+ * - tools/list
92
+ * - tools/call
93
+ * - ping
94
+ */
95
+ async function handle(req) {
96
+ const id = req.id;
97
+ const method = req.method;
98
+ const params = req.params || {};
99
+
100
+ if (method === 'initialize') {
101
+ return makeResult(id, {
102
+ protocolVersion: PROTOCOL_VERSION,
103
+ capabilities: {
104
+ tools: {},
105
+ },
106
+ serverInfo: SERVER_INFO,
107
+ });
108
+ }
109
+
110
+ if (method === 'notifications/initialized' || method === 'initialized') {
111
+ // Notifications have no id and expect no response.
112
+ return null;
113
+ }
114
+
115
+ if (method === 'ping') {
116
+ return makeResult(id, {});
117
+ }
118
+
119
+ if (method === 'tools/list') {
120
+ return makeResult(id, {
121
+ tools: TOOLS.map((t) => ({
122
+ name: t.name,
123
+ description: t.description,
124
+ inputSchema: t.inputSchema,
125
+ })),
126
+ });
127
+ }
128
+
129
+ if (method === 'tools/call') {
130
+ const name = params.name;
131
+ const args = params.arguments || {};
132
+ const tool = TOOLS.find((t) => t.name === name);
133
+ if (!tool) {
134
+ return makeResult(id, {
135
+ content: [{ type: 'text', text: JSON.stringify({ error: 'unknown_tool', tool: name }) }],
136
+ isError: true,
137
+ });
138
+ }
139
+ try {
140
+ const result = await tool.handler(args);
141
+ return makeResult(id, {
142
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
143
+ });
144
+ } catch (e) {
145
+ return makeResult(id, {
146
+ content: [{ type: 'text', text: JSON.stringify({ error: 'tool_threw', message: e.message }) }],
147
+ isError: true,
148
+ });
149
+ }
150
+ }
151
+
152
+ // Unknown method.
153
+ return makeError(id, -32601, `Method not found: ${method}`);
154
+ }
155
+
156
+ /**
157
+ * Read stdin line-by-line. Each line is a complete JSON-RPC message. Per the
158
+ * MCP stdio spec, embedded newlines are not allowed inside a message.
159
+ */
160
+ function startStdioLoop() {
161
+ let buf = '';
162
+ let inFlight = 0;
163
+ let stdinEnded = false;
164
+
165
+ process.stdin.setEncoding('utf8');
166
+
167
+ async function processRequest(req) {
168
+ inFlight++;
169
+ try {
170
+ const resp = await handle(req);
171
+ if (resp !== null) send(resp);
172
+ } catch (e) {
173
+ // eslint-disable-next-line no-console
174
+ process.stderr.write(`@powforge/captcha-mcp: handler error: ${e.message}\n`);
175
+ if (req && typeof req.id !== 'undefined') {
176
+ send(makeError(req.id, -32603, `Internal error: ${e.message}`));
177
+ }
178
+ } finally {
179
+ inFlight--;
180
+ if (stdinEnded && inFlight === 0) process.exit(0);
181
+ }
182
+ }
183
+
184
+ process.stdin.on('data', (chunk) => {
185
+ buf += chunk;
186
+ let idx;
187
+ while ((idx = buf.indexOf('\n')) >= 0) {
188
+ const line = buf.slice(0, idx).trim();
189
+ buf = buf.slice(idx + 1);
190
+ if (!line) continue;
191
+
192
+ let req;
193
+ try {
194
+ req = JSON.parse(line);
195
+ } catch (e) {
196
+ // eslint-disable-next-line no-console
197
+ process.stderr.write(`@powforge/captcha-mcp: malformed JSON-RPC line: ${e.message}\n`);
198
+ continue;
199
+ }
200
+
201
+ // Fire-and-track. processRequest decrements inFlight when done.
202
+ processRequest(req);
203
+ }
204
+ });
205
+
206
+ process.stdin.on('end', () => {
207
+ // Client closed the pipe. Wait for in-flight handlers to finish before
208
+ // exiting so async tool calls (HTTP fetches) can return their responses.
209
+ stdinEnded = true;
210
+ if (inFlight === 0) process.exit(0);
211
+ });
212
+
213
+ // Diagnostic banner on stderr only. Stdout is reserved for JSON-RPC.
214
+ process.stderr.write(
215
+ `@powforge/captcha-mcp ${SERVER_INFO.version} ready. ` +
216
+ `CAPTCHA_URL=${process.env.CAPTCHA_URL || 'http://localhost:3077'}\n`
217
+ );
218
+ }
219
+
220
+ startStdioLoop();