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 +27 -3
- package/package.json +4 -2
- package/src/cache.js +250 -0
- package/src/cachedClient.js +127 -0
- package/src/cli.js +5 -5
- package/src/client.js +3 -3
- package/src/index.js +64 -4
- package/src/server.js +3 -2
- package/src/sync.js +238 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Andru Revenue Intelligence MCP Server
|
|
2
2
|
|
|
3
|
-
|
|
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://
|
|
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://
|
|
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.
|
|
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://
|
|
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://
|
|
44
|
-
ANDRU_API_URL API endpoint (default: https://
|
|
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://
|
|
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://
|
|
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://
|
|
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 = '
|
|
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://
|
|
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://
|
|
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://
|
|
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
|
-
|
|
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://
|
|
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
|
+
}
|