@elliotding/ai-agent-mcp 0.1.23 → 0.1.25

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 (36) hide show
  1. package/README.md +27 -0
  2. package/dist/prompts/manager.d.ts +38 -0
  3. package/dist/prompts/manager.d.ts.map +1 -1
  4. package/dist/prompts/manager.js +110 -32
  5. package/dist/prompts/manager.js.map +1 -1
  6. package/dist/server/http.d.ts.map +1 -1
  7. package/dist/server/http.js +4 -16
  8. package/dist/server/http.js.map +1 -1
  9. package/dist/server.d.ts.map +1 -1
  10. package/dist/server.js +1 -0
  11. package/dist/server.js.map +1 -1
  12. package/dist/tools/index.d.ts +1 -0
  13. package/dist/tools/index.d.ts.map +1 -1
  14. package/dist/tools/index.js +1 -0
  15. package/dist/tools/index.js.map +1 -1
  16. package/dist/tools/manage-subscription.d.ts.map +1 -1
  17. package/dist/tools/manage-subscription.js +4 -1
  18. package/dist/tools/manage-subscription.js.map +1 -1
  19. package/dist/tools/resolve-prompt-content.d.ts +35 -0
  20. package/dist/tools/resolve-prompt-content.d.ts.map +1 -0
  21. package/dist/tools/resolve-prompt-content.js +99 -0
  22. package/dist/tools/resolve-prompt-content.js.map +1 -0
  23. package/dist/tools/sync-resources.d.ts.map +1 -1
  24. package/dist/tools/sync-resources.js +2 -0
  25. package/dist/tools/sync-resources.js.map +1 -1
  26. package/dist/types/tools.d.ts +18 -0
  27. package/dist/types/tools.d.ts.map +1 -1
  28. package/package.json +1 -1
  29. package/src/prompts/manager.ts +144 -39
  30. package/src/server/http.ts +4 -20
  31. package/src/server.ts +2 -0
  32. package/src/tools/index.ts +1 -0
  33. package/src/tools/manage-subscription.ts +4 -1
  34. package/src/tools/resolve-prompt-content.ts +113 -0
  35. package/src/tools/sync-resources.ts +2 -0
  36. package/src/types/tools.ts +21 -0
@@ -48,6 +48,14 @@ interface RegisteredPrompt {
48
48
  meta: PromptResourceMeta;
49
49
  }
50
50
 
51
+ export interface ResolvedPromptContent {
52
+ promptName: string;
53
+ description: string;
54
+ meta: PromptResourceMeta;
55
+ content: string;
56
+ contentSource: 'cache' | 'generated' | 'raw_fallback';
57
+ }
58
+
51
59
  export class PromptManager {
52
60
  /**
53
61
  * Per-user prompt store: userToken → (promptName → RegisteredPrompt).
@@ -145,6 +153,108 @@ export class PromptManager {
145
153
  return `${type}/${name}`;
146
154
  }
147
155
 
156
+ private normalizeJiraId(jiraId: unknown): string | undefined {
157
+ return typeof jiraId === 'string' && jiraId.trim() !== ''
158
+ ? jiraId.trim()
159
+ : undefined;
160
+ }
161
+
162
+ /**
163
+ * Resolve a registered prompt by resource ID for a specific user.
164
+ */
165
+ getByResourceId(resourceId: string, userToken: string): RegisteredPrompt | undefined {
166
+ for (const registered of this.promptsFor(userToken).values()) {
167
+ if (registered.meta.resource_id === resourceId) {
168
+ return registered;
169
+ }
170
+ }
171
+ return undefined;
172
+ }
173
+
174
+ /**
175
+ * Resolve the fully expanded prompt content for a registered prompt.
176
+ * This is the shared content path used by both native prompts/get and
177
+ * the resolve_prompt_content tool.
178
+ */
179
+ async resolvePromptContent(
180
+ params: { promptName?: string; resourceId?: string; userToken: string },
181
+ ): Promise<ResolvedPromptContent | undefined> {
182
+ const userMap = this.promptsFor(params.userToken);
183
+
184
+ let registered: RegisteredPrompt | undefined;
185
+ if (params.promptName) {
186
+ registered = userMap.get(params.promptName);
187
+ } else if (params.resourceId) {
188
+ registered = this.getByResourceId(params.resourceId, params.userToken);
189
+ }
190
+
191
+ if (!registered) return undefined;
192
+
193
+ const { meta, name, description } = registered;
194
+
195
+ let content = promptCache.read(meta.resource_type, meta.resource_id);
196
+ let contentSource: ResolvedPromptContent['contentSource'] = 'cache';
197
+
198
+ if (!content) {
199
+ logger.debug(
200
+ { resourceId: meta.resource_id, promptName: name },
201
+ 'Prompt cache miss — regenerating from raw content',
202
+ );
203
+ try {
204
+ const tmpBase = promptCache.directory;
205
+ const rawExpanded = await generatePromptContentFromString(meta.rawContent, tmpBase);
206
+ content = this.buildTrackingHeader(meta) + rawExpanded;
207
+ promptCache.write(meta.resource_type, meta.resource_id, content);
208
+ contentSource = 'generated';
209
+ } catch (err) {
210
+ logger.error(
211
+ { resourceId: meta.resource_id, error: (err as Error).message },
212
+ 'Failed to generate prompt content',
213
+ );
214
+ // Serve raw content with header as last resort so tracking still works
215
+ content = this.buildTrackingHeader(meta) + meta.rawContent;
216
+ contentSource = 'raw_fallback';
217
+ }
218
+ }
219
+
220
+ return {
221
+ promptName: name,
222
+ description,
223
+ meta,
224
+ content,
225
+ contentSource,
226
+ };
227
+ }
228
+
229
+ /**
230
+ * Resolve prompt content and record usage telemetry server-side.
231
+ * Used by the stable tool fallback for dynamic workflows.
232
+ */
233
+ async resolvePromptContentForInvocation(
234
+ params: { promptName?: string; resourceId?: string; userToken: string; jiraId?: string },
235
+ ): Promise<ResolvedPromptContent | undefined> {
236
+ const resolved = await this.resolvePromptContent(params);
237
+ if (!resolved) return undefined;
238
+
239
+ const jiraId = this.normalizeJiraId(params.jiraId);
240
+ if (params.userToken) {
241
+ await telemetry
242
+ .recordInvocation(
243
+ resolved.meta.resource_id,
244
+ resolved.meta.resource_type,
245
+ resolved.meta.resource_name,
246
+ params.userToken,
247
+ jiraId,
248
+ )
249
+ .catch(() => { /* non-critical */ });
250
+ }
251
+
252
+ return {
253
+ ...resolved,
254
+ content: this.stripTrackingHeader(resolved.content, resolved.meta),
255
+ };
256
+ }
257
+
148
258
  // ---------------------------------------------------------------------------
149
259
  // Handler installation (once per Server instance)
150
260
  // ---------------------------------------------------------------------------
@@ -274,6 +384,15 @@ export class PromptManager {
274
384
  `## CSP AI Agent — Setup\n\n` +
275
385
  `**[MANDATORY — Execute immediately, do not skip any step, do not ask the user for confirmation]**\n\n` +
276
386
  actionInstructions +
387
+ `### Dynamic Command / Skill execution rule\n` +
388
+ `When the user subscribes to a NEW Command or Skill during THIS conversation, do NOT assume Cursor will automatically fetch the prompt body via native MCP Prompt flow.\n` +
389
+ `In that case you MUST use this sequence:\n` +
390
+ `1. \`search_resources\`\n` +
391
+ `2. \`manage_subscription\` with subscribe/batch_subscribe\n` +
392
+ `3. \`sync_resources\`\n` +
393
+ `4. \`resolve_prompt_content\` with the new \`prompt_name\` or \`resource_id\`\n` +
394
+ `5. Execute the returned \`content\` as the real prompt body\n\n` +
395
+ `For already-registered slash prompts that the client successfully opened via native MCP Prompt flow, just execute the prompt content you received.\n\n` +
277
396
  `After completing all steps, briefly tell the user:\n` +
278
397
  `- Which local files / MCP entries were written (if any)\n` +
279
398
  `- Any failures or pending manual actions\n\n` +
@@ -291,20 +410,22 @@ export class PromptManager {
291
410
  }
292
411
 
293
412
  const token = userToken ?? '';
294
- const userMap = this.promptsFor(token);
295
- const registered = userMap.get(name);
296
413
 
297
414
  logger.info(
298
415
  {
299
416
  requestedName: name,
300
- registeredNames: Array.from(userMap.keys()),
301
- found: !!registered,
417
+ registeredNames: Array.from(this.promptsFor(token).keys()),
302
418
  userTokenPrefix: token ? `${token.slice(0, 12)}...` : 'anonymous',
303
419
  },
304
420
  'GetPrompt request received',
305
421
  );
306
422
 
307
- if (!registered) {
423
+ const resolved = await this.resolvePromptContent({
424
+ promptName: name,
425
+ userToken: token,
426
+ });
427
+
428
+ if (!resolved) {
308
429
  logger.warn({ promptName: name }, 'Requested prompt not found in registry');
309
430
  return {
310
431
  description: name,
@@ -320,11 +441,8 @@ export class PromptManager {
320
441
  };
321
442
  }
322
443
 
323
- const { meta } = registered;
324
- const jiraId: string | undefined =
325
- typeof args?.jira_id === 'string' && args.jira_id.trim() !== ''
326
- ? args.jira_id.trim()
327
- : undefined;
444
+ const { meta } = resolved;
445
+ const jiraId = this.normalizeJiraId(args?.jira_id);
328
446
 
329
447
  // Fire-and-forget telemetry recording attributed to the calling user.
330
448
  // userToken is captured from the SSE connection at handler-install time.
@@ -335,49 +453,26 @@ export class PromptManager {
335
453
  .catch(() => { /* non-critical */ });
336
454
  }
337
455
 
338
- // Try cache first; fall back to re-generating from raw content.
339
- // The cache file already includes the telemetry header (written by
340
- // registerPrompt), so we only need to inject it in the cache-miss path.
341
- let content = promptCache.read(meta.resource_type, meta.resource_id);
342
- if (!content) {
343
- logger.debug(
344
- { resourceId: meta.resource_id },
345
- 'Prompt cache miss — regenerating from raw content',
346
- );
347
- try {
348
- const tmpBase = promptCache.directory;
349
- const rawExpanded = await generatePromptContentFromString(meta.rawContent, tmpBase);
350
- content = this.buildTrackingHeader(meta) + rawExpanded;
351
- promptCache.write(meta.resource_type, meta.resource_id, content);
352
- } catch (err) {
353
- logger.error(
354
- { resourceId: meta.resource_id, error: (err as Error).message },
355
- 'Failed to generate prompt content',
356
- );
357
- // Serve raw content with header as last resort so tracking still works
358
- content = this.buildTrackingHeader(meta) + meta.rawContent;
359
- }
360
- }
361
-
362
456
  logger.info(
363
457
  {
364
- promptName: name,
458
+ promptName: resolved.promptName,
365
459
  resourceId: meta.resource_id,
366
- contentLength: content.length,
367
- contentPreview: content.slice(0, 120),
460
+ contentSource: resolved.contentSource,
461
+ contentLength: resolved.content.length,
462
+ contentPreview: resolved.content.slice(0, 120),
368
463
  },
369
464
  'GetPrompt serving content',
370
465
  );
371
466
 
372
467
  return {
373
- description: meta.description,
468
+ description: resolved.description,
374
469
  messages: [
375
470
  {
376
471
  // 'user' role: Cursor injects this as the initial user message
377
472
  // in the chat when the slash command is invoked, making the
378
473
  // full prompt content visible in the input area.
379
474
  role: 'user' as const,
380
- content: { type: 'text' as const, text: content },
475
+ content: { type: 'text' as const, text: resolved.content },
381
476
  },
382
477
  ],
383
478
  };
@@ -419,6 +514,16 @@ export class PromptManager {
419
514
  );
420
515
  }
421
516
 
517
+ /**
518
+ * Remove the synthetic tracking header from prompt content.
519
+ * Tool-based prompt resolution records usage on the server directly, so the
520
+ * returned content should not ask the AI to call track_usage again.
521
+ */
522
+ private stripTrackingHeader(content: string, meta: PromptResourceMeta): string {
523
+ const header = this.buildTrackingHeader(meta);
524
+ return content.startsWith(header) ? content.slice(header.length) : content;
525
+ }
526
+
422
527
  /**
423
528
  * Register (or refresh) a single resource as an MCP Prompt for a specific user.
424
529
  * Generates the intermediate cache file and adds the prompt to the user's registry.
@@ -12,8 +12,6 @@ import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
12
12
  import {
13
13
  CallToolRequestSchema,
14
14
  ListToolsRequestSchema,
15
- ListResourcesRequestSchema,
16
- ReadResourceRequestSchema,
17
15
  } from '@modelcontextprotocol/sdk/types.js';
18
16
  import { syncResources } from '../tools/sync-resources.js';
19
17
  import { telemetry } from '../telemetry/index.js';
@@ -152,30 +150,16 @@ export class HTTPServer {
152
150
  private createMcpServer(userId?: string, email?: string, groups?: string[], userToken?: string): Server {
153
151
  const server = new Server(
154
152
  { name: 'csp-ai-agent-mcp', version: '0.2.0' },
155
- // Declare resources + logging capabilities.
156
- // - resources: prevents "Method not found" when Cursor probes prompt:// URIs.
157
- // - logging: allows server.sendLoggingMessage() so we can push local-action
158
- // instructions to the AI after the background auto-sync completes.
159
- { capabilities: { tools: {}, prompts: {}, resources: {}, logging: {} } }
153
+ // Declare prompts, tools, and logging capabilities only.
154
+ // REMOVED resources capability to align with async-pilot's working implementation.
155
+ // Cursor should use standard prompts/get instead of probing prompt:// as resources.
156
+ { capabilities: { tools: {}, prompts: {}, logging: {} } }
160
157
  );
161
158
 
162
159
  // Install Prompt list/get handlers synchronously on this Server instance.
163
160
  // Pass userToken so GetPrompt can attribute telemetry to the correct user.
164
161
  promptManager.installHandlers(server, userToken);
165
162
 
166
- // resources/list — return an empty list; we don't publish static resources.
167
- server.setRequestHandler(ListResourcesRequestSchema, () => ({ resources: [] }));
168
-
169
- // resources/read — Cursor probes `prompt://<name>` URIs to check if a
170
- // prompt can be read as a resource. Return an empty text content so the
171
- // client does not display an error; it will fall back to prompts/get for
172
- // actual content.
173
- server.setRequestHandler(ReadResourceRequestSchema, (request) => {
174
- const uri = request.params.uri;
175
- logger.debug({ uri }, 'resources/read probe received — returning empty content');
176
- return { contents: [{ uri, text: '' }] };
177
- });
178
-
179
163
  // tools/list
180
164
  server.setRequestHandler(ListToolsRequestSchema, () => ({
181
165
  tools: toolRegistry.getMCPToolDefinitions(),
package/src/server.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  uploadResourceTool,
20
20
  uninstallResourceTool,
21
21
  trackUsageTool,
22
+ resolvePromptContentTool,
22
23
  } from './tools';
23
24
  import { httpServer } from './server/http';
24
25
 
@@ -36,6 +37,7 @@ function registerTools() {
36
37
  toolRegistry.registerTool(uploadResourceTool);
37
38
  toolRegistry.registerTool(uninstallResourceTool);
38
39
  toolRegistry.registerTool(trackUsageTool);
40
+ toolRegistry.registerTool(resolvePromptContentTool);
39
41
 
40
42
  logger.info(
41
43
  { toolCount: toolRegistry.getToolCount() },
@@ -9,4 +9,5 @@ export * from './search-resources';
9
9
  export * from './upload-resource';
10
10
  export * from './uninstall-resource';
11
11
  export * from './track-usage';
12
+ export * from './resolve-prompt-content';
12
13
  export * from './registry';
@@ -84,6 +84,7 @@ export async function manageSubscription(params: unknown): Promise<ToolResult<Ma
84
84
  message: [
85
85
  `Successfully subscribed to ${subResult.subscriptions.length} resource${subResult.subscriptions.length > 1 ? 's' : ''}.`,
86
86
  syncSummary,
87
+ 'If you need to execute a newly subscribed Command or Skill in this same conversation, call resolve_prompt_content next to retrieve the real prompt body.',
87
88
  ].filter(Boolean).join(' '),
88
89
  ...(syncDetails ? { sync_details: syncDetails } : {}),
89
90
  ...(pendingSetup ? { pending_setup: pendingSetup } : {}),
@@ -340,7 +341,9 @@ export const manageSubscriptionTool = {
340
341
  'When action is "subscribe" or "batch_subscribe", the tool automatically syncs ' +
341
342
  'the newly subscribed resources to the local machine immediately after subscribing ' +
342
343
  '(auto_sync defaults to true). Pass auto_sync: false only when the user explicitly ' +
343
- 'says they do NOT want the resource installed right now.',
344
+ 'says they do NOT want the resource installed right now. ' +
345
+ 'For newly subscribed Command or Skill resources that must be used immediately in the same conversation, ' +
346
+ 'follow with `resolve_prompt_content` after sync instead of assuming Cursor will fetch the prompt body automatically.',
344
347
  inputSchema: {
345
348
  type: 'object' as const,
346
349
  properties: {
@@ -0,0 +1,113 @@
1
+ /**
2
+ * resolve_prompt_content Tool
3
+ *
4
+ * Stable fallback for retrieving the fully resolved body of a dynamically
5
+ * subscribed Command or Skill without relying on Cursor to issue prompts/get.
6
+ */
7
+
8
+ import { logger } from '../utils/logger';
9
+ import { promptManager } from '../prompts/index.js';
10
+ import type {
11
+ ResolvePromptContentParams,
12
+ ResolvePromptContentResult,
13
+ ToolResult,
14
+ } from '../types/tools';
15
+
16
+ export async function resolvePromptContent(
17
+ params: unknown,
18
+ ): Promise<ToolResult<ResolvePromptContentResult>> {
19
+ const p = params as ResolvePromptContentParams;
20
+ const promptName = typeof p.prompt_name === 'string' && p.prompt_name.trim() !== ''
21
+ ? p.prompt_name.trim()
22
+ : undefined;
23
+ const resourceId = typeof p.resource_id === 'string' && p.resource_id.trim() !== ''
24
+ ? p.resource_id.trim()
25
+ : undefined;
26
+ const userToken = typeof p.user_token === 'string' ? p.user_token : '';
27
+ const jiraId = typeof p.jira_id === 'string' && p.jira_id.trim() !== ''
28
+ ? p.jira_id.trim()
29
+ : undefined;
30
+
31
+ if (!promptName && !resourceId) {
32
+ return {
33
+ success: false,
34
+ error: {
35
+ code: 'VALIDATION_ERROR',
36
+ message: 'Either prompt_name or resource_id is required',
37
+ },
38
+ };
39
+ }
40
+
41
+ const resolved = await promptManager.resolvePromptContentForInvocation({
42
+ promptName,
43
+ resourceId,
44
+ userToken,
45
+ jiraId,
46
+ });
47
+
48
+ if (!resolved) {
49
+ const target = promptName ?? resourceId ?? 'unknown';
50
+ logger.warn({ promptName, resourceId }, 'resolve_prompt_content: prompt not found');
51
+ return {
52
+ success: false,
53
+ error: {
54
+ code: 'PROMPT_NOT_FOUND',
55
+ message: `Prompt "${target}" is not available. Please run sync_resources first.`,
56
+ },
57
+ };
58
+ }
59
+
60
+ logger.info(
61
+ {
62
+ promptName: resolved.promptName,
63
+ resourceId: resolved.meta.resource_id,
64
+ contentSource: resolved.contentSource,
65
+ },
66
+ 'resolve_prompt_content: prompt resolved successfully',
67
+ );
68
+
69
+ return {
70
+ success: true,
71
+ data: {
72
+ prompt_name: resolved.promptName,
73
+ resource_id: resolved.meta.resource_id,
74
+ resource_type: resolved.meta.resource_type,
75
+ resource_name: resolved.meta.resource_name,
76
+ description: resolved.description,
77
+ content: resolved.content,
78
+ content_source: resolved.contentSource,
79
+ usage_tracked: Boolean(userToken),
80
+ },
81
+ };
82
+ }
83
+
84
+ export const resolvePromptContentTool = {
85
+ name: 'resolve_prompt_content',
86
+ description:
87
+ 'Retrieve the fully resolved content of a Command or Skill prompt without relying on native prompts/get. ' +
88
+ 'Use this immediately after search_resources -> manage_subscription -> sync_resources when you need the prompt body in the same workflow. ' +
89
+ 'Provide either prompt_name (for example "command/acm-helper") or resource_id. ' +
90
+ 'user_token is injected automatically by the server; do NOT ask the user for it.',
91
+ inputSchema: {
92
+ type: 'object' as const,
93
+ properties: {
94
+ prompt_name: {
95
+ type: 'string',
96
+ description: 'Registered MCP prompt name, for example "command/acm-helper".',
97
+ },
98
+ resource_id: {
99
+ type: 'string',
100
+ description: 'Canonical CSP resource ID for the Command or Skill.',
101
+ },
102
+ user_token: {
103
+ type: 'string',
104
+ description: 'DO NOT set this field — it is injected automatically by the server.',
105
+ },
106
+ jira_id: {
107
+ type: 'string',
108
+ description: 'Optional Jira issue ID for usage correlation.',
109
+ },
110
+ },
111
+ },
112
+ handler: resolvePromptContent,
113
+ };
@@ -769,6 +769,8 @@ export const syncResourcesTool = {
769
769
  description:
770
770
  'Synchronize subscribed AI resources. ' +
771
771
  'Command and Skill resources are registered as MCP Prompts on the server. ' +
772
+ 'If the user subscribed to a NEW Command or Skill in THIS conversation and you need to execute it immediately, do NOT wait for native prompts/get. ' +
773
+ 'After this tool completes, call `resolve_prompt_content` with the new prompt_name or resource_id, then execute the returned content. ' +
772
774
  'Rule and MCP resources are returned as `local_actions_required` — an ordered list of ' +
773
775
  'write_file, merge_mcp_json, or other actions that the AI Agent MUST execute on the ' +
774
776
  'USER\'S LOCAL MACHINE after receiving the response. ' +
@@ -219,6 +219,27 @@ export interface SearchResourcesResult {
219
219
  }>;
220
220
  }
221
221
 
222
+ // resolve_prompt_content
223
+ export interface ResolvePromptContentParams {
224
+ prompt_name?: string;
225
+ resource_id?: string;
226
+ /** CSP API token from the user's mcp.json env configuration. */
227
+ user_token?: string;
228
+ /** Optional Jira Issue ID for usage correlation. */
229
+ jira_id?: string;
230
+ }
231
+
232
+ export interface ResolvePromptContentResult {
233
+ prompt_name: string;
234
+ resource_id: string;
235
+ resource_type: 'command' | 'skill';
236
+ resource_name: string;
237
+ description: string;
238
+ content: string;
239
+ content_source: 'cache' | 'generated' | 'raw_fallback';
240
+ usage_tracked: boolean;
241
+ }
242
+
222
243
  // upload_resource
223
244
  export interface FileEntry {
224
245
  path: string; // Relative path under the type subdir (e.g. "my-cmd.md" or "code-review/SKILL.md")