@elliotding/ai-agent-mcp 0.1.2 → 0.1.4

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 (104) hide show
  1. package/.prompt-cache/cmd-cmd-client-sdk-ai-hub-generate-testcase.md +101 -0
  2. package/.prompt-cache/cmd-cmd-client-sdk-ai-hub-submit_zct_job.md +158 -0
  3. package/.prompt-cache/skill-skill-client-sdk-ai-hub-analyze-conf-status.md +311 -0
  4. package/.prompt-cache/skill-skill-client-sdk-ai-hub-analyze-sdk-log.md +64 -0
  5. package/.prompt-cache/skill-skill-client-sdk-ai-hub-analyze-zmb-log-errors.md +84 -0
  6. package/ai-resource-telemetry.json +22 -0
  7. package/dist/api/client.d.ts +76 -8
  8. package/dist/api/client.d.ts.map +1 -1
  9. package/dist/api/client.js +86 -40
  10. package/dist/api/client.js.map +1 -1
  11. package/dist/auth/permissions.d.ts.map +1 -1
  12. package/dist/auth/permissions.js +6 -0
  13. package/dist/auth/permissions.js.map +1 -1
  14. package/dist/config/index.d.ts +6 -1
  15. package/dist/config/index.d.ts.map +1 -1
  16. package/dist/config/index.js +1 -3
  17. package/dist/config/index.js.map +1 -1
  18. package/dist/index.js +12 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/prompts/cache.d.ts +69 -0
  21. package/dist/prompts/cache.d.ts.map +1 -0
  22. package/dist/prompts/cache.js +163 -0
  23. package/dist/prompts/cache.js.map +1 -0
  24. package/dist/prompts/generator.d.ts +49 -0
  25. package/dist/prompts/generator.d.ts.map +1 -0
  26. package/dist/prompts/generator.js +158 -0
  27. package/dist/prompts/generator.js.map +1 -0
  28. package/dist/prompts/index.d.ts +13 -0
  29. package/dist/prompts/index.d.ts.map +1 -0
  30. package/dist/prompts/index.js +24 -0
  31. package/dist/prompts/index.js.map +1 -0
  32. package/dist/prompts/manager.d.ts +106 -0
  33. package/dist/prompts/manager.d.ts.map +1 -0
  34. package/dist/prompts/manager.js +263 -0
  35. package/dist/prompts/manager.js.map +1 -0
  36. package/dist/server/http.d.ts.map +1 -1
  37. package/dist/server/http.js +61 -17
  38. package/dist/server/http.js.map +1 -1
  39. package/dist/server.d.ts.map +1 -1
  40. package/dist/server.js +43 -0
  41. package/dist/server.js.map +1 -1
  42. package/dist/telemetry/index.d.ts +3 -0
  43. package/dist/telemetry/index.d.ts.map +1 -0
  44. package/dist/telemetry/index.js +7 -0
  45. package/dist/telemetry/index.js.map +1 -0
  46. package/dist/telemetry/manager.d.ts +149 -0
  47. package/dist/telemetry/manager.d.ts.map +1 -0
  48. package/dist/telemetry/manager.js +368 -0
  49. package/dist/telemetry/manager.js.map +1 -0
  50. package/dist/tools/index.d.ts +1 -0
  51. package/dist/tools/index.d.ts.map +1 -1
  52. package/dist/tools/index.js +1 -0
  53. package/dist/tools/index.js.map +1 -1
  54. package/dist/tools/manage-subscription.d.ts +4 -0
  55. package/dist/tools/manage-subscription.d.ts.map +1 -1
  56. package/dist/tools/manage-subscription.js +36 -7
  57. package/dist/tools/manage-subscription.js.map +1 -1
  58. package/dist/tools/search-resources.d.ts +4 -0
  59. package/dist/tools/search-resources.d.ts.map +1 -1
  60. package/dist/tools/search-resources.js +6 -1
  61. package/dist/tools/search-resources.js.map +1 -1
  62. package/dist/tools/sync-resources.d.ts +13 -4
  63. package/dist/tools/sync-resources.d.ts.map +1 -1
  64. package/dist/tools/sync-resources.js +127 -6
  65. package/dist/tools/sync-resources.js.map +1 -1
  66. package/dist/tools/track-usage.d.ts +63 -0
  67. package/dist/tools/track-usage.d.ts.map +1 -0
  68. package/dist/tools/track-usage.js +90 -0
  69. package/dist/tools/track-usage.js.map +1 -0
  70. package/dist/tools/uninstall-resource.d.ts.map +1 -1
  71. package/dist/tools/uninstall-resource.js +53 -3
  72. package/dist/tools/uninstall-resource.js.map +1 -1
  73. package/dist/tools/upload-resource.d.ts +4 -0
  74. package/dist/tools/upload-resource.d.ts.map +1 -1
  75. package/dist/tools/upload-resource.js +164 -23
  76. package/dist/tools/upload-resource.js.map +1 -1
  77. package/dist/types/tools.d.ts +17 -2
  78. package/dist/types/tools.d.ts.map +1 -1
  79. package/dist/utils/cursor-paths.d.ts +10 -0
  80. package/dist/utils/cursor-paths.d.ts.map +1 -1
  81. package/dist/utils/cursor-paths.js +13 -0
  82. package/dist/utils/cursor-paths.js.map +1 -1
  83. package/package.json +1 -1
  84. package/src/api/client.ts +191 -71
  85. package/src/auth/permissions.ts +6 -0
  86. package/src/config/index.ts +11 -5
  87. package/src/index.ts +18 -0
  88. package/src/prompts/cache.ts +140 -0
  89. package/src/prompts/generator.ts +142 -0
  90. package/src/prompts/index.ts +20 -0
  91. package/src/prompts/manager.ts +342 -0
  92. package/src/server/http.ts +69 -17
  93. package/src/server.ts +13 -0
  94. package/src/telemetry/index.ts +10 -0
  95. package/src/telemetry/manager.ts +419 -0
  96. package/src/tools/index.ts +1 -0
  97. package/src/tools/manage-subscription.ts +41 -7
  98. package/src/tools/search-resources.ts +14 -6
  99. package/src/tools/sync-resources.ts +141 -9
  100. package/src/tools/track-usage.ts +113 -0
  101. package/src/tools/uninstall-resource.ts +62 -4
  102. package/src/tools/upload-resource.ts +204 -31
  103. package/src/types/tools.ts +17 -2
  104. package/src/utils/cursor-paths.ts +13 -0
@@ -12,8 +12,12 @@ import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
12
12
  import {
13
13
  CallToolRequestSchema,
14
14
  ListToolsRequestSchema,
15
+ ListResourcesRequestSchema,
16
+ ReadResourceRequestSchema,
15
17
  } from '@modelcontextprotocol/sdk/types.js';
16
18
  import { syncResources } from '../tools/sync-resources.js';
19
+ import { telemetry } from '../telemetry/index.js';
20
+ import { promptManager } from '../prompts/index.js';
17
21
  import { config } from '../config';
18
22
  import { logger } from '../utils/logger';
19
23
  import { sessionManager } from '../session/manager';
@@ -103,17 +107,19 @@ export class HTTPServer {
103
107
  // ─────────────────────────────────────────────────────────────────────────
104
108
 
105
109
  private setupRoutes(): void {
110
+ const basePath = config.http?.basePath ?? '';
111
+
106
112
  // Health check
107
- this.fastify.get('/health', this.handleHealth.bind(this));
113
+ this.fastify.get(`${basePath}/health`, this.handleHealth.bind(this));
108
114
 
109
115
  // SSE connection — GET establishes the stream (SDK standard)
110
- this.fastify.get('/sse', {
116
+ this.fastify.get(`${basePath}/sse`, {
111
117
  preHandler: tokenAuthOrLegacyMiddleware,
112
118
  handler: this.handleSSEConnection.bind(this),
113
119
  });
114
120
 
115
121
  // Message endpoint — POST delivers JSON-RPC messages, sessionId in query
116
- this.fastify.post('/message', this.handleMessage.bind(this));
122
+ this.fastify.post(`${basePath}/message`, this.handleMessage.bind(this));
117
123
 
118
124
  // OAuth discovery — return 404 so Cursor skips OAuth handshake
119
125
  this.fastify.get('/.well-known/oauth-authorization-server', async (_req, reply) => {
@@ -125,10 +131,11 @@ export class HTTPServer {
125
131
  server: 'CSP AI Agent MCP Server',
126
132
  version: '1.0.0',
127
133
  transport: 'sse',
134
+ basePath: basePath || '(none)',
128
135
  endpoints: {
129
- health: 'GET /health',
130
- sse: 'GET /sse',
131
- message: 'POST /message?sessionId=<id>',
136
+ health: `GET ${basePath}/health`,
137
+ sse: `GET ${basePath}/sse`,
138
+ message: `POST ${basePath}/message?sessionId=<id>`,
132
139
  },
133
140
  }));
134
141
  }
@@ -142,12 +149,31 @@ export class HTTPServer {
142
149
  * A fresh instance is created per SSE connection so that each session is
143
150
  * isolated (matching ACM's createMCPServer-per-connection pattern).
144
151
  */
145
- private createMcpServer(userId?: string, email?: string, groups?: string[]): Server {
152
+ private createMcpServer(userId?: string, email?: string, groups?: string[], userToken?: string): Server {
146
153
  const server = new Server(
147
154
  { name: 'csp-ai-agent-mcp', version: '0.2.0' },
148
- { capabilities: { tools: {} } }
155
+ // Declare resources capability so Cursor does not emit "Method not found"
156
+ // when it probes prompt:// URIs via the resources/read protocol.
157
+ { capabilities: { tools: {}, prompts: {}, resources: {} } }
149
158
  );
150
159
 
160
+ // Install Prompt list/get handlers synchronously on this Server instance.
161
+ // Pass userToken so GetPrompt can attribute telemetry to the correct user.
162
+ promptManager.installHandlers(server, userToken);
163
+
164
+ // resources/list — return an empty list; we don't publish static resources.
165
+ server.setRequestHandler(ListResourcesRequestSchema, () => ({ resources: [] }));
166
+
167
+ // resources/read — Cursor probes `prompt://<name>` URIs to check if a
168
+ // prompt can be read as a resource. Return an empty text content so the
169
+ // client does not display an error; it will fall back to prompts/get for
170
+ // actual content.
171
+ server.setRequestHandler(ReadResourceRequestSchema, (request) => {
172
+ const uri = request.params.uri;
173
+ logger.debug({ uri }, 'resources/read probe received — returning empty content');
174
+ return { contents: [{ uri, text: '' }] };
175
+ });
176
+
151
177
  // tools/list
152
178
  server.setRequestHandler(ListToolsRequestSchema, () => ({
153
179
  tools: toolRegistry.getMCPToolDefinitions(),
@@ -157,7 +183,10 @@ export class HTTPServer {
157
183
  // Runs in the background so it never blocks the connection setup.
158
184
  server.oninitialized = () => {
159
185
  logger.info({ userId }, 'MCP initialized — triggering background sync_resources');
160
- syncResources({ mode: 'incremental', scope: 'global' }).then((result) => {
186
+ // Flush any pending telemetry immediately on (re)connect so events from
187
+ // before a disconnect are not held until the next 10-second tick.
188
+ telemetry.flushOnReconnect();
189
+ syncResources({ mode: 'incremental', scope: 'global', user_token: userToken }).then((result) => {
161
190
  if (result.success) {
162
191
  logger.info(
163
192
  { userId, synced: result.data?.summary?.synced, cached: result.data?.summary?.cached },
@@ -186,8 +215,17 @@ export class HTTPServer {
186
215
  }
187
216
  }
188
217
 
218
+ // Inject the authenticated token so every tool can call the CSP API without
219
+ // requiring the AI to know about or pass user_token explicitly.
220
+ // The AI-supplied user_token (if any) takes precedence; otherwise we fall back
221
+ // to the token from the SSE connection that created this session.
222
+ const enrichedArgs: Record<string, unknown> = {
223
+ user_token: userToken,
224
+ ...args,
225
+ };
226
+
189
227
  try {
190
- const result = await toolRegistry.callTool(name, args as Record<string, unknown>);
228
+ const result = await toolRegistry.callTool(name, enrichedArgs);
191
229
  const text = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
192
230
  return { content: [{ type: 'text' as const, text }] };
193
231
  } catch (err) {
@@ -224,6 +262,11 @@ export class HTTPServer {
224
262
  return;
225
263
  }
226
264
 
265
+ // Update telemetry with the authenticated token so flush() can report even
266
+ // when CSP_API_TOKEN is not injected via process env (SSE transport delivers
267
+ // the token through the Authorization header, not via mcp.json env injection).
268
+ telemetry.setUserToken(token);
269
+
227
270
  try {
228
271
  // Keep our session manager in sync for health/monitoring endpoints
229
272
  const sessionOptions = request.user
@@ -249,8 +292,15 @@ export class HTTPServer {
249
292
  }
250
293
  }, 30_000);
251
294
 
252
- // Create SDK transport it takes ownership of reply.raw
253
- const transport = new SSEServerTransport('/message', reply.raw);
295
+ // Build the absolute message URL for the SSE endpoint event.
296
+ // Cursor (and other MCP clients) resolve the endpoint event data as a URL;
297
+ // using a relative path causes some clients to misinterpret it as a redirect.
298
+ // We use the Host header when available so the URL matches what the client
299
+ // actually connected to (important behind proxies / ngrok / etc.).
300
+ const basePath = config.http?.basePath ?? '';
301
+ const host = request.headers.host ?? `${config.http?.host ?? '127.0.0.1'}:${config.http?.port ?? 3000}`;
302
+ const messageUrl = `http://${host}${basePath}/message`;
303
+ const transport = new SSEServerTransport(messageUrl, reply.raw);
254
304
  const sdkSessionId = transport.sessionId;
255
305
  this.sseTransports.set(sdkSessionId, transport);
256
306
 
@@ -269,7 +319,8 @@ export class HTTPServer {
269
319
  const mcpServer = this.createMcpServer(
270
320
  request.user?.userId,
271
321
  request.user?.email,
272
- request.user?.groups
322
+ request.user?.groups,
323
+ token,
273
324
  );
274
325
  await mcpServer.connect(transport);
275
326
 
@@ -377,13 +428,14 @@ export class HTTPServer {
377
428
  try {
378
429
  const host = config.http?.host || '0.0.0.0';
379
430
  const port = config.http?.port || 3000;
431
+ const basePath = config.http?.basePath ?? '';
380
432
 
381
433
  await this.fastify.listen({ host, port });
382
434
 
383
- logger.info({ host, port }, 'HTTP server started');
384
- logger.info(`Health check: http://${host}:${port}/health`);
385
- logger.info(`SSE endpoint: http://${host}:${port}/sse`);
386
- logger.info(`Message endpoint: http://${host}:${port}/message?sessionId=<id>`);
435
+ logger.info({ host, port, basePath }, 'HTTP server started');
436
+ logger.info(`Health check: http://${host}:${port}${basePath}/health`);
437
+ logger.info(`SSE endpoint: http://${host}:${port}${basePath}/sse`);
438
+ logger.info(`Message endpoint: http://${host}:${port}${basePath}/message?sessionId=<id>`);
387
439
  } catch (error) {
388
440
  logger.error({ error }, 'Failed to start HTTP server');
389
441
  throw error;
package/src/server.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  searchResourcesTool,
19
19
  uploadResourceTool,
20
20
  uninstallResourceTool,
21
+ trackUsageTool,
21
22
  } from './tools';
22
23
  import { httpServer } from './server/http';
23
24
 
@@ -34,6 +35,7 @@ function registerTools() {
34
35
  toolRegistry.registerTool(searchResourcesTool);
35
36
  toolRegistry.registerTool(uploadResourceTool);
36
37
  toolRegistry.registerTool(uninstallResourceTool);
38
+ toolRegistry.registerTool(trackUsageTool);
37
39
 
38
40
  logger.info(
39
41
  { toolCount: toolRegistry.getToolCount() },
@@ -56,10 +58,16 @@ async function startStdioServer(): Promise<void> {
56
58
  {
57
59
  capabilities: {
58
60
  tools: {},
61
+ prompts: {},
59
62
  },
60
63
  }
61
64
  );
62
65
 
66
+ // Install Prompt list/get handlers so Command and Skill resources are
67
+ // exposed as MCP Prompts (Cursor slash commands).
68
+ const { promptManager } = await import('./prompts/index.js');
69
+ promptManager.installHandlers(server);
70
+
63
71
  // Handle tools/list request
64
72
  server.setRequestHandler(ListToolsRequestSchema, () => {
65
73
  const tools = toolRegistry.getMCPToolDefinitions();
@@ -134,6 +142,11 @@ async function startStdioServer(): Promise<void> {
134
142
  const transport = new StdioServerTransport();
135
143
  await server.connect(transport);
136
144
 
145
+ // Flush any pending telemetry immediately — stdio reconnects when Cursor
146
+ // restarts the process, so treat connect as a reconnect event.
147
+ const { telemetry: tel } = await import('./telemetry/index.js');
148
+ tel.flushOnReconnect();
149
+
137
150
  logger.info('✅ MCP Server started successfully (stdio transport)');
138
151
  }
139
152
 
@@ -0,0 +1,10 @@
1
+ export { TelemetryManager, telemetry } from './manager.js';
2
+ export type {
3
+ ResourceCategory,
4
+ InvocationEvent,
5
+ SubscribedRule,
6
+ ConfiguredMcp,
7
+ TelemetryFile,
8
+ TelemetryReportPayload,
9
+ ReportFn,
10
+ } from './manager.js';
@@ -0,0 +1,419 @@
1
+ /**
2
+ * TelemetryManager: records local AI resource invocation events and periodically
3
+ * flushes them to the remote telemetry API.
4
+ *
5
+ * Local storage: {MCP Server CWD}/ai-resource-telemetry.json
6
+ *
7
+ * Multi-user design:
8
+ * - The file is keyed by user token so that data for different users is stored
9
+ * and reported independently. Each top-level key in the `users` map is a
10
+ * user token; all events, rules and MCPs belong to that token's owner.
11
+ * - On flush, each user's data is sent with that user's own token, so the
12
+ * server can attribute the telemetry to the correct account.
13
+ *
14
+ * Other design notes:
15
+ * - File is stored in the MCP Server's runtime working directory (not ~/.cursor/).
16
+ * - Atomic write-then-rename pattern prevents file corruption on concurrent
17
+ * writes or unexpected process termination.
18
+ * - Periodic flush is fire-and-forget; failures retry up to MAX_RETRIES times
19
+ * with exponential back-off, then silently drop — main tool flow is never blocked.
20
+ * - Rules cannot track individual invocations (Cursor injects them silently).
21
+ * We report the subscribed list as a snapshot on every flush instead.
22
+ * - MCPs are tracked as a configured-list snapshot only.
23
+ * - jira_id is an optional per-invocation annotation stored separately per key.
24
+ */
25
+
26
+ import * as fs from 'fs';
27
+ import * as path from 'path';
28
+
29
+ export type ResourceCategory = 'command' | 'skill' | 'mcp';
30
+
31
+ export interface InvocationEvent {
32
+ resource_id: string;
33
+ resource_type: ResourceCategory;
34
+ resource_name: string;
35
+ invocation_count: number;
36
+ first_invoked_at: string;
37
+ last_invoked_at: string;
38
+ /** Optional Jira Issue ID for usage correlation (e.g. "PROJ-12345"). */
39
+ jira_id?: string;
40
+ }
41
+
42
+ export interface SubscribedRule {
43
+ resource_id: string;
44
+ resource_name: string;
45
+ subscribed_at: string;
46
+ }
47
+
48
+ export interface ConfiguredMcp {
49
+ resource_id: string;
50
+ resource_name: string;
51
+ configured_at: string;
52
+ }
53
+
54
+ /** Per-user telemetry data stored under `users[token]`. */
55
+ export interface UserTelemetry {
56
+ last_reported_at: string | null;
57
+ pending_events: InvocationEvent[];
58
+ subscribed_rules: SubscribedRule[];
59
+ configured_mcps: ConfiguredMcp[];
60
+ }
61
+
62
+ /** Top-level file structure (v2: multi-user). */
63
+ export interface TelemetryFile {
64
+ client_version: string;
65
+ /** Map of user token → per-user telemetry data. */
66
+ users: Record<string, UserTelemetry>;
67
+ }
68
+
69
+ export interface TelemetryReportPayload {
70
+ client_version: string;
71
+ reported_at: string;
72
+ events: InvocationEvent[];
73
+ subscribed_rules: SubscribedRule[];
74
+ configured_mcps: ConfiguredMcp[];
75
+ }
76
+
77
+ // Injected at flush time by the server; avoids circular import with api/client
78
+ export type ReportFn = (payload: TelemetryReportPayload, userToken: string) => Promise<void>;
79
+
80
+ /** Default file name placed in the MCP Server's CWD. */
81
+ const DEFAULT_FILE_NAME = 'ai-resource-telemetry.json';
82
+
83
+ const DEFAULT_VERSION = '0.1.5';
84
+ const MAX_RETRIES = 3;
85
+ const RETRY_BASE_MS = 500;
86
+
87
+ /** Build the aggregation key for an invocation event. */
88
+ function aggregationKey(resourceId: string, jiraId?: string): string {
89
+ return jiraId ? `${resourceId}|${jiraId}` : resourceId;
90
+ }
91
+
92
+ /** Return an empty per-user telemetry record. */
93
+ function emptyUserTelemetry(): UserTelemetry {
94
+ return {
95
+ last_reported_at: null,
96
+ pending_events: [],
97
+ subscribed_rules: [],
98
+ configured_mcps: [],
99
+ };
100
+ }
101
+
102
+ export class TelemetryManager {
103
+ private filePath: string;
104
+ private clientVersion: string;
105
+ private timer: ReturnType<typeof setInterval> | null = null;
106
+ private reportFn: ReportFn | null = null;
107
+ private userTokenFn: (() => string | undefined) | null = null;
108
+ /** Tracks all tokens seen from active SSE connections for multi-user flush. */
109
+ private activeTokens: Set<string> = new Set();
110
+ /** Simple mutex: true while a file write is in progress. */
111
+ private writing = false;
112
+ private writeQueue: Array<() => void> = [];
113
+
114
+ /**
115
+ * @param filePath Absolute path to the telemetry JSON file.
116
+ * Defaults to `{CWD}/ai-resource-telemetry.json`.
117
+ * @param clientVersion Reported client version string.
118
+ */
119
+ constructor(filePath?: string, clientVersion?: string) {
120
+ this.filePath = filePath ?? path.join(process.cwd(), DEFAULT_FILE_NAME);
121
+ this.clientVersion = clientVersion ?? DEFAULT_VERSION;
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Public API
126
+ // ---------------------------------------------------------------------------
127
+
128
+ /**
129
+ * Configure the function used to send telemetry to the server.
130
+ * Called during server initialisation to inject the API client without
131
+ * creating a circular dependency.
132
+ */
133
+ configure(reportFn: ReportFn, userTokenFn: () => string | undefined): void {
134
+ this.reportFn = reportFn;
135
+ this.userTokenFn = userTokenFn;
136
+ }
137
+
138
+ /**
139
+ * Register a token from a newly authenticated SSE connection.
140
+ *
141
+ * - Adds the token to the active-token set (used for multi-user flush).
142
+ * - Initialises the per-user slot in the file if it does not yet exist.
143
+ */
144
+ setUserToken(token: string): void {
145
+ this.activeTokens.add(token);
146
+ // Ensure the user slot exists without overwriting existing data.
147
+ this.withFileLock(async () => {
148
+ const data = this.readFile();
149
+ if (!data.users[token]) {
150
+ data.users[token] = emptyUserTelemetry();
151
+ this.writeFile(data);
152
+ }
153
+ }).catch(() => { /* best-effort */ });
154
+ }
155
+
156
+ /**
157
+ * Record one invocation of a Command or Skill resource for a specific user.
158
+ *
159
+ * Events are aggregated by (resource_id, jira_id) key — successive calls for
160
+ * the same key increment the counter rather than appending duplicate entries.
161
+ *
162
+ * @param resourceId Canonical resource ID.
163
+ * @param resourceType 'command' | 'skill'
164
+ * @param resourceName Human-readable name.
165
+ * @param userToken Token of the user who invoked the resource.
166
+ * @param jiraId Optional Jira Issue ID for correlation.
167
+ */
168
+ async recordInvocation(
169
+ resourceId: string,
170
+ resourceType: ResourceCategory,
171
+ resourceName: string,
172
+ userToken: string,
173
+ jiraId?: string,
174
+ ): Promise<void> {
175
+ await this.withFileLock(async () => {
176
+ const data = this.readFile();
177
+ const user = this.ensureUserSlot(data, userToken);
178
+ const now = new Date().toISOString();
179
+ const key = aggregationKey(resourceId, jiraId);
180
+
181
+ const existing = user.pending_events.find(
182
+ (e) => aggregationKey(e.resource_id, e.jira_id) === key,
183
+ );
184
+
185
+ if (existing) {
186
+ existing.invocation_count += 1;
187
+ existing.last_invoked_at = now;
188
+ } else {
189
+ const event: InvocationEvent = {
190
+ resource_id: resourceId,
191
+ resource_type: resourceType,
192
+ resource_name: resourceName,
193
+ invocation_count: 1,
194
+ first_invoked_at: now,
195
+ last_invoked_at: now,
196
+ };
197
+ // Only attach jira_id when defined (field must be absent, not null).
198
+ if (jiraId) event.jira_id = jiraId;
199
+ user.pending_events.push(event);
200
+ }
201
+ this.writeFile(data);
202
+ });
203
+ }
204
+
205
+ /**
206
+ * Replace the full list of subscribed Rules for a specific user.
207
+ * Called after sync_resources or manage_subscription completes.
208
+ */
209
+ async updateSubscribedRules(rules: SubscribedRule[], userToken: string): Promise<void> {
210
+ await this.withFileLock(async () => {
211
+ const data = this.readFile();
212
+ this.ensureUserSlot(data, userToken).subscribed_rules = rules;
213
+ this.writeFile(data);
214
+ });
215
+ }
216
+
217
+ /**
218
+ * Replace the full list of configured MCPs for a specific user.
219
+ * Called after sync_resources or manage_subscription completes for MCP resources.
220
+ */
221
+ async updateConfiguredMcps(mcps: ConfiguredMcp[], userToken: string): Promise<void> {
222
+ await this.withFileLock(async () => {
223
+ const data = this.readFile();
224
+ this.ensureUserSlot(data, userToken).configured_mcps = mcps;
225
+ this.writeFile(data);
226
+ });
227
+ }
228
+
229
+ /**
230
+ * Flush pending telemetry for ALL active users.
231
+ *
232
+ * Each user's data is sent with that user's own token so the server can
233
+ * attribute it to the correct account. The periodic timer calls this so
234
+ * that all connected users are reported in the same tick.
235
+ */
236
+ async flush(): Promise<void> {
237
+ if (!this.reportFn) return;
238
+
239
+ // Collect tokens: active SSE tokens + env fallback (stdio / tests)
240
+ const tokens = new Set(this.activeTokens);
241
+ const envToken = this.userTokenFn?.();
242
+ if (envToken) tokens.add(envToken);
243
+
244
+ if (tokens.size === 0) return;
245
+
246
+ const data = await new Promise<TelemetryFile>((resolve) => {
247
+ this.withFileLock(async () => {
248
+ resolve(this.readFile());
249
+ }).catch(() => resolve(this.readFile()));
250
+ });
251
+
252
+ await Promise.all(
253
+ Array.from(tokens).map((token) => this.flushUser(token, data)),
254
+ );
255
+ }
256
+
257
+ /** Start the periodic flush timer (flushes all active users each tick). */
258
+ startPeriodicFlush(intervalMs = 10_000): void {
259
+ if (this.timer) return;
260
+ this.timer = setInterval(() => {
261
+ this.flush().catch(() => { /* already handled inside flush */ });
262
+ }, intervalMs);
263
+ if (this.timer.unref) this.timer.unref();
264
+ }
265
+
266
+ /** Stop the periodic flush timer (call before final flush on shutdown). */
267
+ stopPeriodicFlush(): void {
268
+ if (this.timer) {
269
+ clearInterval(this.timer);
270
+ this.timer = null;
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Trigger an immediate flush when a client reconnects to the MCP server.
276
+ * Fire-and-forget — errors are already handled inside flush().
277
+ */
278
+ flushOnReconnect(): void {
279
+ this.flush().catch(() => { /* handled inside flush */ });
280
+ }
281
+
282
+ // ---------------------------------------------------------------------------
283
+ // Private helpers
284
+ // ---------------------------------------------------------------------------
285
+
286
+ /** Flush one user's pending data using that user's own token. */
287
+ private async flushUser(token: string, data: TelemetryFile): Promise<void> {
288
+ if (!this.reportFn) return;
289
+ const user = data.users[token];
290
+ if (!user) return;
291
+
292
+ const payload: TelemetryReportPayload = {
293
+ client_version: this.clientVersion,
294
+ reported_at: new Date().toISOString(),
295
+ events: user.pending_events,
296
+ subscribed_rules: user.subscribed_rules,
297
+ configured_mcps: user.configured_mcps,
298
+ };
299
+
300
+ await this.reportWithRetry(payload, token);
301
+ }
302
+
303
+ /** Get or create the per-user slot, mutating `data.users` in place. */
304
+ private ensureUserSlot(data: TelemetryFile, token: string): UserTelemetry {
305
+ if (!data.users[token]) {
306
+ data.users[token] = emptyUserTelemetry();
307
+ }
308
+ return data.users[token]!;
309
+ }
310
+
311
+ private readFile(): TelemetryFile {
312
+ try {
313
+ const raw = fs.readFileSync(this.filePath, 'utf8');
314
+ const parsed = JSON.parse(raw) as Partial<TelemetryFile> & {
315
+ // v1 compat: flat structure without `users`
316
+ pending_events?: InvocationEvent[];
317
+ subscribed_rules?: SubscribedRule[];
318
+ configured_mcps?: ConfiguredMcp[];
319
+ };
320
+
321
+ // Migrate v1 flat file to v2 multi-user structure.
322
+ // We cannot recover the original token, so v1 data is discarded.
323
+ if (!parsed.users) {
324
+ return { client_version: this.clientVersion, users: {} };
325
+ }
326
+
327
+ return {
328
+ client_version: parsed.client_version ?? this.clientVersion,
329
+ users: parsed.users,
330
+ };
331
+ } catch {
332
+ return { client_version: this.clientVersion, users: {} };
333
+ }
334
+ }
335
+
336
+ private writeFile(data: TelemetryFile): void {
337
+ const dir = path.dirname(this.filePath);
338
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
339
+ const tmp = `${this.filePath}.${process.pid}.tmp`;
340
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
341
+ fs.renameSync(tmp, this.filePath);
342
+ }
343
+
344
+ /** Serialises file access to prevent concurrent write conflicts. */
345
+ private async withFileLock(fn: () => Promise<void>): Promise<void> {
346
+ return new Promise<void>((resolve, reject) => {
347
+ const run = async () => {
348
+ this.writing = true;
349
+ try {
350
+ await fn();
351
+ resolve();
352
+ } catch (err) {
353
+ reject(err);
354
+ } finally {
355
+ this.writing = false;
356
+ const next = this.writeQueue.shift();
357
+ if (next) next();
358
+ }
359
+ };
360
+
361
+ if (this.writing) {
362
+ this.writeQueue.push(run);
363
+ } else {
364
+ run();
365
+ }
366
+ });
367
+ }
368
+
369
+ private async reportWithRetry(payload: TelemetryReportPayload, token: string): Promise<void> {
370
+ let lastErr: unknown;
371
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
372
+ try {
373
+ await this.reportFn!(payload, token);
374
+ // Success — subtract only the events that were included in this payload.
375
+ // New events may have been appended to the file between the time we read
376
+ // the snapshot and now, so we must NOT blindly wipe pending_events=[].
377
+ // Instead, re-read the file under lock and decrement each reported
378
+ // event's invocation_count; remove it only when the count reaches zero.
379
+ await this.withFileLock(async () => {
380
+ const data = this.readFile();
381
+ const user = data.users[token];
382
+ if (!user) return;
383
+
384
+ for (const reported of payload.events) {
385
+ const key = aggregationKey(reported.resource_id, reported.jira_id);
386
+ const idx = user.pending_events.findIndex(
387
+ (e) => aggregationKey(e.resource_id, e.jira_id) === key,
388
+ );
389
+ if (idx === -1) continue;
390
+ const live = user.pending_events[idx]!;
391
+ live.invocation_count -= reported.invocation_count;
392
+ if (live.invocation_count <= 0) {
393
+ user.pending_events.splice(idx, 1);
394
+ }
395
+ }
396
+
397
+ user.last_reported_at = payload.reported_at;
398
+ this.writeFile(data);
399
+ });
400
+ return;
401
+ } catch (err) {
402
+ lastErr = err;
403
+ if (attempt < MAX_RETRIES - 1) {
404
+ await sleep(RETRY_BASE_MS * Math.pow(2, attempt));
405
+ }
406
+ }
407
+ }
408
+ if (process.env.NODE_ENV !== 'test') {
409
+ process.stderr.write(`[telemetry] flush failed after ${MAX_RETRIES} retries: ${lastErr}\n`);
410
+ }
411
+ }
412
+ }
413
+
414
+ function sleep(ms: number): Promise<void> {
415
+ return new Promise((res) => setTimeout(res, ms));
416
+ }
417
+
418
+ /** Singleton instance shared across the server process. */
419
+ export const telemetry = new TelemetryManager();
@@ -8,4 +8,5 @@ export * from './manage-subscription';
8
8
  export * from './search-resources';
9
9
  export * from './upload-resource';
10
10
  export * from './uninstall-resource';
11
+ export * from './track-usage';
11
12
  export * from './registry';