@ziggs-ai/ziggs-mcp 0.9.2 → 0.9.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.
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { READ_ONLY, WRITE, DESTRUCTIVE } from './toolAnnotations.js';
3
+ import { registerStrictTool } from './strictParams.js';
3
4
  import { toolError } from './toolError.js';
4
5
  /** One JSON text-content result shape for every MCP tool (was copied 3×). */
5
6
  export function textResult(data) {
@@ -58,7 +59,7 @@ export function registerCapability(server, cap, creds, opts = {}) {
58
59
  : cap.annotation === 'destructive'
59
60
  ? DESTRUCTIVE
60
61
  : WRITE;
61
- server.tool(cap.names.mcp, opts.description ?? cap.descriptions.mcp, toZodShape(cap.params), annotations, async (args) => {
62
+ registerStrictTool(server, cap.names.mcp, opts.description ?? cap.descriptions.mcp, toZodShape(cap.params), annotations, async (args) => {
62
63
  try {
63
64
  const env = { creds, webUrl: opts.webUrl, surface: 'mcp' };
64
65
  const result = await cap.handler(args, env);
package/dist/config.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import 'dotenv/config';
2
2
  import { z } from 'zod';
3
+ import { configureApiClient } from '@ziggs-ai/api-client';
3
4
  import { MINT_KEY_HELP, resolveDelegateAgentId } from './operatorKey.js';
4
5
  const envSchema = z.object({
5
6
  ZIGGS_API_URL: z.string().optional(),
@@ -51,8 +52,12 @@ export function loadConfig() {
51
52
  catch (e) {
52
53
  throw e instanceof Error ? e : new Error(String(e));
53
54
  }
54
- if (parsed.data.ZIGGS_API_URL && !process.env.HTTP_URL) {
55
- process.env.HTTP_URL = parsed.data.ZIGGS_API_URL;
55
+ // api-client reads injected config, not the environment (ZIG-652). This
56
+ // server owns its process, so it hands over whichever base URL it resolved.
57
+ const httpUrl = process.env.HTTP_URL || parsed.data.ZIGGS_API_URL;
58
+ if (httpUrl) {
59
+ process.env.HTTP_URL = httpUrl;
60
+ configureApiClient({ httpUrl });
56
61
  }
57
62
  return {
58
63
  ...parsed.data,
@@ -1,4 +1,4 @@
1
- import type { Creds } from '@ziggs-ai/api-client';
1
+ import { type Creds } from '@ziggs-ai/api-client';
2
2
  import type { ZiggsMcpConfig } from './config.js';
3
3
  export declare function parseBearerAuthorization(header: string | string[] | undefined): string;
4
4
  /** Per-connection credentials from HTTP Authorization (ZIG-431 / ZIG-466). */
@@ -1,3 +1,4 @@
1
+ import { configureApiClient } from '@ziggs-ai/api-client';
1
2
  import { resolveDelegateAgentId, MINT_KEY_HELP } from './operatorKey.js';
2
3
  export function parseBearerAuthorization(header) {
3
4
  const raw = Array.isArray(header) ? header[0] : header;
@@ -15,6 +16,9 @@ export function connectionFromBearer(bearer, httpBaseUrl, ownerUserId) {
15
16
  const resolvedAgentId = resolveDelegateAgentId(bearer, undefined);
16
17
  if (httpBaseUrl) {
17
18
  process.env.HTTP_URL = httpBaseUrl;
19
+ // Same global reach as the env write it replaces (ZIG-652): api-client
20
+ // holds one base URL per process, so the last connection wins here too.
21
+ configureApiClient({ httpUrl: httpBaseUrl });
18
22
  }
19
23
  const cfg = {
20
24
  ZIGGS_OPERATOR_KEY: bearer,
@@ -125,10 +125,11 @@ export function buildReadPlan(inbox, grantsByScope, self = { agentId: '' }) {
125
125
  break;
126
126
  case 'task-state':
127
127
  case 'agreement':
128
- // Deliberately no read call. These arrive as standing state elsewhere on
129
- // the envelope `tasksAwaitingMe` and `proposalsAwaitingMe` and both
130
- // already contribute their own plan entries above. A read here would be
131
- // a duplicate of a call the agent has been handed.
128
+ case 'quest':
129
+ // Deliberately no read call. Tasks/proposals arrive as standing state
130
+ // elsewhere on the envelope; quests (ZIG-1185) ride `questsAwaitingMe`
131
+ // and are host-triaged with a plain string compare never an LLM read
132
+ // plan entry (that would recreate the per-quest token drain).
132
133
  break;
133
134
  default:
134
135
  // Compile-time exhaustiveness: a kind added to the vocabulary no longer
@@ -0,0 +1,24 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js';
3
+ import { z, type ZodRawShape, type ZodObject } from 'zod';
4
+ /**
5
+ * ZIG-1215 — registering a tool with a plain `ZodRawShape` lets the MCP SDK
6
+ * wrap it in a default `z.object()`, which *strips* unknown keys instead of
7
+ * rejecting them. A misspelled param (`nweChat` for `newChat`) was therefore
8
+ * dropped and the call ran anyway, reporting success for the opposite of what
9
+ * the caller asked for.
10
+ *
11
+ * A human typing that into a form gets a red field; an agent got silence, and
12
+ * silence reads as confirmation — there was no feedback loop to self-correct
13
+ * against. So every tool schema is built strict, and the rejection names both
14
+ * the offending key and the params the tool actually accepts.
15
+ */
16
+ export type StrictShape<S extends ZodRawShape> = ZodObject<S, 'strict', z.ZodTypeAny>;
17
+ export declare function strictParams<S extends ZodRawShape>(toolName: string, shape: S): StrictShape<S>;
18
+ /**
19
+ * `server.tool(name, description, shape, annotations, cb)` with the shape made
20
+ * strict. The positional `tool()` overloads only accept a raw shape (a built
21
+ * ZodObject is parsed as annotations there), so the strict schema has to go
22
+ * through `registerTool`'s config object.
23
+ */
24
+ export declare function registerStrictTool<S extends ZodRawShape>(server: McpServer, name: string, description: string, shape: S, annotations: ToolAnnotations, cb: Parameters<typeof server.registerTool<never, StrictShape<S>>>[2]): void;
@@ -0,0 +1,32 @@
1
+ import { z } from 'zod';
2
+ export function strictParams(toolName, shape) {
3
+ const accepted = Object.keys(shape);
4
+ return z
5
+ .object(shape, {
6
+ // zod's default is "Unrecognized key(s) in object: 'nweChat'" — true but
7
+ // it leaves the caller to guess what the right key was. The issue carries
8
+ // `keys`, so the message can name the typo and the menu next to it.
9
+ errorMap: (issue, ctx) => {
10
+ if (issue.code === z.ZodIssueCode.unrecognized_keys) {
11
+ const offenders = issue.keys.map((k) => `"${k}"`).join(', ');
12
+ const menu = accepted.length ? accepted.join(', ') : '(none)';
13
+ return {
14
+ message: `Unknown parameter(s) ${offenders} — ${toolName} did not run. ` +
15
+ `Accepted params: ${menu}. Check for a typo and call again; ` +
16
+ `nothing was changed.`,
17
+ };
18
+ }
19
+ return { message: ctx.defaultError };
20
+ },
21
+ })
22
+ .strict();
23
+ }
24
+ /**
25
+ * `server.tool(name, description, shape, annotations, cb)` with the shape made
26
+ * strict. The positional `tool()` overloads only accept a raw shape (a built
27
+ * ZodObject is parsed as annotations there), so the strict schema has to go
28
+ * through `registerTool`'s config object.
29
+ */
30
+ export function registerStrictTool(server, name, description, shape, annotations, cb) {
31
+ server.registerTool(name, { description, inputSchema: strictParams(name, shape), annotations }, cb);
32
+ }
package/dist/tools.js CHANGED
@@ -17,6 +17,7 @@ function buildRelayCoordinatorTaskBody(opts) {
17
17
  };
18
18
  }
19
19
  import { READ_ONLY, WRITE, DESTRUCTIVE } from './toolAnnotations.js';
20
+ import { registerStrictTool } from './strictParams.js';
20
21
  import { toolError } from './toolError.js';
21
22
  import { registerCapability, registerCapabilities, textResult, } from './capabilityAdapter.js';
22
23
  // ZIG-557: the protocol sentences (loop / ack / humanAttention) are sourced
@@ -132,7 +133,7 @@ async function loadSessionActionsPayload(creds, cfg, opts) {
132
133
  // browse view and the relay-provisioning composite.
133
134
  function registerMarketplaceTools(server, creds) {
134
135
  registerCapability(server, marketplaceViewCapability, creds);
135
- server.tool('ziggs_provision_relay_workers', 'Initiator path: provision per-step worker agreements before relay kickoff. Reuses active delegations under the hire, claims standing offers when available, otherwise proposes delegations (worker must approve — never impersonated). Returns relay:v1 payload and POST /tasks body when all steps are active.', {
136
+ registerStrictTool(server, 'ziggs_provision_relay_workers', 'Initiator path: provision per-step worker agreements before relay kickoff. Reuses active delegations under the hire, claims standing offers when available, otherwise proposes delegations (worker must approve — never impersonated). Returns relay:v1 payload and POST /tasks body when all steps are active.', {
136
137
  hireAgreementId: z.string(),
137
138
  chatId: z
138
139
  .string()
@@ -188,7 +189,7 @@ function registerMarketplaceTools(server, creds) {
188
189
  }
189
190
  function registerConnectionTools(server, creds) {
190
191
  registerCapability(server, connectionProxyCapability, creds);
191
- server.tool('ziggs_connection_list', 'Discover the third-party connections (credentials like GitHub/Jira, NOT agent-to-agent Links — see ziggs_link_list for that) you hold grants for (e.g. "is GitHub connected?") without the owner sharing connectionId/grantId out of band. ' +
192
+ registerStrictTool(server, 'ziggs_connection_list', 'Discover the third-party connections (credentials like GitHub/Jira, NOT agent-to-agent Links — see ziggs_link_list for that) you hold grants for (e.g. "is GitHub connected?") without the owner sharing connectionId/grantId out of band. ' +
192
193
  'Returns, per connection: connectionId, provider, and the grant(s) you hold — each as the canonical grant shape (grantId, scope, caveats, and grant health active/expired/revoked). ' +
193
194
  'Read-only — never returns credential material. Feed the connectionId + a grantId with health "active" into ziggs_connection_proxy to actually use it.', {}, READ_ONLY, async () => {
194
195
  try {
@@ -202,7 +203,7 @@ function registerConnectionTools(server, creds) {
202
203
  registerCapability(server, requestConnectionCapability, creds);
203
204
  }
204
205
  export function registerZiggsTools(server, creds, cfg) {
205
- server.tool('ziggs_auth_status', 'Verify MCP OAuth binding: delegate agent id, owner user id, and org scope. Call after connect before inbox/chats. Includes pendingDecisions summary when approve/reject is waiting. ("Connection" refers only to third-party credential connections, see ziggs_connection_proxy.)', {}, READ_ONLY, async () => {
206
+ registerStrictTool(server, 'ziggs_auth_status', 'Verify MCP OAuth binding: delegate agent id, owner user id, and org scope. Call after connect before inbox/chats. Includes pendingDecisions summary when approve/reject is waiting. ("Connection" refers only to third-party credential connections, see ziggs_connection_proxy.)', {}, READ_ONLY, async () => {
206
207
  const claims = decodeOperatorKeyClaims(creds.operatorKey);
207
208
  const webOrigin = resolveWebAppOrigin(cfg.ZIGGS_WEB_URL);
208
209
  // ZIG-1120 #4 — delegate access and session actions are independent.
@@ -276,7 +277,7 @@ export function registerZiggsTools(server, creds, cfg) {
276
277
  : 'Next: ziggs_inbox or ziggs_pending_decisions at session start.',
277
278
  });
278
279
  });
279
- server.tool('ziggs_org_list', 'List every org you (the operator) belong to — { orgId, name, kind, role }. Unlike ziggs_grant_list (granted scopes only), this is your full membership — useful before OAuth reconnect when the human wants to pick a target org.', {}, READ_ONLY, async () => {
280
+ registerStrictTool(server, 'ziggs_org_list', 'List every org you (the operator) belong to — { orgId, name, kind, role }. Unlike ziggs_grant_list (granted scopes only), this is your full membership — useful before OAuth reconnect when the human wants to pick a target org.', {}, READ_ONLY, async () => {
280
281
  try {
281
282
  const orgs = await fetchMyOrgs(creds);
282
283
  return textResult({ count: orgs.length, orgs });
@@ -285,7 +286,7 @@ export function registerZiggsTools(server, creds, cfg) {
285
286
  return toolError(e);
286
287
  }
287
288
  });
288
- server.tool('ziggs_pending_decisions', ZIGGS_PENDING_DECISIONS_DESCRIPTION, {}, READ_ONLY, async () => {
289
+ registerStrictTool(server, 'ziggs_pending_decisions', ZIGGS_PENDING_DECISIONS_DESCRIPTION, {}, READ_ONLY, async () => {
289
290
  try {
290
291
  const payload = await loadSessionActionsPayload(creds, cfg);
291
292
  const decisions = (payload.decisions ?? []);
@@ -303,7 +304,7 @@ export function registerZiggsTools(server, creds, cfg) {
303
304
  }
304
305
  });
305
306
  if (cfg.debugTools) {
306
- server.tool('ziggs_smoke_impersonation', '[Internal/debug] Connectivity check for the operator-key impersonation path — lists agreements and snapshots the first chat. Not part of normal delegate workflow; use ziggs_agreement_list / ziggs_context_snapshot instead.', {}, READ_ONLY, async () => {
307
+ registerStrictTool(server, 'ziggs_smoke_impersonation', '[Internal/debug] Connectivity check for the operator-key impersonation path — lists agreements and snapshots the first chat. Not part of normal delegate workflow; use ziggs_agreement_list / ziggs_context_snapshot instead.', {}, READ_ONLY, async () => {
307
308
  try {
308
309
  const agreements = await getMyAgreements({}, creds);
309
310
  const chats = await listMyChats(creds);
@@ -325,7 +326,7 @@ export function registerZiggsTools(server, creds, cfg) {
325
326
  }
326
327
  });
327
328
  }
328
- server.tool('ziggs_context_snapshot', 'One-shot orientation for a chat: history, agreements (with which party is you), and the roster of agents/users — grant-fenced. Use when entering a chat you have not read yet; follow up with ziggs_context_read forward deltas from the returned latestSequence.', {
329
+ registerStrictTool(server, 'ziggs_context_snapshot', 'One-shot orientation for a chat: history, agreements (with which party is you), and the roster of agents/users — grant-fenced. Use when entering a chat you have not read yet; follow up with ziggs_context_read forward deltas from the returned latestSequence.', {
329
330
  chatId: z.string().describe('Chat id to snapshot'),
330
331
  maxMessages: z.number().optional().describe('Optional message history cap'),
331
332
  contextGrantId: z
@@ -345,7 +346,7 @@ export function registerZiggsTools(server, creds, cfg) {
345
346
  return toolError(e);
346
347
  }
347
348
  });
348
- server.tool('ziggs_agreement_list', 'List agreements you are a party to — your hires, proposals, and work (default scope "mine"). Pass scope "reachable" to list every agreement your grant can read in the org, including ones you are not a party to; the isYou flags on each row mark which party (if any) is you.', {
349
+ registerStrictTool(server, 'ziggs_agreement_list', 'List agreements you are a party to — your hires, proposals, and work (default scope "mine"). Pass scope "reachable" to list every agreement your grant can read in the org, including ones you are not a party to; the isYou flags on each row mark which party (if any) is you.', {
349
350
  scope: z
350
351
  .enum(['mine', 'reachable'])
351
352
  .optional()
@@ -366,7 +367,7 @@ export function registerZiggsTools(server, creds, cfg) {
366
367
  return toolError(e);
367
368
  }
368
369
  });
369
- server.tool('ziggs_agreement_get', 'Fetch a single agreement by id.', { agreementId: z.string() }, READ_ONLY, async ({ agreementId }) => {
370
+ registerStrictTool(server, 'ziggs_agreement_get', 'Fetch a single agreement by id.', { agreementId: z.string() }, READ_ONLY, async ({ agreementId }) => {
370
371
  try {
371
372
  const agreement = await getAgreement(agreementId, creds);
372
373
  if (!agreement)
@@ -377,7 +378,7 @@ export function registerZiggsTools(server, creds, cfg) {
377
378
  return toolError(e);
378
379
  }
379
380
  });
380
- server.tool('ziggs_chat_list', 'List chats the delegate agent is a member of (GET /chats/mine).', {}, READ_ONLY, async () => {
381
+ registerStrictTool(server, 'ziggs_chat_list', 'List chats the delegate agent is a member of (GET /chats/mine).', {}, READ_ONLY, async () => {
381
382
  try {
382
383
  const chats = await listMyChats(creds);
383
384
  return textResult({ count: chats.length, chats });
@@ -387,7 +388,7 @@ export function registerZiggsTools(server, creds, cfg) {
387
388
  }
388
389
  });
389
390
  registerCapability(server, openConversationCapability, creds);
390
- server.tool('ziggs_chat_send', ZIGGS_SEND_MESSAGE_DESCRIPTION, {
391
+ registerStrictTool(server, 'ziggs_chat_send', ZIGGS_SEND_MESSAGE_DESCRIPTION, {
391
392
  chatId: z.string(),
392
393
  receiverId: z
393
394
  .string()
@@ -423,7 +424,7 @@ export function registerZiggsTools(server, creds, cfg) {
423
424
  return toolError(e);
424
425
  }
425
426
  });
426
- server.tool('ziggs_agreement_propose', 'Propose an agreement — direct, broadcast, hand-off, or link; there are no separate publish tools. DIRECT: proposedTo = one counterparty id, chatId required. Omit providerId (or set it to proposedTo) to commission the recipient (they work, your side pays); set providerId to your own agent id to offer (you work, proposedTo pays); a third-party providerId brokers (they work, proposedTo pays) and requires that provider to have a matching active offer. BROADCAST: proposedTo "everyone" (fully public) or "org" (your active org only), chatId optional — with no providerId this publishes a QUEST (whoever claims does the work, your side pays); with providerId = your own id it publishes a STANDING OFFER (you work, the claimer pays). HAND-OFF (share an agent you hired): set parentAgreementId = that ACTIVE hire and providerId = its provider; proposedTo may be "everyone"/"org" (claimable) or a specific beneficiary id (they approve directly). The provider stays pinned — whoever claims/approves is the CUSTOMER the work is done for, never the worker, and on a priced hand-off they are also the payer (price omitted/0 = free: nobody is billed for their tasks). Handing off someone else\'s agent leaves that provider\'s approval pending — it must accept once before the hand-off can be claimed. Claiming is ziggs_agreement_claim; browsing is ziggs_marketplace_view. LINK: engagementKind "link" with proposedTo = an agent id proposes bilateral trust (no chat, no money). The server routes parties.proposedTo to that agent\'s owner human (a person decides who their delegate trusts) — the id you pass may differ from parties.proposedTo in the response; when it does, `note` explains the rewrite. Approve via ziggs_agreement_respond. ROLES: proposedTo is the CUSTOMER — the party the work is done for; the payer is only who pays, always derived server-side as the non-providing side — there is no payer input. engagementKind "service" (default) = one deliverable; "hire" = ongoing engagement. Agreements are STANDING by default (lifecycle "open": no expiry, unlimited tasks) — hire once, then keep spawning tasks under the same agreement; set expiresAt (time-bound) or maxExecutions (count-bound) only when the engagement should end on its own. price is recorded on the agreement but does not itself trigger a transfer.', {
427
+ registerStrictTool(server, 'ziggs_agreement_propose', 'Propose an agreement — direct, broadcast, hand-off, or link; there are no separate publish tools. DIRECT: proposedTo = one counterparty id, chatId required. Omit providerId (or set it to proposedTo) to commission the recipient (they work, your side pays); set providerId to your own agent id to offer (you work, proposedTo pays); a third-party providerId brokers (they work, proposedTo pays) and requires that provider to have a matching active offer. BROADCAST: proposedTo "everyone" (fully public) or "org" (your active org only), chatId optional — with no providerId this publishes a QUEST (whoever claims does the work, your side pays); with providerId = your own id it publishes a STANDING OFFER (you work, the claimer pays). HAND-OFF (share an agent you hired): set parentAgreementId = that ACTIVE hire and providerId = its provider; proposedTo may be "everyone"/"org" (claimable) or a specific beneficiary id (they approve directly). The provider stays pinned — whoever claims/approves is the CUSTOMER the work is done for, never the worker, and on a priced hand-off they are also the payer (price omitted/0 = free: nobody is billed for their tasks). Handing off someone else\'s agent leaves that provider\'s approval pending — it must accept once before the hand-off can be claimed. Claiming is ziggs_agreement_claim; browsing is ziggs_marketplace_view. LINK: engagementKind "link" with proposedTo = an agent id proposes bilateral trust (no chat, no money). The server routes parties.proposedTo to that agent\'s owner human (a person decides who their delegate trusts) — the id you pass may differ from parties.proposedTo in the response; when it does, `note` explains the rewrite. Approve via ziggs_agreement_respond. ROLES: proposedTo is the CUSTOMER — the party the work is done for; the payer is only who pays, always derived server-side as the non-providing side — there is no payer input. engagementKind "service" (default) = one deliverable; "hire" = ongoing engagement. Agreements are STANDING by default (lifecycle "open": no expiry, unlimited tasks) — hire once, then keep spawning tasks under the same agreement; set expiresAt (time-bound) or maxExecutions (count-bound) only when the engagement should end on its own. price is recorded on the agreement but does not itself trigger a transfer.', {
427
428
  proposedTo: z
428
429
  .string()
429
430
  .describe('Counterparty id for a direct proposal, or "everyone"/"org" to broadcast'),
@@ -503,7 +504,7 @@ export function registerZiggsTools(server, creds, cfg) {
503
504
  }
504
505
  });
505
506
  registerCapability(server, agreementClaimCapability, creds);
506
- server.tool('ziggs_agreement_subcontract', 'Delegate part of an engagement to another agent under an existing parent agreement (a sub-agreement; the worker must approve — never impersonated). Use when you hold an active agreement and want a third agent to do a slice of it. Requires parentAgreementId and the chat you are coordinating in. Spawn tasks for the worker under the sub-agreement once it is active.', {
507
+ registerStrictTool(server, 'ziggs_agreement_subcontract', 'Delegate part of an engagement to another agent under an existing parent agreement (a sub-agreement; the worker must approve — never impersonated). Use when you hold an active agreement and want a third agent to do a slice of it. Requires parentAgreementId and the chat you are coordinating in. Spawn tasks for the worker under the sub-agreement once it is active.', {
507
508
  parentAgreementId: z.string().describe('The active agreement you are delegating under'),
508
509
  executorId: z.string().describe('Agent doing the delegated work'),
509
510
  chatId: z.string().describe('Chat the delegation is coordinated in'),
@@ -541,7 +542,7 @@ export function registerZiggsTools(server, creds, cfg) {
541
542
  if (!cfg.coreOnly) {
542
543
  registerMarketplaceTools(server, creds);
543
544
  }
544
- server.tool('ziggs_agreement_respond', 'Approve or reject a pending DIRECT agreement proposal addressed to YOU — your own party slot (PUT /approvals/:partyId, which takes a decision only from the party itself). A proposal bound to your PRINCIPAL instead is not yours to answer and this tool refuses it: consent is deliberately withheld from delegates, so no retry and no other tool changes it — the human decides it in the Ziggs app, under Agreements. ziggs_pending_decisions marks which is which with respondableBy (agent | human), so check there before calling. Open broadcasts (quests, standing offers, link invites) have no personal approval slot — claim those with ziggs_agreement_claim instead, or ignore them to pass. ONE exception: a hand-off that pins YOU (or your agent) as provider carries your pending approval even as an open broadcast — approving it consents to serving whoever claims it (the row stays open for claims); rejecting cancels the hand-off.', {
545
+ registerStrictTool(server, 'ziggs_agreement_respond', 'Approve or reject a pending DIRECT agreement proposal addressed to YOU — your own party slot (PUT /approvals/:partyId, which takes a decision only from the party itself). A proposal bound to your PRINCIPAL instead is not yours to answer and this tool refuses it: consent is deliberately withheld from delegates, so no retry and no other tool changes it — the human decides it in the Ziggs app, under Agreements. ziggs_pending_decisions marks which is which with respondableBy (agent | human), so check there before calling. Open broadcasts (quests, standing offers, link invites) have no personal approval slot — claim those with ziggs_agreement_claim instead, or ignore them to pass. ONE exception: a hand-off that pins YOU (or your agent) as provider carries your pending approval even as an open broadcast — approving it consents to serving whoever claims it (the row stays open for claims); rejecting cancels the hand-off.', {
545
546
  agreementId: z.string(),
546
547
  action: z.enum(['approve', 'reject']),
547
548
  }, WRITE, async ({ agreementId, action }) => {
@@ -559,7 +560,7 @@ export function registerZiggsTools(server, creds, cfg) {
559
560
  return toolError(e);
560
561
  }
561
562
  });
562
- server.tool('ziggs_agreement_revoke', 'Revoke any agreement you are a party to — hire, service, quest, standing offer, or link (DELETE /agreements/:id). Either party may revoke; this ends the engagement immediately. Revoking a link ends cross-org reach to that peer; revoking an open broadcast takes it off the marketplace.', {
563
+ registerStrictTool(server, 'ziggs_agreement_revoke', 'Revoke any agreement you are a party to — hire, service, quest, standing offer, or link (DELETE /agreements/:id). Either party may revoke; this ends the engagement immediately. Revoking a link ends cross-org reach to that peer; revoking an open broadcast takes it off the marketplace.', {
563
564
  agreementId: z.string().describe('Agreement to revoke'),
564
565
  }, DESTRUCTIVE, async ({ agreementId }) => {
565
566
  try {
@@ -578,7 +579,7 @@ export function registerZiggsTools(server, creds, cfg) {
578
579
  return toolError(e);
579
580
  }
580
581
  });
581
- server.tool('ziggs_agreement_counter', 'Counter a pending proposal with revised terms instead of approving or rejecting (POST /agreements/:id/counter). Provide only the terms you want to change — price, description, expiry, or lifecycle; omitted fields keep the original proposal\'s value. The counter goes back to the counterparty as a fresh pending proposal for them to approve/reject/counter. Read the current terms first with ziggs_agreement_get.', {
582
+ registerStrictTool(server, 'ziggs_agreement_counter', 'Counter a pending proposal with revised terms instead of approving or rejecting (POST /agreements/:id/counter). Provide only the terms you want to change — price, description, expiry, or lifecycle; omitted fields keep the original proposal\'s value. The counter goes back to the counterparty as a fresh pending proposal for them to approve/reject/counter. Read the current terms first with ziggs_agreement_get.', {
582
583
  agreementId: z.string().describe('The pending agreement to counter'),
583
584
  price: z
584
585
  .number()
@@ -607,7 +608,7 @@ export function registerZiggsTools(server, creds, cfg) {
607
608
  return toolError(e);
608
609
  }
609
610
  });
610
- server.tool('ziggs_agreement_fulfill', 'END an agreement you PROVIDE — permanently (POST /agreements/:id/fulfill). Fulfilling terminates the whole relationship, not one deliverable: every grant the agreement conferred (context, connection, payment) is revoked, its shared space is torn down, and it cannot be reopened — the counterparty would have to re-hire you from scratch. Finished WORK is reported with ziggs_task_set_result, which closes the task and leaves the agreement standing for the next one. Only fulfill a count/time-bound engagement whose full scope is delivered and where nothing more is expected — never a standing hire that just finished a task. Party-gated server-side: only the providing side can fulfill.', {
611
+ registerStrictTool(server, 'ziggs_agreement_fulfill', 'END an agreement you PROVIDE — permanently (POST /agreements/:id/fulfill). Fulfilling terminates the whole relationship, not one deliverable: every grant the agreement conferred (context, connection, payment) is revoked, its shared space is torn down, and it cannot be reopened — the counterparty would have to re-hire you from scratch. Finished WORK is reported with ziggs_task_set_result, which closes the task and leaves the agreement standing for the next one. Only fulfill a count/time-bound engagement whose full scope is delivered and where nothing more is expected — never a standing hire that just finished a task. Party-gated server-side: only the providing side can fulfill.', {
611
612
  agreementId: z.string().describe('The agreement you provide, to mark fulfilled'),
612
613
  }, WRITE, async ({ agreementId }) => {
613
614
  try {
@@ -618,7 +619,7 @@ export function registerZiggsTools(server, creds, cfg) {
618
619
  return toolError(e);
619
620
  }
620
621
  });
621
- server.tool('ziggs_inbox', ZIGGS_INBOX_DESCRIPTION, {
622
+ registerStrictTool(server, 'ziggs_inbox', ZIGGS_INBOX_DESCRIPTION, {
622
623
  ack: z
623
624
  .string()
624
625
  .optional()
@@ -689,7 +690,7 @@ export function registerZiggsTools(server, creds, cfg) {
689
690
  // ---------------------------------------------------------------------------
690
691
  // Task mutation tools (ZIG-555)
691
692
  // ---------------------------------------------------------------------------
692
- server.tool('ziggs_task_create', 'Create a task under an agreement. Every task belongs to exactly one agreement (agreementId required). Pass plan to give the task its checklist in the same call — every step needs a non-blank description, since that is the label whoever is watching reads before anything closes. Leave plan off to start without one and post it later with ziggs_task_replace_plan.', {
693
+ registerStrictTool(server, 'ziggs_task_create', 'Create a task under an agreement. Every task belongs to exactly one agreement (agreementId required). Pass plan to give the task its checklist in the same call — every step needs a non-blank description, since that is the label whoever is watching reads before anything closes. Leave plan off to start without one and post it later with ziggs_task_replace_plan.', {
693
694
  agreementId: z.string().describe('Agreement this task belongs to'),
694
695
  description: z.string().describe('What the task entails'),
695
696
  parentTaskId: z.string().optional().describe('Parent task id for sub-tasks'),
@@ -744,7 +745,7 @@ export function registerZiggsTools(server, creds, cfg) {
744
745
  return toolError(e);
745
746
  }
746
747
  });
747
- server.tool('ziggs_task_set_result', 'Transition a task to a terminal state (completed / failed / cancelled) and record the result. Enforces the state machine — only active tasks can be transitioned.', {
748
+ registerStrictTool(server, 'ziggs_task_set_result', 'Transition a task to a terminal state (completed / failed / cancelled) and record the result. Enforces the state machine — only active tasks can be transitioned.', {
748
749
  taskId: z.string(),
749
750
  state: z.enum(['completed', 'failed', 'cancelled']),
750
751
  result: z
@@ -770,7 +771,7 @@ export function registerZiggsTools(server, creds, cfg) {
770
771
  return toolError(e);
771
772
  }
772
773
  });
773
- server.tool('ziggs_task_replace_plan', 'Replace the plan for a task with the full ordered step list you provide — existing steps are replaced wholesale, not appended to. Use this to post progress: resend the whole plan with completed steps marked in their descriptions.', {
774
+ registerStrictTool(server, 'ziggs_task_replace_plan', 'Replace the plan for a task with the full ordered step list you provide — existing steps are replaced wholesale, not appended to. Use this to post progress: resend the whole plan with completed steps marked in their descriptions.', {
774
775
  taskId: z.string(),
775
776
  steps: z
776
777
  .array(z.object({
@@ -788,7 +789,7 @@ export function registerZiggsTools(server, creds, cfg) {
788
789
  return toolError(e);
789
790
  }
790
791
  });
791
- server.tool('ziggs_task_list', 'List tasks reachable by this delegate agent (GET /tasks). Scope is determined by the operator key — same reach as chats and agreements. Supports optional state filter, cursor pagination, and assignee filtering.', {
792
+ registerStrictTool(server, 'ziggs_task_list', 'List tasks reachable by this delegate agent (GET /tasks). Scope is determined by the operator key — same reach as chats and agreements. Supports optional state filter, cursor pagination, and assignee filtering.', {
792
793
  state: z
793
794
  .string()
794
795
  .optional()
@@ -813,7 +814,7 @@ export function registerZiggsTools(server, creds, cfg) {
813
814
  return toolError(e);
814
815
  }
815
816
  });
816
- server.tool('ziggs_task_get', 'Fetch a single task by id (GET /tasks/:id). Use this when a human hands you a taskId directly (e.g. "work on task_…") so you can read the work-order — its description, plan, assignee, state, and result — before acting. Same operator-key scope as ziggs_task_list; pairs with ziggs_task_set_result to close the task.', { taskId: z.string() }, READ_ONLY, async ({ taskId }) => {
817
+ registerStrictTool(server, 'ziggs_task_get', 'Fetch a single task by id (GET /tasks/:id). Use this when a human hands you a taskId directly (e.g. "work on task_…") so you can read the work-order — its description, plan, assignee, state, and result — before acting. Same operator-key scope as ziggs_task_list; pairs with ziggs_task_set_result to close the task.', { taskId: z.string() }, READ_ONLY, async ({ taskId }) => {
817
818
  try {
818
819
  const task = await getTask(taskId, creds);
819
820
  if (!task)
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { ContextGrantsClient, addChatMember, contextBounds, resolveOrgScopeId, LINK_CAPABILITIES, DISCOVERY_CAPABILITIES, contextDelegateCapability, } from '@ziggs-ai/api-client';
3
3
  import { WRITE, DESTRUCTIVE } from './toolAnnotations.js';
4
+ import { registerStrictTool } from './strictParams.js';
4
5
  import { toolError } from './toolError.js';
5
6
  import { registerCapabilities, registerCapability, textResult } from './capabilityAdapter.js';
6
7
  // ZIG-1037: `artifact` is the narrowest context scope — one specific artifact,
@@ -15,7 +16,7 @@ const DEFAULT_WEB_URL = 'https://ziggsai.com';
15
16
  export function registerTrustTools(server, creds, cfg) {
16
17
  const webUrl = cfg?.ZIGGS_WEB_URL?.replace(/\/$/, '') ?? DEFAULT_WEB_URL;
17
18
  registerCapabilities(server, DISCOVERY_CAPABILITIES, creds);
18
- server.tool('ziggs_context_issue_grant', 'Issue bounded context access. ONE scope works for you as a delegate: chat, which admits the agent via POST /chats/:id/members (agent-invite → pending_approval until humans consent). Agreement, org AND artifact scope all mint a NEW root grant, which is a human-authority action — acting for a principal you are denied (AGENT_LACKS_HUMAN_AUTHORITY) on all three alike, before the scope is even read. Your paths instead: ziggs_artifact_share for an artifact YOU authored (no human authority needed), ziggs_context_delegate to hand a peer a narrower slice of a grant you already hold, or ask your human to issue it. Artifact scope is always from-start (the artifact predates any watermark you could set) and refuses from-now. Defaults: from-now, narrow scope.', {
19
+ registerStrictTool(server, 'ziggs_context_issue_grant', 'Issue bounded context access. ONE scope works for you as a delegate: chat, which admits the agent via POST /chats/:id/members (agent-invite → pending_approval until humans consent). Agreement, org AND artifact scope all mint a NEW root grant, which is a human-authority action — acting for a principal you are denied (AGENT_LACKS_HUMAN_AUTHORITY) on all three alike, before the scope is even read. Your paths instead: ziggs_artifact_share for an artifact YOU authored (no human authority needed), ziggs_context_delegate to hand a peer a narrower slice of a grant you already hold, or ask your human to issue it. Artifact scope is always from-start (the artifact predates any watermark you could set) and refuses from-now. Defaults: from-now, narrow scope.', {
19
20
  holderId: z.string().describe('Bare agent id receiving the grant'),
20
21
  scopeKind: grantScopeKindSchema,
21
22
  scopeId: z.string().describe('chatId, agreementId, orgId, or artifactId'),
@@ -95,7 +96,7 @@ export function registerTrustTools(server, creds, cfg) {
95
96
  // layer, including the linkSummary shaping the mutations now share.
96
97
  registerCapabilities(server, LINK_CAPABILITIES, creds, { webUrl });
97
98
  }
98
- server.tool('ziggs_context_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 do NOT hold (one you issued, or on a scope you own) is a human-authority action: as a delegate you are limited to grants you hold; the human/owner does the rest.', {
99
+ registerStrictTool(server, 'ziggs_context_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 do NOT hold (one you issued, or on a scope you own) is a human-authority action: as a delegate you are limited to grants you hold; the human/owner does the rest.', {
99
100
  grantId: z.string(),
100
101
  }, DESTRUCTIVE, async ({ grantId }) => {
101
102
  try {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ziggs-ai/ziggs-mcp",
3
- "version": "0.9.2",
4
- "description": "MCP server for Claude Code, Cursor, and other MCP hosts \u2014 act as your Ziggs delegate agent",
3
+ "version": "0.9.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": {
7
7
  "ziggs-mcp": "./dist/index.js"
@@ -36,7 +36,7 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "@modelcontextprotocol/sdk": "^1.29.0",
39
- "@ziggs-ai/api-client": "^0.9.1",
39
+ "@ziggs-ai/api-client": "^0.9.3",
40
40
  "dotenv": "^16.6.1",
41
41
  "zod": "^3.24.2"
42
42
  },