@elliotding/ai-agent-mcp 0.1.15 → 0.1.18

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.
@@ -307,11 +307,33 @@ export class HTTPServer {
307
307
  // Build the absolute message URL for the SSE endpoint event.
308
308
  // Cursor (and other MCP clients) resolve the endpoint event data as a URL;
309
309
  // using a relative path causes some clients to misinterpret it as a redirect.
310
- // We use the Host header when available so the URL matches what the client
311
- // actually connected to (important behind proxies / ngrok / etc.).
310
+ //
311
+ // Priority order for determining the public base URL:
312
+ // 1. PUBLIC_URL env var — explicit override for reverse-proxy / Docker deployments
313
+ // where the Host header reflects the internal address (e.g. 0.0.0.0:5080)
314
+ // rather than the externally reachable hostname.
315
+ // Set this to the full external origin, e.g. https://mcp.example.com
316
+ // 2. X-Forwarded-Host / X-Forwarded-Proto headers — set by well-configured proxies
317
+ // 3. Host header from the incoming request (may be the internal address behind nginx)
318
+ // 4. Fallback to HTTP_HOST:HTTP_PORT from config
312
319
  const basePath = config.http?.basePath ?? '';
313
- const host = request.headers.host ?? `${config.http?.host ?? '127.0.0.1'}:${config.http?.port ?? 3000}`;
314
- const messageUrl = `http://${host}${basePath}/message`;
320
+ let messageUrl: string;
321
+
322
+ const publicUrl = process.env['PUBLIC_URL'];
323
+ if (publicUrl) {
324
+ // Strip trailing slash, then append basePath + /message
325
+ messageUrl = `${publicUrl.replace(/\/$/, '')}${basePath}/message`;
326
+ } else {
327
+ const forwardedProto = request.headers['x-forwarded-proto'];
328
+ const forwardedHost = request.headers['x-forwarded-host'];
329
+ const proto = Array.isArray(forwardedProto) ? forwardedProto[0] : (forwardedProto ?? 'http');
330
+ const host = Array.isArray(forwardedHost)
331
+ ? forwardedHost[0]
332
+ : (forwardedHost ?? request.headers.host ?? `${config.http?.host ?? '127.0.0.1'}:${config.http?.port ?? 3000}`);
333
+ messageUrl = `${proto}://${host}${basePath}/message`;
334
+ }
335
+
336
+ logger.info({ messageUrl }, 'Message endpoint constructed for SSE client');
315
337
  const transport = new SSEServerTransport(messageUrl, reply.raw);
316
338
  const sdkSessionId = transport.sessionId;
317
339
  this.sseTransports.set(sdkSessionId, transport);
@@ -103,6 +103,18 @@ export async function manageSubscription(params: unknown): Promise<ToolResult<Ma
103
103
 
104
104
  logger.debug({ resourceIds: typedParams.resource_ids }, 'Unsubscribing from resources...');
105
105
 
106
+ // Build a resource_id → type map from the current subscription list so
107
+ // uninstall actions can be scoped precisely to rule vs mcp resources.
108
+ let idToType: Map<string, string> = new Map();
109
+ try {
110
+ const currentSubs = await apiClient.getSubscriptions({}, typedParams.user_token);
111
+ for (const s of currentSubs.subscriptions) {
112
+ idToType.set(s.id, s.type);
113
+ }
114
+ } catch (e) {
115
+ logger.warn({ error: (e as Error).message }, 'Could not fetch subscriptions for type resolution — uninstall will emit both rule+mcp actions as fallback');
116
+ }
117
+
106
118
  // Cancel server-side subscription
107
119
  await apiClient.unsubscribe(typedParams.resource_ids, typedParams.user_token);
108
120
  logger.info({ count: typedParams.resource_ids.length }, 'Server-side subscriptions removed');
@@ -112,17 +124,22 @@ export async function manageSubscription(params: unknown): Promise<ToolResult<Ma
112
124
  const uninstallResults: Array<{ id: string; removed: boolean; detail: string }> = [];
113
125
  for (const resourceId of typedParams.resource_ids) {
114
126
  // Determine if this is a Command or Skill by checking the prompt registry.
115
- // Resource IDs follow the pattern: cmd-<source>-<name> or skill-<source>-<name>.
116
- const isCommand = resourceId.startsWith('cmd-');
117
- const isSkill = resourceId.startsWith('skill-');
118
- if (isCommand || isSkill) {
119
- const resourceType = isCommand ? 'command' : 'skill';
120
- // Extract resource name from the ID (cmd-<team>-<name> or skill-<team>-<name>).
121
- const parts = resourceId.split('-');
122
- const resourceName = parts.slice(2).join('-') || resourceId;
123
- promptManager.unregisterPrompt(resourceId, resourceType as 'command' | 'skill', resourceName, typedParams.user_token ?? '');
127
+ // API resource IDs are UUIDs (e.g. "0ccd800f..."), NOT prefixed with "cmd-"/"skill-".
128
+ // Check whether any registered prompt for this user matches the resource_id.
129
+ const matchedPromptName = promptManager.promptNames(typedParams.user_token ?? '').find(
130
+ (name) => {
131
+ // Prompt names are "<type>/<resource_name>"; check by looking up the registered meta.
132
+ const registered = promptManager.getByPromptName(name, typedParams.user_token ?? '');
133
+ return registered?.meta?.resource_id === resourceId;
134
+ },
135
+ );
136
+ if (matchedPromptName) {
137
+ const parts = matchedPromptName.split('/');
138
+ const resourceType = (parts[0] ?? 'command') as 'command' | 'skill';
139
+ const resourceName = parts.slice(1).join('/') || matchedPromptName;
140
+ promptManager.unregisterPrompt(resourceId, resourceType, resourceName, typedParams.user_token ?? '');
124
141
  uninstallResults.push({ id: resourceId, removed: true, detail: `Unregistered MCP Prompt for "${resourceName}"` });
125
- logger.info({ resourceId, resourceType }, 'MCP Prompt unregistered on unsubscribe');
142
+ logger.info({ resourceId, resourceType, matchedPromptName }, 'MCP Prompt unregistered on unsubscribe');
126
143
  continue;
127
144
  }
128
145
  // Use the last segment of the resource ID as the search pattern
@@ -139,11 +156,13 @@ export async function manageSubscription(params: unknown): Promise<ToolResult<Ma
139
156
  namePart,
140
157
  ]));
141
158
 
159
+ const resolvedType = idToType.get(resourceId) as 'rule' | 'mcp' | undefined;
142
160
  let uninstalled = false;
143
161
  for (const pattern of patternsToTry) {
144
162
  const uninstallResult = await uninstallResource({
145
163
  resource_id_or_name: pattern,
146
164
  remove_from_account: false, // already unsubscribed above
165
+ ...(resolvedType ? { resource_type: resolvedType } : {}),
147
166
  });
148
167
  if (uninstallResult.success && uninstallResult.data && uninstallResult.data.removed_resources.length > 0) {
149
168
  uninstallResults.push({ id: resourceId, removed: true, detail: `Removed local files for "${pattern}"` });
@@ -18,6 +18,7 @@
18
18
 
19
19
  import * as fs from 'fs/promises';
20
20
  import * as path from 'path';
21
+ import { createHash } from 'crypto';
21
22
  import { logger, logToolCall, logToolStep, logToolResult } from '../utils/logger';
22
23
  import { apiClient } from '../api/client';
23
24
  import { multiSourceGitManager } from '../git/multi-source-manager';
@@ -36,6 +37,39 @@ import type {
36
37
  import { telemetry } from '../telemetry/index.js';
37
38
  import { promptManager } from '../prompts/index.js';
38
39
 
40
+ /**
41
+ * Server-side in-memory download cache.
42
+ *
43
+ * Purpose: avoid redundant API download calls for resources whose content has
44
+ * not changed between syncs IN THE SAME SERVER SESSION.
45
+ *
46
+ * IMPORTANT — this cache ONLY skips the network download; it NEVER skips
47
+ * generating LocalAction instructions. Whether the user's local files are
48
+ * already up-to-date is determined client-side via the `content_hash` field
49
+ * on write_file actions and `skip_if_exists` on merge_mcp_json actions.
50
+ * This ensures a manual sync always re-delivers actions so the user can
51
+ * recover deleted local files, even when the resource content is unchanged.
52
+ *
53
+ * Key format: `${userToken}::${resourceId}`
54
+ * Value: the last downloadResource() response (hash + files).
55
+ *
56
+ * The cache is process-scoped and cleared on server restart.
57
+ */
58
+ interface CachedDownload {
59
+ hash: string;
60
+ files: Array<{ path: string; content: string }>;
61
+ }
62
+ const downloadCache = new Map<string, CachedDownload>();
63
+
64
+ function syncCacheKey(userToken: string, resourceId: string): string {
65
+ return `${userToken}::${resourceId}`;
66
+ }
67
+
68
+ /** Compute SHA-256 hex digest of a UTF-8 string. */
69
+ function sha256(content: string): string {
70
+ return createHash('sha256').update(content, 'utf8').digest('hex');
71
+ }
72
+
39
73
  /**
40
74
  * Extract the `description` field from YAML frontmatter in a Markdown file.
41
75
  * Frontmatter is delimited by leading `---` and closing `---` lines.
@@ -153,21 +187,40 @@ export async function syncResources(params: unknown): Promise<ToolResult<SyncRes
153
187
  // getCursorResourcePath throws for unrecognised types, caught below.
154
188
  const destPath = getCursorResourcePath(sub.type, sub.name);
155
189
 
156
- // In check mode: just report whether the resource already exists locally.
190
+ // In check mode: report whether the resource is already available.
191
+ // Command/Skill: check the in-memory Prompt registry (no local files).
192
+ // Rule/MCP: check whether the local file / mcp.json entry exists.
157
193
  if (mode === 'check') {
158
- try {
159
- await fs.access(destPath);
160
- tally.cached++;
161
- details.push({ id: sub.id, name: sub.name, action: 'cached', version: resourceVersion });
162
- logToolStep('sync_resources', 'Resource already present (check mode)', {
163
- resourceId: sub.id, destPath,
164
- });
165
- } catch {
166
- tally.failed++;
167
- details.push({ id: sub.id, name: sub.name, action: 'failed', version: resourceVersion });
168
- logToolStep('sync_resources', 'Resource missing (check mode)', {
169
- resourceId: sub.id, destPath,
170
- });
194
+ if (sub.type === 'command' || sub.type === 'skill') {
195
+ const meta = {
196
+ resource_id: sub.id,
197
+ resource_type: sub.type as 'command' | 'skill',
198
+ resource_name: sub.name,
199
+ team: (sub as any).team ?? 'general',
200
+ };
201
+ const isRegistered = promptManager.has(promptManager.buildPromptName(meta), userToken ?? '');
202
+ if (isRegistered) {
203
+ tally.cached++;
204
+ details.push({ id: sub.id, name: sub.name, action: 'cached', version: resourceVersion });
205
+ } else {
206
+ tally.failed++;
207
+ details.push({ id: sub.id, name: sub.name, action: 'failed', version: resourceVersion });
208
+ }
209
+ } else {
210
+ try {
211
+ await fs.access(destPath);
212
+ tally.cached++;
213
+ details.push({ id: sub.id, name: sub.name, action: 'cached', version: resourceVersion });
214
+ logToolStep('sync_resources', 'Resource already present (check mode)', {
215
+ resourceId: sub.id, destPath,
216
+ });
217
+ } catch {
218
+ tally.failed++;
219
+ details.push({ id: sub.id, name: sub.name, action: 'failed', version: resourceVersion });
220
+ logToolStep('sync_resources', 'Resource missing (check mode)', {
221
+ resourceId: sub.id, destPath,
222
+ });
223
+ }
171
224
  }
172
225
  continue;
173
226
  }
@@ -280,18 +333,40 @@ export async function syncResources(params: unknown): Promise<ToolResult<SyncRes
280
333
  continue;
281
334
  }
282
335
 
283
- // Download all files for this resource from the CSP server.
284
- logToolStep('sync_resources', 'Downloading resource', {
285
- resourceId: sub.id,
286
- resourceType: sub.type,
287
- });
288
- const tDl = Date.now();
289
- const downloadResult = await apiClient.downloadResource(sub.id, userToken);
290
- logToolStep('sync_resources', 'Download complete', {
291
- resourceId: sub.id,
292
- fileCount: downloadResult.files.length,
293
- duration: Date.now() - tDl,
294
- });
336
+ // ── Download (with server-session cache) ─────────────────────────────
337
+ // The download cache avoids redundant API calls when the same resource
338
+ // is synced multiple times within one server session without any content
339
+ // change. It ONLY caches the network response; LocalAction generation
340
+ // always proceeds so that users can recover deleted local files by
341
+ // re-running sync — even when the resource content is unchanged.
342
+ const cacheKey = syncCacheKey(userToken ?? '', sub.id);
343
+ let downloadResult: { hash: string; files: Array<{ path: string; content: string }> };
344
+
345
+ const cached = downloadCache.get(cacheKey);
346
+ if (mode === 'incremental' && cached) {
347
+ // Reuse the previously downloaded content without hitting the API.
348
+ // full mode always bypasses this branch to guarantee a fresh download.
349
+ downloadResult = cached;
350
+ logToolStep('sync_resources', 'Using cached download (no API call)', {
351
+ resourceId: sub.id,
352
+ cachedHash: cached.hash,
353
+ });
354
+ } else {
355
+ logToolStep('sync_resources', 'Downloading resource', {
356
+ resourceId: sub.id,
357
+ resourceType: sub.type,
358
+ });
359
+ const tDl = Date.now();
360
+ const apiResult = await apiClient.downloadResource(sub.id, userToken);
361
+ logToolStep('sync_resources', 'Download complete', {
362
+ resourceId: sub.id,
363
+ fileCount: apiResult.files.length,
364
+ duration: Date.now() - tDl,
365
+ });
366
+ downloadResult = { hash: apiResult.hash, files: apiResult.files };
367
+ // Refresh cache with the latest download.
368
+ downloadCache.set(cacheKey, downloadResult);
369
+ }
295
370
 
296
371
  // When the API returns no files (expected when the MCP server is deployed
297
372
  // remotely and content lives in the server-side git repo), fall back to
@@ -374,7 +449,14 @@ export async function syncResources(params: unknown): Promise<ToolResult<SyncRes
374
449
  const normalised = path.normalize(file.path);
375
450
  if (normalised.startsWith('..')) continue;
376
451
  const fileDest = `${installDir}/${normalised}`;
377
- localActions.push({ action: 'write_file', path: fileDest, content: file.content });
452
+ // Carry content_hash so the AI can skip the write when the
453
+ // local file already has identical content.
454
+ localActions.push({
455
+ action: 'write_file',
456
+ path: fileDest,
457
+ content: file.content,
458
+ content_hash: sha256(file.content),
459
+ });
378
460
  writeActions.push(fileDest);
379
461
  }
380
462
  const env = (cfg['env'] ?? {}) as Record<string, string>;
@@ -390,6 +472,9 @@ export async function syncResources(params: unknown): Promise<ToolResult<SyncRes
390
472
  mcp_json_path: mcpJsonPath,
391
473
  server_name: serverName,
392
474
  entry: { ...cfg, args },
475
+ // skip_if_exists: preserve user-edited env values; the entry
476
+ // is already configured if the key exists in mcp.json.
477
+ skip_if_exists: true,
393
478
  ...(missingEnv.length > 0 ? {
394
479
  missing_env: missingEnv,
395
480
  setup_hint: `Fill in env vars in ${mcpJsonPath} under mcpServers["${sub.name}"]: ${missingEnv.join(', ')}.`,
@@ -421,6 +506,9 @@ export async function syncResources(params: unknown): Promise<ToolResult<SyncRes
421
506
  mcp_json_path: mcpJsonPath,
422
507
  server_name: serverName,
423
508
  entry: e,
509
+ // skip_if_exists: user may have customised env values; do
510
+ // not overwrite an existing entry on every incremental sync.
511
+ skip_if_exists: true,
424
512
  ...(missingEnv.length > 0 ? {
425
513
  missing_env: missingEnv,
426
514
  setup_hint: `Fill in env vars in ${mcpJsonPath} under mcpServers["${serverName}"]: ${missingEnv.join(', ')}.`,
@@ -450,7 +538,12 @@ export async function syncResources(params: unknown): Promise<ToolResult<SyncRes
450
538
  const normalised = path.normalize(file.path);
451
539
  if (normalised.startsWith('..')) continue;
452
540
  const fileDest = `${installDir}/${normalised}`;
453
- localActions.push({ action: 'write_file', path: fileDest, content: file.content });
541
+ localActions.push({
542
+ action: 'write_file',
543
+ path: fileDest,
544
+ content: file.content,
545
+ content_hash: sha256(file.content),
546
+ });
454
547
  writeActions.push(fileDest);
455
548
  }
456
549
  const jsEntry = resourceFiles.find((f) => f.path.endsWith('.js'));
@@ -463,6 +556,7 @@ export async function syncResources(params: unknown): Promise<ToolResult<SyncRes
463
556
  mcp_json_path: mcpJsonPath,
464
557
  server_name: sub.name,
465
558
  entry: { command: cmd, args: [entryPath] },
559
+ skip_if_exists: true,
466
560
  });
467
561
  logger.info(
468
562
  {
@@ -487,7 +581,8 @@ export async function syncResources(params: unknown): Promise<ToolResult<SyncRes
487
581
 
488
582
  // ── Rule resource ─────────────────────────────────────────────────────
489
583
  // Return write_file actions; the AI writes the files locally.
490
- // Use client-side path so the path resolves on the user's machine.
584
+ // Each action carries content_hash so the AI can skip the write when
585
+ // the local file already has identical content.
491
586
  if (sub.type === 'rule') {
492
587
  const typeDir = getCursorTypeDirForClient(sub.type);
493
588
  const writeActions: string[] = [];
@@ -503,6 +598,7 @@ export async function syncResources(params: unknown): Promise<ToolResult<SyncRes
503
598
  action: 'write_file',
504
599
  path: destPath,
505
600
  content: file.content,
601
+ content_hash: sha256(file.content),
506
602
  });
507
603
  writeActions.push(destPath);
508
604
  }
@@ -638,10 +734,17 @@ export const syncResourcesTool = {
638
734
  'Rule and MCP resources are returned as `local_actions_required` — an ordered list of ' +
639
735
  'write_file, merge_mcp_json, or other actions that the AI Agent MUST execute on the ' +
640
736
  'USER\'S LOCAL MACHINE after receiving the response. ' +
641
- 'Execute every action in the list before reporting success to the user. ' +
642
- 'For merge_mcp_json actions: read the existing ~/.cursor/mcp.json, smart-merge the ' +
643
- 'provided entry (preserve existing user env values), then write the file back. ' +
644
- 'For write_file actions: create any missing parent directories then write the file.',
737
+ 'Execute every action in the list in order before reporting success to the user. ' +
738
+ 'For write_file actions: ' +
739
+ ' (1) If the action has a `content_hash` field, read the existing file (if any), compute its SHA-256, ' +
740
+ ' and SKIP the write if the digests match the file is already up-to-date. ' +
741
+ ' (2) Otherwise create any missing parent directories then write the file. ' +
742
+ 'For merge_mcp_json actions: ' +
743
+ ' (1) If the action has `skip_if_exists: true` AND `mcpServers[server_name]` already exists ' +
744
+ ' in ~/.cursor/mcp.json, SKIP this action entirely — the server is already configured ' +
745
+ ' and any user-edited env values must not be overwritten. ' +
746
+ ' (2) Otherwise read the existing ~/.cursor/mcp.json, smart-merge the provided entry ' +
747
+ ' (preserve existing user env values), then write the file back.',
645
748
  inputSchema: {
646
749
  type: 'object' as const,
647
750
  properties: {
@@ -87,32 +87,38 @@ export async function uninstallResource(params: unknown): Promise<ToolResult<Uni
87
87
  // The MCP server may be running remotely; we must NOT touch the server's
88
88
  // own filesystem. Instead we return delete/remove instructions so the AI
89
89
  // Agent performs them on the user's LOCAL machine.
90
- logger.debug({ pattern }, 'Building local uninstall actions for Rule/MCP resource...');
90
+ logger.debug({ pattern, resourceType: typedParams.resource_type }, 'Building local uninstall actions for Rule/MCP resource...');
91
91
 
92
92
  const localActions: LocalAction[] = [];
93
93
  // Use client-side tilde-based paths; the MCP server may be running remotely
94
94
  // and its os.homedir() would resolve to the server's home, not the user's.
95
95
  const mcpJsonPath = `${getCursorRootDirForClient()}/mcp.json`;
96
96
 
97
- // Rule: queue delete for ~/.cursor/rules/<pattern>.mdc and .md variants.
98
- // We cannot scan the server's filesystem for the user's rules, so we emit
99
- // delete actions for the two common extensions and let the AI skip missing files.
100
- const rulesDir = getCursorTypeDirForClient('rule');
101
- for (const ext of ['.mdc', '.md']) {
102
- const filePath = `${rulesDir}/${pattern}${ext}`;
103
- localActions.push({ action: 'delete_file', path: filePath });
104
- removedResources.push({ id: pattern, name: pattern, path: filePath });
97
+ // When resource_type is provided, only emit the relevant actions.
98
+ // When unknown, emit both (AI skips missing files gracefully).
99
+ const knownType = typedParams.resource_type;
100
+ const isRule = !knownType || knownType === 'rule';
101
+ const isMcp = !knownType || knownType === 'mcp';
102
+
103
+ if (isRule) {
104
+ // Rule: delete ~/.cursor/rules/<pattern>.mdc and .md variants.
105
+ const rulesDir = getCursorTypeDirForClient('rule');
106
+ for (const ext of ['.mdc', '.md']) {
107
+ const filePath = `${rulesDir}/${pattern}${ext}`;
108
+ localActions.push({ action: 'delete_file', path: filePath });
109
+ removedResources.push({ id: pattern, name: pattern, path: filePath });
110
+ }
105
111
  }
106
112
 
107
- // MCP: queue delete of install directory (Format A) + remove mcp.json entry.
108
- // For Format B (remote URL only) there is no local directory, but we still
109
- // need to remove the mcp.json entry the AI will skip the delete if the
110
- // directory does not exist.
111
- const mcpDir = getCursorTypeDirForClient('mcp');
112
- const mcpInstallDir = `${mcpDir}/${pattern}`;
113
- localActions.push({ action: 'delete_file', path: mcpInstallDir, recursive: true });
114
- localActions.push({ action: 'remove_mcp_json_entry', mcp_json_path: mcpJsonPath, server_name: pattern });
115
- removedResources.push({ id: pattern, name: pattern, path: mcpInstallDir });
113
+ if (isMcp) {
114
+ // MCP: delete install directory (Format A may not exist for remote-URL MCPs)
115
+ // and remove the mcpServers entry from mcp.json.
116
+ const mcpDir = getCursorTypeDirForClient('mcp');
117
+ const mcpInstallDir = `${mcpDir}/${pattern}`;
118
+ localActions.push({ action: 'delete_file', path: mcpInstallDir, recursive: true });
119
+ localActions.push({ action: 'remove_mcp_json_entry', mcp_json_path: mcpJsonPath, server_name: pattern });
120
+ removedResources.push({ id: pattern, name: pattern, path: mcpInstallDir });
121
+ }
116
122
 
117
123
  if (removedResources.length === 0 && localActions.length === 0) {
118
124
  throw createValidationError(
@@ -22,6 +22,13 @@ export interface WriteFileAction {
22
22
  path: string;
23
23
  /** UTF-8 file content to write. */
24
24
  content: string;
25
+ /**
26
+ * SHA-256 hex digest of `content` (without leading "sha256:").
27
+ * When present, the AI MUST read the existing file at `path` (if any),
28
+ * compute its SHA-256, and SKIP the write if the digests match.
29
+ * This prevents redundant writes on incremental syncs.
30
+ */
31
+ content_hash?: string;
25
32
  }
26
33
 
27
34
  export interface DeleteFileAction {
@@ -46,6 +53,13 @@ export interface MergeMcpJsonAction {
46
53
  setup_hint?: string;
47
54
  /** Path to a local setup/readme doc if one exists in the install dir. */
48
55
  setup_doc?: string;
56
+ /**
57
+ * When true, the AI MUST skip this action if `mcpServers[server_name]`
58
+ * already exists in mcp.json (regardless of content).
59
+ * Use this for idempotent installs where re-writing would clobber
60
+ * user-customised values (e.g. env vars the user has already filled in).
61
+ */
62
+ skip_if_exists?: boolean;
49
63
  }
50
64
 
51
65
  export interface RemoveMcpJsonEntryAction {
@@ -242,6 +256,8 @@ export interface UploadResourceResult {
242
256
  export interface UninstallResourceParams {
243
257
  resource_id_or_name: string;
244
258
  remove_from_account?: boolean;
259
+ /** When known, the resource type — narrows which local_actions are emitted. */
260
+ resource_type?: 'command' | 'skill' | 'rule' | 'mcp';
245
261
  /** CSP API token from the user's mcp.json env configuration. */
246
262
  user_token?: string;
247
263
  }