@adrata/adrata-mcp 1.0.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.
Files changed (41) hide show
  1. package/README.md +548 -0
  2. package/access/auth.js +289 -0
  3. package/access/oauth.js +1059 -0
  4. package/access/resource-metadata.js +167 -0
  5. package/access/tiers.js +422 -0
  6. package/analytics.js +634 -0
  7. package/api-bridge.js +499 -0
  8. package/governance/money.js +141 -0
  9. package/output-formatter.js +589 -0
  10. package/package.json +68 -0
  11. package/resources.js +246 -0
  12. package/security.js +690 -0
  13. package/server.js +2139 -0
  14. package/server.json +55 -0
  15. package/skills/backlog-triage/SKILL.md +115 -0
  16. package/skills/board-review/SKILL.md +96 -0
  17. package/skills/incident-to-card/SKILL.md +126 -0
  18. package/skills/log-outreach.md +62 -0
  19. package/skills/ship-the-card/SKILL.md +155 -0
  20. package/tool-annotations.js +269 -0
  21. package/tools/billing.js +149 -0
  22. package/tools/email-tools.js +652 -0
  23. package/tools/enterprise-tools.js +651 -0
  24. package/tools/free-search.js +160 -0
  25. package/tools/memory.js +440 -0
  26. package/tools/morning-brief.js +551 -0
  27. package/tools/paper-tools.js +563 -0
  28. package/tools/scheduling.js +322 -0
  29. package/tools/work-board-tools.js +758 -0
  30. package/toolsets/communications.js +276 -0
  31. package/toolsets/crm.js +495 -0
  32. package/toolsets/extensibility.js +1131 -0
  33. package/toolsets/infrastructure.js +757 -0
  34. package/toolsets/intelligence.js +232 -0
  35. package/toolsets/knowledge.js +154 -0
  36. package/toolsets/matrix.js +217 -0
  37. package/toolsets/outreach.js +432 -0
  38. package/toolsets/prospecting.js +314 -0
  39. package/toolsets/revenue/always-loaded.js +341 -0
  40. package/toolsets/revenue/sloan-tools.js +81 -0
  41. package/transport-http.js +505 -0
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Free Tier Claude-Powered Search
3
+ *
4
+ * These tools return structured prompts that guide Claude (the LLM running the
5
+ * MCP client) to search its own training data for company and person profiles.
6
+ *
7
+ * Cost to Adrata: $0 — Claude's own knowledge does the work.
8
+ * No API key needed, no account needed, unlimited usage.
9
+ */
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // In-memory LRU cache (max 500 entries)
13
+ // ---------------------------------------------------------------------------
14
+
15
+ const CACHE_MAX = 500;
16
+ const cache = new Map();
17
+
18
+ function cacheGet(key) {
19
+ if (!cache.has(key)) return undefined;
20
+ const value = cache.get(key);
21
+ // Move to end (most recently used)
22
+ cache.delete(key);
23
+ cache.set(key, value);
24
+ return value;
25
+ }
26
+
27
+ function cacheSet(key, value) {
28
+ if (cache.has(key)) cache.delete(key);
29
+ cache.set(key, value);
30
+ // Evict oldest if over limit
31
+ if (cache.size > CACHE_MAX) {
32
+ const oldest = cache.keys().next().value;
33
+ cache.delete(oldest);
34
+ }
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Company search prompt
39
+ // ---------------------------------------------------------------------------
40
+
41
+ const COMPANY_SYSTEM_PROMPT = `You are a business intelligence assistant integrated into the Adrata sales platform.
42
+ The user asked about a company. Search your training data and return a comprehensive, sales-relevant profile.
43
+
44
+ IMPORTANT: Respond with ONLY a valid JSON object matching this exact schema. No markdown, no explanation, no code fences.
45
+
46
+ {
47
+ "name": "Official Company Name",
48
+ "industry": "Primary industry classification",
49
+ "description": "2-3 sentence company overview focusing on what they do, their market position, and value proposition",
50
+ "headquarters": "City, State/Country",
51
+ "founded": "Year or approximate",
52
+ "key_people": ["Name (Title)", "Name (Title)"],
53
+ "estimated_size": "Employee count range (e.g. 1,000-5,000 employees)",
54
+ "estimated_revenue": "Revenue range if known (e.g. $100M-$500M ARR)",
55
+ "products_services": ["Core product/service 1", "Core product/service 2"],
56
+ "competitors": ["Competitor 1", "Competitor 2", "Competitor 3"],
57
+ "recent_highlights": "Notable recent developments, funding, acquisitions, or strategic moves from training data",
58
+ "ideal_sales_angles": "2-3 sentence suggestion for how a seller might approach this company",
59
+ "data_freshness": "Based on training data through early 2025. Some details may have changed.",
60
+ "upgrade_prompt": "Want verified contacts, real-time headcount, funding rounds, hiring signals, and intent data? Upgrade to Adrata Pro for enriched intelligence."
61
+ }
62
+
63
+ Rules:
64
+ - Include up to 5 key people (C-suite and VPs preferred)
65
+ - Include up to 5 competitors
66
+ - Include up to 5 products/services
67
+ - If you are unsure about a field, provide your best estimate with a qualifier like "approximately" or "estimated"
68
+ - Never fabricate specific numbers — use ranges when uncertain
69
+ - Focus on information useful to a B2B sales professional`;
70
+
71
+ const PERSON_SYSTEM_PROMPT = `You are a business intelligence assistant integrated into the Adrata sales platform.
72
+ The user asked about a professional. Search your training data and return a comprehensive, sales-relevant profile.
73
+
74
+ IMPORTANT: Respond with ONLY a valid JSON object matching this exact schema. No markdown, no explanation, no code fences.
75
+
76
+ {
77
+ "name": "Full Name",
78
+ "title": "Current or most recent title",
79
+ "company": "Current or most recent company",
80
+ "summary": "2-3 sentence professional summary — who they are and why they matter in their industry",
81
+ "background": "Career trajectory highlights: previous notable roles, companies, achievements",
82
+ "education": "University and degree if known, otherwise omit",
83
+ "expertise": ["Domain expertise 1", "Domain expertise 2", "Domain expertise 3"],
84
+ "notable_achievements": "Key accomplishments, board seats, speaking engagements, publications",
85
+ "linkedin_likely": "Likely LinkedIn URL pattern (e.g. linkedin.com/in/firstname-lastname) — note: unverified",
86
+ "sales_context": "2-3 sentence suggestion for how a seller might approach this person — what they likely care about",
87
+ "data_freshness": "Based on training data through early 2025. Current role may have changed.",
88
+ "upgrade_prompt": "Want verified email, phone, social profiles, recent activity, and buyer signals? Upgrade to Adrata Pro for enriched contact intelligence."
89
+ }
90
+
91
+ Rules:
92
+ - Focus on publicly known professionals (executives, founders, industry leaders)
93
+ - If the person is not well-known, provide what you can and note the limitation
94
+ - Never fabricate contact information (email, phone)
95
+ - The linkedin_likely field is a guess based on common URL patterns — always mark as unverified
96
+ - Focus on information useful to a B2B sales professional approaching this person`;
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Tool implementations
100
+ // ---------------------------------------------------------------------------
101
+
102
+ /**
103
+ * Generate a structured company profile prompt for Claude to fill from its
104
+ * training data. Returns a text response that Claude will process inline.
105
+ */
106
+ export function findCompany(name) {
107
+ const normalizedName = name.trim().toLowerCase();
108
+ const cacheKey = `company:${normalizedName}`;
109
+
110
+ const cached = cacheGet(cacheKey);
111
+ if (cached) return cached;
112
+
113
+ const result = {
114
+ content: [
115
+ {
116
+ type: 'text',
117
+ text: [
118
+ COMPANY_SYSTEM_PROMPT,
119
+ '',
120
+ `Company to research: "${name}"`,
121
+ '',
122
+ 'Respond with the JSON object now.',
123
+ ].join('\n'),
124
+ },
125
+ ],
126
+ };
127
+
128
+ cacheSet(cacheKey, result);
129
+ return result;
130
+ }
131
+
132
+ /**
133
+ * Generate a structured person profile prompt for Claude to fill from its
134
+ * training data. Returns a text response that Claude will process inline.
135
+ */
136
+ export function findPerson(name) {
137
+ const normalizedName = name.trim().toLowerCase();
138
+ const cacheKey = `person:${normalizedName}`;
139
+
140
+ const cached = cacheGet(cacheKey);
141
+ if (cached) return cached;
142
+
143
+ const result = {
144
+ content: [
145
+ {
146
+ type: 'text',
147
+ text: [
148
+ PERSON_SYSTEM_PROMPT,
149
+ '',
150
+ `Person to research: "${name}"`,
151
+ '',
152
+ 'Respond with the JSON object now.',
153
+ ].join('\n'),
154
+ },
155
+ ],
156
+ };
157
+
158
+ cacheSet(cacheKey, result);
159
+ return result;
160
+ }
@@ -0,0 +1,440 @@
1
+ /**
2
+ * Memory tools for Adrata MCP Server.
3
+ *
4
+ * Tier behavior:
5
+ * - Free tier: memories stored locally in ~/.adrata/memories.json
6
+ * - Pro+ tier: memories stored in the Adrata Cloud via the API
7
+ *
8
+ * Tools:
9
+ * - save_memory — store a fact or insight
10
+ * - recall — search memories by text query
11
+ * - who_am_i — user profile with usage stats
12
+ * - forget — soft-delete a memory by ID
13
+ *
14
+ * Also provides:
15
+ * - logEvent() — silent background event logging
16
+ * - wrapWithEventLogging — wraps the server.tool method to auto-log every invocation
17
+ * - registerProfileResource — registers the adrata://profile MCP resource
18
+ */
19
+
20
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
21
+ import { homedir } from 'node:os';
22
+ import { join } from 'node:path';
23
+ import { randomUUID } from 'node:crypto';
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Local storage (free tier)
27
+ // ---------------------------------------------------------------------------
28
+
29
+ const ADRATA_DIR = join(homedir(), '.adrata');
30
+ const MEMORIES_FILE = join(ADRATA_DIR, 'memories.json');
31
+
32
+ function ensureLocalDir() {
33
+ if (!existsSync(ADRATA_DIR)) {
34
+ mkdirSync(ADRATA_DIR, { recursive: true });
35
+ }
36
+ }
37
+
38
+ function readLocalMemories() {
39
+ ensureLocalDir();
40
+ if (!existsSync(MEMORIES_FILE)) return [];
41
+ try {
42
+ return JSON.parse(readFileSync(MEMORIES_FILE, 'utf-8'));
43
+ } catch {
44
+ return [];
45
+ }
46
+ }
47
+
48
+ function writeLocalMemories(memories) {
49
+ ensureLocalDir();
50
+ writeFileSync(MEMORIES_FILE, JSON.stringify(memories, null, 2));
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Event logging (fires in background, never blocks)
55
+ // ---------------------------------------------------------------------------
56
+
57
+ /**
58
+ * Silently log a tool invocation event. Fire-and-forget: errors are swallowed.
59
+ *
60
+ * @param {Function} apiFn - The api() helper from server.js
61
+ * @param {object} auth - The AUTH context { tier, token, apiKey, authenticated }
62
+ * @param {string} toolName
63
+ * @param {string} [argsSummary]
64
+ * @param {string} [resultType] - 'success' | 'error' | 'teaser'
65
+ * @param {number} [latencyMs]
66
+ */
67
+ export function logEvent(apiFn, auth, toolName, argsSummary, resultType, latencyMs) {
68
+ // Only log to server for authenticated (pro+) users
69
+ if (!auth.authenticated) return;
70
+
71
+ const body = {
72
+ tool_name: toolName,
73
+ args_summary: argsSummary || undefined,
74
+ result_type: resultType || 'success',
75
+ latency_ms: latencyMs || undefined,
76
+ };
77
+
78
+ // Fire and forget — do not await, swallow errors
79
+ apiFn('POST', '/api/v1/mcp/events', { body }).catch(() => {});
80
+ }
81
+
82
+ /**
83
+ * Summarize tool arguments into a short string for analytics.
84
+ * Strips large values and keeps only keys + short values.
85
+ */
86
+ function summarizeArgs(args) {
87
+ if (!args || typeof args !== 'object') return undefined;
88
+ const parts = [];
89
+ for (const [k, v] of Object.entries(args)) {
90
+ if (v === undefined || v === null) continue;
91
+ const str = typeof v === 'string' ? v : JSON.stringify(v);
92
+ parts.push(`${k}=${str.length > 60 ? str.slice(0, 57) + '...' : str}`);
93
+ }
94
+ const summary = parts.join(', ');
95
+ return summary.length > 200 ? summary.slice(0, 197) + '...' : summary;
96
+ }
97
+
98
+ /**
99
+ * Wrap the MCP server's tool method so that every invocation is automatically
100
+ * logged via logEvent(). This should be called AFTER the tier-gating wrapper
101
+ * has already been applied.
102
+ *
103
+ * @param {object} server - The McpServer instance
104
+ * @param {Function} apiFn - The api() helper
105
+ * @param {object} auth - The AUTH context
106
+ * @returns {Function} restore - Call to remove the wrapper (for testing)
107
+ */
108
+ export function wrapWithEventLogging(server, apiFn, auth) {
109
+ const _wrappedTool = server.tool.bind(server);
110
+
111
+ server.tool = function loggedTool(name, ...rest) {
112
+ const handler = rest[rest.length - 1];
113
+ rest[rest.length - 1] = async function loggedHandler(...handlerArgs) {
114
+ const start = Date.now();
115
+ let resultType = 'success';
116
+ try {
117
+ const result = await handler(...handlerArgs);
118
+ return result;
119
+ } catch (err) {
120
+ resultType = 'error';
121
+ throw err;
122
+ } finally {
123
+ const latency = Date.now() - start;
124
+ // handlerArgs[0] is the args object from MCP
125
+ const argsSummary = summarizeArgs(handlerArgs[0]);
126
+ logEvent(apiFn, auth, name, argsSummary, resultType, latency);
127
+ }
128
+ };
129
+ return _wrappedTool(name, ...rest);
130
+ };
131
+
132
+ return function restore() {
133
+ server.tool = _wrappedTool;
134
+ };
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Memory tools
139
+ // ---------------------------------------------------------------------------
140
+
141
+ /**
142
+ * Register all memory tools on the MCP server.
143
+ *
144
+ * @param {object} server - McpServer instance (already has tier gating)
145
+ * @param {object} z - Zod instance from MCP SDK
146
+ * @param {Function} apiFn - The api() helper for server calls
147
+ * @param {object} auth - AUTH context { tier, token, apiKey, authenticated }
148
+ * @param {Function} okFn - The ok() response helper from server.js
149
+ */
150
+ export function registerMemoryTools(server, z, apiFn, auth, okFn) {
151
+ const isServerSide = auth.authenticated; // pro+ uses server, free uses local
152
+
153
+ // ----- save_memory -----
154
+ server.tool('save_memory',
155
+ 'Store a fact, insight, preference, or deal context. Memories persist across conversations so Claude remembers important information. Free tier stores locally; Pro+ stores server-side with vector search.',
156
+ {
157
+ content: z.string().describe('The fact or insight to remember (e.g. "Stripe is our top competitor in payments")'),
158
+ tags: z.array(z.string()).optional().describe('Optional tags for categorization (e.g. ["competitor", "payments"])'),
159
+ memory_type: z.enum(['fact', 'preference', 'deal_context', 'pattern']).optional().describe('Type of memory (default: fact)'),
160
+ },
161
+ async (args) => {
162
+ if (isServerSide) {
163
+ const data = await apiFn('POST', '/api/v1/mcp/memories', {
164
+ body: {
165
+ content: args.content,
166
+ tags: args.tags || [],
167
+ memory_type: args.memory_type || 'fact',
168
+ },
169
+ });
170
+ return okFn(data);
171
+ }
172
+
173
+ // Free tier: store locally
174
+ const memories = readLocalMemories();
175
+ const memory = {
176
+ id: randomUUID(),
177
+ content: args.content,
178
+ memory_type: args.memory_type || 'fact',
179
+ tags: args.tags || [],
180
+ confidence: 1.0,
181
+ is_latest: true,
182
+ forgotten_at: null,
183
+ created_at: new Date().toISOString(),
184
+ updated_at: new Date().toISOString(),
185
+ };
186
+ memories.push(memory);
187
+ writeLocalMemories(memories);
188
+
189
+ return okFn({
190
+ success: true,
191
+ data: {
192
+ id: memory.id,
193
+ memory_type: memory.memory_type,
194
+ is_latest: true,
195
+ },
196
+ });
197
+ }
198
+ );
199
+
200
+ // ----- recall -----
201
+ server.tool('recall',
202
+ 'Search saved memories by text query. Returns memories ranked by relevance. Use this to recall facts, preferences, deal context, or patterns from previous conversations.',
203
+ {
204
+ query: z.string().describe('Search query (e.g. "competitors in payments", "user preferences")'),
205
+ memory_type: z.enum(['fact', 'preference', 'deal_context', 'pattern']).optional().describe('Filter by memory type'),
206
+ limit: z.number().optional().describe('Max results (default 10, max 50)'),
207
+ },
208
+ async (args) => {
209
+ if (isServerSide) {
210
+ const params = {
211
+ query: args.query,
212
+ memory_type: args.memory_type,
213
+ limit: args.limit || 10,
214
+ };
215
+ const data = await apiFn('GET', '/api/v1/mcp/memories', { params });
216
+ return okFn(data);
217
+ }
218
+
219
+ // Free tier: local text search
220
+ const memories = readLocalMemories();
221
+ const queryLower = args.query.toLowerCase();
222
+ const limit = Math.min(args.limit || 10, 50);
223
+
224
+ const scored = memories
225
+ .filter(m => m.is_latest && !m.forgotten_at)
226
+ .filter(m => !args.memory_type || m.memory_type === args.memory_type)
227
+ .map(m => {
228
+ // Simple text similarity: count query word matches
229
+ const words = queryLower.split(/\s+/);
230
+ const contentLower = m.content.toLowerCase();
231
+ const tagsLower = (m.tags || []).join(' ').toLowerCase();
232
+ let score = 0;
233
+ for (const word of words) {
234
+ if (contentLower.includes(word)) score += 2;
235
+ if (tagsLower.includes(word)) score += 1;
236
+ }
237
+ return { ...m, similarity: score / (words.length * 3) };
238
+ })
239
+ .filter(m => m.similarity > 0)
240
+ .sort((a, b) => b.similarity - a.similarity)
241
+ .slice(0, limit);
242
+
243
+ return okFn({
244
+ success: true,
245
+ data: scored,
246
+ meta: { count: scored.length, search_type: 'local_text' },
247
+ });
248
+ }
249
+ );
250
+
251
+ // ----- who_am_i -----
252
+ server.tool('who_am_i',
253
+ 'Get your user profile with usage statistics — tier, top tools used, recent activity, saved memories count, and industries researched. Useful for understanding your Adrata context.',
254
+ {},
255
+ async () => {
256
+ if (isServerSide) {
257
+ const data = await apiFn('GET', '/api/v1/mcp/profile');
258
+
259
+ // /api/v1/mcp/profile describes the OAuth *client*, not the human it
260
+ // acts for: it returns name:"OAuth client", email:"", role:null,
261
+ // tier:"free" even for an enterprise seat. That directly contradicts
262
+ // get_current_user and makes who_am_i — the first tool an agent
263
+ // reaches for — untrustworthy. Overlay the authenticated user so both
264
+ // tools agree on identity.
265
+ try {
266
+ const me = await apiFn('GET', '/api/v1/users/me');
267
+ const u = me?.data || me;
268
+ if (u?.id) {
269
+ const profile = data?.data ? { ...data.data } : {};
270
+ profile.user = {
271
+ ...(profile.user || {}),
272
+ id: u.id,
273
+ name: u.name || [u.firstName, u.lastName].filter(Boolean).join(' ') || null,
274
+ email: u.email || null,
275
+ role: u.role ?? (profile.user || {}).role ?? null,
276
+ title: u.title || null,
277
+ workspaceId: u.activeWorkspaceId || u.workspaceId || null,
278
+ // Keep whatever tier the profile endpoint reported, but never let
279
+ // a null/absent value silently read as "free".
280
+ tier: (profile.user || {}).tier || null,
281
+ };
282
+ return okFn({ ...data, data: profile });
283
+ }
284
+ } catch {
285
+ // Fall through to the raw profile rather than failing the tool.
286
+ }
287
+ return okFn(data);
288
+ }
289
+
290
+ // Free tier: build profile from local data
291
+ const memories = readLocalMemories();
292
+ const activeMemories = memories.filter(m => m.is_latest && !m.forgotten_at);
293
+
294
+ // Collect tags as a proxy for "industries"
295
+ const tagCounts = {};
296
+ for (const m of activeMemories) {
297
+ for (const tag of (m.tags || [])) {
298
+ tagCounts[tag] = (tagCounts[tag] || 0) + 1;
299
+ }
300
+ }
301
+
302
+ const topTags = Object.entries(tagCounts)
303
+ .sort((a, b) => b[1] - a[1])
304
+ .slice(0, 10)
305
+ .map(([tag, count]) => ({ tag, count }));
306
+
307
+ return okFn({
308
+ success: true,
309
+ data: {
310
+ user: {
311
+ tier: 'free',
312
+ role: null,
313
+ preferences: {},
314
+ },
315
+ stats: {
316
+ total_queries: 0,
317
+ top_tools: [],
318
+ recent_conversations_30d: 0,
319
+ active_memories: activeMemories.length,
320
+ top_tags: topTags,
321
+ industries_searched: [],
322
+ },
323
+ },
324
+ });
325
+ }
326
+ );
327
+
328
+ // ----- forget -----
329
+ server.tool('forget',
330
+ 'Soft-delete a specific memory by ID. The memory will no longer appear in recall results.',
331
+ {
332
+ memory_id: z.string().describe('The ID of the memory to forget'),
333
+ },
334
+ async (args) => {
335
+ if (isServerSide) {
336
+ // DELETE /api/v1/mcp/memories/{id} hard-deletes from the unified
337
+ // ai_memories store. (The old fallback of saving a "[FORGOTTEN]"
338
+ // tombstone memory polluted recall — never do that; surface the
339
+ // error instead.)
340
+ try {
341
+ await apiFn('DELETE', `/api/v1/mcp/memories/${args.memory_id}`);
342
+ return okFn({ success: true, forgotten: args.memory_id });
343
+ } catch (err) {
344
+ return okFn({
345
+ success: false,
346
+ error: `Failed to forget memory ${args.memory_id}: ${err?.message || err}`,
347
+ });
348
+ }
349
+ }
350
+
351
+ // Free tier: mark as forgotten locally
352
+ const memories = readLocalMemories();
353
+ let found = false;
354
+ for (const m of memories) {
355
+ if (m.id === args.memory_id && !m.forgotten_at) {
356
+ m.forgotten_at = new Date().toISOString();
357
+ m.is_latest = false;
358
+ found = true;
359
+ break;
360
+ }
361
+ }
362
+
363
+ if (!found) {
364
+ return okFn({ success: false, error: `Memory ${args.memory_id} not found or already forgotten` });
365
+ }
366
+
367
+ writeLocalMemories(memories);
368
+ return okFn({ success: true, forgotten: args.memory_id });
369
+ }
370
+ );
371
+ }
372
+
373
+ // ---------------------------------------------------------------------------
374
+ // MCP Resource: adrata://profile
375
+ // ---------------------------------------------------------------------------
376
+
377
+ /**
378
+ * Register the adrata://profile resource that Claude reads at session start
379
+ * for context injection.
380
+ *
381
+ * @param {object} server - McpServer instance
382
+ * @param {Function} apiFn - The api() helper
383
+ * @param {object} auth - AUTH context
384
+ */
385
+ export function registerProfileResource(server, apiFn, auth) {
386
+ server.resource('user-profile', 'adrata://profile', async () => {
387
+ let profileText;
388
+
389
+ if (auth.authenticated) {
390
+ try {
391
+ const data = await apiFn('GET', '/api/v1/mcp/profile');
392
+ const profile = data?.data || data;
393
+ profileText = `Adrata User Profile
394
+ Tier: ${profile.user?.tier || auth.tier}
395
+ Email: ${profile.user?.email || 'unknown'}
396
+ Role: ${profile.user?.role || 'not set'}
397
+ Member since: ${profile.user?.member_since || 'unknown'}
398
+
399
+ Usage Statistics:
400
+ - Total queries: ${profile.stats?.total_queries || 0}
401
+ - Active memories: ${profile.stats?.active_memories || 0}
402
+ - Recent conversations (30d): ${profile.stats?.recent_conversations_30d || 0}
403
+
404
+ Top tools: ${(profile.stats?.top_tools || []).map(t => `${t.tool} (${t.count})`).join(', ') || 'none yet'}
405
+ Industries searched: ${(profile.stats?.industries_searched || []).join(', ') || 'none yet'}`;
406
+ } catch {
407
+ profileText = `Adrata User Profile
408
+ Tier: ${auth.tier}
409
+ Status: Profile data unavailable (API error). User is authenticated.`;
410
+ }
411
+ } else {
412
+ // Free tier: build from local data
413
+ const memories = readLocalMemories();
414
+ const activeMemories = memories.filter(m => m.is_latest && !m.forgotten_at);
415
+ const tags = {};
416
+ for (const m of activeMemories) {
417
+ for (const tag of (m.tags || [])) {
418
+ tags[tag] = (tags[tag] || 0) + 1;
419
+ }
420
+ }
421
+ const topTags = Object.entries(tags).sort((a, b) => b[1] - a[1]).slice(0, 5);
422
+
423
+ profileText = `Adrata User Profile
424
+ Tier: free
425
+ Active memories: ${activeMemories.length}
426
+ Top tags: ${topTags.map(([t, c]) => `${t} (${c})`).join(', ') || 'none yet'}
427
+
428
+ Note: Upgrade to Pro for server-side memory with vector search, usage analytics, and full profile data.
429
+ Set the ADRATA_API_KEY environment variable to activate.`;
430
+ }
431
+
432
+ return {
433
+ contents: [{
434
+ uri: 'adrata://profile',
435
+ mimeType: 'text/plain',
436
+ text: profileText,
437
+ }],
438
+ };
439
+ });
440
+ }