@ganju/utils 0.0.10 → 0.0.11

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.
@@ -0,0 +1,217 @@
1
+ "use strict";
2
+ // When an organization turns on "confirm sensitive actions", a channel bot that
3
+ // wants to run a sensitive tool asks first, and the call runs only if the
4
+ // participant's next message is a plain yes. These are the pieces of that rule
5
+ // that aren't the runner's own bookkeeping.
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.formatConfirmationSummary = exports.isConfirmationReply = exports.parseConfirmationButton = exports.confirmationButtonId = exports.confirmationButtonLabels = exports.languageFromPhoneNumber = exports.annotationsForEffect = exports.TOOL_EFFECTS = void 0;
8
+ // What an owner says a tool they wrote does, in the words the dashboard asks
9
+ // it in, and the MCP annotations each answer stands for: `read` only looks
10
+ // things up, `write` changes things that can be undone, `sensitive` sends,
11
+ // deletes, charges or can't be undone — the kind a confirming channel asks
12
+ // about.
13
+ exports.TOOL_EFFECTS = ['read', 'write', 'sensitive'];
14
+ const annotationsForEffect = (effect) => effect === 'read'
15
+ ? { readOnlyHint: true, destructiveHint: false }
16
+ : effect === 'write'
17
+ ? { readOnlyHint: false, destructiveHint: false }
18
+ : { readOnlyHint: false, destructiveHint: true };
19
+ exports.annotationsForEffect = annotationsForEffect;
20
+ // Whole replies that mean yes, lowercased with accents and punctuation
21
+ // removed. English, Spanish and Portuguese, the languages bots here answer in.
22
+ const AFFIRMATIVE = new Set([
23
+ 'y',
24
+ 'yes',
25
+ 'yeah',
26
+ 'yep',
27
+ 'yup',
28
+ 'sure',
29
+ 'ok',
30
+ 'okay',
31
+ 'confirm',
32
+ 'confirmed',
33
+ 'approve',
34
+ 'approved',
35
+ 'go',
36
+ 'go ahead',
37
+ 'do it',
38
+ 'send',
39
+ 'send it',
40
+ 'proceed',
41
+ 'yes please',
42
+ 'yes send it',
43
+ 'yes do it',
44
+ 'yes go ahead',
45
+ 'yes confirm',
46
+ 'sure thing',
47
+ 'go for it',
48
+ 'sounds good',
49
+ 'looks good',
50
+ 'all good',
51
+ 'correct',
52
+ 'si hazlo',
53
+ 'si adelante',
54
+ 'si envialo ya',
55
+ 'perfecto',
56
+ 'correcto',
57
+ 'esta bien',
58
+ 'ok dale',
59
+ 'ok envialo',
60
+ 'si',
61
+ 'sip',
62
+ 'claro',
63
+ 'dale',
64
+ 'va',
65
+ 'vale',
66
+ 'listo',
67
+ 'confirmo',
68
+ 'confirmado',
69
+ 'confirmar',
70
+ 'adelante',
71
+ 'hazlo',
72
+ 'envialo',
73
+ 'enviar',
74
+ 'de acuerdo',
75
+ 'si por favor',
76
+ 'si claro',
77
+ 'si dale',
78
+ 'si envialo',
79
+ 'si confirmo',
80
+ 'sim',
81
+ 'pode',
82
+ 'pode enviar'
83
+ ]);
84
+ const AFFIRMATIVE_EMOJI = new Set(['👍', '✅', '👌', '🆗']);
85
+ // The Yes/No buttons offered under a confirmation question, in the
86
+ // participant's language when the platform reports it. The labels are also
87
+ // what a tap sends where a button is just a canned reply (Telegram's reply
88
+ // keyboard), so "yes" must read as a yes to isConfirmationReply — and it does,
89
+ // in every language here.
90
+ const BUTTON_LABELS = {
91
+ en: { yes: '✅ Yes', no: '❌ No' },
92
+ es: { yes: '✅ Sí', no: '❌ No' },
93
+ pt: { yes: '✅ Sim', no: '❌ Não' }
94
+ };
95
+ // WhatsApp reports no locale, so a button's language comes from the calling
96
+ // code of the participant's number: the Spanish- and Portuguese-speaking
97
+ // countries, English otherwise. Longest prefix wins.
98
+ const CALLING_CODE_LANGUAGE = {
99
+ '34': 'es',
100
+ '51': 'es',
101
+ '52': 'es',
102
+ '53': 'es',
103
+ '54': 'es',
104
+ '56': 'es',
105
+ '57': 'es',
106
+ '58': 'es',
107
+ '240': 'es',
108
+ '502': 'es',
109
+ '503': 'es',
110
+ '504': 'es',
111
+ '505': 'es',
112
+ '506': 'es',
113
+ '507': 'es',
114
+ '591': 'es',
115
+ '593': 'es',
116
+ '595': 'es',
117
+ '598': 'es',
118
+ '1787': 'es',
119
+ '1809': 'es',
120
+ '1829': 'es',
121
+ '1849': 'es',
122
+ '55': 'pt',
123
+ '238': 'pt',
124
+ '244': 'pt',
125
+ '245': 'pt',
126
+ '258': 'pt',
127
+ '351': 'pt'
128
+ };
129
+ const languageFromPhoneNumber = (phone) => {
130
+ const digits = phone.replace(/\D/g, '');
131
+ for (let length = 4; length >= 2; length--) {
132
+ const language = CALLING_CODE_LANGUAGE[digits.slice(0, length)];
133
+ if (language)
134
+ return language;
135
+ }
136
+ return null;
137
+ };
138
+ exports.languageFromPhoneNumber = languageFromPhoneNumber;
139
+ const confirmationButtonLabels = (locale) => {
140
+ const language = (locale || '').toLowerCase().split(/[-_]/)[0];
141
+ return BUTTON_LABELS[language] ?? BUTTON_LABELS.en;
142
+ };
143
+ exports.confirmationButtonLabels = confirmationButtonLabels;
144
+ // Where the platform sends back a button's id rather than its label (Discord,
145
+ // WhatsApp), the id says which answer it is and who was asked: in a shared
146
+ // channel everyone sees the buttons, and only that participant's tap counts.
147
+ // The runner never sees the id — a tap reaches it as the text "yes" or "no".
148
+ const CONFIRMATION_BUTTON_PREFIX = 'ganju-confirm';
149
+ const confirmationButtonId = (answer, externalParticipantId) => `${CONFIRMATION_BUTTON_PREFIX}:${answer}:${externalParticipantId}`;
150
+ exports.confirmationButtonId = confirmationButtonId;
151
+ const parseConfirmationButton = (id) => {
152
+ const [prefix, answer, ...rest] = id.split(':');
153
+ const externalParticipantId = rest.join(':');
154
+ if (prefix !== CONFIRMATION_BUTTON_PREFIX || !externalParticipantId) {
155
+ return null;
156
+ }
157
+ if (answer !== 'yes' && answer !== 'no')
158
+ return null;
159
+ return { text: answer, externalParticipantId };
160
+ };
161
+ exports.parseConfirmationButton = parseConfirmationButton;
162
+ const normalize = (text) => text
163
+ .toLowerCase()
164
+ .normalize('NFKD')
165
+ .replace(/[̀-ͯ]/g, '')
166
+ .replace(/[^a-z0-9\s]+/g, ' ')
167
+ .replace(/\s+/g, ' ')
168
+ .trim();
169
+ /**
170
+ * Whether a reply confirms the pending action. Deliberately narrow: the whole
171
+ * message has to be a yes. "Yes, but send it to Ana instead" is not one — it
172
+ * asks for a different action, and running the original would do exactly
173
+ * what the person just said not to. Anything that isn't a clear yes cancels,
174
+ * so the worst a misread costs is asking again.
175
+ */
176
+ const isConfirmationReply = (text) => {
177
+ const trimmed = text.trim();
178
+ if (AFFIRMATIVE_EMOJI.has(trimmed))
179
+ return true;
180
+ return AFFIRMATIVE.has(normalize(trimmed));
181
+ };
182
+ exports.isConfirmationReply = isConfirmationReply;
183
+ // How much of each argument the summary shows, and how many arguments.
184
+ const SUMMARY_VALUE_MAX = 200;
185
+ const SUMMARY_ARGUMENTS_MAX = 10;
186
+ const summaryValue = (value) => {
187
+ const text = typeof value === 'string'
188
+ ? value
189
+ : (JSON.stringify(value) ?? String(value));
190
+ // One line, and no backticks, so a value can neither fake another line of
191
+ // the summary nor break out of its code span into formatting or links.
192
+ const flat = text.replace(/\s+/g, ' ').replace(/`/g, "'").trim();
193
+ return flat.length > SUMMARY_VALUE_MAX
194
+ ? `${flat.slice(0, SUMMARY_VALUE_MAX)}…`
195
+ : flat;
196
+ };
197
+ /**
198
+ * What the held calls will do, written from the calls themselves and appended
199
+ * under the model's question. The model words the question, but a yes runs the
200
+ * stored calls — so the participant has to see those, not only the model's
201
+ * account of them, which a prompt injection earlier in the turn could have
202
+ * bent.
203
+ */
204
+ const formatConfirmationSummary = (calls, titleOf) => calls
205
+ .map(call => {
206
+ const entries = Object.entries(call.arguments ?? {}).filter(([, value]) => value !== undefined && value !== null && value !== '');
207
+ const lines = entries
208
+ .slice(0, SUMMARY_ARGUMENTS_MAX)
209
+ .map(([key, value]) => `- ${key}: \`${summaryValue(value)}\``);
210
+ if (entries.length > SUMMARY_ARGUMENTS_MAX)
211
+ lines.push('- …');
212
+ // The hourglass says "not done yet" in every language, whatever the
213
+ // model's own words above it claim.
214
+ return [`⏳ **${titleOf(call.name)}**`, ...lines].join('\n');
215
+ })
216
+ .join('\n\n');
217
+ exports.formatConfirmationSummary = formatConfirmationSummary;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ganju/utils",
3
- "version": "0.0.10",
3
+ "version": "0.0.11",
4
4
  "description": "Shared constants, schemas and helpers for Ganju",
5
5
  "license": "Apache-2.0",
6
6
  "author": "MontoyaAndres <andresmontoyafcb@gmail.com>",
package/src/constants.ts CHANGED
@@ -304,6 +304,16 @@ const RESOURCE_SYNC_BATCH_SIZE = 50;
304
304
  // plan, and a website sync re-crawls every page, so it can't be a button to
305
305
  // press in a loop.
306
306
  const RESOURCE_SYNC_MANUAL_COOLDOWN_MS = 5 * 60 * 1000;
307
+ // How long a sync may stay PENDING before it counts as stuck rather than
308
+ // running. A lost queue message or a job that exhausted its retries never
309
+ // completes its root, and a root that stays PENDING would otherwise be skipped
310
+ // by the hourly job and refused by "Sync now" forever. Twelve hours outlasts
311
+ // any realistic wait: the crawl-page queue runs 5 at a time for every org, and
312
+ // one hourly run can queue 50 sites — thousands of pages, hours of backlog. A
313
+ // shorter window restarts syncs that are merely queued, re-fetching every page
314
+ // into the same backlog; a longer one leaves a stuck daily source missing more
315
+ // than one sync.
316
+ const RESOURCE_SYNC_STALE_MS = 12 * 60 * 60 * 1000;
307
317
 
308
318
  const CRAWL_RENDERER_CHEERIO = 'cheerio' as 'cheerio';
309
319
  const CRAWL_RENDERER_PLAYWRIGHT = 'playwright' as 'playwright';
@@ -758,6 +768,49 @@ const CHANNEL_DEBOUNCE_JOIN = '\n';
758
768
  const CHANNEL_DEBOUNCE_RETRY_MS = 5000;
759
769
  const CHANNEL_DEBOUNCE_MAX_ATTEMPTS = 3;
760
770
 
771
+ // Confirming sensitive actions, for organizations that turn it on. A channel
772
+ // bot that wants to run a tool annotated `destructiveHint` asks first, and the
773
+ // call runs only if the participant's next message is a plain yes.
774
+ //
775
+ // How long the question stays open. An answer after that is read as a new
776
+ // message, never as consent to something asked long ago.
777
+ const TOOL_CONFIRMATION_TTL_MS = 30 * 60 * 1000;
778
+ // The tool result the model gets instead of running the call. It is what makes
779
+ // the model write the question — in the participant's language, naming what
780
+ // the call would do — rather than claiming the action happened.
781
+ // The call's exact arguments are listed under the model's question by the
782
+ // runner, so the model only has to say what it is about to do.
783
+ const TOOL_CONFIRMATION_REQUIRED_RESULT =
784
+ 'Not run yet: this action needs the user to confirm it first. In one or ' +
785
+ 'two sentences, tell them what it will do (who it goes to, what will be ' +
786
+ 'changed or deleted) and ask them to answer yes to go ahead or no to ' +
787
+ 'cancel — in their own language, never quoting English words at someone ' +
788
+ 'writing in another one. The exact details are listed for them under your ' +
789
+ 'message. It runs only if their next message is yes. Do not say it was done.';
790
+ // Told to the model on every turn of an organization that confirms: the
791
+ // asking is done for it, so it calls the tool when asked instead of asking
792
+ // first itself — and calls every action a request needs in one step, since
793
+ // the turn stops at the first held call and each later one would be a
794
+ // question of its own.
795
+ const TOOL_CONFIRMATION_SYSTEM_NOTE =
796
+ 'Actions that send, change or delete something are confirmed with the ' +
797
+ 'user by the system before they run. Ask for any detail you are missing, ' +
798
+ 'but once you have what the action needs, call the tool — do not ask the ' +
799
+ 'user to confirm it yourself; the system asks them and runs it on their ' +
800
+ 'yes. When a request needs several such actions (say, delete an event ' +
801
+ 'and create its replacement), call them all in the same step, so the ' +
802
+ 'user confirms them together once.';
803
+ // The system-prompt addition for the step that writes the question, with the
804
+ // held actions' names in place of {{actions}}.
805
+ const TOOL_CONFIRMATION_ASK_INSTRUCTION =
806
+ 'IMPORTANT: these actions have NOT been done: {{actions}}. They are ' +
807
+ 'waiting for the user to confirm them. Your reply must ask the user to ' +
808
+ 'confirm, and must not say or suggest that they were done, nor describe ' +
809
+ 'or make up any result from them.';
810
+ // The question when the model wrote none, above the runner's own summary.
811
+ const TOOL_CONFIRMATION_FALLBACK_QUESTION =
812
+ 'Before I do this, please confirm. Reply yes to go ahead, or no to cancel.';
813
+
761
814
  const TELEGRAM_SECRET_HEADER = 'x-telegram-bot-api-secret-token';
762
815
  const TELEGRAM_API_BASE = 'https://api.telegram.org';
763
816
  const TELEGRAM_MESSAGE_LIMIT = 3500;
@@ -817,8 +870,12 @@ const DISCORD_TIMESTAMP_HEADER = 'x-signature-timestamp';
817
870
  // Interaction request types and response types (Discord API).
818
871
  const DISCORD_INTERACTION_TYPE_PING = 1;
819
872
  const DISCORD_INTERACTION_TYPE_APPLICATION_COMMAND = 2;
873
+ // A tap on a message button — the Yes/No under a confirmation question.
874
+ const DISCORD_INTERACTION_TYPE_MESSAGE_COMPONENT = 3;
820
875
  const DISCORD_INTERACTION_RESPONSE_PONG = 1;
821
876
  const DISCORD_INTERACTION_RESPONSE_DEFERRED = 5;
877
+ // Edits the message the tapped button is on, in the same response.
878
+ const DISCORD_INTERACTION_RESPONSE_UPDATE_MESSAGE = 7;
822
879
  // Channel types we map to conversation scopes: DM (1) / group DM (3) = private;
823
880
  // everything else (guild text channels, threads) = channel.
824
881
  const DISCORD_CHANNEL_TYPE_DM = 1;
@@ -847,6 +904,8 @@ const WHATSAPP_API_BASE = 'https://graph.facebook.com';
847
904
  const WHATSAPP_API_VERSION = 'v25.0';
848
905
  // A WhatsApp text message body caps at 4096 chars; we chunk a bit under that.
849
906
  const WHATSAPP_MESSAGE_LIMIT = 4000;
907
+ // The body of an interactive (reply-button) message holds at most this much.
908
+ const WHATSAPP_INTERACTIVE_BODY_LIMIT = 1024;
850
909
  const WHATSAPP_SIGNATURE_HEADER = 'x-hub-signature-256';
851
910
  const WHATSAPP_SIGNATURE_PREFIX = 'sha256=';
852
911
  // GET-handshake query params + the expected hub.mode value.
@@ -2546,6 +2605,7 @@ export const constants = {
2546
2605
  RESOURCE_SYNC_INTERVAL_MS,
2547
2606
  RESOURCE_SYNC_BATCH_SIZE,
2548
2607
  RESOURCE_SYNC_MANUAL_COOLDOWN_MS,
2608
+ RESOURCE_SYNC_STALE_MS,
2549
2609
  CRAWL_RENDERER_CHEERIO,
2550
2610
  CRAWL_RENDERER_PLAYWRIGHT,
2551
2611
  CRAWL_RENDERERS,
@@ -2604,6 +2664,11 @@ export const constants = {
2604
2664
  SHARED_KEY_HISTORY_LIMIT,
2605
2665
  SHARED_KEY_MAX_TOOL_LOOPS,
2606
2666
  CHANNEL_MAX_TOOLS,
2667
+ TOOL_CONFIRMATION_TTL_MS,
2668
+ TOOL_CONFIRMATION_REQUIRED_RESULT,
2669
+ TOOL_CONFIRMATION_FALLBACK_QUESTION,
2670
+ TOOL_CONFIRMATION_ASK_INSTRUCTION,
2671
+ TOOL_CONFIRMATION_SYSTEM_NOTE,
2607
2672
  CHANNEL_DEBOUNCE_DEFAULT_MS,
2608
2673
  CHANNEL_DEBOUNCE_MIN_MS,
2609
2674
  CHANNEL_DEBOUNCE_MAX_MS,
@@ -2636,8 +2701,10 @@ export const constants = {
2636
2701
  DISCORD_TIMESTAMP_HEADER,
2637
2702
  DISCORD_INTERACTION_TYPE_PING,
2638
2703
  DISCORD_INTERACTION_TYPE_APPLICATION_COMMAND,
2704
+ DISCORD_INTERACTION_TYPE_MESSAGE_COMPONENT,
2639
2705
  DISCORD_INTERACTION_RESPONSE_PONG,
2640
2706
  DISCORD_INTERACTION_RESPONSE_DEFERRED,
2707
+ DISCORD_INTERACTION_RESPONSE_UPDATE_MESSAGE,
2641
2708
  DISCORD_CHANNEL_TYPE_DM,
2642
2709
  DISCORD_CHANNEL_TYPE_GROUP_DM,
2643
2710
  DISCORD_GATEWAY_OP_DISPATCH,
@@ -2652,6 +2719,7 @@ export const constants = {
2652
2719
  WHATSAPP_API_BASE,
2653
2720
  WHATSAPP_API_VERSION,
2654
2721
  WHATSAPP_MESSAGE_LIMIT,
2722
+ WHATSAPP_INTERACTIVE_BODY_LIMIT,
2655
2723
  WHATSAPP_SIGNATURE_HEADER,
2656
2724
  WHATSAPP_SIGNATURE_PREFIX,
2657
2725
  WHATSAPP_HUB_MODE_PARAM,
package/src/index.ts CHANGED
@@ -117,7 +117,11 @@ import type { OAuthProviderConfig } from './oauthProviders';
117
117
  import { resolveAttachment } from './attachment';
118
118
  import { isExposedResource } from './exposedResource';
119
119
  import type { ExposableResource } from './exposedResource';
120
- import { isSyncableResource, resourceSyncProvider } from './syncableResource';
120
+ import {
121
+ isSyncableResource,
122
+ isResourceSyncInProgress,
123
+ resourceSyncProvider
124
+ } from './syncableResource';
121
125
  import type {
122
126
  SyncableResource,
123
127
  ResourceSyncProvider
@@ -169,6 +173,7 @@ import type {
169
173
  CatalogGroup,
170
174
  CatalogTool,
171
175
  CatalogToolDescriptor,
176
+ ToolAnnotations,
172
177
  ToolGroupKey,
173
178
  ToolKey
174
179
  } from './toolCatalog';
@@ -220,6 +225,21 @@ import type {
220
225
  ChannelBufferEnvelope,
221
226
  ChannelBufferFlush
222
227
  } from './channelDebounce';
228
+ import {
229
+ isConfirmationReply,
230
+ confirmationButtonLabels,
231
+ confirmationButtonId,
232
+ parseConfirmationButton,
233
+ languageFromPhoneNumber,
234
+ formatConfirmationSummary,
235
+ annotationsForEffect,
236
+ TOOL_EFFECTS
237
+ } from './toolConfirmation';
238
+ import type {
239
+ PendingToolConfirmation,
240
+ PendingToolConfirmations,
241
+ ToolEffect
242
+ } from './toolConfirmation';
223
243
  import {
224
244
  isResourceSourceEnabled,
225
245
  isDownloadableSource,
@@ -301,6 +321,7 @@ export const utils = {
301
321
  resolveAttachment,
302
322
  isExposedResource,
303
323
  isSyncableResource,
324
+ isResourceSyncInProgress,
304
325
  resourceSyncProvider,
305
326
  describeVendorError,
306
327
  withDeadline,
@@ -358,6 +379,14 @@ export const utils = {
358
379
  channelBufferKey,
359
380
  batchCommand,
360
381
  joinBufferedMessages,
382
+ isConfirmationReply,
383
+ confirmationButtonLabels,
384
+ confirmationButtonId,
385
+ parseConfirmationButton,
386
+ languageFromPhoneNumber,
387
+ formatConfirmationSummary,
388
+ annotationsForEffect,
389
+ TOOL_EFFECTS,
361
390
  isResourceSourceEnabled,
362
391
  isDownloadableSource,
363
392
  safeHostname,
@@ -386,6 +415,10 @@ export type {
386
415
  CatalogGroup,
387
416
  CatalogTool,
388
417
  CatalogToolDescriptor,
418
+ ToolAnnotations,
419
+ PendingToolConfirmation,
420
+ PendingToolConfirmations,
421
+ ToolEffect,
389
422
  ToolGroupKey,
390
423
  ToolKey,
391
424
  CalendarConfigField,
package/src/schema.ts CHANGED
@@ -5,6 +5,7 @@ import { isAllowedLlmBaseUrl, requiresLlmBaseUrl } from './llmBaseUrl';
5
5
  import { isReservedSlug, isValidSlugFormat } from './slug';
6
6
  import { isReservedToolName } from './reservedToolName';
7
7
  import { slugifyTitle } from './slugifyTitle';
8
+ import { TOOL_EFFECTS } from './toolConfirmation';
8
9
 
9
10
  // A prompt title becomes a slash command; it must not collide with a command
10
11
  // the channel runner handles itself (e.g. `/link`).
@@ -43,11 +44,20 @@ const ORGANIZATION_CREATE_VIEW = z.object({
43
44
  projectDescription: z.string().max(500)
44
45
  });
45
46
 
46
- const ORGANIZATION_UPDATE = z.object({
47
- id: z.uuid(),
48
- userId: z.uuid(),
49
- name: z.string().min(3).max(100)
50
- });
47
+ // Each setting is saved on its own — the name from its form, the switches the
48
+ // moment they're flipped — so every field is optional, but not all at once.
49
+ const ORGANIZATION_UPDATE = z
50
+ .object({
51
+ id: z.uuid(),
52
+ userId: z.uuid(),
53
+ name: z.string().min(3).max(100).optional(),
54
+ requireToolConfirmation: z.boolean().optional()
55
+ })
56
+ .refine(
57
+ value =>
58
+ value.name !== undefined || value.requireToolConfirmation !== undefined,
59
+ { message: 'Nothing to update' }
60
+ );
51
61
 
52
62
  const ORGANIZATION_GET = z.object({
53
63
  id: z.uuid(),
@@ -851,6 +861,12 @@ const HTTP_ENDPOINT_CONFIG = z
851
861
  contentType: constants.HTTP_ENDPOINT_RESPONSE_CONTENT_TYPE_AUTO,
852
862
  maxBytes: constants.HTTP_ENDPOINT_DEFAULT_MAX_BYTES
853
863
  }),
864
+ // What the endpoint does, as its owner says (`read`, `write` or
865
+ // `sensitive`). Absent means the method decides: GET and HEAD read,
866
+ // anything else is sensitive — right for most, but a POST that only
867
+ // searches needs its owner to say so, or a confirming channel asks
868
+ // before every search.
869
+ effect: z.enum(TOOL_EFFECTS).optional(),
854
870
  // Auth — credentials referenced by id, never inlined.
855
871
  auth: HTTP_ENDPOINT_AUTH.default({
856
872
  kind: constants.HTTP_ENDPOINT_AUTH_KIND_NONE
@@ -1056,6 +1072,19 @@ const CUSTOM_CODE_CONFIG_WRITE = CUSTOM_CODE_CONFIG.superRefine((cfg, ctx) => {
1056
1072
  // that version registering, and the owner would see tools quietly disappear with
1057
1073
  // only a log line to explain it. Reservation belongs to the manifest below,
1058
1074
  // which is the write path.
1075
+ // MCP tool annotations as a tool's author declares them — every hint optional,
1076
+ // with the spec's own defaults for what is left out. The channel runner reads
1077
+ // them to decide what to confirm, so a tool that doesn't say it is read-only
1078
+ // is treated as one that may change things.
1079
+ const TOOL_ANNOTATIONS = z
1080
+ .object({
1081
+ readOnlyHint: z.boolean().optional(),
1082
+ destructiveHint: z.boolean().optional(),
1083
+ idempotentHint: z.boolean().optional(),
1084
+ openWorldHint: z.boolean().optional()
1085
+ })
1086
+ .strict();
1087
+
1059
1088
  const CUSTOM_CODE_TOOL = z.object({
1060
1089
  name: z
1061
1090
  .string()
@@ -1068,7 +1097,8 @@ const CUSTOM_CODE_TOOL = z.object({
1068
1097
  title: z.string().min(1).max(200).optional(),
1069
1098
  description: z.string().max(2000).optional(),
1070
1099
  inputSchema: SCHEMA_DEFINITION.default({ type: 'object', properties: {} }),
1071
- outputSchema: SCHEMA_DEFINITION.optional()
1100
+ outputSchema: SCHEMA_DEFINITION.optional(),
1101
+ annotations: TOOL_ANNOTATIONS.optional()
1072
1102
  });
1073
1103
 
1074
1104
  // The manifest a `ganju deploy` (or the dashboard) uploads alongside a bundle.
@@ -1648,6 +1678,7 @@ export const Schema = {
1648
1678
  CUSTOM_CODE_CONFIG,
1649
1679
  CUSTOM_CODE_CONFIG_WRITE,
1650
1680
  CUSTOM_CODE_TOOL,
1681
+ TOOL_ANNOTATIONS,
1651
1682
  CUSTOM_CODE_MANIFEST,
1652
1683
  ARTIFACT_CUSTOM_CODE_CREATE_VERSION,
1653
1684
  ARTIFACT_CUSTOM_CODE_UPLOAD_BUNDLE,
@@ -1707,6 +1738,10 @@ export interface McpProxyDiscoveredTool {
1707
1738
  title?: string;
1708
1739
  description?: string;
1709
1740
  inputSchema: unknown;
1741
+ // The remote's own annotations, passed through at registration. Absent on
1742
+ // discoveries made before they were recorded — the tool then counts as one
1743
+ // that may change things until the server is discovered again.
1744
+ annotations?: z.infer<typeof TOOL_ANNOTATIONS>;
1710
1745
  }
1711
1746
 
1712
1747
  // One remote resource discovered from a proxied MCP server. Identified by uri;
@@ -46,3 +46,23 @@ export const resourceSyncProvider = (
46
46
 
47
47
  export const isSyncableResource = (resource: SyncableResource): boolean =>
48
48
  resourceSyncProvider(resource) !== null;
49
+
50
+ // Whether a sync root is still genuinely syncing: PENDING, and started within
51
+ // RESOURCE_SYNC_STALE_MS. Past that, a PENDING root is stuck — a job was lost
52
+ // or gave up — and may be synced again, which re-queues whatever it left
53
+ // unfinished. A root that never synced counts from its creation, so a first
54
+ // import that stalled recovers too.
55
+ export const isResourceSyncInProgress = (
56
+ resource: {
57
+ status: string;
58
+ syncStartedAt?: Date | string | null;
59
+ createdAt: Date | string;
60
+ },
61
+ now: number = Date.now()
62
+ ): boolean => {
63
+ if (resource.status !== constants.STATUS_PENDING) return false;
64
+ const startedAt = new Date(
65
+ resource.syncStartedAt ?? resource.createdAt
66
+ ).getTime();
67
+ return now - startedAt < constants.RESOURCE_SYNC_STALE_MS;
68
+ };