@cachly-dev/mcp-server 0.10.46 → 0.10.47

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/dist/index.js CHANGED
@@ -45,11 +45,15 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
45
45
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
46
46
  import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
47
47
  import { Redis } from 'ioredis';
48
+ import { AsyncLocalStorage } from 'node:async_hooks';
48
49
  // ── Config ────────────────────────────────────────────────────────────────────
49
50
  const API_URL = process.env.CACHLY_API_URL ?? 'https://api.cachly.dev';
50
51
  let JWT = process.env.CACHLY_JWT ?? '';
51
52
  const EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? '';
52
- const CURRENT_VERSION = '0.10.46';
53
+ const CURRENT_VERSION = '0.10.47';
54
+ const reqCtx = new AsyncLocalStorage();
55
+ function getJWT() { return reqCtx.getStore()?.jwt || JWT; }
56
+ function getDefaultInstanceId() { return reqCtx.getStore()?.instanceId || _defaultInstanceId; }
53
57
  // ── Default Instance Resolution (for Smithery & single-credential setups) ────
54
58
  // When CACHLY_BRAIN_INSTANCE_ID is set, tools can omit the instance_id parameter.
55
59
  // When neither is set, we auto-fetch the first running instance once per process.
@@ -57,9 +61,13 @@ let _defaultInstanceId = process.env.CACHLY_BRAIN_INSTANCE_ID ?? '';
57
61
  // Timestamp of last failed fetch — retries after 30 s (not permanently blocked).
58
62
  let _defaultInstanceLastAttempt = 0;
59
63
  async function resolveDefaultInstanceId() {
64
+ // In an HTTP request scope the instance ID is fixed by the caller's header.
65
+ const scopedId = reqCtx.getStore()?.instanceId;
66
+ if (scopedId)
67
+ return scopedId;
60
68
  if (_defaultInstanceId)
61
69
  return _defaultInstanceId;
62
- if (!JWT)
70
+ if (!getJWT())
63
71
  return '';
64
72
  // Cooldown: don't hammer the API on every tool call after a transient failure.
65
73
  const now = Date.now();
@@ -68,7 +76,7 @@ async function resolveDefaultInstanceId() {
68
76
  _defaultInstanceLastAttempt = now;
69
77
  try {
70
78
  const res = await fetch(`${API_URL}/api/v1/instances`, {
71
- headers: { Authorization: `Bearer ${JWT}`, Accept: 'application/json' },
79
+ headers: { Authorization: `Bearer ${getJWT()}`, Accept: 'application/json' },
72
80
  signal: AbortSignal.timeout(5000),
73
81
  });
74
82
  if (!res.ok)
@@ -154,7 +162,7 @@ async function pollDeviceFlow(flow) {
154
162
  try {
155
163
  const autoRes = await fetch(`${API_URL}/api/v1/instances/auto`, {
156
164
  method: 'POST',
157
- headers: { Authorization: `Bearer ${JWT}`, 'Content-Type': 'application/json' },
165
+ headers: { Authorization: `Bearer ${getJWT()}`, 'Content-Type': 'application/json' },
158
166
  signal: AbortSignal.timeout(12000),
159
167
  });
160
168
  if (autoRes.ok) {
@@ -167,7 +175,7 @@ async function pollDeviceFlow(flow) {
167
175
  // Log so telemetry can detect free-tier auto-provision failures
168
176
  void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
169
177
  method: 'POST',
170
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${JWT}` },
178
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getJWT()}` },
171
179
  body: JSON.stringify({ event: 'auto_provision_failed', status: autoRes.status, version: CURRENT_VERSION }),
172
180
  signal: AbortSignal.timeout(3000),
173
181
  }).catch(() => { });
@@ -180,7 +188,7 @@ async function pollDeviceFlow(flow) {
180
188
  if (_defaultInstanceId) {
181
189
  try {
182
190
  const checkRes = await fetch(`${API_URL}/api/v1/instances/${_defaultInstanceId}`, {
183
- headers: { Authorization: `Bearer ${JWT}`, Accept: 'application/json' },
191
+ headers: { Authorization: `Bearer ${getJWT()}`, Accept: 'application/json' },
184
192
  signal: AbortSignal.timeout(4000),
185
193
  });
186
194
  if (checkRes.ok) {
@@ -191,7 +199,7 @@ async function pollDeviceFlow(flow) {
191
199
  while (checkInst.status === 'provisioning' && Date.now() < pDeadline) {
192
200
  await new Promise(r => setTimeout(r, 3000));
193
201
  const rr = await fetch(`${API_URL}/api/v1/instances/${_defaultInstanceId}`, {
194
- headers: { Authorization: `Bearer ${JWT}`, Accept: 'application/json' },
202
+ headers: { Authorization: `Bearer ${getJWT()}`, Accept: 'application/json' },
195
203
  signal: AbortSignal.timeout(4000),
196
204
  }).catch(() => null);
197
205
  if (rr?.ok) {
@@ -250,7 +258,7 @@ async function getConnection(instance_id) {
250
258
  // Telemetry: unreachable instance so the team can proactively investigate
251
259
  void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
252
260
  method: 'POST',
253
- headers: { 'Content-Type': 'application/json', ...(JWT ? { Authorization: `Bearer ${JWT}` } : {}) },
261
+ headers: { 'Content-Type': 'application/json', ...(getJWT() ? { Authorization: `Bearer ${getJWT()}` } : {}) },
254
262
  body: JSON.stringify({ event: 'instance_not_reachable', instance_id, status: inst.status, version: CURRENT_VERSION }),
255
263
  signal: AbortSignal.timeout(3000),
256
264
  }).catch(() => { });
@@ -319,7 +327,7 @@ async function getConnection(instance_id) {
319
327
  const msg = err instanceof Error ? err.message : String(err);
320
328
  void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
321
329
  method: 'POST',
322
- headers: { 'Content-Type': 'application/json', ...(JWT ? { Authorization: `Bearer ${JWT}` } : {}) },
330
+ headers: { 'Content-Type': 'application/json', ...(getJWT() ? { Authorization: `Bearer ${getJWT()}` } : {}) },
323
331
  body: JSON.stringify({ event: 'redis_connect_failed', instance_id, host: inst.host, port: inst.port, error: msg, version: CURRENT_VERSION }),
324
332
  signal: AbortSignal.timeout(3000),
325
333
  }).catch(() => { });
@@ -333,14 +341,14 @@ async function getConnection(instance_id) {
333
341
  }
334
342
  // ── API helper ────────────────────────────────────────────────────────────────
335
343
  async function apiFetch(path, options = {}) {
336
- checkJwt(JWT);
344
+ checkJwt(getJWT());
337
345
  const timeoutMs = Number(process.env.CACHLY_API_TIMEOUT_MS ?? 15_000);
338
346
  const res = await fetch(`${API_URL}${path}`, {
339
347
  ...options,
340
348
  signal: AbortSignal.timeout(timeoutMs),
341
349
  headers: {
342
350
  'Content-Type': 'application/json',
343
- Authorization: `Bearer ${JWT}`,
351
+ Authorization: `Bearer ${getJWT()}`,
344
352
  ...options.headers,
345
353
  },
346
354
  });
@@ -384,7 +392,7 @@ function sendFunnelEvent(event, extra) {
384
392
  headers: { 'Content-Type': 'application/json' },
385
393
  body: JSON.stringify({
386
394
  event, version: CURRENT_VERSION, editor: detectEditor(),
387
- ...(JWT ? { jwt: JWT } : {}),
395
+ ...(getJWT() ? { jwt: getJWT() } : {}),
388
396
  ...extra,
389
397
  }),
390
398
  signal: AbortSignal.timeout(3000),
@@ -408,7 +416,7 @@ async function sendAnonymousTelemetry(toolName) {
408
416
  }
409
417
  async function handleTool(name, args) {
410
418
  // Guard: if no JWT, return actionable onboarding message instead of HTTP 401
411
- if (!JWT) {
419
+ if (!getJWT()) {
412
420
  void sendAnonymousTelemetry(name);
413
421
  // ── Zero-credential device flow ─────────────────────────────────────
414
422
  // 1st call: start device flow, return code + URL
@@ -485,14 +493,14 @@ async function handleTool(name, args) {
485
493
  // Delegate brain tools (learn, recall, session, etc.)
486
494
  const brainResult = await handleBrainTool(name, args, getConnection, apiFetch);
487
495
  if (brainResult !== null) {
488
- if (!_firstCallSuccessSent && JWT) {
496
+ if (!_firstCallSuccessSent && getJWT()) {
489
497
  _firstCallSuccessSent = true;
490
- sendFunnelEvent('first_call_success', { tool: name, instance_id: args.instance_id ?? _defaultInstanceId ?? '' });
498
+ sendFunnelEvent('first_call_success', { tool: name, instance_id: args.instance_id ?? getDefaultInstanceId() ?? '' });
491
499
  }
492
500
  // Per-tool telemetry — fires after every successful brain tool call.
493
501
  // api_key carries the cky_live_... token so the backend can resolve tenant + increment counters.
494
- const instanceId = args.instance_id ?? _defaultInstanceId ?? '';
495
- const telemetryExtra = JWT ? { api_key: JWT, instance_id: instanceId } : { instance_id: instanceId };
502
+ const instanceId = args.instance_id ?? getDefaultInstanceId() ?? '';
503
+ const telemetryExtra = getJWT() ? { api_key: getJWT(), instance_id: instanceId } : { instance_id: instanceId };
496
504
  if (name === 'recall_best_solution') {
497
505
  // recall_best_solution → increments BrainRecallCount + triggers level-up logic
498
506
  sendFunnelEvent('recall_best_solution', telemetryExtra);
@@ -557,9 +565,9 @@ async function handleTool(name, args) {
557
565
  // Delegate context tools (remember/recall/list/forget)
558
566
  const contextResult = await handleContextTool(name, args, getConnection, apiFetch);
559
567
  if (contextResult !== null) {
560
- if (!_firstCallSuccessSent && JWT) {
568
+ if (!_firstCallSuccessSent && getJWT()) {
561
569
  _firstCallSuccessSent = true;
562
- sendFunnelEvent('first_call_success', { tool: name, instance_id: args.instance_id ?? _defaultInstanceId ?? '' });
570
+ sendFunnelEvent('first_call_success', { tool: name, instance_id: args.instance_id ?? getDefaultInstanceId() ?? '' });
563
571
  }
564
572
  return contextResult;
565
573
  }
@@ -584,8 +592,8 @@ async function handleTool(name, args) {
584
592
  if (syndicateResult !== null) {
585
593
  // brain_predict is in syndicate handler — track telemetry here.
586
594
  if (name === 'brain_predict') {
587
- const instanceId = args.instance_id ?? _defaultInstanceId ?? '';
588
- const telemetryExtra = JWT ? { api_key: JWT, instance_id: instanceId } : { instance_id: instanceId };
595
+ const instanceId = args.instance_id ?? getDefaultInstanceId() ?? '';
596
+ const telemetryExtra = getJWT() ? { api_key: getJWT(), instance_id: instanceId } : { instance_id: instanceId };
589
597
  sendFunnelEvent('brain_predict', telemetryExtra);
590
598
  }
591
599
  return syndicateResult;
@@ -593,8 +601,8 @@ async function handleTool(name, args) {
593
601
  const fedbrainResult = await handleFedbrainTool(name, args, getConnection, apiFetch);
594
602
  if (fedbrainResult !== null) {
595
603
  // brain_from_git and brain_predict_failures are in fedbrain handler.
596
- const instanceId = args.instance_id ?? _defaultInstanceId ?? '';
597
- const telemetryExtra = JWT ? { api_key: JWT, instance_id: instanceId } : { instance_id: instanceId };
604
+ const instanceId = args.instance_id ?? getDefaultInstanceId() ?? '';
605
+ const telemetryExtra = getJWT() ? { api_key: getJWT(), instance_id: instanceId } : { instance_id: instanceId };
598
606
  if (name === 'brain_from_git') {
599
607
  sendFunnelEvent('brain_from_git', telemetryExtra);
600
608
  }
@@ -622,7 +630,7 @@ async function handleTool(name, args) {
622
630
  healthStatus = `unreachable: ${e.message}`;
623
631
  }
624
632
  // Check JWT / auth
625
- if (!JWT) {
633
+ if (!getJWT()) {
626
634
  return [
627
635
  `📡 **cachly API Status**`,
628
636
  ``,
@@ -636,7 +644,7 @@ async function handleTool(name, args) {
636
644
  // Decode JWT claims (inspection only, no verification)
637
645
  let authInfo = '❌ invalid JWT format';
638
646
  try {
639
- const parts = JWT.split('.');
647
+ const parts = getJWT().split('.');
640
648
  if (parts.length === 3) {
641
649
  const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'));
642
650
  const sub = payload.sub ?? '(unknown)';
@@ -668,7 +676,7 @@ async function handleTool(name, args) {
668
676
  const lines = ['\n\n🧠 **Brain Instances:**'];
669
677
  for (const inst of instances) {
670
678
  const badge = inst.status === 'running' ? '🟢' : inst.status === 'provisioning' ? '🟡' : '🔴';
671
- const defaultMark = inst.id === _defaultInstanceId ? ' ← active' : '';
679
+ const defaultMark = inst.id === getDefaultInstanceId() ? ' ← active' : '';
672
680
  lines.push(` ${badge} **${inst.name}** (\`${inst.id}\`) · ${inst.status}${defaultMark}`);
673
681
  if (inst.status === 'running' && inst.host) {
674
682
  // Try a quick ping on the cached connection
@@ -2022,7 +2030,6 @@ if (process.argv[2] === 'setup') {
2022
2030
  const AUTH_BASE = 'https://auth.cachly.dev/realms/cachly/protocol/openid-connect';
2023
2031
  const CLIENT_ID = 'cachly-cli';
2024
2032
  console.log('Step 1: Sign in to cachly (free, no credit card)\n');
2025
- sendFunnelEvent('setup_auth_started');
2026
2033
  // Start device flow
2027
2034
  let deviceCode = '', userCode = '', verifyUri = '', pollInterval = 5000;
2028
2035
  try {
@@ -2048,7 +2055,6 @@ if (process.argv[2] === 'setup') {
2048
2055
  rl.close();
2049
2056
  process.exit(1);
2050
2057
  }
2051
- sendFunnelEvent('setup_auth_completed');
2052
2058
  console.log('');
2053
2059
  deviceCode = ''; // mark as fallback so we skip polling
2054
2060
  }
@@ -2081,7 +2087,6 @@ if (process.argv[2] === 'setup') {
2081
2087
  if (tokenData.access_token) {
2082
2088
  token = tokenData.access_token;
2083
2089
  console.log(' \x1b[32m✓ Authorized!\x1b[0m\n');
2084
- sendFunnelEvent('setup_auth_completed');
2085
2090
  break;
2086
2091
  }
2087
2092
  // authorization_pending = keep polling; slow_down = increase interval
@@ -2216,7 +2221,6 @@ if (process.argv[2] === 'setup') {
2216
2221
  console.log(` (Run with --instance-id <id> to use a different one)\n`);
2217
2222
  }
2218
2223
  console.log(`✓ Instance: ${instance.name} (${instance.id.slice(0, 8)}…)\n`);
2219
- sendFunnelEvent('setup_instance_ready', { instance_id: instance.id });
2220
2224
  // ── Step 3: Detect editors ────────────────────────────────────────────────
2221
2225
  const cwd = process.cwd();
2222
2226
  const detected = [];
@@ -2465,7 +2469,7 @@ if (process.argv[2] === 'index') {
2465
2469
  const instanceId = flag('instance-id') ?? process.env.CACHLY_BRAIN_INSTANCE_ID;
2466
2470
  const maxFiles = parseInt(flag('max-files') ?? '500', 10);
2467
2471
  const namespace = flag('namespace') ?? 'cachly:sem:code';
2468
- if (!instanceId || !JWT) {
2472
+ if (!instanceId || !getJWT()) {
2469
2473
  console.error('\n❌ CACHLY_BRAIN_INSTANCE_ID and CACHLY_JWT must be set\n');
2470
2474
  console.error(' export CACHLY_BRAIN_INSTANCE_ID=<uuid>');
2471
2475
  console.error(' export CACHLY_JWT=<cky_live_...>');
@@ -2502,7 +2506,7 @@ if (process.argv[2] === 'learn-git') {
2502
2506
  const repoDir = resolve(flag('dir') ?? argv.find(a => !a.startsWith('--')) ?? '.');
2503
2507
  const instanceId = flag('instance-id') ?? process.env.CACHLY_BRAIN_INSTANCE_ID;
2504
2508
  const maxCommits = parseInt(flag('max-commits') ?? '50', 10);
2505
- if (!instanceId || !JWT) {
2509
+ if (!instanceId || !getJWT()) {
2506
2510
  console.error('\n❌ CACHLY_BRAIN_INSTANCE_ID and CACHLY_JWT must be set\n');
2507
2511
  console.error(' export CACHLY_BRAIN_INSTANCE_ID=<uuid>');
2508
2512
  console.error(' export CACHLY_JWT=<cky_live_...>');
@@ -2534,7 +2538,8 @@ if (process.argv[2] === 'learn-git') {
2534
2538
  // actionable message in their editor's MCP log instead of silent failures.
2535
2539
  // Skip for CLI commands that intentionally run without credentials.
2536
2540
  const _cliNoAuthCommands = ['demo', 'share', 'health', 'setup', 'init', 'digest', 'invite', 'badge', 'join', 'upgrade'];
2537
- if (!JWT && !_cliNoAuthCommands.includes(process.argv[2] ?? '') && !(!process.argv[2] && process.stdout.isTTY)) {
2541
+ // In multi-tenant HTTP mode (PORT set) we never need a startup JWT — per-request auth.
2542
+ if (!JWT && !process.env.PORT && !_cliNoAuthCommands.includes(process.argv[2] ?? '') && !(!process.argv[2] && process.stdout.isTTY)) {
2538
2543
  // No args + no JWT in a non-TTY context = running as MCP server without credentials.
2539
2544
  // Print actionable setup banner to stdout so it's captured by callers/tests.
2540
2545
  const banner = '\n' +
@@ -2601,9 +2606,39 @@ const httpPort = process.env.PORT ? parseInt(process.env.PORT, 10) : undefined;
2601
2606
  if (httpPort) {
2602
2607
  // ── HTTP mode (Streamable HTTP transport) ───────────────────────────────
2603
2608
  // Used for Smithery URL deployment: PORT=3000 node dist/index.js
2609
+ // Multi-tenant: per-request JWT + instance ID resolved via:
2610
+ // 1. Authorization: Bearer <jwt> + X-Cachly-Instance-Id: <uuid>
2611
+ // 2. Smithery config: ?config=base64url({CACHLY_JWT, CACHLY_BRAIN_INSTANCE_ID, ...})
2612
+ // Falls back to module-level env-derived values when neither is provided.
2604
2613
  const { createServer } = await import('node:http');
2605
2614
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
2606
2615
  await server.connect(transport);
2616
+ function extractReqCtx(req, url) {
2617
+ let jwtVal = '';
2618
+ let instId = '';
2619
+ const auth = req.headers['authorization'];
2620
+ if (typeof auth === 'string' && auth.toLowerCase().startsWith('bearer ')) {
2621
+ jwtVal = auth.slice(7).trim();
2622
+ }
2623
+ const hdrInst = req.headers['x-cachly-instance-id'];
2624
+ if (typeof hdrInst === 'string')
2625
+ instId = hdrInst.trim();
2626
+ // Smithery passes user config as a base64url-encoded JSON blob in ?config=
2627
+ const cfgParam = url.searchParams.get('config');
2628
+ if (cfgParam && (!jwtVal || !instId)) {
2629
+ try {
2630
+ const b64 = cfgParam.replace(/-/g, '+').replace(/_/g, '/');
2631
+ const padded = b64 + '='.repeat((4 - b64.length % 4) % 4);
2632
+ const json = JSON.parse(Buffer.from(padded, 'base64').toString('utf8'));
2633
+ if (!jwtVal && typeof json.CACHLY_JWT === 'string')
2634
+ jwtVal = json.CACHLY_JWT;
2635
+ if (!instId && typeof json.CACHLY_BRAIN_INSTANCE_ID === 'string')
2636
+ instId = json.CACHLY_BRAIN_INSTANCE_ID;
2637
+ }
2638
+ catch { /* malformed config — ignore, fall back to globals */ }
2639
+ }
2640
+ return { jwt: jwtVal, instanceId: instId };
2641
+ }
2607
2642
  const httpServer = createServer((req, res) => {
2608
2643
  const url = new URL(req.url ?? '/', `http://localhost:${httpPort}`);
2609
2644
  if (url.pathname === '/health') {
@@ -2611,6 +2646,8 @@ if (httpPort) {
2611
2646
  res.end(JSON.stringify({
2612
2647
  status: 'ok',
2613
2648
  version: CURRENT_VERSION,
2649
+ transport: 'streamable-http',
2650
+ multiTenant: true,
2614
2651
  zeroResults: {
2615
2652
  total: zeroResultsTotal,
2616
2653
  last10: ZERO_RESULTS_LOG.slice(-10).map(e => ({ query: e.query, ts: new Date(e.ts).toISOString() })),
@@ -2619,7 +2656,16 @@ if (httpPort) {
2619
2656
  return;
2620
2657
  }
2621
2658
  if (url.pathname === '/mcp' || url.pathname === '/') {
2622
- transport.handleRequest(req, res);
2659
+ const ctx = extractReqCtx(req, url);
2660
+ reqCtx.run(ctx, () => {
2661
+ transport.handleRequest(req, res).catch((err) => {
2662
+ process.stderr.write(`cachly-mcp HTTP handler error: ${err.message}\n`);
2663
+ if (!res.headersSent) {
2664
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2665
+ res.end(JSON.stringify({ error: 'internal_error' }));
2666
+ }
2667
+ });
2668
+ });
2623
2669
  return;
2624
2670
  }
2625
2671
  res.writeHead(404);