@ziggs-ai/ziggs-mcp 0.1.24 → 0.1.26

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/README.md CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  MCP (stdio) server for **Claude Code**, **Cursor**, and other MCP hosts.
4
4
 
5
- **In scope:** chat, agreements, scope, context discovery/reads, artifacts.
6
- **Out of scope (by design):** agent `transfer`, hire agreements, capability tokens / bounded spend.
5
+ **In scope:** chat, agreements (service and hire, direct or published), scope, context discovery/reads, artifacts.
6
+ **Out of scope (by design):** agent `transfer`, payment grants / bounded spend.
7
7
 
8
8
  ---
9
9
 
@@ -1,17 +1,22 @@
1
1
  import type { InboxEnvelope, Task } from '@ziggs-ai/api-client';
2
- export type PendingDecisionKind = 'proposal' | 'link_request';
2
+ export type PendingDecisionKind = 'proposal' | 'link_request' | 'mcp_server_request';
3
3
  export interface PendingDecisionItem {
4
4
  kind: PendingDecisionKind;
5
+ /** Agreement id for proposals; requestId for link / MCP server requests. */
5
6
  agreementId: string;
6
7
  title: string;
7
8
  subtitle: string | null;
8
9
  proposedAt: string | null;
9
10
  proposedAtLabel: string | null;
10
11
  appUrl: string;
11
- respondApprove: string;
12
- respondReject: string;
13
- sayApprove: string;
14
- sayReject: string;
12
+ /**
13
+ * Null for mcp_server_request — connecting a server (OAuth) happens in the
14
+ * browser at /app/settings/connections; there is no MCP respond tool.
15
+ */
16
+ respondApprove: string | null;
17
+ respondReject: string | null;
18
+ sayApprove: string | null;
19
+ sayReject: string | null;
15
20
  }
16
21
  export interface ActiveWorkItem {
17
22
  taskId: string;
@@ -26,11 +31,28 @@ export interface ActiveWorkItem {
26
31
  export declare function resolveWebAppOrigin(webUrl?: string | null): string;
27
32
  export declare function agreementAppUrl(origin: string, agreementId: string): string;
28
33
  export declare function agreementsListAppUrl(origin: string): string;
34
+ /** Where the human connects MCP servers and grants tools (ZIG-686). */
35
+ export declare function connectionsSettingsAppUrl(origin: string): string;
29
36
  export declare function buildPendingDecisionItems(inbox: InboxEnvelope, webOrigin: string): PendingDecisionItem[];
37
+ /**
38
+ * ZIG-658: keep only tasks this delegate (or its principal) is expected to
39
+ * execute. The backend's GET /tasks returns every task reachable in the org,
40
+ * so the session card must not present all of them as "your work".
41
+ *
42
+ * A task is "for me" when:
43
+ * - it is explicitly assigned (`assigneeId`) to one of my ids, or
44
+ * - it has no explicit assignee and one of my ids sits on the executing side
45
+ * of the task/agreement (executor, agent, provider, providerAgent,
46
+ * proposedTo).
47
+ * Tasks with no assignee and no readable agreement parties are excluded —
48
+ * "can't tell" must not render as "yours".
49
+ */
50
+ export declare function filterTasksForDelegate(tasks: Task[], selfIds: ReadonlySet<string>): Task[];
30
51
  export declare function buildActiveWorkItems(tasks: Task[], webOrigin: string): ActiveWorkItem[];
31
52
  export declare function buildDecisionChatCard(items: PendingDecisionItem[], opts: {
32
53
  truncatedProposals?: number;
33
54
  truncatedConnectionRequests?: number;
55
+ truncatedMcpServerRequests?: number;
34
56
  agreementsListAppUrl?: string;
35
57
  }): string;
36
58
  export declare function buildWorkChatCard(work: ActiveWorkItem[], listUrl?: string): string;
@@ -38,6 +60,7 @@ export declare function buildWorkChatCard(work: ActiveWorkItem[], listUrl?: stri
38
60
  export declare function buildSessionChatCard(decisions: PendingDecisionItem[], work: ActiveWorkItem[], opts: {
39
61
  truncatedProposals?: number;
40
62
  truncatedConnectionRequests?: number;
63
+ truncatedMcpServerRequests?: number;
41
64
  agreementsListAppUrl?: string;
42
65
  }): string;
43
66
  /** Structured session payload for MCP tools (ZIG-625 + active work). */
@@ -9,6 +9,10 @@ export function agreementAppUrl(origin, agreementId) {
9
9
  export function agreementsListAppUrl(origin) {
10
10
  return `${origin}/app/agreements`;
11
11
  }
12
+ /** Where the human connects MCP servers and grants tools (ZIG-686). */
13
+ export function connectionsSettingsAppUrl(origin) {
14
+ return `${origin}/app/settings/connections`;
15
+ }
12
16
  function truncateText(text, max = TITLE_MAX) {
13
17
  const oneLine = text.replace(/\s+/g, ' ').trim();
14
18
  if (oneLine.length <= max)
@@ -75,6 +79,23 @@ function linkToItem(c, origin) {
75
79
  sayReject: `reject link ${id}`,
76
80
  };
77
81
  }
82
+ function mcpServerRequestToItem(r, origin) {
83
+ const tools = r.tools?.length ? r.tools.join(', ') : 'no tools listed';
84
+ const reason = r.reason?.trim() || null;
85
+ return {
86
+ kind: 'mcp_server_request',
87
+ agreementId: r.requestId,
88
+ title: truncateText(`Connect MCP server · ${r.serverUrl}`),
89
+ subtitle: truncateText(reason ? `${reason} — tools: ${tools}` : `Tools: ${tools}`, 160),
90
+ proposedAt: r.requestedAt,
91
+ proposedAtLabel: formatWhen(r.requestedAt),
92
+ appUrl: connectionsSettingsAppUrl(origin),
93
+ respondApprove: null,
94
+ respondReject: null,
95
+ sayApprove: null,
96
+ sayReject: null,
97
+ };
98
+ }
78
99
  export function buildPendingDecisionItems(inbox, webOrigin) {
79
100
  const items = [];
80
101
  for (const p of inbox.proposalsAwaitingMe ?? []) {
@@ -83,8 +104,37 @@ export function buildPendingDecisionItems(inbox, webOrigin) {
83
104
  for (const c of inbox.connectionRequestsAwaitingMe ?? []) {
84
105
  items.push(linkToItem(c, webOrigin));
85
106
  }
107
+ for (const r of inbox.mcpServerRequestsAwaitingMe ?? []) {
108
+ items.push(mcpServerRequestToItem(r, webOrigin));
109
+ }
86
110
  return items;
87
111
  }
112
+ /**
113
+ * ZIG-658: keep only tasks this delegate (or its principal) is expected to
114
+ * execute. The backend's GET /tasks returns every task reachable in the org,
115
+ * so the session card must not present all of them as "your work".
116
+ *
117
+ * A task is "for me" when:
118
+ * - it is explicitly assigned (`assigneeId`) to one of my ids, or
119
+ * - it has no explicit assignee and one of my ids sits on the executing side
120
+ * of the task/agreement (executor, agent, provider, providerAgent,
121
+ * proposedTo).
122
+ * Tasks with no assignee and no readable agreement parties are excluded —
123
+ * "can't tell" must not render as "yours".
124
+ */
125
+ export function filterTasksForDelegate(tasks, selfIds) {
126
+ const mine = (id) => typeof id === 'string' && id.length > 0 && selfIds.has(id);
127
+ return tasks.filter((t) => {
128
+ if (t.assigneeId)
129
+ return mine(t.assigneeId);
130
+ if (mine(t.executorId) || mine(t.agentId))
131
+ return true;
132
+ const p = t.agreement?.parties;
133
+ if (!p)
134
+ return false;
135
+ return mine(p.provider) || mine(p.providerAgent) || mine(p.proposedTo);
136
+ });
137
+ }
88
138
  export function buildActiveWorkItems(tasks, webOrigin) {
89
139
  return tasks
90
140
  .filter((t) => t.state === 'active')
@@ -105,17 +155,26 @@ export function buildActiveWorkItems(tasks, webOrigin) {
105
155
  });
106
156
  }
107
157
  function kindLabel(kind) {
108
- return kind === 'proposal' ? 'Agreement proposal' : 'Agent link request';
158
+ if (kind === 'proposal')
159
+ return 'Agreement proposal';
160
+ if (kind === 'link_request')
161
+ return 'Agent link request';
162
+ return 'MCP server request';
109
163
  }
110
164
  function kindHint(kind) {
111
- return kind === 'proposal'
112
- ? 'Someone proposed work or terms — your approval opens or rejects it.'
113
- : 'Another agent wants to link — your approval enables cross-org reach.';
165
+ if (kind === 'proposal') {
166
+ return 'Someone proposed work or terms — your approval opens or rejects it.';
167
+ }
168
+ if (kind === 'link_request') {
169
+ return 'Another agent wants to link — your approval enables cross-org reach.';
170
+ }
171
+ return 'Your agent asks you to connect an MCP server and grant it the listed tools — connect (OAuth) or reject in the browser.';
114
172
  }
115
173
  function buildDecisionSection(items, opts) {
116
174
  const truncatedProposals = opts.truncatedProposals ?? 0;
117
175
  const truncatedConnectionRequests = opts.truncatedConnectionRequests ?? 0;
118
- const truncated = truncatedProposals + truncatedConnectionRequests;
176
+ const truncatedMcpServerRequests = opts.truncatedMcpServerRequests ?? 0;
177
+ const truncated = truncatedProposals + truncatedConnectionRequests + truncatedMcpServerRequests;
119
178
  if (!items.length && truncated === 0)
120
179
  return [];
121
180
  const lines = [];
@@ -136,18 +195,27 @@ function buildDecisionSection(items, opts) {
136
195
  lines.push(`> ${item.subtitle}`);
137
196
  }
138
197
  lines.push('');
139
- lines.push(`[Review in Ziggs →](${item.appUrl})`);
140
- lines.push('');
141
- lines.push('| You say in chat | What the agent runs |');
142
- lines.push('|:----------------|:--------------------|');
143
- lines.push(`| \`${item.sayApprove}\` | \`${item.respondApprove}\` |`);
144
- lines.push(`| \`${item.sayReject}\` | \`${item.respondReject}\` |`);
198
+ if (item.sayApprove && item.respondApprove && item.sayReject && item.respondReject) {
199
+ lines.push(`[Review in Ziggs →](${item.appUrl})`);
200
+ lines.push('');
201
+ lines.push('| You say in chat | What the agent runs |');
202
+ lines.push('|:----------------|:--------------------|');
203
+ lines.push(`| \`${item.sayApprove}\` | \`${item.respondApprove}\` |`);
204
+ lines.push(`| \`${item.sayReject}\` | \`${item.respondReject}\` |`);
205
+ }
206
+ else {
207
+ // Browser-only decision (mcp_server_request): connecting a server runs
208
+ // OAuth consent — no MCP tool can approve it from chat.
209
+ lines.push(`[Connect or reject in Ziggs →](${item.appUrl})`);
210
+ lines.push('');
211
+ lines.push('_This one is decided in the browser — nothing to approve from chat._');
212
+ }
145
213
  lines.push('');
146
214
  }
147
215
  if (truncated > 0) {
148
216
  lines.push('---');
149
217
  lines.push('');
150
- lines.push(`_+${truncated} more pending (${truncatedProposals} proposal(s), ${truncatedConnectionRequests} link(s)) — not listed here._`);
218
+ lines.push(`_+${truncated} more pending (${truncatedProposals} proposal(s), ${truncatedConnectionRequests} link(s), ${truncatedMcpServerRequests} MCP server request(s)) — not listed here._`);
151
219
  lines.push('');
152
220
  }
153
221
  return lines;
@@ -188,11 +256,13 @@ function buildWorkSection(work, startIndex = 1) {
188
256
  export function buildDecisionChatCard(items, opts) {
189
257
  const truncatedProposals = opts.truncatedProposals ?? 0;
190
258
  const truncatedConnectionRequests = opts.truncatedConnectionRequests ?? 0;
191
- const truncated = truncatedProposals + truncatedConnectionRequests;
259
+ const truncatedMcpServerRequests = opts.truncatedMcpServerRequests ?? 0;
260
+ const truncated = truncatedProposals + truncatedConnectionRequests + truncatedMcpServerRequests;
192
261
  if (!items.length && truncated === 0)
193
262
  return '';
194
263
  const proposals = items.filter((i) => i.kind === 'proposal').length;
195
264
  const links = items.filter((i) => i.kind === 'link_request').length;
265
+ const mcpRequests = items.filter((i) => i.kind === 'mcp_server_request').length;
196
266
  const listedTotal = items.length + truncated;
197
267
  const lines = [
198
268
  `### 🔔 Ziggs — **${listedTotal}** ${listedTotal === 1 ? 'item needs' : 'items need'} your decision`,
@@ -201,9 +271,12 @@ export function buildDecisionChatCard(items, opts) {
201
271
  '|:--|--:|',
202
272
  `| Agreement proposals | **${proposals}** |`,
203
273
  `| Agent link requests | **${links}** |`,
274
+ ...(mcpRequests + truncatedMcpServerRequests > 0
275
+ ? [`| MCP server requests | **${mcpRequests}** |`]
276
+ : []),
204
277
  ...(truncated > 0 ? [`| _Not shown (inbox cap)_ | _+${truncated}_ |`] : []),
205
278
  '',
206
- '> **Heads-up:** Ziggs MCP is pull-only — nothing pops up in Cursor until inbox is checked. **You** approve or reject in this chat; the agent calls `ziggs_respond_to_agreement` only after you say so.',
279
+ '> **Heads-up:** Ziggs MCP is pull-only — nothing pops up in Cursor until inbox is checked. **You** approve or reject in this chat; the agent calls `ziggs_respond_to_agreement` only after you say so. MCP server requests are the exception — those are connected or rejected in the browser.',
207
280
  '',
208
281
  ...buildDecisionSection(items, opts),
209
282
  ];
@@ -232,12 +305,15 @@ export function buildWorkChatCard(work, listUrl) {
232
305
  export function buildSessionChatCard(decisions, work, opts) {
233
306
  const truncatedProposals = opts.truncatedProposals ?? 0;
234
307
  const truncatedConnectionRequests = opts.truncatedConnectionRequests ?? 0;
235
- const truncated = truncatedProposals + truncatedConnectionRequests;
308
+ const truncatedMcpServerRequests = opts.truncatedMcpServerRequests ?? 0;
309
+ const truncated = truncatedProposals + truncatedConnectionRequests + truncatedMcpServerRequests;
236
310
  const decisionListed = decisions.length + truncated;
237
311
  const workCount = work.length;
238
312
  const total = decisionListed + workCount;
239
313
  if (total === 0)
240
314
  return '';
315
+ const mcpRequestCount = decisions.filter((d) => d.kind === 'mcp_server_request').length +
316
+ truncatedMcpServerRequests;
241
317
  const lines = [
242
318
  `### 🔔 Ziggs — **${total}** ${total === 1 ? 'thing needs' : 'things need'} you`,
243
319
  '',
@@ -248,11 +324,16 @@ export function buildSessionChatCard(decisions, work, opts) {
248
324
  `| Approve / reject | **${decisionListed}** |`,
249
325
  `| — proposals | ${decisions.filter((d) => d.kind === 'proposal').length}${truncatedProposals ? ` (+${truncatedProposals} hidden)` : ''} |`,
250
326
  `| — link requests | ${decisions.filter((d) => d.kind === 'link_request').length}${truncatedConnectionRequests ? ` (+${truncatedConnectionRequests} hidden)` : ''} |`,
327
+ ...(mcpRequestCount > 0
328
+ ? [
329
+ `| — MCP server requests | ${decisions.filter((d) => d.kind === 'mcp_server_request').length}${truncatedMcpServerRequests ? ` (+${truncatedMcpServerRequests} hidden)` : ''} |`,
330
+ ]
331
+ : []),
251
332
  ]
252
333
  : []),
253
334
  ...(workCount > 0 ? [`| Active tasks (your work) | **${workCount}** |`] : []),
254
335
  '',
255
- '> **Heads-up:** MCP is pull-only — check at session start. **Decisions:** you say approve/reject. **Tasks:** say `work on <taskId>` — the agent implements and reports on Ziggs.',
336
+ '> **Heads-up:** MCP is pull-only — check at session start. **Decisions:** you say approve/reject (MCP server requests are connected in the browser instead). **Tasks:** say `work on <taskId>` — the agent implements and reports on Ziggs.',
256
337
  '',
257
338
  ];
258
339
  let sectionIndex = 1;
@@ -275,13 +356,18 @@ export function formatPendingDecisionsPayload(inbox, webOrigin, opts) {
275
356
  const activeWork = buildActiveWorkItems(opts?.activeTasks ?? [], webOrigin);
276
357
  const truncatedProposals = inbox.truncatedProposals ?? 0;
277
358
  const truncatedConnectionRequests = inbox.truncatedConnectionRequests ?? 0;
278
- const pendingCount = decisions.length + truncatedProposals + truncatedConnectionRequests;
359
+ const truncatedMcpServerRequests = inbox.truncatedMcpServerRequests ?? 0;
360
+ const pendingCount = decisions.length +
361
+ truncatedProposals +
362
+ truncatedConnectionRequests +
363
+ truncatedMcpServerRequests;
279
364
  const activeWorkCount = activeWork.length;
280
365
  const actionCount = pendingCount + activeWorkCount;
281
366
  const listUrl = agreementsListAppUrl(webOrigin);
282
367
  const cardOpts = {
283
368
  truncatedProposals,
284
369
  truncatedConnectionRequests,
370
+ truncatedMcpServerRequests,
285
371
  agreementsListAppUrl: listUrl,
286
372
  };
287
373
  const decisionChatCard = buildDecisionChatCard(decisions, cardOpts);
@@ -289,6 +375,7 @@ export function formatPendingDecisionsPayload(inbox, webOrigin, opts) {
289
375
  const sessionChatCard = buildSessionChatCard(decisions, activeWork, cardOpts);
290
376
  const proposalCount = decisions.filter((d) => d.kind === 'proposal').length;
291
377
  const linkCount = decisions.filter((d) => d.kind === 'link_request').length;
378
+ const mcpServerRequestCount = decisions.filter((d) => d.kind === 'mcp_server_request').length;
292
379
  const instruction = actionCount > 0
293
380
  ? 'Paste sessionChatCard at the top of your reply. Decisions: wait for explicit approve/reject before ziggs_respond_to_agreement. Tasks: when the human says work on <taskId>, read context and implement.'
294
381
  : 'No pending decisions or active tasks — continue with ziggs_inbox for scope news.';
@@ -302,15 +389,20 @@ export function formatPendingDecisionsPayload(inbox, webOrigin, opts) {
302
389
  summary: {
303
390
  proposals: proposalCount + truncatedProposals,
304
391
  linkRequests: linkCount + truncatedConnectionRequests,
392
+ mcpServerRequests: mcpServerRequestCount + truncatedMcpServerRequests,
305
393
  activeTasks: activeWorkCount,
306
394
  listed: decisions.length,
307
- truncated: truncatedProposals + truncatedConnectionRequests,
395
+ truncated: truncatedProposals +
396
+ truncatedConnectionRequests +
397
+ truncatedMcpServerRequests,
308
398
  },
309
399
  decisions,
310
400
  activeWork,
311
401
  truncatedProposals,
312
402
  truncatedConnectionRequests,
403
+ truncatedMcpServerRequests,
313
404
  agreementsListAppUrl: listUrl,
405
+ connectionsSettingsAppUrl: connectionsSettingsAppUrl(webOrigin),
314
406
  ...(decisionChatCard ? { decisionChatCard } : {}),
315
407
  ...(workChatCard ? { workChatCard } : {}),
316
408
  ...(sessionChatCard ? { sessionChatCard } : {}),
@@ -327,7 +419,12 @@ export function buildPendingNextActions(decisions, work = []) {
327
419
  actions.push('Wait for the human to say approve/reject (e.g. `approve agr-…`) — do not auto-respond.');
328
420
  }
329
421
  for (const d of decisions.slice(0, 4)) {
330
- actions.push(`${kindLabel(d.kind)} ${d.agreementId}: \`${d.sayApprove}\` or \`${d.sayReject}\``);
422
+ if (d.sayApprove && d.sayReject) {
423
+ actions.push(`${kindLabel(d.kind)} ${d.agreementId}: \`${d.sayApprove}\` or \`${d.sayReject}\``);
424
+ }
425
+ else {
426
+ actions.push(`${kindLabel(d.kind)} ${d.agreementId}: connect or reject in the browser → ${d.appUrl}`);
427
+ }
331
428
  }
332
429
  for (const w of work.slice(0, 4)) {
333
430
  actions.push(`Active task ${w.taskId}: human says \`${w.sayWork}\` to start implementation.`);
@@ -0,0 +1,25 @@
1
+ /**
2
+ * ZIG-667 — one error surface for every MCP tool.
3
+ *
4
+ * Raw client exceptions read like
5
+ * `ContextReadClient.read messages 403 {"error":"not authorized for this scope"}`
6
+ * — an internal class.method name, a raw HTTP status, and a raw backend body.
7
+ * LLM callers stall on stack-trace prose; they recover from errors they can
8
+ * parse. This module strips the internals, keeps a stable machine-readable
9
+ * code, and for authorization denials says what to do next.
10
+ */
11
+ export interface ToolErrorShape {
12
+ code: string;
13
+ message: string;
14
+ hint?: string;
15
+ }
16
+ /** Classify a raw client error message into a stable shape. */
17
+ export declare function classifyToolError(rawMessage: string): ToolErrorShape;
18
+ /** MCP tool error result: machine-readable code + cleaned message (+ hint). */
19
+ export declare function toolError(message: string): {
20
+ content: {
21
+ type: "text";
22
+ text: string;
23
+ }[];
24
+ isError: boolean;
25
+ };
@@ -0,0 +1,81 @@
1
+ /**
2
+ * ZIG-667 — one error surface for every MCP tool.
3
+ *
4
+ * Raw client exceptions read like
5
+ * `ContextReadClient.read messages 403 {"error":"not authorized for this scope"}`
6
+ * — an internal class.method name, a raw HTTP status, and a raw backend body.
7
+ * LLM callers stall on stack-trace prose; they recover from errors they can
8
+ * parse. This module strips the internals, keeps a stable machine-readable
9
+ * code, and for authorization denials says what to do next.
10
+ */
11
+ const CLIENT_PREFIX = /^[A-Z][A-Za-z0-9]*Client\.[A-Za-z0-9_]+\s+/;
12
+ const HTTP_STATUS = /(?:^|\s)([1-5]\d{2})(?=\s|$)/;
13
+ const SCOPE_DENIED_HINT = 'You are not authorized for this scope. To get access: ask the counterparty ' +
14
+ 'to issue you a context grant (they run ziggs_issue_grant), or request a ' +
15
+ 'bilateral link first (ziggs_request_link). Check what you can already ' +
16
+ 'reach with ziggs_discover_context / ziggs_get_scope.';
17
+ function codeForStatus(status) {
18
+ if (status === 401)
19
+ return 'NOT_AUTHENTICATED';
20
+ if (status === 403)
21
+ return 'NOT_AUTHORIZED';
22
+ if (status === 404)
23
+ return 'NOT_FOUND';
24
+ if (status === 409)
25
+ return 'CONFLICT';
26
+ if (status === 429)
27
+ return 'RATE_LIMITED';
28
+ if (status >= 500)
29
+ return 'UPSTREAM_ERROR';
30
+ if (status >= 400)
31
+ return 'BAD_REQUEST';
32
+ return 'TOOL_ERROR';
33
+ }
34
+ /** Pull a human reason out of an embedded backend JSON body, if any. */
35
+ function extractBodyReason(raw) {
36
+ const start = raw.indexOf('{');
37
+ if (start === -1)
38
+ return null;
39
+ try {
40
+ const parsed = JSON.parse(raw.slice(start));
41
+ const reason = parsed['error'] ?? parsed['message'];
42
+ return typeof reason === 'string' && reason.trim() ? reason.trim() : null;
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ }
48
+ /** Classify a raw client error message into a stable shape. */
49
+ export function classifyToolError(rawMessage) {
50
+ const cleaned = rawMessage.replace(CLIENT_PREFIX, '').trim();
51
+ const statusMatch = HTTP_STATUS.exec(cleaned);
52
+ const status = statusMatch ? Number(statusMatch[1]) : null;
53
+ const bodyReason = extractBodyReason(cleaned);
54
+ if (status === null) {
55
+ return { code: 'TOOL_ERROR', message: cleaned || rawMessage };
56
+ }
57
+ const code = codeForStatus(status);
58
+ // Prefer the backend's own reason over the transport framing.
59
+ const message = bodyReason ?? cleaned;
60
+ // Context-scope denials (the backend says "…for this scope") get the scope
61
+ // code and a recovery path. Other 403s (connection grants, party checks)
62
+ // keep the generic code — their fixes live in other domains.
63
+ if (code === 'NOT_AUTHORIZED' && /\bscope\b/i.test(message)) {
64
+ return {
65
+ code: 'NOT_AUTHORIZED_FOR_SCOPE',
66
+ message,
67
+ hint: SCOPE_DENIED_HINT,
68
+ };
69
+ }
70
+ return { code, message };
71
+ }
72
+ /** MCP tool error result: machine-readable code + cleaned message (+ hint). */
73
+ export function toolError(message) {
74
+ const shape = classifyToolError(message);
75
+ return {
76
+ content: [
77
+ { type: 'text', text: JSON.stringify({ error: shape }, null, 2) },
78
+ ],
79
+ isError: true,
80
+ };
81
+ }
package/dist/tools.js CHANGED
@@ -4,9 +4,10 @@ import { getAgreement, getMyAgreements, listMyChats, openConversation, proposeDi
4
4
  import { decodeOperatorKeyClaims } from './operatorKey.js';
5
5
  import { registerTrustTools } from './trustTools.js';
6
6
  import { formatInboxToolResult, buildReadContextReadPlan, } from './inboxToolResult.js';
7
- import { formatPendingDecisionsPayload, resolveWebAppOrigin, buildPendingNextActions, } from './pendingDecisions.js';
7
+ import { connectionsSettingsAppUrl, filterTasksForDelegate, formatPendingDecisionsPayload, resolveWebAppOrigin, buildPendingNextActions, } from './pendingDecisions.js';
8
8
  import { PROTOCOL } from './protocol/delegateProtocol.js';
9
9
  import { READ_ONLY, WRITE, DESTRUCTIVE } from './toolAnnotations.js';
10
+ import { toolError } from './toolError.js';
10
11
  // ZIG-557: the protocol sentences (loop / ack / humanAttention) are sourced
11
12
  // from the shared const so this description can't drift from SKILL / server
12
13
  // instructions / .cursorrules.
@@ -48,12 +49,6 @@ function textResult(data) {
48
49
  content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
49
50
  };
50
51
  }
51
- function toolError(message) {
52
- return {
53
- content: [{ type: 'text', text: message }],
54
- isError: true,
55
- };
56
- }
57
52
  const scopeKindSchema = z.enum(['chat', 'agreement', 'task', 'counterparty']);
58
53
  const contextReadTypeSchema = z.enum([
59
54
  'messages',
@@ -84,6 +79,8 @@ async function writeArtifactStrict(creds, input) {
84
79
  const body = await res.text().catch(() => '');
85
80
  throw new Error(`POST /artifacts ${res.status} ${body.slice(0, 200)}`);
86
81
  }
82
+ const body = (await res.json().catch(() => ({})));
83
+ return { artifactId: body.artifactId };
87
84
  }
88
85
  // ZIG-569 — defense-in-depth mirror of the backend leak-guard
89
86
  // (assertProxyResponseDoesNotLeakTokens). The backend strips the *specific*
@@ -205,6 +202,64 @@ async function listConnectionsForHolder(creds) {
205
202
  }
206
203
  return parsed?.['connections'] ?? [];
207
204
  }
205
+ /**
206
+ * ZIG-686 — agent-initiated MCP connection request: ask the principal to
207
+ * connect a remote MCP server and grant this agent the listed tools. Creates a
208
+ * pending decision the human resolves in the browser (OAuth consent or a grant
209
+ * from an existing connection) — never from chat.
210
+ */
211
+ async function createMcpConnectionRequest(creds, input) {
212
+ const url = `${getBackendUrl()}/connections/mcp/requests`;
213
+ const res = await fetch(url, {
214
+ method: 'POST',
215
+ headers: {
216
+ 'content-type': 'application/json',
217
+ Authorization: `Bearer ${creds.operatorKey}`,
218
+ 'X-Agent-Id': creds.agentId,
219
+ },
220
+ body: JSON.stringify({
221
+ serverUrl: input.serverUrl,
222
+ tools: input.tools,
223
+ reason: input.reason,
224
+ }),
225
+ });
226
+ const body = await res.text().catch(() => '');
227
+ if (!res.ok) {
228
+ throw new Error(`POST /connections/mcp/requests ${res.status} ${body.slice(0, 200)}`);
229
+ }
230
+ return body ? JSON.parse(body) : {};
231
+ }
232
+ /** ZIG-686 — the requests this agent made, each with status / connectionId / grantId. */
233
+ async function listMcpConnectionRequests(creds) {
234
+ const url = `${getBackendUrl()}/connections/mcp/requests`;
235
+ const res = await fetch(url, {
236
+ method: 'GET',
237
+ headers: {
238
+ Authorization: `Bearer ${creds.operatorKey}`,
239
+ 'X-Agent-Id': creds.agentId,
240
+ },
241
+ });
242
+ const body = await res.text().catch(() => '');
243
+ if (!res.ok) {
244
+ throw new Error(`GET /connections/mcp/requests ${res.status} ${body.slice(0, 200)}`);
245
+ }
246
+ const parsed = body ? JSON.parse(body) : null;
247
+ return parsed?.['requests'] ?? [];
248
+ }
249
+ /**
250
+ * Ids this delegate answers for: its own agent id plus its principal's user
251
+ * id (operator-key ownerId / ZIGGS_OWNER_USER_ID). Used to decide which
252
+ * active tasks count as "your work" on session cards (ZIG-658).
253
+ */
254
+ function delegateSelfIds(creds, cfg) {
255
+ const ids = new Set([creds.agentId]);
256
+ const ownerId = decodeOperatorKeyClaims(creds.operatorKey)?.ownerId;
257
+ if (ownerId)
258
+ ids.add(ownerId);
259
+ if (cfg.ZIGGS_OWNER_USER_ID)
260
+ ids.add(cfg.ZIGGS_OWNER_USER_ID);
261
+ return ids;
262
+ }
208
263
  async function loadSessionActionsPayload(creds, cfg) {
209
264
  const webOrigin = resolveWebAppOrigin(cfg.ZIGGS_WEB_URL);
210
265
  const client = new InboxClient(creds.operatorKey, creds.agentId);
@@ -212,7 +267,7 @@ async function loadSessionActionsPayload(creds, cfg) {
212
267
  let activeTasks = [];
213
268
  try {
214
269
  const listed = await listTasks({ state: 'active', limit: 20 }, creds);
215
- activeTasks = listed.tasks ?? [];
270
+ activeTasks = filterTasksForDelegate(listed.tasks ?? [], delegateSelfIds(creds, cfg));
216
271
  }
217
272
  catch {
218
273
  // Inbox is still useful when task listing fails.
@@ -287,7 +342,7 @@ export function registerZiggsTools(server, creds, cfg) {
287
342
  : 'Next: ziggs_inbox or ziggs_pending_decisions at session start.',
288
343
  });
289
344
  });
290
- server.tool('ziggs_switch_org', 'ZIG-640 — 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.', {
345
+ 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.', {
291
346
  orgId: z.string().describe('Organization id to act in'),
292
347
  confirm: z
293
348
  .literal(true)
@@ -433,7 +488,7 @@ export function registerZiggsTools(server, creds, cfg) {
433
488
  return toolError(e.message);
434
489
  }
435
490
  });
436
- server.tool('ziggs_propose_agreement', 'Propose a direct service agreement to one counterparty in a chat, as the payer-side delegate. Requires payerId (or ZIGGS_OWNER_USER_ID). price is recorded on the agreement but does not itself trigger a transfer — V1 has no real payment rail yet.', {
491
+ server.tool('ziggs_propose_agreement', 'Propose a direct agreement to one counterparty in a chat, as the payer-side delegate. engagementKind "service" (default) = one deliverable; "hire" = an ongoing engagement (same kinds ziggs_publish_offer accepts). Requires payerId (or ZIGGS_OWNER_USER_ID). price is recorded on the agreement but does not itself trigger a transfer — V1 has no real payment rail yet.', {
437
492
  proposedTo: z.string(),
438
493
  chatId: z.string(),
439
494
  description: z.string(),
@@ -442,7 +497,11 @@ export function registerZiggsTools(server, creds, cfg) {
442
497
  .optional()
443
498
  .describe('Human user id = payer (your userId)'),
444
499
  price: z.number().optional().describe('Optional; does not trigger transfer by itself'),
445
- }, WRITE, async ({ proposedTo, chatId, description, payerId, price }) => {
500
+ engagementKind: z
501
+ .enum(['hire', 'service'])
502
+ .optional()
503
+ .describe("'service' (default) = one-off deliverable; 'hire' = ongoing engagement"),
504
+ }, WRITE, async ({ proposedTo, chatId, description, payerId, price, engagementKind }) => {
446
505
  try {
447
506
  const resolvedPayer = payerId ?? cfg.ZIGGS_OWNER_USER_ID;
448
507
  if (!resolvedPayer) {
@@ -454,7 +513,7 @@ export function registerZiggsTools(server, creds, cfg) {
454
513
  description,
455
514
  payerId: resolvedPayer,
456
515
  price,
457
- engagementKind: 'service',
516
+ engagementKind: engagementKind ?? 'service',
458
517
  }, creds);
459
518
  return textResult({ agreement });
460
519
  }
@@ -562,7 +621,7 @@ export function registerZiggsTools(server, creds, cfg) {
562
621
  let activeTasks = [];
563
622
  try {
564
623
  const listed = await listTasks({ state: 'active', limit: 20 }, creds);
565
- activeTasks = listed.tasks ?? [];
624
+ activeTasks = filterTasksForDelegate(listed.tasks ?? [], delegateSelfIds(creds, cfg));
566
625
  }
567
626
  catch {
568
627
  // omit work card when tasks fail
@@ -649,7 +708,7 @@ export function registerZiggsTools(server, creds, cfg) {
649
708
  if ((chatId && agreementId) || (!chatId && !agreementId)) {
650
709
  return toolError('Pass exactly one of chatId or agreementId');
651
710
  }
652
- await writeArtifactStrict(creds, {
711
+ const { artifactId } = await writeArtifactStrict(creds, {
653
712
  text,
654
713
  visibility,
655
714
  chatId,
@@ -659,6 +718,7 @@ export function registerZiggsTools(server, creds, cfg) {
659
718
  });
660
719
  return textResult({
661
720
  ok: true,
721
+ artifactId,
662
722
  visibility,
663
723
  chatId,
664
724
  agreementId,
@@ -796,5 +856,46 @@ export function registerZiggsTools(server, creds, cfg) {
796
856
  return toolError(e.message);
797
857
  }
798
858
  });
859
+ server.tool('ziggs_request_connection', 'Ask your principal (the human) to connect a remote MCP server and grant you the listed tools. ' +
860
+ 'Creates a pending decision — the human connects (OAuth) or rejects it in the browser under Settings → Connections; there is no MCP tool to approve it, so tell them and link the returned approveUrl. ' +
861
+ 'Check the outcome with ziggs_request_connection_status: a fulfilled request carries the connectionId + grantId to use with ziggs_connection_proxy.', {
862
+ serverUrl: z.string().describe('Remote MCP server URL (https)'),
863
+ tools: z
864
+ .array(z.string())
865
+ .describe("Tool names you want — become the grant's allowed_actions caveats"),
866
+ reason: z
867
+ .string()
868
+ .optional()
869
+ .describe('Plain-language reason shown to the human deciding'),
870
+ }, WRITE, async ({ serverUrl, tools, reason }) => {
871
+ try {
872
+ const result = await createMcpConnectionRequest(creds, {
873
+ serverUrl,
874
+ tools,
875
+ reason,
876
+ });
877
+ const approveUrl = connectionsSettingsAppUrl(resolveWebAppOrigin(cfg.ZIGGS_WEB_URL));
878
+ return textResult({
879
+ ok: true,
880
+ ...result,
881
+ approveUrl,
882
+ note: 'Pending your principal\'s decision. Tell the human now (pull-only MCP has no push) and link the approveUrl — they connect or reject there. ' +
883
+ 'Poll ziggs_request_connection_status for the outcome; fulfilled requests carry connectionId + grantId for ziggs_connection_proxy.',
884
+ });
885
+ }
886
+ catch (e) {
887
+ return toolError(e.message);
888
+ }
889
+ });
890
+ server.tool('ziggs_request_connection_status', 'List the MCP server connection requests this agent made with ziggs_request_connection, each with status pending | fulfilled | rejected. ' +
891
+ 'A fulfilled request carries the connectionId + grantId to feed into ziggs_connection_proxy (it also appears in ziggs_list_my_connections).', {}, READ_ONLY, async () => {
892
+ try {
893
+ const requests = await listMcpConnectionRequests(creds);
894
+ return textResult({ requests });
895
+ }
896
+ catch (e) {
897
+ return toolError(e.message);
898
+ }
899
+ });
799
900
  registerTrustTools(server, creds, cfg);
800
901
  }
@@ -1,17 +1,12 @@
1
1
  import { z } from 'zod';
2
2
  import { AgentSearchClient, ContextGrantsClient, createAgreement, listAgreements, revokeAgreement, claimAgreement, addChatMember, } from '@ziggs-ai/api-client';
3
3
  import { READ_ONLY, WRITE, DESTRUCTIVE } from './toolAnnotations.js';
4
+ import { toolError } from './toolError.js';
4
5
  function textResult(data) {
5
6
  return {
6
7
  content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
7
8
  };
8
9
  }
9
- function toolError(message) {
10
- return {
11
- content: [{ type: 'text', text: message }],
12
- isError: true,
13
- };
14
- }
15
10
  const grantScopeKindSchema = z.enum(['chat', 'agreement', 'org']);
16
11
  const contextTemporalSchema = z.enum(['from-now', 'from-start']);
17
12
  const DEFAULT_WEB_URL = 'https://ziggsai.com';
@@ -29,9 +24,21 @@ export function registerTrustTools(server, creds, cfg) {
29
24
  if (!result.success) {
30
25
  return toolError(result.error ?? result.message ?? 'search failed');
31
26
  }
27
+ if (!result.agents?.length) {
28
+ // ZIG-664: a bare {count: 0} reads as "discovery is down" to LLM
29
+ // callers — say what was searched and how to recover instead.
30
+ return textResult({
31
+ count: 0,
32
+ agents: [],
33
+ searched: ['published store', 'your org-mates', 'your linked delegates'],
34
+ hint: 'Zero hits means no agent profile matched these terms — discovery itself is up. ' +
35
+ 'Matching is lexical against agent name/description/tags, so try shorter or different keywords. ' +
36
+ 'If you already know the agent, pass its exact agent id as the query to resolve it directly.',
37
+ });
38
+ }
32
39
  return textResult({
33
- count: result.agents?.length ?? 0,
34
- agents: result.agents ?? [],
40
+ count: result.agents.length,
41
+ agents: result.agents,
35
42
  });
36
43
  }
37
44
  catch (e) {
@@ -197,13 +204,38 @@ export function registerTrustTools(server, creds, cfg) {
197
204
  return toolError(e.message);
198
205
  }
199
206
  });
200
- server.tool('ziggs_list_links', 'List link agreements for this delegate — bilateral agent-to-agent trust relationships, NOT third-party service connections (see ziggs_list_my_connections for those) (GET /agreements?engagementKind=link). Each item exposes parties.creatorAgent (requester), parties.providerAgent (target), parties.proposedTo (target owner), proposal.status and status. Approve pending links via ziggs_respond_to_agreement.', {}, READ_ONLY, async () => {
207
+ server.tool('ziggs_list_links', 'List link agreements for this delegate — bilateral agent-to-agent trust relationships, NOT third-party service connections (see ziggs_list_my_connections for those) (GET /agreements?engagementKind=link). Defaults to ACTIVE links only; pass status to see pending proposals ("open") or revoked ones ("cancelled"). Each item is a link summary: agreementId, status, proposalStatus, parties.creatorAgent (requester), parties.providerAgent (target), parties.proposedTo (target owner). Approve pending links via ziggs_respond_to_agreement.', {
208
+ status: z
209
+ .enum(['active', 'open', 'cancelled', 'all'])
210
+ .optional()
211
+ .describe('active (default) = established links; open = pending proposals/invites awaiting approval or claim; cancelled = revoked; all = every link regardless of status'),
212
+ }, READ_ONLY, async ({ status }) => {
201
213
  try {
202
- const links = await listAgreements({ engagementKind: 'link' }, creds);
214
+ const resolvedStatus = status ?? 'active';
215
+ const links = await listAgreements({
216
+ engagementKind: 'link',
217
+ ...(resolvedStatus === 'all' ? {} : { status: resolvedStatus }),
218
+ }, creds);
219
+ // ZIG-670: link-shaped summaries, not raw agreement documents — the
220
+ // money block, approvals array, and Mongo internals are noise here.
221
+ const summaries = links.map((a) => ({
222
+ agreementId: a.agreementId,
223
+ status: a.status,
224
+ proposalStatus: a.proposalStatus,
225
+ parties: {
226
+ creatorAgent: a.parties?.creatorAgent ?? null,
227
+ providerAgent: a.parties?.providerAgent ?? null,
228
+ creator: a.parties?.creator ?? null,
229
+ proposedTo: a.parties?.proposedTo ?? null,
230
+ },
231
+ ...(a.description ? { description: a.description } : {}),
232
+ createdAt: a.createdAt,
233
+ }));
203
234
  const hasActive = links.some((a) => a.status === 'active');
204
235
  return textResult({
205
- count: links.length,
206
- links,
236
+ count: summaries.length,
237
+ status: resolvedStatus,
238
+ links: summaries,
207
239
  ...(hasActive
208
240
  ? {
209
241
  nextSteps: 'A link is reach-only. Use ziggs_open_conversation (participantId = peer agent id) and/or ziggs_issue_grant before reading context.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ziggs-ai/ziggs-mcp",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
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.19",
39
+ "@ziggs-ai/api-client": "^0.1.20",
40
40
  "dotenv": "^16.6.1",
41
41
  "zod": "^3.24.2"
42
42
  },