mcp-server-andru-intelligence 1.2.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 +2 -2
- 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
|
@@ -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
|
|
|
@@ -149,7 +149,7 @@ Claude Desktop/Code → MCP Server (stdio) → Andru API (HTTPS)
|
|
|
149
149
|
Andru also supports the Agent-to-Agent (A2A) protocol for direct agent-to-agent communication. The AgentCard is available at:
|
|
150
150
|
|
|
151
151
|
```
|
|
152
|
-
https://
|
|
152
|
+
https://hs-andru-test.onrender.com/.well-known/agent.json
|
|
153
153
|
```
|
|
154
154
|
|
|
155
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
|
+
}
|