@ziggs-ai/ziggs-mcp 0.1.28 → 0.1.30

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.
package/dist/tools.js CHANGED
@@ -171,6 +171,49 @@ async function fetchDelegateAccess(creds) {
171
171
  }
172
172
  return body ? JSON.parse(body) : {};
173
173
  }
174
+ /**
175
+ * ZIG-739 — the operator's full org membership (not just granted scopes, which
176
+ * is all ziggs_discover_context sees). Lets the delegate resolve an org name to
177
+ * an id and offer a pick-list instead of demanding a pasted org_... id.
178
+ */
179
+ async function fetchMyOrgs(creds) {
180
+ const url = `${getBackendUrl()}/orgs/me`;
181
+ const res = await fetch(url, {
182
+ method: 'GET',
183
+ headers: {
184
+ Authorization: `Bearer ${creds.operatorKey}`,
185
+ 'X-Agent-Id': creds.agentId,
186
+ },
187
+ });
188
+ const body = await res.text().catch(() => '');
189
+ if (!res.ok) {
190
+ throw new Error(`GET /orgs/me ${res.status} ${body.slice(0, 200)}`);
191
+ }
192
+ const parsed = body ? JSON.parse(body) : {};
193
+ return (parsed.orgs ?? []).map((o) => ({
194
+ orgId: o.orgId,
195
+ name: o.name,
196
+ kind: o.kind,
197
+ role: o.role,
198
+ }));
199
+ }
200
+ /**
201
+ * ZIG-739 — resolve an org selector (exact org_... id OR a name/handle) against
202
+ * the operator's memberships. Exact id wins; otherwise case-insensitive name
203
+ * match. Ambiguous names return the candidates rather than guessing.
204
+ */
205
+ function resolveOrgSelector(orgs, selector) {
206
+ const byId = orgs.find((o) => o.orgId === selector);
207
+ if (byId)
208
+ return { status: 'ok', orgId: byId.orgId };
209
+ const needle = selector.toLowerCase();
210
+ const byName = orgs.filter((o) => o.name.toLowerCase() === needle);
211
+ if (byName.length === 1)
212
+ return { status: 'ok', orgId: byName[0].orgId };
213
+ if (byName.length > 1)
214
+ return { status: 'ambiguous', matches: byName };
215
+ return { status: 'not-found' };
216
+ }
174
217
  /**
175
218
  * ZIG-641 — cross-connection discovery: every connection this agent holds a
176
219
  * grant for, joined with provider + health, so ziggs_connection_proxy's
@@ -345,19 +388,40 @@ export function registerZiggsTools(server, creds, cfg) {
345
388
  : 'Next: ziggs_inbox or ziggs_pending_decisions at session start.',
346
389
  });
347
390
  });
348
- server.tool('ziggs_switch_org', 'Switch which org this MCP OAuth session acts in without reconnecting. Existing Bearer unchanged; runtime org flips server-side. Requires confirm=true (party-identity change). Target org must be one you belong to. Call ziggs_auth_status after to verify actingOrgId.', {
349
- orgId: z.string().describe('Organization id to act in'),
391
+ server.tool('ziggs_list_my_orgs', 'List every org you (the operator) belong to { orgId, name, kind, role }. Unlike ziggs_discover_context (granted scopes only), this is your full membership, so you can resolve an org name to an id and offer the human a pick-list before ziggs_switch_org.', {}, READ_ONLY, async () => {
392
+ try {
393
+ const orgs = await fetchMyOrgs(creds);
394
+ return textResult({ count: orgs.length, orgs });
395
+ }
396
+ catch (e) {
397
+ return toolError(e.message);
398
+ }
399
+ });
400
+ server.tool('ziggs_switch_org', 'Switch which org this MCP OAuth session acts in without reconnecting. Existing Bearer unchanged; runtime org flips server-side. Requires confirm=true (party-identity change). Accepts an org id (org_...) OR an org name — names resolve against your memberships (see ziggs_list_my_orgs); an ambiguous name returns the candidates. Call ziggs_auth_status after to verify actingOrgId.', {
401
+ org: z
402
+ .string()
403
+ .describe('Org id (org_...) or org name to act in — must be one you belong to'),
350
404
  confirm: z
351
405
  .literal(true)
352
406
  .describe('Must be true — confirms the human approved switching acting org'),
353
- }, WRITE, async ({ orgId, confirm }) => {
407
+ }, WRITE, async ({ org, confirm }) => {
354
408
  if (confirm !== true) {
355
409
  return toolError('confirm must be true — org switch changes which workspace you act in');
356
410
  }
411
+ const selector = org.trim();
357
412
  try {
358
- const result = await rebindDelegateOrg(creds, orgId.trim());
413
+ const orgs = await fetchMyOrgs(creds);
414
+ const resolved = resolveOrgSelector(orgs, selector);
415
+ if (resolved.status === 'ambiguous') {
416
+ return toolError(`"${selector}" matches ${resolved.matches.length} orgs — switch by exact orgId. Candidates: ${JSON.stringify(resolved.matches)}`);
417
+ }
418
+ if (resolved.status === 'not-found') {
419
+ return toolError(`No org matches "${selector}". You belong to: ${JSON.stringify(orgs)}`);
420
+ }
421
+ const result = await rebindDelegateOrg(creds, resolved.orgId);
359
422
  return textResult({
360
423
  ok: true,
424
+ resolvedOrgId: resolved.orgId,
361
425
  ...result,
362
426
  note: result.unchanged
363
427
  ? 'Already acting in this org — no changes made.'
@@ -475,13 +539,22 @@ export function registerZiggsTools(server, creds, cfg) {
475
539
  .string()
476
540
  .optional()
477
541
  .describe('Usually "message" for user-visible chat'),
478
- }, WRITE, async ({ chatId, receiverId, text, entryType }) => {
542
+ idempotencyKey: z
543
+ .string()
544
+ .optional()
545
+ .describe('Retry-safe key. Reuse the SAME key when re-sending the SAME logical message (e.g. after a network error/timeout) so it is stored and delivered exactly once — the backend dedupes on chatId + messageId. Use a fresh key (or omit) for a genuinely new message.'),
546
+ }, WRITE, async ({ chatId, receiverId, text, entryType, idempotencyKey }) => {
479
547
  try {
480
548
  const result = await sendChatMessage({
481
549
  chatId,
482
550
  receiverId,
483
551
  text,
484
- messageId: `mcp_${randomUUID()}`,
552
+ // A stable idempotencyKey maps to a stable messageId so a retried
553
+ // logical send collapses to one row/one delivery (ZIG-718 backend
554
+ // dedupe); otherwise each call is a distinct message.
555
+ messageId: idempotencyKey
556
+ ? `mcp_idem_${idempotencyKey}`
557
+ : `mcp_${randomUUID()}`,
485
558
  entryType: entryType ?? 'message',
486
559
  contentType: 'text',
487
560
  }, creds);
@@ -656,6 +729,16 @@ export function registerZiggsTools(server, creds, cfg) {
656
729
  return toolError(e.message);
657
730
  }
658
731
  });
732
+ server.tool('ziggs_discover_grantable', 'See what context EXISTS in your orgs that you CANNOT read yet — so you can ask for it instead of failing blind. Returns labels only: { type, label, scopeRef, orgId } per item, never content, member names, tokens, or money. Bounded to orgs you have an active agreement in. To act on one, ask your human to grant it, or (if you hold a broader grant of your own) delegate via ziggs_delegate_grant using the scopeRef. Use ziggs_discover_context for what you already hold; this is what you lack.', {}, READ_ONLY, async () => {
733
+ try {
734
+ const client = new ContextDiscoveryClient(creds.operatorKey, creds.agentId);
735
+ const items = await client.discoverGrantable();
736
+ return textResult({ count: items.length, items });
737
+ }
738
+ catch (e) {
739
+ return toolError(e.message);
740
+ }
741
+ });
659
742
  server.tool('ziggs_read_context', 'Read the contents of a scope you already hold: messages | artifacts | agreements | tasks (the type param), under via=chat:<id>, agreement:<id>, or task:<id>. Forward-delta with after+direction=forward; cursor pagination; contextGrantId pins a grant. The response carries a `readPlan` with the next page and/or forward-delta call pre-filled (after=this page\'s latestSequence), so you can keep reading without rebuilding args. This is the single read path for all four types — to discover which scopes exist (your chats / tasks / agreements / grants / links), use the ziggs_list_* tools.', {
660
743
  type: contextReadTypeSchema.describe('Resource type to read'),
661
744
  via: z
@@ -746,9 +829,13 @@ export function registerZiggsTools(server, creds, cfg) {
746
829
  .string()
747
830
  .optional()
748
831
  .describe('Agent or user ID to explicitly assign this task to — must be a party to the agreement'),
749
- }, WRITE, async ({ agreementId, description, parentTaskId, assigneeId }) => {
832
+ inputArtifactIds: z
833
+ .array(z.string())
834
+ .optional()
835
+ .describe('Artifact ids this task consumes as structured inputs (ZIG-734) — pass prior-step output handles without embedding them in description'),
836
+ }, WRITE, async ({ agreementId, description, parentTaskId, assigneeId, inputArtifactIds }) => {
750
837
  try {
751
- const task = await createTask({ agreementId, description, parentTaskId, assigneeId }, creds);
838
+ const task = await createTask({ agreementId, description, parentTaskId, assigneeId, inputArtifactIds }, creds);
752
839
  return textResult({ ok: true, task });
753
840
  }
754
841
  catch (e) {
@@ -110,7 +110,7 @@ export function registerTrustTools(server, creds, cfg) {
110
110
  return toolError(e.message);
111
111
  }
112
112
  });
113
- server.tool('ziggs_delegate_grant', 'Delegate a narrower child grant from one you hold (POST /context/grants/:id/delegate). Delegation only narrows scope/expiry/temporal — never broadens.', {
113
+ server.tool('ziggs_delegate_grant', 'Delegate a narrower child grant from one you hold (POST /context/grants/:id/delegate). Delegation only narrows scope/expiry/temporal — never broadens. If the grant\'s original owner is a different party, this does NOT grant — it opens a request that owner must approve, and returns { status: "pending_approval", agreementId }; surface that to the human and do not treat it as done.', {
114
114
  parentGrantId: z.string(),
115
115
  holderId: z.string().describe('Agent receiving the delegated grant'),
116
116
  scopeKind: grantScopeKindSchema,
@@ -124,13 +124,23 @@ export function registerTrustTools(server, creds, cfg) {
124
124
  }, WRITE, async ({ parentGrantId, holderId, scopeKind, scopeId, temporal, expiresAt, watermarkAt, }) => {
125
125
  try {
126
126
  const client = new ContextGrantsClient(creds.operatorKey, creds.agentId);
127
- const grant = await client.delegateGrant(parentGrantId, {
127
+ const result = await client.delegateGrant(parentGrantId, {
128
128
  holderId,
129
129
  scope: { kind: scopeKind, id: scopeId },
130
130
  temporal,
131
131
  expiresAt,
132
132
  watermarkAt,
133
133
  });
134
+ if (result.status === 'pending_approval') {
135
+ return textResult({
136
+ status: 'pending_approval',
137
+ message: "This grant's original owner must approve sharing it. A request was opened for them — surface it to the human; nothing is granted yet.",
138
+ parentGrantId,
139
+ agreementId: result.agreementId,
140
+ ownerId: result.ownerId,
141
+ });
142
+ }
143
+ const grant = result.grant;
134
144
  return textResult({
135
145
  status: 'delegated',
136
146
  parentGrantId,
@@ -265,7 +275,7 @@ export function registerTrustTools(server, creds, cfg) {
265
275
  return toolError(e.message);
266
276
  }
267
277
  });
268
- server.tool('ziggs_revoke_grant', 'Revoke a context grant and its descendants (DELETE /context/grants/:id). Requires context:admin on the operator key.', {
278
+ server.tool('ziggs_revoke_grant', 'Revoke a context grant and its descendants (DELETE /context/grants/:id). You can revoke (narrow) any grant you hold — this needs no special scope. Revoking a grant you issued or whose scope you own, but do not hold, requires context:admin.', {
269
279
  grantId: z.string(),
270
280
  }, DESTRUCTIVE, async ({ grantId }) => {
271
281
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ziggs-ai/ziggs-mcp",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
4
4
  "description": "MCP server for Claude Code, Cursor, and other MCP hosts — act as your Ziggs delegate agent",
5
5
  "type": "module",
6
6
  "bin": {
@@ -36,7 +36,7 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "@modelcontextprotocol/sdk": "^1.29.0",
39
- "@ziggs-ai/api-client": "^0.1.24",
39
+ "@ziggs-ai/api-client": "^0.1.25",
40
40
  "dotenv": "^16.6.1",
41
41
  "zod": "^3.24.2"
42
42
  },
@@ -39,7 +39,7 @@ The sections below elaborate this protocol with tools, examples, and edge cases.
39
39
  ## Session start — pending decisions + inbox
40
40
 
41
41
  1. Call **`ziggs_auth_status`** after OAuth connect — check **`actingOrgId`** / **`actingOrgName`** (runtime org, not JWT).
42
- 2. To switch org without reconnecting: **`ziggs_switch_org`** with `confirm: true`, then re-check **`ziggs_auth_status`**.
42
+ 2. To switch org without reconnecting: **`ziggs_switch_org`** (`org` = an org id **or** a name, `confirm: true`), then re-check **`ziggs_auth_status`**. Don't make the human paste an `org_...` id — call **`ziggs_list_my_orgs`** to resolve a name and offer a pick-list; an ambiguous name returns the candidates.
43
43
  3. Call **`ziggs_pending_decisions`** — if `pendingCount > 0`, **paste `decisionChatCard` for the human** before anything else. Wait for explicit approve/reject; then `ziggs_respond_to_agreement`.
44
44
  3. Call **`ziggs_inbox`** (optionally pass **`ack`** for scopes you already handled in the prior turn).
45
45
  4. Read the envelope: scope news counts, `humanAttention`, and **`decisionChatCard`** when present.