@lifeaitools/clauth 0.5.2 → 0.7.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.
@@ -5,6 +5,7 @@
5
5
  // Supports: start (background daemon), stop, restart, ping, foreground
6
6
 
7
7
  import http from "http";
8
+ import crypto from "crypto";
8
9
  import fs from "fs";
9
10
  import os from "os";
10
11
  import path from "path";
@@ -153,6 +154,18 @@ function dashboardHtml(port, whitelist) {
153
154
  .btn-tunnel-stop{background:#1e293b;color:#f87171;border:1px solid #334155;padding:6px 12px;font-size:.8rem;border-radius:6px;cursor:pointer;font-weight:500}
154
155
  .btn-tunnel-stop:hover{background:#2d1f1f;border-color:#f87171}
155
156
  .tunnel-err{font-size:.78rem;color:#f87171;width:100%;margin-top:4px}
157
+ .mcp-setup{background:#0f1d2d;border:1px solid #1e3a5f;border-radius:8px;padding:1rem 1.25rem;margin-bottom:1.25rem;display:none}
158
+ .mcp-setup.open{display:block}
159
+ .mcp-setup-title{font-size:.85rem;font-weight:600;color:#e2e8f0;margin-bottom:.75rem}
160
+ .mcp-row{display:flex;align-items:center;gap:8px;margin-bottom:8px}
161
+ .mcp-label{font-size:.72rem;color:#64748b;min-width:80px;text-transform:uppercase;letter-spacing:.5px;font-weight:600}
162
+ .mcp-val{flex:1;font-family:'Courier New',monospace;font-size:.82rem;color:#60a5fa;background:#0a0f1a;border:1px solid #1e3a5f;border-radius:4px;padding:6px 10px;word-break:break-all;user-select:all}
163
+ .mcp-copy{background:none;border:1px solid #334155;color:#94a3b8;border-radius:4px;padding:4px 8px;cursor:pointer;font-size:.75rem;font-family:'Courier New',monospace;transition:all .15s;flex-shrink:0}
164
+ .mcp-copy:hover{border-color:#60a5fa;color:#60a5fa}
165
+ .mcp-copy.ok{border-color:#4ade80;color:#4ade80}
166
+ .btn-mcp-setup{background:#1e293b;color:#94a3b8;border:1px solid #334155;padding:6px 12px;font-size:.8rem;border-radius:6px;cursor:pointer;font-weight:500;transition:all .15s}
167
+ .btn-mcp-setup:hover{border-color:#60a5fa;color:#60a5fa}
168
+ .btn-mcp-setup:disabled{opacity:.4;cursor:not-allowed}
156
169
  .footer{margin-top:2rem;font-size:.75rem;color:#475569;text-align:center}
157
170
  .oauth-fields{display:flex;flex-direction:column;gap:8px;margin-bottom:8px}
158
171
  .oauth-field{display:flex;flex-direction:column;gap:3px}
@@ -223,11 +236,34 @@ function dashboardHtml(port, whitelist) {
223
236
  <button class="btn-check" id="btn-tunnel-test" style="display:none;padding:6px 12px;font-size:.8rem" onclick="testTunnel()">Test</button>
224
237
  <button class="btn-claude" id="btn-claude" disabled onclick="openClaude()">Connect claude.ai</button>
225
238
  <button class="btn-tunnel-stop" id="btn-tunnel-toggle" style="display:none" onclick="toggleTunnel()">Stop</button>
239
+ <button class="btn-mcp-setup" id="btn-mcp-setup" style="display:none" onclick="toggleMcpSetup()">Setup MCP</button>
226
240
  <div class="tunnel-err" id="tunnel-err" style="display:none"></div>
227
241
  </div>
228
242
 
243
+ <div class="mcp-setup" id="mcp-setup-panel">
244
+ <div class="mcp-setup-title">claude.ai MCP Integration</div>
245
+ <div class="oauth-fields">
246
+ <div class="mcp-row">
247
+ <span class="mcp-label">URL</span>
248
+ <span class="mcp-val" id="mcp-url">—</span>
249
+ <button class="mcp-copy" onclick="copyMcp('mcp-url')">copy</button>
250
+ </div>
251
+ <div class="mcp-row">
252
+ <span class="mcp-label">Client ID</span>
253
+ <span class="mcp-val" id="mcp-client-id">—</span>
254
+ <button class="mcp-copy" onclick="copyMcp('mcp-client-id')">copy</button>
255
+ </div>
256
+ <div class="mcp-row">
257
+ <span class="mcp-label">Secret</span>
258
+ <span class="mcp-val" id="mcp-client-secret">—</span>
259
+ <button class="mcp-copy" onclick="copyMcp('mcp-client-secret')">copy</button>
260
+ </div>
261
+ </div>
262
+ <div style="font-size:.72rem;color:#64748b;margin-top:4px">Paste these into <a href="https://claude.ai/settings/integrations" target="_blank" style="color:#60a5fa">claude.ai Settings → Integrations</a></div>
263
+ </div>
264
+
229
265
  <div id="grid" class="grid"><p class="loading">Loading services…</p></div>
230
- <div class="footer">localhost:${port} · 127.0.0.1 only · 3-strike lockout</div>
266
+ <div class="footer">localhost:${port} · 127.0.0.1 only · 10-strike lockout</div>
231
267
  </div>
232
268
 
233
269
  <script>
@@ -714,6 +750,7 @@ async function updateTunnelUI() {
714
750
  const label = document.getElementById("tunnel-label");
715
751
  const btn = document.getElementById("btn-claude");
716
752
  const togBtn = document.getElementById("btn-tunnel-toggle");
753
+ const mcpBtn = document.getElementById("btn-mcp-setup");
717
754
  const errEl = document.getElementById("tunnel-err");
718
755
 
719
756
  try {
@@ -725,10 +762,12 @@ async function updateTunnelUI() {
725
762
  dot.className = "tunnel-dot err";
726
763
  label.innerHTML = '<strong>claude.ai MCP</strong> — ' + t.error;
727
764
  btn.disabled = true;
765
+ mcpBtn.style.display = "none";
728
766
  togBtn.style.display = "none";
729
767
  togBtn.textContent = "Start Tunnel";
730
768
  togBtn.onclick = () => toggleTunnel("start");
731
769
  togBtn.style.display = "inline-block";
770
+ document.getElementById("mcp-setup-panel").classList.remove("open");
732
771
  return "error";
733
772
  }
734
773
 
@@ -740,6 +779,7 @@ async function updateTunnelUI() {
740
779
  togBtn.textContent = "Stop Tunnel";
741
780
  togBtn.onclick = () => toggleTunnel("stop");
742
781
  togBtn.style.display = "inline-block";
782
+ mcpBtn.style.display = "inline-block";
743
783
  return "connected";
744
784
  }
745
785
 
@@ -747,6 +787,7 @@ async function updateTunnelUI() {
747
787
  dot.className = "tunnel-dot starting";
748
788
  label.innerHTML = '<strong>claude.ai MCP</strong> — starting tunnel…';
749
789
  btn.disabled = true;
790
+ mcpBtn.style.display = "none";
750
791
  togBtn.textContent = "Stop Tunnel";
751
792
  togBtn.onclick = () => toggleTunnel("stop");
752
793
  togBtn.style.display = "inline-block";
@@ -757,16 +798,20 @@ async function updateTunnelUI() {
757
798
  dot.className = "tunnel-dot off";
758
799
  label.innerHTML = '<strong>claude.ai MCP</strong> — tunnel not running';
759
800
  btn.disabled = true;
801
+ mcpBtn.style.display = "none";
760
802
  togBtn.textContent = "Start Tunnel";
761
803
  togBtn.onclick = () => toggleTunnel("start");
762
804
  togBtn.style.display = "inline-block";
805
+ document.getElementById("mcp-setup-panel").classList.remove("open");
763
806
  return "stopped";
764
807
 
765
808
  } catch {
766
809
  dot.className = "tunnel-dot off";
767
810
  label.innerHTML = '<strong>claude.ai MCP</strong> — unable to reach daemon';
768
811
  btn.disabled = true;
812
+ mcpBtn.style.display = "none";
769
813
  togBtn.style.display = "none";
814
+ document.getElementById("mcp-setup-panel").classList.remove("open");
770
815
  return "error";
771
816
  }
772
817
  }
@@ -827,6 +872,32 @@ function openClaude() {
827
872
  });
828
873
  }
829
874
 
875
+ async function toggleMcpSetup() {
876
+ const panel = document.getElementById("mcp-setup-panel");
877
+ const isOpen = panel.classList.toggle("open");
878
+ if (isOpen) {
879
+ try {
880
+ const m = await fetch(BASE + "/mcp-setup").then(r => r.json());
881
+ document.getElementById("mcp-url").textContent = m.url || "(tunnel not running)";
882
+ document.getElementById("mcp-client-id").textContent = m.clientId || "—";
883
+ document.getElementById("mcp-client-secret").textContent = m.clientSecret || "—";
884
+ } catch {
885
+ document.getElementById("mcp-url").textContent = "(error fetching)";
886
+ }
887
+ }
888
+ }
889
+
890
+ function copyMcp(elId) {
891
+ const val = document.getElementById(elId).textContent;
892
+ if (!val || val === "—" || val.startsWith("(")) return;
893
+ const btn = document.getElementById(elId).nextElementSibling;
894
+ navigator.clipboard.writeText(val).then(() => {
895
+ btn.textContent = "ok";
896
+ btn.classList.add("ok");
897
+ setTimeout(() => { btn.textContent = "copy"; btn.classList.remove("ok"); }, 1500);
898
+ }).catch(() => {});
899
+ }
900
+
830
901
  boot();
831
902
  </script>
832
903
  </body>
@@ -844,7 +915,7 @@ function readBody(req) {
844
915
  }
845
916
 
846
917
  // ── Server logic (shared by foreground + daemon) ─────────────
847
- function createServer(initPassword, whitelist, port) {
918
+ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
848
919
  // Ensure Windows system tools are reachable (bash shells may lack these on PATH)
849
920
  if (os.platform() === "win32") {
850
921
  const sys32 = "C:\\Windows\\System32";
@@ -855,7 +926,7 @@ function createServer(initPassword, whitelist, port) {
855
926
  process.env.PATH = (process.env.PATH || "") + ";" + sys32;
856
927
  }
857
928
  }
858
- const MAX_FAILS = 3;
929
+ const MAX_FAILS = 10;
859
930
  let failCount = 0;
860
931
  let password = initPassword || null; // null = locked; set via POST /auth
861
932
  const machineHash = getMachineHash();
@@ -863,7 +934,7 @@ function createServer(initPassword, whitelist, port) {
863
934
  const CORS = {
864
935
  "Access-Control-Allow-Origin": "*",
865
936
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
866
- "Access-Control-Allow-Headers": "Content-Type",
937
+ "Access-Control-Allow-Headers": "Content-Type, Authorization, Mcp-Session-Id",
867
938
  };
868
939
 
869
940
  // ── MCP SSE session tracking ──────────────────────────────
@@ -891,13 +962,39 @@ function createServer(initPassword, whitelist, port) {
891
962
  let tunnelUrl = null;
892
963
  let tunnelError = null;
893
964
 
965
+ // ── OAuth provider (self-contained for claude.ai MCP) ──────
966
+ const oauthClients = new Map(); // client_id → { client_secret, redirect_uris, client_name }
967
+ const oauthCodes = new Map(); // code → { client_id, redirect_uri, code_challenge, expires }
968
+ const oauthTokens = new Set(); // active access tokens
969
+
970
+ // Pre-generate a stable client for claude.ai (shown at startup)
971
+ const OAUTH_CLIENT_ID = crypto.randomBytes(16).toString("hex");
972
+ const OAUTH_CLIENT_SECRET = crypto.randomBytes(32).toString("hex");
973
+ oauthClients.set(OAUTH_CLIENT_ID, {
974
+ client_id: OAUTH_CLIENT_ID, client_secret: OAUTH_CLIENT_SECRET,
975
+ client_name: "claude.ai", redirect_uris: ["https://claude.ai/api/mcp/auth_callback"],
976
+ grant_types: ["authorization_code"], response_types: ["code"],
977
+ token_endpoint_auth_method: "client_secret_post",
978
+ });
979
+
980
+ function oauthBase() { return tunnelUrl || `http://127.0.0.1:${port}`; }
981
+ function sha256base64url(str) { return crypto.createHash("sha256").update(str).digest("base64url"); }
982
+
983
+ function readRawBody(req) {
984
+ return new Promise((resolve, reject) => {
985
+ let data = "";
986
+ req.on("data", chunk => { data += chunk; if (data.length > 65536) reject(new Error("Body too large")); });
987
+ req.on("end", () => resolve(data));
988
+ req.on("error", reject);
989
+ });
990
+ }
991
+
894
992
  async function startTunnel() {
895
993
  if (tunnelProc) return; // already running
896
994
  tunnelUrl = null;
897
995
  tunnelError = null;
898
996
 
899
997
  try {
900
- // Quick tunnel — no DNS/config needed, new random URL each session
901
998
  // Resolve cloudflared binary — may not be on PATH in bash shells
902
999
  let cfBin = "cloudflared";
903
1000
  if (os.platform() === "win32") {
@@ -913,17 +1010,30 @@ function createServer(initPassword, whitelist, port) {
913
1010
  }
914
1011
  }
915
1012
 
916
- const proc = spawnProc(cfBin, ["tunnel", "--url", `http://127.0.0.1:${port}`], {
1013
+ // Named tunnel (fixed subdomain) or quick tunnel (random URL)
1014
+ let args;
1015
+ if (tunnelHostname) {
1016
+ // Named tunnel: cloudflared tunnel run (uses ~/.cloudflared/config.yml)
1017
+ // Config maps hostname → local service, so no --url needed
1018
+ args = ["tunnel", "run"];
1019
+ tunnelUrl = `https://${tunnelHostname}`;
1020
+ } else {
1021
+ // Quick tunnel — random URL each session
1022
+ args = ["tunnel", "--url", `http://127.0.0.1:${port}`];
1023
+ }
1024
+
1025
+ const proc = spawnProc(cfBin, args, {
917
1026
  stdio: ["ignore", "pipe", "pipe"],
918
1027
  });
919
1028
 
920
1029
  tunnelProc = proc;
921
1030
 
922
- // cloudflared prints the quick tunnel URL to stderr
1031
+ // cloudflared prints output to stderr
923
1032
  let stderrBuf = "";
924
1033
  proc.stderr.on("data", (chunk) => {
925
1034
  stderrBuf += chunk.toString();
926
- if (!tunnelUrl) {
1035
+ // For quick tunnels, capture the random URL from stderr
1036
+ if (!tunnelHostname && !tunnelUrl) {
927
1037
  const match = stderrBuf.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
928
1038
  if (match) {
929
1039
  tunnelUrl = match[0];
@@ -954,6 +1064,11 @@ function createServer(initPassword, whitelist, port) {
954
1064
  // Give it a moment to start
955
1065
  await new Promise(r => setTimeout(r, 4000));
956
1066
 
1067
+ if (tunnelHostname && tunnelProc) {
1068
+ const logLine = `[${new Date().toISOString()}] Named tunnel started: ${tunnelUrl}\n`;
1069
+ try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
1070
+ }
1071
+
957
1072
  } catch (err) {
958
1073
  tunnelError = err.message;
959
1074
  tunnelProc = null;
@@ -1006,9 +1121,18 @@ function createServer(initPassword, whitelist, port) {
1006
1121
  }
1007
1122
 
1008
1123
  const server = http.createServer(async (req, res) => {
1009
- // Hard reject anything not from loopback
1010
1124
  const remote = req.socket.remoteAddress;
1011
1125
  const isLocal = remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1";
1126
+
1127
+ const url = new URL(req.url, `http://127.0.0.1:${port}`);
1128
+ const reqPath = url.pathname;
1129
+ const method = req.method;
1130
+
1131
+ // Log every request
1132
+ const logLine = `[${new Date().toISOString()}] ${method} ${reqPath} from=${remote} local=${isLocal}\n`;
1133
+ try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
1134
+
1135
+ // Hard reject anything not from loopback
1012
1136
  if (!isLocal) {
1013
1137
  return strike(res, 403, `Rejected non-local address: ${remote}`);
1014
1138
  }
@@ -1019,11 +1143,213 @@ function createServer(initPassword, whitelist, port) {
1019
1143
  return res.end();
1020
1144
  }
1021
1145
 
1022
- const url = new URL(req.url, `http://127.0.0.1:${port}`);
1023
- const reqPath = url.pathname;
1024
- const method = req.method;
1146
+ // ── OAuth Discovery (RFC 9728 + RFC 8414) ──────────────
1147
+ if (reqPath === "/.well-known/oauth-protected-resource" ||
1148
+ reqPath === "/.well-known/oauth-protected-resource/mcp" ||
1149
+ reqPath === "/.well-known/oauth-protected-resource/sse") {
1150
+ const base = oauthBase();
1151
+ res.writeHead(200, { "Content-Type": "application/json", ...CORS });
1152
+ return res.end(JSON.stringify({
1153
+ resource: `${base}/mcp`,
1154
+ authorization_servers: [base],
1155
+ scopes_supported: ["mcp:tools"],
1156
+ bearer_methods_supported: ["header"],
1157
+ }));
1158
+ }
1159
+
1160
+ if (reqPath === "/.well-known/oauth-authorization-server") {
1161
+ const base = oauthBase();
1162
+ res.writeHead(200, { "Content-Type": "application/json", ...CORS });
1163
+ return res.end(JSON.stringify({
1164
+ issuer: base,
1165
+ authorization_endpoint: `${base}/authorize`,
1166
+ token_endpoint: `${base}/token`,
1167
+ registration_endpoint: `${base}/register`,
1168
+ response_types_supported: ["code"],
1169
+ grant_types_supported: ["authorization_code"],
1170
+ code_challenge_methods_supported: ["S256"],
1171
+ scopes_supported: ["mcp:tools"],
1172
+ }));
1173
+ }
1174
+
1175
+ // ── Dynamic Client Registration (RFC 7591) ──────────────
1176
+ if (method === "POST" && reqPath === "/register") {
1177
+ let body;
1178
+ try { body = await readBody(req); } catch {
1179
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
1180
+ return res.end(JSON.stringify({ error: "invalid_request" }));
1181
+ }
1182
+ const clientId = crypto.randomBytes(16).toString("hex");
1183
+ const clientSecret = crypto.randomBytes(32).toString("hex");
1184
+ const client = {
1185
+ client_id: clientId, client_secret: clientSecret,
1186
+ client_name: body.client_name || "unknown",
1187
+ redirect_uris: body.redirect_uris || [],
1188
+ grant_types: body.grant_types || ["authorization_code"],
1189
+ response_types: body.response_types || ["code"],
1190
+ token_endpoint_auth_method: body.token_endpoint_auth_method || "client_secret_post",
1191
+ };
1192
+ oauthClients.set(clientId, client);
1193
+ const logMsg = `[${new Date().toISOString()}] OAuth: registered client ${clientId} (${client.client_name})\n`;
1194
+ try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
1195
+ res.writeHead(201, { "Content-Type": "application/json", ...CORS });
1196
+ return res.end(JSON.stringify(client));
1197
+ }
1198
+
1199
+ // ── Authorization endpoint — auto-approve ──────────────
1200
+ if (method === "GET" && reqPath === "/authorize") {
1201
+ const clientId = url.searchParams.get("client_id");
1202
+ const redirectUri = url.searchParams.get("redirect_uri");
1203
+ const state = url.searchParams.get("state");
1204
+ const codeChallenge = url.searchParams.get("code_challenge");
1205
+ const codeChallengeMethod = url.searchParams.get("code_challenge_method");
1206
+
1207
+ if (!clientId || !redirectUri) {
1208
+ res.writeHead(400, { "Content-Type": "text/plain", ...CORS });
1209
+ return res.end("Missing client_id or redirect_uri");
1210
+ }
1211
+
1212
+ const code = crypto.randomBytes(32).toString("hex");
1213
+ oauthCodes.set(code, {
1214
+ client_id: clientId, redirect_uri: redirectUri,
1215
+ code_challenge: codeChallenge, code_challenge_method: codeChallengeMethod,
1216
+ expires: Date.now() + 300_000,
1217
+ });
1218
+
1219
+ const redirect = new URL(redirectUri);
1220
+ redirect.searchParams.set("code", code);
1221
+ if (state) redirect.searchParams.set("state", state);
1222
+
1223
+ const logMsg = `[${new Date().toISOString()}] OAuth: authorize → code issued for ${clientId}, redirecting to ${redirect.origin}\n`;
1224
+ try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
1225
+ res.writeHead(302, { Location: redirect.toString(), ...CORS });
1226
+ return res.end();
1227
+ }
1025
1228
 
1026
- // ── MCP SSE transport ─────────────────────────────────
1229
+ // ── Token endpoint ──────────────────────────────────────
1230
+ if (method === "POST" && reqPath === "/token") {
1231
+ let body;
1232
+ const ct = req.headers["content-type"] || "";
1233
+ try {
1234
+ if (ct.includes("application/json")) {
1235
+ body = await readBody(req);
1236
+ } else {
1237
+ const raw = await readRawBody(req);
1238
+ body = Object.fromEntries(new URLSearchParams(raw));
1239
+ }
1240
+ } catch {
1241
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
1242
+ return res.end(JSON.stringify({ error: "invalid_request" }));
1243
+ }
1244
+
1245
+ if (body.grant_type !== "authorization_code") {
1246
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
1247
+ return res.end(JSON.stringify({ error: "unsupported_grant_type" }));
1248
+ }
1249
+
1250
+ const stored = oauthCodes.get(body.code);
1251
+ if (!stored || stored.expires < Date.now()) {
1252
+ oauthCodes.delete(body.code);
1253
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
1254
+ return res.end(JSON.stringify({ error: "invalid_grant" }));
1255
+ }
1256
+
1257
+ // PKCE verification
1258
+ if (stored.code_challenge && body.code_verifier) {
1259
+ const computed = sha256base64url(body.code_verifier);
1260
+ if (computed !== stored.code_challenge) {
1261
+ oauthCodes.delete(body.code);
1262
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
1263
+ return res.end(JSON.stringify({ error: "invalid_grant", error_description: "PKCE verification failed" }));
1264
+ }
1265
+ }
1266
+
1267
+ oauthCodes.delete(body.code);
1268
+ const accessToken = crypto.randomBytes(32).toString("hex");
1269
+ oauthTokens.add(accessToken);
1270
+
1271
+ const logMsg = `[${new Date().toISOString()}] OAuth: token issued for client ${stored.client_id}\n`;
1272
+ try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
1273
+ res.writeHead(200, { "Content-Type": "application/json", ...CORS });
1274
+ return res.end(JSON.stringify({ access_token: accessToken, token_type: "Bearer", expires_in: 86400 }));
1275
+ }
1276
+
1277
+ // ── MCP OAuth-protected endpoint (for claude.ai web) ──
1278
+ // POST /mcp — requires Bearer token; returns 401 to trigger OAuth flow
1279
+ if (method === "POST" && reqPath === "/mcp") {
1280
+ const authHeader = req.headers.authorization;
1281
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
1282
+ const base = oauthBase();
1283
+ res.writeHead(401, {
1284
+ "Content-Type": "application/json",
1285
+ "WWW-Authenticate": `Bearer resource_metadata="${base}/.well-known/oauth-protected-resource"`,
1286
+ ...CORS,
1287
+ });
1288
+ return res.end(JSON.stringify({ error: "unauthorized" }));
1289
+ }
1290
+ const token = authHeader.slice(7);
1291
+ if (!oauthTokens.has(token)) {
1292
+ res.writeHead(401, { "Content-Type": "application/json", ...CORS });
1293
+ return res.end(JSON.stringify({ error: "invalid_token" }));
1294
+ }
1295
+ // Token valid — fall through to MCP handling below
1296
+ }
1297
+
1298
+ // ── MCP Streamable HTTP transport (2025-03-26 spec) ──
1299
+ // POST /sse or POST /mcp — JSON-RPC over HTTP
1300
+ if (method === "POST" && (reqPath === "/sse" || reqPath === "/mcp")) {
1301
+ let body;
1302
+ try { body = await readBody(req); } catch {
1303
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
1304
+ return res.end(JSON.stringify({ error: "Invalid JSON" }));
1305
+ }
1306
+
1307
+ const id = body.id;
1308
+ const rpcMethod = body.method;
1309
+ const logMsg = `[${new Date().toISOString()}] Streamable HTTP: ${rpcMethod} id=${id}\n`;
1310
+ try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
1311
+
1312
+ // Notifications — no response needed
1313
+ if (rpcMethod === "notifications/initialized" || rpcMethod === "initialized") {
1314
+ res.writeHead(204, CORS);
1315
+ return res.end();
1316
+ }
1317
+
1318
+ if (rpcMethod === "initialize") {
1319
+ const result = {
1320
+ protocolVersion: "2025-03-26",
1321
+ serverInfo: { name: "clauth", version: VERSION },
1322
+ capabilities: { tools: {} }
1323
+ };
1324
+ res.writeHead(200, { "Content-Type": "application/json", ...CORS });
1325
+ return res.end(JSON.stringify({ jsonrpc: "2.0", id, result }));
1326
+ }
1327
+
1328
+ if (rpcMethod === "tools/list") {
1329
+ res.writeHead(200, { "Content-Type": "application/json", ...CORS });
1330
+ return res.end(JSON.stringify({ jsonrpc: "2.0", id, result: { tools: MCP_TOOLS } }));
1331
+ }
1332
+
1333
+ if (rpcMethod === "tools/call") {
1334
+ const { name, arguments: args } = body.params || {};
1335
+ const vault = sseVault();
1336
+ try {
1337
+ const result = await handleMcpTool(vault, name, args || {});
1338
+ password = vault.password;
1339
+ res.writeHead(200, { "Content-Type": "application/json", ...CORS });
1340
+ return res.end(JSON.stringify({ jsonrpc: "2.0", id, result }));
1341
+ } catch (err) {
1342
+ res.writeHead(200, { "Content-Type": "application/json", ...CORS });
1343
+ return res.end(JSON.stringify({ jsonrpc: "2.0", id, result: mcpError(`Internal error: ${err.message}`) }));
1344
+ }
1345
+ }
1346
+
1347
+ // Unknown method
1348
+ res.writeHead(200, { "Content-Type": "application/json", ...CORS });
1349
+ return res.end(JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32601, message: `Unknown method: ${rpcMethod}` } }));
1350
+ }
1351
+
1352
+ // ── MCP SSE transport (legacy) ───────────────────────
1027
1353
  // GET /sse — open SSE stream, receive endpoint event
1028
1354
  if (method === "GET" && reqPath === "/sse") {
1029
1355
  const sessionId = `ses_${++sseCounter}_${Date.now()}`;
@@ -1038,7 +1364,9 @@ function createServer(initPassword, whitelist, port) {
1038
1364
  sseSessions.set(sessionId, { res, initialized: false });
1039
1365
 
1040
1366
  // Send the endpoint URI the client should POST to
1041
- const endpoint = `/message?sessionId=${sessionId}`;
1367
+ // Use absolute URL when tunnel is active so remote clients can resolve it
1368
+ const basePath = tunnelUrl || `http://127.0.0.1:${port}`;
1369
+ const endpoint = `${basePath}/message?sessionId=${sessionId}`;
1042
1370
  res.write(`event: endpoint\ndata: ${endpoint}\n\n`);
1043
1371
 
1044
1372
  // Keepalive every 15s
@@ -1162,6 +1490,15 @@ function createServer(initPassword, whitelist, port) {
1162
1490
  });
1163
1491
  }
1164
1492
 
1493
+ // GET /mcp-setup — OAuth credentials for claude.ai MCP setup (localhost only)
1494
+ if (method === "GET" && reqPath === "/mcp-setup") {
1495
+ return ok(res, {
1496
+ url: tunnelUrl && tunnelUrl.startsWith("http") ? `${tunnelUrl}/mcp` : null,
1497
+ clientId: OAUTH_CLIENT_ID,
1498
+ clientSecret: OAUTH_CLIENT_SECRET,
1499
+ });
1500
+ }
1501
+
1165
1502
  // POST /tunnel — start or stop tunnel manually
1166
1503
  if (method === "POST" && reqPath === "/tunnel") {
1167
1504
  if (lockedGuard(res)) return;
@@ -1446,10 +1783,21 @@ function createServer(initPassword, whitelist, port) {
1446
1783
  }
1447
1784
  }
1448
1785
 
1449
- // Unknown route
1786
+ // Unknown route — don't count browser/MCP noise as auth failures
1787
+ // Don't count browser noise, MCP discovery probes, or OAuth probes as auth failures
1788
+ const isBenign = reqPath.startsWith("/.well-known/") || [
1789
+ "/favicon.ico", "/robots.txt", "/apple-touch-icon.png", "/apple-touch-icon-precomposed.png",
1790
+ "/sse", "/mcp", "/message", "/register", "/authorize", "/token",
1791
+ ].includes(reqPath);
1792
+ if (isBenign) {
1793
+ res.writeHead(404, { "Content-Type": "application/json", ...CORS });
1794
+ return res.end(JSON.stringify({ error: "Not found" }));
1795
+ }
1450
1796
  return strike(res, 404, `Unknown endpoint: ${reqPath}`);
1451
1797
  });
1452
1798
 
1799
+ server.__oauthClientId = OAUTH_CLIENT_ID;
1800
+ server.__oauthClientSecret = OAUTH_CLIENT_SECRET;
1453
1801
  return server;
1454
1802
  }
1455
1803
 
@@ -1465,6 +1813,7 @@ async function verifyAuth(password) {
1465
1813
  async function actionStart(opts) {
1466
1814
  const port = parseInt(opts.port || "52437", 10);
1467
1815
  const password = opts.pw;
1816
+ const tunnelHostname = opts.tunnel || null;
1468
1817
  const whitelist = opts.services
1469
1818
  ? opts.services.split(",").map(s => s.trim().toLowerCase())
1470
1819
  : null;
@@ -1490,7 +1839,7 @@ async function actionStart(opts) {
1490
1839
  }
1491
1840
  }
1492
1841
 
1493
- const server = createServer(password, whitelist, port);
1842
+ const server = createServer(password, whitelist, port, tunnelHostname);
1494
1843
  server.listen(port, "127.0.0.1", () => {
1495
1844
  writePid(process.pid, port);
1496
1845
  const msg = `[${new Date().toISOString()}] clauth serve started — PID ${process.pid}, port ${port}, services: ${whitelist ? whitelist.join(",") : "all"}\n`;
@@ -1534,6 +1883,7 @@ async function actionStart(opts) {
1534
1883
  const childArgs = [cliEntry, "serve", "start", "--port", String(port)];
1535
1884
  if (password) childArgs.push("--pw", password);
1536
1885
  if (opts.services) childArgs.push("--services", opts.services);
1886
+ if (tunnelHostname) childArgs.push("--tunnel", tunnelHostname);
1537
1887
 
1538
1888
  const out = fs.openSync(LOG_FILE, "a");
1539
1889
  const child = spawn(process.execPath, childArgs, {
@@ -1650,6 +2000,7 @@ async function actionRestart(opts) {
1650
2000
  async function actionForeground(opts) {
1651
2001
  const port = parseInt(opts.port || "52437", 10);
1652
2002
  const password = opts.pw || null;
2003
+ const tunnelHostname = opts.tunnel || null;
1653
2004
  const whitelist = opts.services
1654
2005
  ? opts.services.split(",").map(s => s.trim().toLowerCase())
1655
2006
  : null;
@@ -1671,10 +2022,19 @@ async function actionForeground(opts) {
1671
2022
  console.log(chalk.gray(` Services: ${whitelist ? whitelist.join(", ") : "all"}`));
1672
2023
  console.log(chalk.gray(` Lockout: 3 failures → exit\n`));
1673
2024
 
1674
- const server = createServer(password, whitelist, port);
2025
+ const server = createServer(password, whitelist, port, tunnelHostname);
1675
2026
  server.listen(port, "127.0.0.1", () => {
1676
2027
  writePid(process.pid, port);
1677
2028
  console.log(chalk.green(` clauth serve → http://127.0.0.1:${port}`));
2029
+ if (tunnelHostname) {
2030
+ console.log(chalk.cyan(` Tunnel: https://${tunnelHostname}/sse`));
2031
+ console.log("");
2032
+ console.log(chalk.yellow(" ── claude.ai Custom Connector ──"));
2033
+ console.log(chalk.white(` URL: https://${tunnelHostname}/mcp`));
2034
+ console.log(chalk.white(` Client ID: ${server.__oauthClientId}`));
2035
+ console.log(chalk.white(` Client Secret: ${server.__oauthClientSecret}`));
2036
+ console.log(chalk.gray(" (paste these into Advanced Settings when adding the connector)"));
2037
+ }
1678
2038
  if (!password) console.log(chalk.cyan(` 👉 Open http://127.0.0.1:${port} to unlock`));
1679
2039
  console.log(chalk.gray(" Ctrl+C to stop\n"));
1680
2040
  // Auto-open browser
@@ -2048,7 +2408,7 @@ function createMcpServer(initPassword, whitelist) {
2048
2408
  get machineHash() { return ensureMachineHash(); },
2049
2409
  whitelist,
2050
2410
  failCount: 0,
2051
- MAX_FAILS: 3,
2411
+ MAX_FAILS: 10,
2052
2412
  };
2053
2413
 
2054
2414
  const rl = createInterface({ input: process.stdin, terminal: false });
package/cli/index.js CHANGED
@@ -12,7 +12,7 @@ import * as api from "./api.js";
12
12
  import os from "os";
13
13
 
14
14
  const config = new Conf(getConfOptions());
15
- const VERSION = "0.5.2";
15
+ const VERSION = "0.7.0";
16
16
 
17
17
  // ============================================================
18
18
  // Password prompt helper
@@ -468,6 +468,7 @@ program
468
468
  .option("--port <n>", "Port (default: 52437)")
469
469
  .option("-p, --pw <password>", "clauth password (optional — omit to start locked, unlock in browser)")
470
470
  .option("--services <list>", "Comma-separated service whitelist (default: all)")
471
+ .option("--tunnel <hostname>", "Fixed tunnel hostname (e.g. clauth.prtrust.fund) — uses named Cloudflare Tunnel instead of random URL")
471
472
  .option("--action <action>", "Internal: action override for daemon child")
472
473
  .addHelpText("after", `
473
474
  Actions:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/clauth",
3
- "version": "0.5.2",
3
+ "version": "0.7.0",
4
4
  "description": "Hardware-bound credential vault for the LIFEAI infrastructure stack",
5
5
  "type": "module",
6
6
  "bin": {