mcp-server-andru-intelligence 1.1.0 → 1.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # Andru Revenue Intelligence MCP Server
2
2
 
3
- The revenue intelligence system that technical founders wish existed when they were Googling "how to interview a VP Sales" at 2 AM. ICP scoring, buyer persona profiling, competitive battlecards, MBTI-adapted messaging, and pre-meeting briefs — built on 20 years of B2B sales pattern data.
3
+ 19 buyer intelligence tools for technical founders who sell to enterprises. ICP scoring, buyer persona simulation, competitive battlecards, MBTI-adapted messaging, deal classification, sales hiring blueprints, VC thesis matching, founder wellness, and pre-meeting briefs — built on 20 years of B2B sales pattern data.
4
4
 
5
5
  Works immediately — no pipeline data required. Describe your product and Andru delivers pre-built buyer intelligence in seconds. Run a full pipeline for intelligence tuned to your specific market.
6
6
 
@@ -23,7 +23,7 @@ ANDRU_API_KEY=sk_live_... npx mcp-server-andru-intelligence
23
23
  | Variable | Required | Default | Description |
24
24
  |----------|----------|---------|-------------|
25
25
  | `ANDRU_API_KEY` | Yes | — | Your Andru Platform API key |
26
- | `ANDRU_API_URL` | No | `https://api.andru.ai` | API base URL |
26
+ | `ANDRU_API_URL` | No | `https://hs-andru-test.onrender.com` | API base URL |
27
27
 
28
28
  Get your API key at [platform.andru-ai.com/settings/api-keys](https://platform.andru-ai.com/settings/api-keys).
29
29
 
@@ -94,6 +94,30 @@ claude mcp add andru-intelligence npx mcp-server-andru-intelligence \
94
94
  | `get_syndication_status` | Shows whether your CRM has your current intelligence or is running on stale data | <200ms |
95
95
  | `trigger_syndication` | Pushes latest intelligence into your CRM — detects which platforms are out of date and updates only what's stale | 5-15s |
96
96
 
97
+ ### Founder Tools
98
+
99
+ | Tool | What It Does | Latency |
100
+ |------|-------------|---------|
101
+ | `get_sales_blueprint` | First sales hire blueprint — JD, comp model, interview questions, and 90-day ramp plan for the stage you're at | 10-20s |
102
+ | `get_thesis_match` | Match your company against VC investment theses — top 5 fits with reasoning for why each thesis applies | 10-20s |
103
+ | `get_founder_wellness` | Burnout risk assessment with recovery recommendations — because 54% of founders are severely stressed and 81% hide it | <200ms |
104
+ | `simulate_buyer_persona` | Practice your pitch against a simulated CFO, CTO, or VP Sales — get the objections before the real meeting | 5-15s |
105
+
106
+ ## CLI
107
+
108
+ All 19 tools are also available from the command line via the companion [`andru-intel`](https://www.npmjs.com/package/andru-intel) package:
109
+
110
+ ```bash
111
+ npx andru-intel list # see all 19 tools
112
+ npx andru-intel score "AI code review" # instant ICP (works offline)
113
+ npx andru-intel persona CFO # buyer persona deep dive
114
+ npx andru-intel blueprint --stage "Series A" --arr "$2M"
115
+ npx andru-intel thesis "AI sales platform" --stage "Seed"
116
+ npx andru-intel roleplay CTO
117
+ npx andru-intel wellness
118
+ npx andru-intel run get_competitive_positioning --companyName "Acme"
119
+ ```
120
+
97
121
  ## Cold-Start Support
98
122
 
99
123
  7 tools work without any pipeline data — just describe your product:
@@ -125,7 +149,7 @@ Claude Desktop/Code → MCP Server (stdio) → Andru API (HTTPS)
125
149
  Andru also supports the Agent-to-Agent (A2A) protocol for direct agent-to-agent communication. The AgentCard is available at:
126
150
 
127
151
  ```
128
- https://platform.andru-ai.com/.well-known/agent.json
152
+ https://hs-andru-test.onrender.com/.well-known/agent.json
129
153
  ```
130
154
 
131
155
  ## Also Available As
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-server-andru-intelligence",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "mcpName": "io.github.geter-andru/andru-intelligence",
5
5
  "description": "Buyer intelligence for technical founders who sell to enterprises — know who to target, what to say, and when to walk away. 19 MCP tools + CLI: ICP scoring, persona simulation, battlecards, deal classification, prospect discovery, sales hiring, investor matching, founder wellness.",
6
6
  "type": "module",
@@ -23,7 +23,9 @@
23
23
  "saas"
24
24
  ],
25
25
  "dependencies": {
26
- "@modelcontextprotocol/sdk": "^1.26.0"
26
+ "@modelcontextprotocol/sdk": "^1.26.0",
27
+ "better-sqlite3": "^12.6.2",
28
+ "ws": "^8.19.0"
27
29
  },
28
30
  "engines": {
29
31
  "node": ">=18.0.0"
package/src/cache.js ADDED
@@ -0,0 +1,250 @@
1
+ /**
2
+ * SQLite Cache Layer (Phase 8 — Local Agent Runtime)
3
+ *
4
+ * Provides offline-capable caching for MCP tool results, resources,
5
+ * and ANDRU_SOUL.md content. Data persists at ~/.andru/cache.db.
6
+ *
7
+ * Cache hierarchy:
8
+ * 1. SQLite (hot path — <1ms reads)
9
+ * 2. Backend API (warm path — network call)
10
+ * 3. Stale cache (cold path — expired but better than nothing)
11
+ *
12
+ * @module cache
13
+ */
14
+
15
+ import Database from 'better-sqlite3';
16
+ import { existsSync, mkdirSync } from 'fs';
17
+ import { join } from 'path';
18
+ import { homedir } from 'os';
19
+
20
+ const CACHE_DIR = join(homedir(), '.andru');
21
+ const DB_PATH = join(CACHE_DIR, 'cache.db');
22
+
23
+ // Default TTLs in seconds
24
+ const TTL = {
25
+ tool_result: 300, // 5 min — tool outputs change often
26
+ resource: 1800, // 30 min — ICP profiles, pipeline data
27
+ soul: 86400, // 24h — soul content rarely changes
28
+ prospect: 3600, // 1h — prospect watch list data
29
+ icp_profile: 1800, // 30 min — ICP profiles
30
+ pipeline: 600, // 10 min — pipeline data
31
+ };
32
+
33
+ let db = null;
34
+
35
+ /**
36
+ * Initialize the SQLite cache. Creates ~/.andru/ and tables if needed.
37
+ * Safe to call multiple times — idempotent.
38
+ *
39
+ * @returns {Database} The database instance
40
+ */
41
+ export function initCache() {
42
+ if (db) return db;
43
+
44
+ // Ensure ~/.andru/ exists
45
+ if (!existsSync(CACHE_DIR)) {
46
+ mkdirSync(CACHE_DIR, { recursive: true });
47
+ }
48
+
49
+ db = new Database(DB_PATH);
50
+
51
+ // WAL mode for better concurrent read performance
52
+ db.pragma('journal_mode = WAL');
53
+
54
+ // Create tables
55
+ db.exec(`
56
+ CREATE TABLE IF NOT EXISTS cache_entries (
57
+ key TEXT PRIMARY KEY,
58
+ category TEXT NOT NULL DEFAULT 'general',
59
+ value TEXT NOT NULL,
60
+ created_at INTEGER NOT NULL,
61
+ expires_at INTEGER NOT NULL,
62
+ access_count INTEGER DEFAULT 0,
63
+ last_accessed_at INTEGER
64
+ );
65
+
66
+ CREATE INDEX IF NOT EXISTS idx_cache_category ON cache_entries(category);
67
+ CREATE INDEX IF NOT EXISTS idx_cache_expires ON cache_entries(expires_at);
68
+
69
+ CREATE TABLE IF NOT EXISTS sync_state (
70
+ key TEXT PRIMARY KEY,
71
+ value TEXT NOT NULL,
72
+ updated_at INTEGER NOT NULL
73
+ );
74
+ `);
75
+
76
+ // Clean expired entries on init (background housekeeping)
77
+ cleanExpired();
78
+
79
+ return db;
80
+ }
81
+
82
+ /**
83
+ * Get a cached value by key. Returns null if not found or expired.
84
+ * Optionally returns stale entries when allowStale=true (for offline fallback).
85
+ *
86
+ * @param {string} key - Cache key
87
+ * @param {object} [opts]
88
+ * @param {boolean} [opts.allowStale=false] - Return expired entries as fallback
89
+ * @returns {{ value: any, stale: boolean } | null}
90
+ */
91
+ export function get(key, { allowStale = false } = {}) {
92
+ if (!db) initCache();
93
+
94
+ const now = Math.floor(Date.now() / 1000);
95
+ const row = db.prepare(
96
+ 'SELECT value, expires_at FROM cache_entries WHERE key = ?'
97
+ ).get(key);
98
+
99
+ if (!row) return null;
100
+
101
+ const isExpired = row.expires_at < now;
102
+
103
+ if (isExpired && !allowStale) return null;
104
+
105
+ // Update access stats
106
+ db.prepare(
107
+ 'UPDATE cache_entries SET access_count = access_count + 1, last_accessed_at = ? WHERE key = ?'
108
+ ).run(now, key);
109
+
110
+ try {
111
+ return { value: JSON.parse(row.value), stale: isExpired };
112
+ } catch {
113
+ return { value: row.value, stale: isExpired };
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Set a cache entry.
119
+ *
120
+ * @param {string} key - Cache key
121
+ * @param {any} value - Value to cache (will be JSON-stringified)
122
+ * @param {object} [opts]
123
+ * @param {string} [opts.category='general'] - Category for bulk operations
124
+ * @param {number} [opts.ttl] - TTL in seconds (defaults to category TTL)
125
+ */
126
+ export function set(key, value, { category = 'general', ttl } = {}) {
127
+ if (!db) initCache();
128
+
129
+ const now = Math.floor(Date.now() / 1000);
130
+ const effectiveTtl = ttl || TTL[category] || TTL.tool_result;
131
+ const expiresAt = now + effectiveTtl;
132
+
133
+ const serialized = typeof value === 'string' ? value : JSON.stringify(value);
134
+
135
+ db.prepare(`
136
+ INSERT OR REPLACE INTO cache_entries (key, category, value, created_at, expires_at, access_count, last_accessed_at)
137
+ VALUES (?, ?, ?, ?, ?, 0, ?)
138
+ `).run(key, category, serialized, now, expiresAt, now);
139
+ }
140
+
141
+ /**
142
+ * Get all entries matching a category. Returns fresh + optionally stale.
143
+ *
144
+ * @param {string} category
145
+ * @param {object} [opts]
146
+ * @param {boolean} [opts.allowStale=false]
147
+ * @returns {Array<{ key: string, value: any, stale: boolean }>}
148
+ */
149
+ export function getByCategory(category, { allowStale = false } = {}) {
150
+ if (!db) initCache();
151
+
152
+ const now = Math.floor(Date.now() / 1000);
153
+ const query = allowStale
154
+ ? 'SELECT key, value, expires_at FROM cache_entries WHERE category = ?'
155
+ : 'SELECT key, value, expires_at FROM cache_entries WHERE category = ? AND expires_at >= ?';
156
+
157
+ const params = allowStale ? [category] : [category, now];
158
+ const rows = db.prepare(query).all(...params);
159
+
160
+ return rows.map(row => {
161
+ try {
162
+ return {
163
+ key: row.key,
164
+ value: JSON.parse(row.value),
165
+ stale: row.expires_at < now,
166
+ };
167
+ } catch {
168
+ return { key: row.key, value: row.value, stale: row.expires_at < now };
169
+ }
170
+ });
171
+ }
172
+
173
+ /**
174
+ * Invalidate a specific key or all keys in a category.
175
+ *
176
+ * @param {string} keyOrCategory - Cache key or category name
177
+ * @param {object} [opts]
178
+ * @param {boolean} [opts.isCategory=false] - If true, delete all entries in category
179
+ */
180
+ export function invalidate(keyOrCategory, { isCategory = false } = {}) {
181
+ if (!db) initCache();
182
+
183
+ if (isCategory) {
184
+ db.prepare('DELETE FROM cache_entries WHERE category = ?').run(keyOrCategory);
185
+ } else {
186
+ db.prepare('DELETE FROM cache_entries WHERE key = ?').run(keyOrCategory);
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Get or set sync state (for tracking last-sync timestamps, etc.).
192
+ */
193
+ export function getSyncState(key) {
194
+ if (!db) initCache();
195
+ const row = db.prepare('SELECT value FROM sync_state WHERE key = ?').get(key);
196
+ if (!row) return null;
197
+ try { return JSON.parse(row.value); } catch { return row.value; }
198
+ }
199
+
200
+ export function setSyncState(key, value) {
201
+ if (!db) initCache();
202
+ const now = Math.floor(Date.now() / 1000);
203
+ const serialized = typeof value === 'string' ? value : JSON.stringify(value);
204
+ db.prepare(
205
+ 'INSERT OR REPLACE INTO sync_state (key, value, updated_at) VALUES (?, ?, ?)'
206
+ ).run(key, serialized, now);
207
+ }
208
+
209
+ /**
210
+ * Cache stats for diagnostics.
211
+ */
212
+ export function getStats() {
213
+ if (!db) initCache();
214
+
215
+ const now = Math.floor(Date.now() / 1000);
216
+ const total = db.prepare('SELECT COUNT(*) as count FROM cache_entries').get();
217
+ const fresh = db.prepare('SELECT COUNT(*) as count FROM cache_entries WHERE expires_at >= ?').get(now);
218
+ const stale = total.count - fresh.count;
219
+ const categories = db.prepare(
220
+ 'SELECT category, COUNT(*) as count FROM cache_entries GROUP BY category'
221
+ ).all();
222
+
223
+ return {
224
+ total: total.count,
225
+ fresh: fresh.count,
226
+ stale,
227
+ categories: Object.fromEntries(categories.map(c => [c.category, c.count])),
228
+ dbPath: DB_PATH,
229
+ };
230
+ }
231
+
232
+ /**
233
+ * Remove all expired entries.
234
+ */
235
+ export function cleanExpired() {
236
+ if (!db) return;
237
+ const now = Math.floor(Date.now() / 1000);
238
+ const result = db.prepare('DELETE FROM cache_entries WHERE expires_at < ?').run(now);
239
+ return result.changes;
240
+ }
241
+
242
+ /**
243
+ * Close the database connection. Call on process exit.
244
+ */
245
+ export function closeCache() {
246
+ if (db) {
247
+ db.close();
248
+ db = null;
249
+ }
250
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Cache-Enhanced Andru API Client (Phase 8)
3
+ *
4
+ * Wraps the base AndruClient with SQLite caching:
5
+ * - On success: cache result, return it
6
+ * - On failure: serve from cache (stale OK)
7
+ * - Offline: serve from cache transparently
8
+ *
9
+ * @module cachedClient
10
+ */
11
+
12
+ import { get, set } from './cache.js';
13
+
14
+ /**
15
+ * Create a cache-enhanced wrapper around the base client.
16
+ *
17
+ * @param {import('./client.js').AndruClient} client - Base API client
18
+ * @returns {object} Client with same interface but cache-backed
19
+ */
20
+ export function createCachedClient(client) {
21
+ return {
22
+ /**
23
+ * Execute a tool with cache fallback.
24
+ */
25
+ async callTool(name, args) {
26
+ const cacheKey = `tool:${name}:${stableHash(args)}`;
27
+
28
+ try {
29
+ const result = await client.callTool(name, args);
30
+
31
+ // Cache successful results (not errors)
32
+ if (!result.isError) {
33
+ set(cacheKey, result, { category: 'tool_result' });
34
+ }
35
+
36
+ return result;
37
+ } catch (err) {
38
+ // Network failure — try cache
39
+ const cached = get(cacheKey, { allowStale: true });
40
+ if (cached) {
41
+ return {
42
+ ...cached.value,
43
+ _fromCache: true,
44
+ _stale: cached.stale,
45
+ };
46
+ }
47
+ throw err;
48
+ }
49
+ },
50
+
51
+ /**
52
+ * Read a resource with cache fallback.
53
+ */
54
+ async readResource(uri) {
55
+ const cacheKey = `resource:${uri}`;
56
+
57
+ try {
58
+ const result = await client.readResource(uri);
59
+ set(cacheKey, result, { category: 'resource' });
60
+ return result;
61
+ } catch (err) {
62
+ const cached = get(cacheKey, { allowStale: true });
63
+ if (cached) {
64
+ return {
65
+ ...cached.value,
66
+ _fromCache: true,
67
+ _stale: cached.stale,
68
+ };
69
+ }
70
+ throw err;
71
+ }
72
+ },
73
+
74
+ /**
75
+ * List tools — uses static catalog, no caching needed.
76
+ */
77
+ async listTools() {
78
+ return client.listTools();
79
+ },
80
+
81
+ /**
82
+ * List resources — uses static catalog, no caching needed.
83
+ */
84
+ async listResources() {
85
+ return client.listResources();
86
+ },
87
+
88
+ /**
89
+ * Pre-warm the cache with frequently-used data.
90
+ * Called after initial WebSocket sync.
91
+ */
92
+ async warmCache() {
93
+ const essentialResources = [
94
+ 'andru://icp/profile',
95
+ 'andru://pipeline/summary',
96
+ 'andru://soul/voice',
97
+ ];
98
+
99
+ const results = [];
100
+ for (const uri of essentialResources) {
101
+ try {
102
+ const result = await client.readResource(uri);
103
+ set(`resource:${uri}`, result, { category: 'resource' });
104
+ results.push({ uri, status: 'cached' });
105
+ } catch {
106
+ results.push({ uri, status: 'failed' });
107
+ }
108
+ }
109
+ return results;
110
+ },
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Stable hash for cache keys — deterministic JSON key ordering.
116
+ * Simple but effective for cache key generation.
117
+ */
118
+ function stableHash(obj) {
119
+ if (!obj || typeof obj !== 'object') return String(obj || '');
120
+ const sorted = JSON.stringify(obj, Object.keys(obj).sort());
121
+ // Simple string hash (djb2)
122
+ let hash = 5381;
123
+ for (let i = 0; i < sorted.length; i++) {
124
+ hash = ((hash << 5) + hash + sorted.charCodeAt(i)) & 0xffffffff;
125
+ }
126
+ return hash.toString(36);
127
+ }
package/src/cli.js CHANGED
@@ -19,7 +19,7 @@
19
19
  *
20
20
  * Environment:
21
21
  * ANDRU_API_KEY (required) — Your Andru Platform API key
22
- * ANDRU_API_URL (optional) — API base URL (default: https://api.andru.ai)
22
+ * ANDRU_API_URL (optional) — API base URL (default: https://hs-andru-test.onrender.com)
23
23
  */
24
24
 
25
25
  import { tools } from './catalog.js';
@@ -40,8 +40,8 @@ Examples:
40
40
  andru-intel get_sales_blueprint --companyStage "Series A" --arrTarget "$5M"
41
41
 
42
42
  Environment:
43
- ANDRU_API_KEY Your API key (get one at https://app.andru.ai/settings/api-keys)
44
- ANDRU_API_URL API endpoint (default: https://api.andru.ai)
43
+ ANDRU_API_KEY Your API key (get one at https://platform.andru-ai.com/settings/api-keys)
44
+ ANDRU_API_URL API endpoint (default: https://hs-andru-test.onrender.com)
45
45
 
46
46
  ${tools.length} tools available. Run 'andru-intel list' to see them all.
47
47
  `);
@@ -166,13 +166,13 @@ async function main() {
166
166
  const apiKey = process.env.ANDRU_API_KEY;
167
167
  if (!apiKey) {
168
168
  console.error('ANDRU_API_KEY not set.');
169
- console.error('Get your API key at https://app.andru.ai/settings/api-keys');
169
+ console.error('Get your API key at https://platform.andru-ai.com/settings/api-keys');
170
170
  console.error('');
171
171
  console.error('Usage: ANDRU_API_KEY=sk_live_... andru-intel ' + toolName + ' [args]');
172
172
  process.exit(1);
173
173
  }
174
174
 
175
- const apiUrl = process.env.ANDRU_API_URL || 'https://api.andru.ai';
175
+ const apiUrl = process.env.ANDRU_API_URL || 'https://hs-andru-test.onrender.com';
176
176
  const client = new AndruClient(apiKey, apiUrl);
177
177
  const toolArgs = parseArgs(argv.slice(1));
178
178
 
package/src/client.js CHANGED
@@ -5,14 +5,14 @@
5
5
  * Authenticates via X-API-Key header.
6
6
  */
7
7
 
8
- const DEFAULT_API_URL = 'https://api.andru.ai';
8
+ const DEFAULT_API_URL = 'https://hs-andru-test.onrender.com';
9
9
  const REQUEST_TIMEOUT_MS = 60_000; // 60s for AI-calling tools
10
- const PACKAGE_VERSION = '0.2.0';
10
+ const PACKAGE_VERSION = '1.3.0';
11
11
 
12
12
  export class AndruClient {
13
13
  /**
14
14
  * @param {string} apiKey - Andru Platform API key
15
- * @param {string} [baseUrl] - API base URL (default: https://api.andru.ai)
15
+ * @param {string} [baseUrl] - API base URL (default: https://hs-andru-test.onrender.com)
16
16
  */
17
17
  constructor(apiKey, baseUrl = DEFAULT_API_URL) {
18
18
  this.apiKey = apiKey;
package/src/index.js CHANGED
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * Environment variables:
10
10
  * ANDRU_API_KEY (required) — Your Andru Platform API key
11
- * ANDRU_API_URL (optional) — API base URL (default: https://api.andru.ai)
11
+ * ANDRU_API_URL (optional) — API base URL (default: https://hs-andru-test.onrender.com)
12
12
  *
13
13
  * Usage:
14
14
  * ANDRU_API_KEY=sk_live_... npx mcp-server-andru-intelligence
@@ -30,20 +30,37 @@
30
30
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
31
31
  import { AndruClient } from './client.js';
32
32
  import { createServer } from './server.js';
33
+ import { initCache, closeCache, getStats } from './cache.js';
34
+ import { createCachedClient } from './cachedClient.js';
35
+ import { startSync, stopSync } from './sync.js';
33
36
 
34
37
  async function main() {
35
38
  const apiKey = process.env.ANDRU_API_KEY;
36
- const apiUrl = process.env.ANDRU_API_URL || 'https://api.andru.ai';
39
+ const apiUrl = process.env.ANDRU_API_URL || 'https://hs-andru-test.onrender.com';
40
+ const cacheEnabled = process.env.ANDRU_CACHE !== 'false'; // Default: enabled
41
+
42
+ // Initialize SQLite cache (Phase 8)
43
+ if (cacheEnabled) {
44
+ try {
45
+ initCache();
46
+ const stats = getStats();
47
+ console.error(`[Andru MCP] Cache initialized: ${stats.total} entries (${stats.fresh} fresh) at ${stats.dbPath}`);
48
+ } catch (err) {
49
+ console.error('[Andru MCP] Cache init failed (continuing without cache):', err.message);
50
+ }
51
+ }
37
52
 
38
53
  // When no API key, server still starts (tool listing works from static catalog,
39
54
  // but tool execution will return an error). This allows registry scanners
40
55
  // to discover tools without needing credentials.
41
56
  let client = null;
42
57
  if (apiKey) {
43
- client = new AndruClient(apiKey, apiUrl);
58
+ const baseClient = new AndruClient(apiKey, apiUrl);
59
+ // Wrap with cache layer (Phase 8)
60
+ client = cacheEnabled ? createCachedClient(baseClient) : baseClient;
44
61
  } else {
45
62
  console.error('[Andru MCP] Warning: ANDRU_API_KEY not set. Tool listing works, but execution requires an API key.');
46
- console.error('[Andru MCP] Get your API key at https://app.andru.ai/settings/api-keys');
63
+ console.error('[Andru MCP] Get your API key at https://platform.andru-ai.com/settings/api-keys');
47
64
  }
48
65
 
49
66
  const server = createServer(client);
@@ -55,6 +72,49 @@ async function main() {
55
72
  if (client) {
56
73
  console.error(`[Andru MCP] API endpoint: ${apiUrl}`);
57
74
  }
75
+
76
+ // Start WebSocket sync (Phase 8) — keeps cache fresh via backend events
77
+ if (cacheEnabled && apiKey) {
78
+ const wsUrl = deriveWsUrl(apiUrl);
79
+ startSync({
80
+ wsUrl,
81
+ token: apiKey,
82
+ onSync: (event) => {
83
+ console.error(`[Andru MCP] Sync event: ${event.type}`);
84
+ },
85
+ onError: (err) => {
86
+ console.error(`[Andru MCP] Sync error: ${err.message}`);
87
+ },
88
+ });
89
+ console.error(`[Andru MCP] Cache sync connecting to ${wsUrl}`);
90
+ }
91
+
92
+ // Cleanup on exit
93
+ process.on('SIGINT', () => {
94
+ stopSync();
95
+ closeCache();
96
+ process.exit(0);
97
+ });
98
+ process.on('SIGTERM', () => {
99
+ stopSync();
100
+ closeCache();
101
+ process.exit(0);
102
+ });
103
+ }
104
+
105
+ /**
106
+ * Derive WebSocket URL from HTTP API URL.
107
+ * https://hs-andru-test.onrender.com → wss://hs-andru-test.onrender.com/ws
108
+ * http://localhost:3001 → ws://localhost:3001/ws
109
+ */
110
+ function deriveWsUrl(apiUrl) {
111
+ const wsOverride = process.env.ANDRU_WS_URL;
112
+ if (wsOverride) return wsOverride;
113
+
114
+ return apiUrl
115
+ .replace(/^https:/, 'wss:')
116
+ .replace(/^http:/, 'ws:')
117
+ .replace(/\/+$/, '') + '/ws';
58
118
  }
59
119
 
60
120
  main().catch((err) => {
package/src/server.js CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
- * Andru MCP Server (Thin Proxy)
2
+ * Andru MCP Server (Thin Proxy + Local Cache)
3
3
  *
4
4
  * Lists tools and resources from the static catalog (no network needed).
5
5
  * Proxies tool execution and resource reads to the Andru backend API.
6
+ * Phase 8: Falls back to SQLite cache when offline.
6
7
  */
7
8
 
8
9
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
@@ -17,7 +18,7 @@ import { tools, resources } from './catalog.js';
17
18
  /**
18
19
  * Create an MCP server backed by the Andru API.
19
20
  *
20
- * @param {import('./client.js').AndruClient | null} client — null during scan mode (no API key)
21
+ * @param {import('./client.js').AndruClient | import('./cachedClient.js').CachedClient | null} client — null during scan mode (no API key). When Phase 8 cache is active, this is a cachedClient wrapper.
21
22
  * @returns {Server}
22
23
  */
23
24
  export function createServer(client) {
package/src/sync.js ADDED
@@ -0,0 +1,238 @@
1
+ /**
2
+ * WebSocket Sync Client (Phase 8 — Local Agent Runtime)
3
+ *
4
+ * Connects to the backend WebSocket server (Phase 6) and keeps
5
+ * the local SQLite cache fresh. Handles:
6
+ *
7
+ * - Auth via Supabase/API token
8
+ * - Cache invalidation events from backend
9
+ * - Reconnection with exponential backoff
10
+ * - Initial bulk sync on first connect
11
+ *
12
+ * @module sync
13
+ */
14
+
15
+ import WebSocket from 'ws';
16
+ import { set, invalidate, getSyncState, setSyncState } from './cache.js';
17
+
18
+ const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 15000, 30000];
19
+ const HEARTBEAT_INTERVAL_MS = 25000; // Slightly under server's 30s
20
+
21
+ let ws = null;
22
+ let reconnectAttempt = 0;
23
+ let heartbeatTimer = null;
24
+ let reconnectTimer = null;
25
+ let isConnecting = false;
26
+
27
+ /**
28
+ * Start the sync client. Connects to backend WebSocket and
29
+ * keeps the local cache updated.
30
+ *
31
+ * @param {object} opts
32
+ * @param {string} opts.wsUrl - WebSocket URL (e.g., wss://hs-andru-test.onrender.com/ws)
33
+ * @param {string} opts.token - Auth token (Supabase JWT or API key)
34
+ * @param {function} [opts.onSync] - Called when sync event received
35
+ * @param {function} [opts.onError] - Called on connection error
36
+ */
37
+ export function startSync({ wsUrl, token, onSync, onError }) {
38
+ if (ws || isConnecting) return;
39
+ isConnecting = true;
40
+
41
+ try {
42
+ ws = new WebSocket(wsUrl);
43
+ } catch (err) {
44
+ isConnecting = false;
45
+ if (onError) onError(err);
46
+ scheduleReconnect({ wsUrl, token, onSync, onError });
47
+ return;
48
+ }
49
+
50
+ ws.on('open', () => {
51
+ isConnecting = false;
52
+ reconnectAttempt = 0;
53
+
54
+ // Authenticate
55
+ ws.send(JSON.stringify({ type: 'auth', token }));
56
+
57
+ // Start client heartbeat
58
+ heartbeatTimer = setInterval(() => {
59
+ if (ws && ws.readyState === WebSocket.OPEN) {
60
+ ws.send(JSON.stringify({ type: 'ping' }));
61
+ }
62
+ }, HEARTBEAT_INTERVAL_MS);
63
+ });
64
+
65
+ ws.on('message', (raw) => {
66
+ let msg;
67
+ try {
68
+ msg = JSON.parse(raw.toString());
69
+ } catch {
70
+ return;
71
+ }
72
+
73
+ // Auth confirmed — request initial sync
74
+ if (msg.type === 'auth_ok') {
75
+ requestInitialSync();
76
+ return;
77
+ }
78
+
79
+ // Cache invalidation events from backend
80
+ if (msg.type === 'cache_invalidate') {
81
+ handleCacheInvalidation(msg);
82
+ if (onSync) onSync(msg);
83
+ return;
84
+ }
85
+
86
+ // Bulk sync response
87
+ if (msg.type === 'sync_data') {
88
+ handleBulkSync(msg);
89
+ if (onSync) onSync(msg);
90
+ return;
91
+ }
92
+
93
+ // Data update pushed from backend (e.g., ICP profile changed)
94
+ if (msg.type === 'data_update') {
95
+ handleDataUpdate(msg);
96
+ if (onSync) onSync(msg);
97
+ return;
98
+ }
99
+
100
+ // Soul preamble update (only the compressed preamble — never the full soul)
101
+ if (msg.type === 'soul_update') {
102
+ // Security: only cache the preamble, never full soul content
103
+ if (msg.preamble) {
104
+ set('soul:preamble', msg.preamble, { category: 'soul' });
105
+ }
106
+ if (msg.versionHash) {
107
+ set('soul:version', msg.versionHash, { category: 'soul' });
108
+ }
109
+ if (onSync) onSync(msg);
110
+ return;
111
+ }
112
+ });
113
+
114
+ ws.on('close', (code) => {
115
+ cleanup();
116
+ // Don't reconnect if auth failed
117
+ if (code === 4003) {
118
+ if (onError) onError(new Error('Authentication failed'));
119
+ return;
120
+ }
121
+ scheduleReconnect({ wsUrl, token, onSync, onError });
122
+ });
123
+
124
+ ws.on('error', (err) => {
125
+ cleanup();
126
+ if (onError) onError(err);
127
+ scheduleReconnect({ wsUrl, token, onSync, onError });
128
+ });
129
+ }
130
+
131
+ /**
132
+ * Stop the sync client. Closes WebSocket and cancels timers.
133
+ */
134
+ export function stopSync() {
135
+ if (reconnectTimer) {
136
+ clearTimeout(reconnectTimer);
137
+ reconnectTimer = null;
138
+ }
139
+ cleanup();
140
+ reconnectAttempt = 0;
141
+ }
142
+
143
+ /**
144
+ * Whether the sync client is currently connected and authenticated.
145
+ */
146
+ export function isSyncConnected() {
147
+ return ws !== null && ws.readyState === WebSocket.OPEN;
148
+ }
149
+
150
+ // ─── Internal ────────────────────────────────────────────────────────────────
151
+
152
+ function cleanup() {
153
+ if (heartbeatTimer) {
154
+ clearInterval(heartbeatTimer);
155
+ heartbeatTimer = null;
156
+ }
157
+ if (ws) {
158
+ ws.removeAllListeners();
159
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
160
+ ws.close();
161
+ }
162
+ ws = null;
163
+ }
164
+ isConnecting = false;
165
+ }
166
+
167
+ function scheduleReconnect(opts) {
168
+ if (reconnectTimer) return;
169
+ const delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)];
170
+ reconnectAttempt++;
171
+ reconnectTimer = setTimeout(() => {
172
+ reconnectTimer = null;
173
+ startSync(opts);
174
+ }, delay);
175
+ }
176
+
177
+ /**
178
+ * Request bulk data sync from backend.
179
+ * Sends last-sync timestamp so backend only sends changes.
180
+ */
181
+ function requestInitialSync() {
182
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
183
+
184
+ const lastSync = getSyncState('last_sync_ts');
185
+
186
+ // Subscribe to cache invalidation channel
187
+ ws.send(JSON.stringify({
188
+ type: 'subscribe',
189
+ channels: ['cache:updates'],
190
+ }));
191
+
192
+ // Request any data that changed since our last sync
193
+ ws.send(JSON.stringify({
194
+ type: 'sync_request',
195
+ since: lastSync || 0,
196
+ }));
197
+ }
198
+
199
+ /**
200
+ * Handle cache invalidation from backend.
201
+ */
202
+ function handleCacheInvalidation(msg) {
203
+ if (msg.key) {
204
+ invalidate(msg.key);
205
+ }
206
+ if (msg.category) {
207
+ invalidate(msg.category, { isCategory: true });
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Handle bulk sync data from backend.
213
+ */
214
+ function handleBulkSync(msg) {
215
+ if (!msg.entries || !Array.isArray(msg.entries)) return;
216
+
217
+ for (const entry of msg.entries) {
218
+ set(entry.key, entry.value, {
219
+ category: entry.category || 'general',
220
+ ttl: entry.ttl,
221
+ });
222
+ }
223
+
224
+ // Record sync timestamp
225
+ setSyncState('last_sync_ts', Date.now());
226
+ }
227
+
228
+ /**
229
+ * Handle individual data update from backend.
230
+ */
231
+ function handleDataUpdate(msg) {
232
+ if (!msg.key || msg.value === undefined) return;
233
+
234
+ set(msg.key, msg.value, {
235
+ category: msg.category || 'general',
236
+ ttl: msg.ttl,
237
+ });
238
+ }