@tribeunal/mcp-server 1.13.0

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.
Files changed (102) hide show
  1. package/CHANGELOG.md +287 -0
  2. package/LICENSE +21 -0
  3. package/README.md +196 -0
  4. package/dist/auth/auth.d.ts +8 -0
  5. package/dist/auth/auth.d.ts.map +1 -0
  6. package/dist/auth/auth.js +14 -0
  7. package/dist/auth/auth.js.map +1 -0
  8. package/dist/client/api-client.d.ts +216 -0
  9. package/dist/client/api-client.d.ts.map +1 -0
  10. package/dist/client/api-client.js +325 -0
  11. package/dist/client/api-client.js.map +1 -0
  12. package/dist/client/from-env.d.ts +17 -0
  13. package/dist/client/from-env.d.ts.map +1 -0
  14. package/dist/client/from-env.js +25 -0
  15. package/dist/client/from-env.js.map +1 -0
  16. package/dist/core/instructions.d.ts +14 -0
  17. package/dist/core/instructions.d.ts.map +1 -0
  18. package/dist/core/instructions.js +21 -0
  19. package/dist/core/instructions.js.map +1 -0
  20. package/dist/core/stdio-register.d.ts +14 -0
  21. package/dist/core/stdio-register.d.ts.map +1 -0
  22. package/dist/core/stdio-register.js +39 -0
  23. package/dist/core/stdio-register.js.map +1 -0
  24. package/dist/core/tools.d.ts +1072 -0
  25. package/dist/core/tools.d.ts.map +1 -0
  26. package/dist/core/tools.js +1041 -0
  27. package/dist/core/tools.js.map +1 -0
  28. package/dist/index.d.ts +3 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/index.js +40 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/server.d.ts +15 -0
  33. package/dist/server.d.ts.map +1 -0
  34. package/dist/server.js +19 -0
  35. package/dist/server.js.map +1 -0
  36. package/dist/tools/activity.d.ts +98 -0
  37. package/dist/tools/activity.d.ts.map +1 -0
  38. package/dist/tools/activity.js +167 -0
  39. package/dist/tools/activity.js.map +1 -0
  40. package/dist/tools/cases.d.ts +154 -0
  41. package/dist/tools/cases.d.ts.map +1 -0
  42. package/dist/tools/cases.js +91 -0
  43. package/dist/tools/cases.js.map +1 -0
  44. package/dist/tools/comments.d.ts +29 -0
  45. package/dist/tools/comments.d.ts.map +1 -0
  46. package/dist/tools/comments.js +17 -0
  47. package/dist/tools/comments.js.map +1 -0
  48. package/dist/tools/decisions.d.ts +297 -0
  49. package/dist/tools/decisions.d.ts.map +1 -0
  50. package/dist/tools/decisions.js +102 -0
  51. package/dist/tools/decisions.js.map +1 -0
  52. package/dist/tools/jury-duty.d.ts +56 -0
  53. package/dist/tools/jury-duty.d.ts.map +1 -0
  54. package/dist/tools/jury-duty.js +39 -0
  55. package/dist/tools/jury-duty.js.map +1 -0
  56. package/dist/tools/sides.d.ts +15 -0
  57. package/dist/tools/sides.d.ts.map +1 -0
  58. package/dist/tools/sides.js +11 -0
  59. package/dist/tools/sides.js.map +1 -0
  60. package/dist/tools/trials.d.ts +78 -0
  61. package/dist/tools/trials.d.ts.map +1 -0
  62. package/dist/tools/trials.js +29 -0
  63. package/dist/tools/trials.js.map +1 -0
  64. package/dist/tools/tribes.d.ts +75 -0
  65. package/dist/tools/tribes.d.ts.map +1 -0
  66. package/dist/tools/tribes.js +47 -0
  67. package/dist/tools/tribes.js.map +1 -0
  68. package/dist/tools/users.d.ts +9 -0
  69. package/dist/tools/users.d.ts.map +1 -0
  70. package/dist/tools/users.js +6 -0
  71. package/dist/tools/users.js.map +1 -0
  72. package/dist/tools/uuid.d.ts +35 -0
  73. package/dist/tools/uuid.d.ts.map +1 -0
  74. package/dist/tools/uuid.js +77 -0
  75. package/dist/tools/uuid.js.map +1 -0
  76. package/dist/tools/votes.d.ts +45 -0
  77. package/dist/tools/votes.d.ts.map +1 -0
  78. package/dist/tools/votes.js +22 -0
  79. package/dist/tools/votes.js.map +1 -0
  80. package/dist/tools/webhooks.d.ts +26 -0
  81. package/dist/tools/webhooks.d.ts.map +1 -0
  82. package/dist/tools/webhooks.js +39 -0
  83. package/dist/tools/webhooks.js.map +1 -0
  84. package/dist/utils/format.d.ts +4 -0
  85. package/dist/utils/format.d.ts.map +1 -0
  86. package/dist/utils/format.js +24 -0
  87. package/dist/utils/format.js.map +1 -0
  88. package/llms-install.md +81 -0
  89. package/package.json +73 -0
  90. package/skills/README.md +43 -0
  91. package/skills/acting-on-verdicts/SKILL.md +103 -0
  92. package/skills/arbitrating-a-dispute/SKILL.md +107 -0
  93. package/skills/convening-a-team-jury/SKILL.md +94 -0
  94. package/skills/deciding-with-a-jury/SKILL.md +120 -0
  95. package/skills/serving-jury-duty/SKILL.md +90 -0
  96. package/skills/using-tribeunal/SKILL.md +56 -0
  97. package/skills/using-tribeunal/references/errors.md +54 -0
  98. package/skills/using-tribeunal/references/tools.md +53 -0
  99. package/skills/weighing-evidence/SKILL.md +94 -0
  100. package/skills/wiring-webhooks/SKILL.md +107 -0
  101. package/skills/wiring-webhooks/references/events.md +52 -0
  102. package/skills/wiring-webhooks/scripts/verify-signature.js +72 -0
@@ -0,0 +1,1041 @@
1
+ import { z } from 'zod';
2
+ import { TribeunalAPIError } from '../client/api-client.js';
3
+ import { UUID_PATTERN, caseWithUuidOnly } from '../tools/uuid.js';
4
+ // Case schemas
5
+ import { SearchCasesSchema, GetCaseSchema, CreateCaseSchema, CloseCaseSchema, ListEvidenceSchema, } from '../tools/cases.js';
6
+ // Voting & evidence schemas
7
+ import { CastVoteSchema, RevokeVoteSchema, GetVoteStatsSchema, RateEvidenceSchema, } from '../tools/votes.js';
8
+ // Comment & evidence-mark schemas
9
+ import { PostCommentSchema, ListCommentsSchema, MarkEvidenceSchema, } from '../tools/comments.js';
10
+ import { ListTribesSchema, GetTribeSchema, ListTribeMembersSchema, JoinTribeSchema, LeaveTribeSchema, CreateTribeSchema, InviteTribeMembersSchema, } from '../tools/tribes.js';
11
+ import { CreateWebhookSchema, ListWebhooksSchema, DeleteWebhookSchema, WEBHOOK_EVENTS, } from '../tools/webhooks.js';
12
+ import { GetUserSchema, } from '../tools/users.js';
13
+ import { JuryDutyAcceptSchema, JuryDutyRejectSchema, JuryDutyHistorySchema, JoinJurySchema, InviteJurorsSchema, } from '../tools/jury-duty.js';
14
+ import { SetSideImageSchema } from '../tools/sides.js';
15
+ // Activity feed + agent-await schemas and loops
16
+ import { GetCaseActivitySchema, AwaitCaseActivitySchema, AwaitVerdictSchema, awaitCaseActivity, awaitVerdict, awaitVerdictNotice, verdictHeadline, } from '../tools/activity.js';
17
+ /**
18
+ * The canonical list of tool definitions advertised via `tools/list`.
19
+ *
20
+ * A single "case" vocabulary: every tool maps directly to a Tribeunal API
21
+ * operation. The stdio server and the Cloudflare worker advertise the SAME
22
+ * set so behaviour is identical regardless of how the caller authenticated.
23
+ */
24
+ export const TOOL_DEFINITIONS = [
25
+ // Case tools
26
+ {
27
+ name: 'tribeunal_create_case',
28
+ title: 'Create case',
29
+ annotations: { title: 'Create case', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
30
+ description: 'Create a new case on Tribeunal for community decision-making (case = jury decides, advice = creator decides, poll = opinion gathering). Use this directly when the user wants to start, decide, settle, or put something to a vote and no specific existing case is referenced — do NOT search first. Set visibility to "private" to keep a case visible only to you, your invited jurors and admins (a private case runs an invited jury). Add allowsGuestVotes to a private case to make a link-poll instead: unlisted everywhere, but readable and votable by anyone you send the link to. A private case answers with a shareUrl — a view-only link (no voting/joining) you can send to anyone; rotate it from the case web page to revoke every old link at once.',
31
+ inputSchema: {
32
+ type: 'object',
33
+ properties: {
34
+ title: { type: 'string', minLength: 3, maxLength: 200, description: 'Case title — the question or statement to be decided' },
35
+ description: { type: 'string', minLength: 10, description: 'Context, background, and criteria for the case' },
36
+ type: { type: 'string', enum: ['case', 'advice', 'poll'], description: 'Case type — case (binding jury decision), advice (input for the creator), or poll (opinion gathering)' },
37
+ juryType: { type: 'string', enum: ['public', 'invited'], default: 'public', description: 'Who can participate — public (anyone) or invited only' },
38
+ visibility: { type: 'string', enum: ['public', 'private'], default: 'public', description: 'Case visibility — public (anyone can find and read it) or private (only you, your invited jurors and admins). A private case must use an invited jury; omit juryType and it is set to invited automatically. One exception: set allowsGuestVotes on a private case and it becomes a link-poll — still absent from every listing, search and feed, but readable and votable by anyone you send the link to — which takes a public jury instead.' },
39
+ sides: {
40
+ type: 'array',
41
+ items: {
42
+ type: 'object',
43
+ properties: {
44
+ name: { type: 'string', description: 'Option/choice name' },
45
+ description: { type: 'string', description: 'Optional description for this choice' },
46
+ image: { type: 'string', format: 'uri', description: 'Optional https image URL for this choice — fetched and re-encoded server-side (png/jpeg/webp, <= 5 MB). Shown on the choice\'s vote card.' },
47
+ },
48
+ required: ['name'],
49
+ },
50
+ minItems: 2,
51
+ maxItems: 10,
52
+ description: 'The choices/options voters pick between (2-10)',
53
+ },
54
+ caseLength: { type: 'number', minimum: 60, maximum: 2592000, default: 86400, description: 'Voting duration in seconds (min: 1 minute, max: 30 days, default: 1 day)' },
55
+ maxAiJurorPercentage: { type: 'integer', minimum: 0, maximum: 100, description: 'Maximum percentage of jurors that may be AI personas (0 = none allowed, 100 = all; default 50)' },
56
+ jurorCount: { type: 'integer', minimum: 2, maximum: 100, description: 'Number of jurors the case asks for (2-100, default 12). It gates opening only when openImmediately is false, where the case waits until this many jurors have joined. For a small invited panel, set this to the number of people you invite.' },
57
+ openImmediately: { type: 'boolean', description: 'Open the case for voting straight away (default true). Invited jurors are still invited and can view, join and vote while it is already open. Set false to hold the case in jury selection until jurorCount jurors have joined, and only then open it.' },
58
+ allowsGuestVotes: { type: 'boolean', description: 'Let visitors without a Tribeunal account vote on this case (default false). Guest votes count in full — they enter the tallies, percentages and the verdict exactly like a registered juror\'s. Requires a public jury; visibility may be either, and pairing it with visibility "private" makes a link-poll: unlisted everywhere, but votable by whoever holds the link. Guests are deduplicated per browser, so a returning visitor changes their vote rather than adding one, but someone determined can still vote again from another browser — enable it where reach matters more than strict one-person-one-vote.' },
59
+ arbitrationMode: { type: 'boolean', description: 'Bind this case to arbitration rules, for a verdict someone outside the case has to rely on (default false). You cannot vote on, join the jury of, or close early a case you created in this mode — an admin closes it, or it closes at its deadline; evidence marks freeze once it closes so the record it was decided on stops moving; and the early-vote and decisive-vote reward multipliers are switched off, so timing your vote no longer multiplies your payout. Requires minVotes of at least 2 (omit it and 3 is used) and cannot be combined with allowsGuestVotes. Use it when the case settles something with stakes — a dispute, a payout, a contract term — rather than gathering opinion.' },
60
+ decisionRequirement: { type: 'string', enum: ['any', 'simple', 'qualified', 'unanimous'], description: 'The weakest outcome this case will accept as a verdict (default "any"). "any" takes whatever the tally gives, down to a plurality. "simple" needs at least half, "qualified" at least 66%, "unanimous" every vote on one side. A case that reaches a stronger result than required still reports the stronger one. On any value other than "any", missing the requirement closes the case with a Void verdict carrying voidReason "requirement_not_met"; an "any" case that merely ties stays Undecided.' },
61
+ minVotes: { type: 'integer', minimum: 0, maximum: 100, description: 'Fewest votes this case needs before it can reach a verdict (0-100, default 0 = no minimum). Close it with fewer and it ends with a Void verdict carrying voidReason "quorum_not_met" rather than deciding on a turnout of one or two.' },
62
+ tags: { type: 'array', items: { type: 'string' }, maxItems: 4, description: 'Up to 4 tags for categorization' },
63
+ },
64
+ required: ['title', 'description', 'type', 'sides'],
65
+ },
66
+ },
67
+ {
68
+ name: 'tribeunal_search_cases',
69
+ title: 'Search cases',
70
+ annotations: { title: 'Search cases', readOnlyHint: true, openWorldHint: false },
71
+ description: 'Find existing cases on Tribeunal by query, status, type, or tags. Use only when the user wants to look up or reference an existing case — to start a new one, use tribeunal_create_case.',
72
+ inputSchema: {
73
+ type: 'object',
74
+ properties: {
75
+ query: { type: 'string', description: 'Search query for case title or description' },
76
+ status: { type: 'string', enum: ['init', 'jury_selection', 'open', 'closed', 'expired', 'suspended'], description: 'Case status filter (open = accepting votes, jury_selection = still assembling its jury)' },
77
+ type: { type: 'string', enum: ['case', 'advice', 'poll'], description: 'Case type filter' },
78
+ tags: { type: 'array', items: { type: 'string' }, description: 'Filter by tags' },
79
+ page: { type: 'number', minimum: 1, default: 1, description: 'Page number for pagination' },
80
+ limit: { type: 'number', minimum: 1, maximum: 100, default: 20, description: 'Number of results per page' },
81
+ },
82
+ },
83
+ },
84
+ {
85
+ name: 'tribeunal_get_case',
86
+ title: 'Get case',
87
+ annotations: { title: 'Get case', readOnlyHint: true, openWorldHint: false },
88
+ description: 'Get detailed information about a specific case. For a private case you own, the response includes a shareUrl: a view-only link (no voting/joining) you can send to anyone; rotate it from the case web page to revoke old links.',
89
+ inputSchema: {
90
+ type: 'object',
91
+ properties: {
92
+ id: { type: 'string', pattern: UUID_PATTERN, description: 'Case UUID (the case uuid, not the numeric id)' },
93
+ },
94
+ required: ['id'],
95
+ },
96
+ },
97
+ {
98
+ name: 'tribeunal_close_case',
99
+ title: 'Close case',
100
+ annotations: { title: 'Close case', readOnlyHint: false, destructiveHint: true, openWorldHint: false },
101
+ description: 'Close one of YOUR cases early (case owner or admin only). Works on open cases and on cases still in jury_selection (an abandoned jury ends Undecided). Pulls the voting deadline to now and triggers the verdict pipeline; the decision is determined asynchronously. Follow up with tribeunal_await_verdict to read the outcome. You cannot close an arbitration-mode case you own: only an admin can, or it closes on its own at its deadline.',
102
+ inputSchema: {
103
+ type: 'object',
104
+ properties: {
105
+ caseId: { type: 'string', pattern: UUID_PATTERN, description: 'Case UUID of the open or jury_selection case to close early (owner or admin only)' },
106
+ },
107
+ required: ['caseId'],
108
+ },
109
+ },
110
+ {
111
+ name: 'tribeunal_set_side_image',
112
+ title: 'Set side image',
113
+ annotations: { title: 'Set side image', readOnlyHint: false, destructiveHint: false, openWorldHint: true },
114
+ description: 'Set or replace the image shown on a case side\'s vote card, fetched from a public https URL. Owner-only. The image is downloaded and re-encoded server-side (png/jpeg/webp, <= 5 MB); http URLs, private/internal hosts and non-images are rejected. Use the case\'s and side\'s `uuid` fields (from tribeunal_get_case), not numeric ids.',
115
+ inputSchema: {
116
+ type: 'object',
117
+ properties: {
118
+ caseId: { type: 'string', pattern: UUID_PATTERN, description: 'Case UUID the side belongs to (the case\'s uuid field)' },
119
+ sideId: { type: 'string', pattern: UUID_PATTERN, description: 'Side UUID to set the image on (the side\'s uuid field)' },
120
+ imageUrl: { type: 'string', format: 'uri', description: 'Public https URL of the image (png/jpeg/webp, <= 5 MB)' },
121
+ },
122
+ required: ['caseId', 'sideId', 'imageUrl'],
123
+ },
124
+ },
125
+ {
126
+ name: 'tribeunal_list_evidence',
127
+ title: 'List evidence',
128
+ annotations: { title: 'List evidence', readOnlyHint: true, openWorldHint: false },
129
+ description: "Get a case's marked evidence — comments and case files the owner/jury marked as evidence (kind: comment|file)",
130
+ inputSchema: {
131
+ type: 'object',
132
+ properties: {
133
+ caseId: { type: 'string', pattern: UUID_PATTERN, description: 'Case UUID to get evidence for' },
134
+ },
135
+ required: ['caseId'],
136
+ },
137
+ },
138
+ // Voting tools
139
+ {
140
+ name: 'tribeunal_cast_vote',
141
+ title: 'Cast vote',
142
+ annotations: { title: 'Cast vote', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
143
+ description: 'Cast a vote on a case for a specific side/option, optionally with a short comment explaining your reasoning (shown in the case activity feed). You cannot vote on an arbitration-mode case you own — its verdict is meant to be relied on by others.',
144
+ inputSchema: {
145
+ type: 'object',
146
+ properties: {
147
+ caseId: { type: 'string', pattern: UUID_PATTERN, description: 'Case UUID to vote on' },
148
+ sideId: { type: 'string', pattern: UUID_PATTERN, description: 'Side UUID to vote for (a side uuid from get_case)' },
149
+ comment: { type: 'string', maxLength: 2000, description: 'Optional short rationale, stored as a vote-linked comment (markable as evidence by the owner/jury)' },
150
+ },
151
+ required: ['caseId', 'sideId'],
152
+ },
153
+ },
154
+ {
155
+ name: 'tribeunal_revoke_vote',
156
+ title: 'Revoke vote',
157
+ annotations: { title: 'Revoke vote', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
158
+ description: 'Revoke a previously cast vote (penalties may apply)',
159
+ inputSchema: {
160
+ type: 'object',
161
+ properties: {
162
+ caseId: { type: 'string', pattern: UUID_PATTERN, description: 'Case UUID' },
163
+ sideId: { type: 'string', pattern: UUID_PATTERN, description: "Side UUID whose vote to revoke (the API resolves the caller's vote by user+case)" },
164
+ },
165
+ required: ['caseId', 'sideId'],
166
+ },
167
+ },
168
+ {
169
+ name: 'tribeunal_get_vote_stats',
170
+ title: 'Get vote stats',
171
+ annotations: { title: 'Get vote stats', readOnlyHint: true, openWorldHint: false },
172
+ description: 'Get real-time voting statistics for a case',
173
+ inputSchema: {
174
+ type: 'object',
175
+ properties: {
176
+ caseId: { type: 'string', pattern: UUID_PATTERN, description: 'Case UUID to get voting statistics for' },
177
+ },
178
+ required: ['caseId'],
179
+ },
180
+ },
181
+ // Comment & evidence-mark tools
182
+ {
183
+ name: 'tribeunal_post_comment',
184
+ title: 'Post comment',
185
+ annotations: { title: 'Post comment', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
186
+ description: 'Post a comment on a case — e.g. your analysis or perspective, in your own voice. Comments appear in the case activity feed and can be marked as evidence by the case owner or jury.',
187
+ inputSchema: {
188
+ type: 'object',
189
+ properties: {
190
+ caseId: { type: 'string', pattern: UUID_PATTERN, description: 'Case UUID to comment on' },
191
+ text: { type: 'string', minLength: 1, maxLength: 5000, description: 'Comment text (1-5000 chars)' },
192
+ },
193
+ required: ['caseId', 'text'],
194
+ },
195
+ },
196
+ {
197
+ name: 'tribeunal_list_comments',
198
+ title: 'List comments',
199
+ annotations: { title: 'List comments', readOnlyHint: true, openWorldHint: false },
200
+ description: "List a case's comments — use it to avoid posting duplicates and to find comment ids for evidence marking",
201
+ inputSchema: {
202
+ type: 'object',
203
+ properties: {
204
+ caseId: { type: 'string', pattern: UUID_PATTERN, description: 'Case UUID to list comments for' },
205
+ },
206
+ required: ['caseId'],
207
+ },
208
+ },
209
+ {
210
+ name: 'tribeunal_mark_evidence',
211
+ title: 'Mark comment as evidence',
212
+ annotations: { title: 'Mark comment as evidence', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
213
+ description: "Mark another user's comment or a case file as evidence (case owner or jury members only; you cannot mark your own comment). On an arbitration-mode case the evidence record freezes once the case leaves open: marking and unmarking both answer 403 evidence_frozen, which means the record is closed, NOT that you lack permission — do not retry.",
214
+ inputSchema: {
215
+ type: 'object',
216
+ properties: {
217
+ kind: { type: 'string', enum: ['comment', 'file'], description: "What to mark: 'comment' or 'file' (case file)" },
218
+ id: { type: 'string', description: 'UUID of the comment or case file' },
219
+ },
220
+ required: ['kind', 'id'],
221
+ },
222
+ },
223
+ {
224
+ name: 'tribeunal_unmark_evidence',
225
+ title: 'Unmark evidence',
226
+ annotations: { title: 'Unmark evidence', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
227
+ description: 'Remove an evidence mark from a comment or case file (case owner or jury members only). On an arbitration-mode case the evidence record freezes once the case leaves open: marking and unmarking both answer 403 evidence_frozen, which means the record is closed, NOT that you lack permission — do not retry.',
228
+ inputSchema: {
229
+ type: 'object',
230
+ properties: {
231
+ kind: { type: 'string', enum: ['comment', 'file'], description: "What to unmark: 'comment' or 'file' (case file)" },
232
+ id: { type: 'string', description: 'UUID of the comment or case file' },
233
+ },
234
+ required: ['kind', 'id'],
235
+ },
236
+ },
237
+ {
238
+ name: 'tribeunal_rate_evidence',
239
+ title: 'Rate evidence',
240
+ annotations: { title: 'Rate evidence', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
241
+ description: 'Rate case-file evidence: 1 (up), 0 (irrelevant), or -1 (down). File-evidence ids only — comments are not ratable.',
242
+ inputSchema: {
243
+ type: 'object',
244
+ properties: {
245
+ evidenceId: { type: 'string', description: 'Case-file evidence ID to rate' },
246
+ rating: { type: 'integer', enum: [-1, 0, 1], description: 'Rating: 1 (up), 0 (irrelevant), or -1 (down)' },
247
+ sideId: { type: 'string', pattern: UUID_PATTERN, description: 'Optional side UUID this rating relates to' },
248
+ },
249
+ required: ['evidenceId', 'rating'],
250
+ },
251
+ },
252
+ // Activity feed & agent-await tools
253
+ {
254
+ name: 'tribeunal_get_case_activity',
255
+ title: 'Get case activity',
256
+ annotations: { title: 'Get case activity', readOnlyHint: true, openWorldHint: false },
257
+ description: "Read a page of a case's activity feed (votes, comments, evidence marks, jury joins, closure) as a cursorable event stream. Returns events[] ascending with a per-event cursor, a latestCursor to continue from, hasMore, and a verdict block (non-null once the case is decided). Use this for a one-shot read; to BLOCK until something happens, use tribeunal_await_case_activity or tribeunal_await_verdict.",
258
+ inputSchema: {
259
+ type: 'object',
260
+ properties: {
261
+ caseId: { type: 'string', pattern: UUID_PATTERN, description: 'Case UUID whose activity to read' },
262
+ after: { type: 'string', description: 'Opaque cursor from a previous response; omit for the tail (latest events)' },
263
+ types: { type: 'array', items: { type: 'string', enum: ['vote', 'vote_revoked', 'comment', 'evidence_marked', 'evidence_unmarked', 'jury_joined', 'trial_closed', 'trial_reopened'] }, description: 'Restrict to these event types' },
264
+ limit: { type: 'number', minimum: 1, maximum: 100, default: 50, description: 'Max events (1-100, default 50)' },
265
+ },
266
+ required: ['caseId'],
267
+ },
268
+ },
269
+ {
270
+ name: 'tribeunal_await_case_activity',
271
+ title: 'Await case activity',
272
+ annotations: { title: 'Await case activity', readOnlyHint: true, openWorldHint: false },
273
+ description: "Block until a NEW event appears on a case (long-poll, up to timeoutS seconds). Omit `after` to watch from now; on re-arm pass the previous latestCursor so nothing is missed. Returns {events, latestCursor, timedOut, waitedS, ...}. PROTOCOL: if timedOut is true, no event arrived yet — re-arm by calling again with after=latestCursor. Check caseEndsAt to know when activity is expected and STOP re-arming well past it (tell the human instead of looping forever).",
274
+ inputSchema: {
275
+ type: 'object',
276
+ properties: {
277
+ caseId: { type: 'string', pattern: UUID_PATTERN, description: 'Case UUID to watch' },
278
+ after: { type: 'string', description: 'Cursor to watch from; omit to anchor at the current tail ("watch from now")' },
279
+ types: { type: 'array', items: { type: 'string', enum: ['vote', 'vote_revoked', 'comment', 'evidence_marked', 'evidence_unmarked', 'jury_joined', 'trial_closed', 'trial_reopened'] }, description: 'Only wake for these event types' },
280
+ timeoutS: { type: 'integer', minimum: 5, maximum: 170, default: 120, description: 'Seconds to block (5-170). On timeout, re-arm with the returned latestCursor.' },
281
+ },
282
+ required: ['caseId'],
283
+ },
284
+ },
285
+ {
286
+ name: 'tribeunal_await_verdict',
287
+ title: 'Await verdict',
288
+ annotations: { title: 'Await verdict', readOnlyHint: true, openWorldHint: false },
289
+ description: "Block until a case reaches its VERDICT (terminal decision), up to timeoutS seconds — returns INSTANTLY if the case is already decided (unlike a cursor-await, which would hang forever after closure). Returns the verdict block {decided, typeName, winningSides, decisionUuid, sides, ...}. AFTER acting on the verdict, post a receipt with tribeunal_post_comment whose text CONTAINS the decisionUuid; first check tribeunal_list_comments and skip if a receipt is already there (idempotency — a reopened case can mint a second decision later).",
290
+ inputSchema: {
291
+ type: 'object',
292
+ properties: {
293
+ caseId: { type: 'string', pattern: UUID_PATTERN, description: 'Case UUID whose verdict to await' },
294
+ timeoutS: { type: 'integer', minimum: 5, maximum: 170, default: 150, description: 'Seconds to block (5-170); instant if already terminal' },
295
+ },
296
+ required: ['caseId'],
297
+ },
298
+ },
299
+ // Tribe tools
300
+ {
301
+ name: 'tribeunal_list_tribes',
302
+ title: 'List tribes',
303
+ annotations: { title: 'List tribes', readOnlyHint: true, openWorldHint: false },
304
+ description: 'List tribes on Tribeunal: every public tribe plus the private tribes you own or belong to — so this is also how you find your own tribes and resolve a tribe name to its uuid (there is no separate "my tribes" tool). Use query to search by name or description. Pass a returned uuid to tribeunal_invite_jurors as tribeId to recruit that whole tribe onto a case jury.',
305
+ inputSchema: {
306
+ type: 'object',
307
+ properties: {
308
+ query: { type: 'string', description: 'Search query for tribe name or description' },
309
+ page: { type: 'number', minimum: 1, default: 1, description: 'Page number for pagination' },
310
+ limit: { type: 'number', minimum: 1, maximum: 100, default: 20, description: 'Number of results per page' },
311
+ },
312
+ },
313
+ },
314
+ {
315
+ name: 'tribeunal_get_tribe',
316
+ title: 'Get tribe',
317
+ annotations: { title: 'Get tribe', readOnlyHint: true, openWorldHint: false },
318
+ description: 'Get a tribe: name, description, visibility, owner, tags and timestamps. The member roster is not part of this response — read it with tribeunal_list_tribe_members, which is visible to the tribe\'s members, its owner and admins only. A private tribe is only readable by its owner, its members and anyone holding a pending invitation; to everyone else it returns 404, the same answer as a tribe that does not exist. For a private tribe you own, the response includes a shareUrl: a view-only link (joining still needs an invite) you can send to anyone; rotate it from the tribe web page to revoke old links.',
319
+ inputSchema: {
320
+ type: 'object',
321
+ properties: {
322
+ id: { type: 'string', pattern: UUID_PATTERN, description: 'Tribe UUID (the tribe\'s uuid field, not its slug or numeric id)' },
323
+ },
324
+ required: ['id'],
325
+ },
326
+ },
327
+ {
328
+ name: 'tribeunal_list_tribe_members',
329
+ title: 'List tribe members',
330
+ annotations: { title: 'List tribe members', readOnlyHint: true, openWorldHint: false },
331
+ description: 'List who is in a tribe: the chieftain plus each member\'s username, role, whether they are an AI, and when they joined. Readable only by the tribe\'s members, its owner and admins — to everyone else it returns the same 404 as an unknown tribe (private) or 403 (a public tribe you are not in). Never exposes emails, credentials or share tokens. Use this to see a roster before inviting a whole tribe to a case jury (tribeunal_invite_jurors accepts a tribeId).',
332
+ inputSchema: {
333
+ type: 'object',
334
+ properties: {
335
+ tribeId: { type: 'string', pattern: UUID_PATTERN, description: 'Tribe UUID whose roster to read (the tribe\'s uuid field, not its slug or numeric id)' },
336
+ page: { type: 'number', minimum: 1, default: 1, description: 'Page number for pagination' },
337
+ limit: { type: 'number', minimum: 1, maximum: 100, default: 20, description: 'Number of members per page' },
338
+ },
339
+ required: ['tribeId'],
340
+ },
341
+ },
342
+ {
343
+ name: 'tribeunal_join_tribe',
344
+ title: 'Join tribe',
345
+ annotations: { title: 'Join tribe', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
346
+ description: 'Join a tribe. Private tribes are invitation-only: joining one without a pending invitation returns 404, the same answer as a tribe that does not exist.',
347
+ inputSchema: {
348
+ type: 'object',
349
+ properties: {
350
+ tribeId: { type: 'string', pattern: UUID_PATTERN, description: 'Tribe UUID to join (the tribe\'s uuid field, not its slug or numeric id)' },
351
+ },
352
+ required: ['tribeId'],
353
+ },
354
+ },
355
+ {
356
+ name: 'tribeunal_leave_tribe',
357
+ title: 'Leave tribe',
358
+ annotations: { title: 'Leave tribe', readOnlyHint: false, destructiveHint: true, openWorldHint: false },
359
+ description: 'Leave a tribe you are currently a member of. Leaving a PRIVATE tribe also consumes the invitation that let you in — you cannot rejoin unless the owner invites you again, so this is irreversible without their action.',
360
+ inputSchema: {
361
+ type: 'object',
362
+ properties: {
363
+ tribeId: { type: 'string', pattern: UUID_PATTERN, description: 'Tribe UUID to leave (the tribe\'s uuid field, not its slug or numeric id)' },
364
+ },
365
+ required: ['tribeId'],
366
+ },
367
+ },
368
+ {
369
+ name: 'tribeunal_create_tribe',
370
+ title: 'Create tribe',
371
+ annotations: { title: 'Create tribe', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
372
+ description: 'Create a new interest-based tribe. Set isPublic to false for a private tribe: hidden from browsing and search, joinable only by invitation. A private tribe answers with a shareUrl — a view-only link (joining still needs an invite) you can send to anyone; rotate it from the tribe web page to revoke old links.',
373
+ inputSchema: {
374
+ type: 'object',
375
+ properties: {
376
+ name: { type: 'string', minLength: 3, maxLength: 100, description: 'Tribe name' },
377
+ description: { type: 'string', minLength: 10, description: 'Tribe description' },
378
+ tags: { type: 'array', items: { type: 'string' }, description: 'Tags for categorization' },
379
+ isPublic: { type: 'boolean', default: true, description: 'Whether the tribe is publicly visible. False creates a private, invitation-only tribe.' },
380
+ },
381
+ required: ['name', 'description'],
382
+ },
383
+ },
384
+ {
385
+ name: 'tribeunal_invite_tribe_members',
386
+ title: 'Invite tribe members',
387
+ annotations: { title: 'Invite tribe members', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
388
+ description: 'Invite people into a PRIVATE tribe you own (or any, as an admin), by username or email. Each invitee is resolved independently and reported back with its own outcome — invited / already_invited / already_member / not_found / self — so an unresolvable name does not fail the batch. An invitee joins by simply opening the tribe page while logged in — the visit accepts the invitation automatically (API callers can still POST join explicitly). Public tribes are already open to everyone, so inviting into one returns 400.',
389
+ inputSchema: {
390
+ type: 'object',
391
+ properties: {
392
+ tribeId: { type: 'string', pattern: UUID_PATTERN, description: 'Tribe UUID to invite people into (the tribe\'s uuid field, not its slug or numeric id) — private tribes only' },
393
+ invitees: {
394
+ type: 'array',
395
+ items: { type: 'string' },
396
+ minItems: 1,
397
+ maxItems: 50,
398
+ description: 'Usernames or email addresses to invite (maximum 50 per call)',
399
+ },
400
+ },
401
+ required: ['tribeId', 'invitees'],
402
+ },
403
+ },
404
+ // Webhook tools
405
+ {
406
+ name: 'tribeunal_create_webhook',
407
+ title: 'Create webhook',
408
+ annotations: { title: 'Create webhook', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
409
+ description: 'Register a URL that Tribeunal will POST your cases\' events to, so you can react to them without polling. Events are owner-scoped: an endpoint receives events for cases YOU own and nothing else. The response contains a signing secret shown ONLY once — store it, then verify every delivery as hmac_sha256(secret, "{X-Tribeunal-Timestamp}.{raw body}") against the hex in X-Tribeunal-Signature (format "v1=<hex>"). Deliveries retry 3 times with backoff and are at-least-once, so deduplicate on X-Tribeunal-Delivery. The URL must be absolute https and must not resolve to a private, loopback, link-local or CGNAT address. Maximum 10 endpoints per account.',
410
+ inputSchema: {
411
+ type: 'object',
412
+ properties: {
413
+ url: { type: 'string', format: 'uri', maxLength: 2048, description: 'HTTPS URL that will receive the signed POST requests' },
414
+ events: {
415
+ type: 'array',
416
+ items: { type: 'string', enum: [...WEBHOOK_EVENTS] },
417
+ minItems: 1,
418
+ description: `Events to subscribe to. One or more of: ${WEBHOOK_EVENTS.join(', ')}`,
419
+ },
420
+ },
421
+ required: ['url', 'events'],
422
+ },
423
+ },
424
+ {
425
+ name: 'tribeunal_list_webhooks',
426
+ title: 'List webhooks',
427
+ annotations: { title: 'List webhooks', readOnlyHint: true, openWorldHint: false },
428
+ description: 'List your registered webhook endpoints with their subscribed events, whether each is active, and delivery health (last status code, consecutive failure count, last successful delivery). Never returns signing secrets — those are shown only when an endpoint is created or its secret is rotated. Use this to find an endpoint\'s uuid before deleting it.',
429
+ inputSchema: { type: 'object', properties: {} },
430
+ },
431
+ {
432
+ name: 'tribeunal_delete_webhook',
433
+ title: 'Delete webhook',
434
+ annotations: { title: 'Delete webhook', readOnlyHint: false, destructiveHint: true, openWorldHint: false },
435
+ description: 'Permanently delete one of your webhook endpoints. Deliveries stop immediately and the signing secret is destroyed — re-registering the same URL issues a NEW secret, so any receiver still using the old one will fail verification. An endpoint you do not own returns 404, the same answer as one that does not exist.',
436
+ inputSchema: {
437
+ type: 'object',
438
+ properties: {
439
+ webhookId: { type: 'string', pattern: UUID_PATTERN, description: 'Webhook endpoint UUID to delete' },
440
+ },
441
+ required: ['webhookId'],
442
+ },
443
+ },
444
+ // User tools
445
+ {
446
+ name: 'tribeunal_get_user',
447
+ title: 'Get user',
448
+ annotations: { title: 'Get user', readOnlyHint: true, openWorldHint: false },
449
+ description: 'Get public profile information for a specific user',
450
+ inputSchema: {
451
+ type: 'object',
452
+ properties: {
453
+ id: { type: 'string', description: 'User ID or username' },
454
+ },
455
+ required: ['id'],
456
+ },
457
+ },
458
+ {
459
+ name: 'tribeunal_get_current_user',
460
+ title: 'Get current user',
461
+ annotations: { title: 'Get current user', readOnlyHint: true, openWorldHint: false },
462
+ description: 'Get profile information for the currently authenticated user',
463
+ inputSchema: {
464
+ type: 'object',
465
+ properties: {},
466
+ },
467
+ },
468
+ // Jury Duty tools
469
+ {
470
+ name: 'tribeunal_jury_duty_status',
471
+ title: 'Jury duty status',
472
+ annotations: { title: 'Jury duty status', readOnlyHint: true, openWorldHint: false },
473
+ description: 'Get current jury duty request status, queue position, and whether user has an active search or assignment',
474
+ inputSchema: {
475
+ type: 'object',
476
+ properties: {},
477
+ },
478
+ },
479
+ {
480
+ name: 'tribeunal_jury_duty_allowance',
481
+ title: 'Jury duty allowance',
482
+ annotations: { title: 'Jury duty allowance', readOnlyHint: true, openWorldHint: false },
483
+ description: 'Get daily jury duty allowance info — how many requests used/remaining today, active jury count vs limit, and reset time',
484
+ inputSchema: {
485
+ type: 'object',
486
+ properties: {},
487
+ },
488
+ },
489
+ {
490
+ name: 'tribeunal_jury_duty_dashboard',
491
+ title: 'Jury duty dashboard',
492
+ annotations: { title: 'Jury duty dashboard', readOnlyHint: true, openWorldHint: false },
493
+ description: 'Get jury duty dashboard with current case assignments (cases to vote on), allowance info, and active request status. Best tool for finding cases assigned to you.',
494
+ inputSchema: {
495
+ type: 'object',
496
+ properties: {},
497
+ },
498
+ },
499
+ {
500
+ name: 'tribeunal_jury_duty_start',
501
+ title: 'Start jury duty session',
502
+ annotations: { title: 'Start jury duty session', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
503
+ description: 'Start a jury duty search — join the matchmaking queue to be assigned to a case needing jurors. Consumes 1 daily allowance.',
504
+ inputSchema: {
505
+ type: 'object',
506
+ properties: {},
507
+ },
508
+ },
509
+ {
510
+ name: 'tribeunal_jury_duty_cancel',
511
+ title: 'Cancel jury duty session',
512
+ annotations: { title: 'Cancel jury duty session', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
513
+ description: 'Cancel an active jury duty search request. Refunds daily allowance if cancelled on the same day.',
514
+ inputSchema: {
515
+ type: 'object',
516
+ properties: {},
517
+ },
518
+ },
519
+ {
520
+ name: 'tribeunal_jury_duty_accept',
521
+ title: 'Accept jury invitation',
522
+ annotations: { title: 'Accept jury invitation', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
523
+ description: 'Accept a jury duty assignment to serve on a specific case',
524
+ inputSchema: {
525
+ type: 'object',
526
+ properties: {
527
+ memberId: { type: 'string', description: 'Member ID from the jury assignment' },
528
+ },
529
+ required: ['memberId'],
530
+ },
531
+ },
532
+ {
533
+ name: 'tribeunal_jury_duty_reject',
534
+ title: 'Decline jury invitation',
535
+ annotations: { title: 'Decline jury invitation', readOnlyHint: false, destructiveHint: true, openWorldHint: false },
536
+ description: 'Reject a jury duty assignment and return to the queue for a different case',
537
+ inputSchema: {
538
+ type: 'object',
539
+ properties: {
540
+ memberId: { type: 'string', description: 'Member ID from the jury assignment' },
541
+ },
542
+ required: ['memberId'],
543
+ },
544
+ },
545
+ {
546
+ name: 'tribeunal_jury_duty_history',
547
+ title: 'Jury duty history',
548
+ annotations: { title: 'Jury duty history', readOnlyHint: true, openWorldHint: false },
549
+ description: 'Get jury duty allowance usage history for the past N days (default 7, max 30)',
550
+ inputSchema: {
551
+ type: 'object',
552
+ properties: {
553
+ days: { type: 'number', minimum: 1, maximum: 30, default: 7, description: 'Number of days of history to retrieve' },
554
+ },
555
+ },
556
+ },
557
+ {
558
+ name: 'tribeunal_join_jury',
559
+ title: 'Join a case jury',
560
+ annotations: { title: 'Join a case jury', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
561
+ description: "Seat yourself on a case's jury. Use when you hold an invitation to an invited-jury case, or a wait-mode case needs jurors; public juries need no seat — vote directly. The server does not check the invite list — never join a jury you were not invited to.",
562
+ inputSchema: {
563
+ type: 'object',
564
+ properties: {
565
+ caseId: { type: 'string', pattern: UUID_PATTERN, description: 'Case UUID of the jury to join' },
566
+ },
567
+ required: ['caseId'],
568
+ },
569
+ },
570
+ {
571
+ name: 'tribeunal_invite_jurors',
572
+ title: 'Invite jurors',
573
+ annotations: { title: 'Invite jurors', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
574
+ description: 'Invite users to the jury of a case you own (owner or admin only). Works on any case regardless of jury type — an invitation is recruitment, not restriction: it notifies the invitee, and merely opening the case page while logged in seats them as a normal juror (no separate accept step); it never restricts the open participation a public case already grants everyone. Provide `invitees` (usernames or emails) and/or a `tribeId` to invite an entire tribe (every current member plus the chieftain) — at least one is required. You must be a member, owner or admin of any tribe you name. Each invitee is processed independently — the response reports invited / duplicate / not_found per entry. The response also echoes the case url and, for a private case, its view-only shareUrl — when telling people about a private case, give them the shareUrl (the bare url 404s anyone without access).',
575
+ inputSchema: {
576
+ type: 'object',
577
+ properties: {
578
+ caseId: { type: 'string', pattern: UUID_PATTERN, description: 'Case UUID (owner or admin only)' },
579
+ invitees: { type: 'array', items: { type: 'string', minLength: 1 }, minItems: 1, maxItems: 50,
580
+ description: 'Usernames or email addresses to invite (1-50). Optional if tribeId is given.' },
581
+ tribeId: { type: 'string', pattern: UUID_PATTERN, description: 'Optional tribe UUID: invite every current member plus the chieftain. You must be a member, owner or admin of the tribe.' },
582
+ },
583
+ required: ['caseId'],
584
+ },
585
+ },
586
+ ];
587
+ /**
588
+ * Dispatch a single tool call against an injected API client.
589
+ *
590
+ * This is the transport-agnostic heart of the server: the stdio `Server` and
591
+ * the Cloudflare `McpAgent` both funnel calls here so the tools behave
592
+ * identically regardless of how the caller authenticated.
593
+ */
594
+ export async function dispatchToolCall(apiClient, toolName, args, ctx = {}) {
595
+ const params = (args ?? {});
596
+ try {
597
+ switch (toolName) {
598
+ // Case tools
599
+ case 'tribeunal_create_case': {
600
+ // A private case must run an invited jury, so an omitted juryType is coerced here
601
+ // BEFORE zod's .default('public') would force a rejecting public/private conflict.
602
+ // A private case that allows anonymous voting is the exception — it is a link-poll
603
+ // and needs the public jury its link holders vote on, so leave that one alone.
604
+ if (params.visibility === 'private' && params.juryType === undefined) {
605
+ params.juryType = params.allowsGuestVotes === true ? 'public' : 'invited';
606
+ }
607
+ const p = CreateCaseSchema.parse(params);
608
+ // UUID-only outward contract: drop the numeric `id` so the agent reuses
609
+ // the `uuid` on follow-up calls (a numeric id would 500 backend-side).
610
+ const createdCase = caseWithUuidOnly(await apiClient.createCase(p));
611
+ // Never fabricate a link: the backend always sends `url`, and a made-up
612
+ // fallback would point at the wrong host.
613
+ const url = createdCase.url;
614
+ // A locked-private case's bare url 404s everyone but the owner; its shareUrl
615
+ // is the only link safe to hand out, so it leads and the bare url is labeled.
616
+ // A link-poll (private + guest votes) is the exception: link holders view AND
617
+ // vote via the bare url, so that stays the shareable link, as for public cases.
618
+ const lockedPrivate = createdCase.visibility === 'private' && createdCase.allowsGuestVotes !== true;
619
+ const lines = ['Case created successfully!', '', `UUID: ${createdCase.uuid}`];
620
+ if (lockedPrivate && createdCase.shareUrl) {
621
+ lines.push('', `You can view and share the case at: ${createdCase.shareUrl}`);
622
+ if (url) {
623
+ lines.push('', `Owner-only URL (requires your login; 404s anyone else): ${url}`);
624
+ }
625
+ }
626
+ else if (lockedPrivate) {
627
+ if (url) {
628
+ lines.push('', `Owner-only URL (requires your login; 404s anyone else): ${url}`);
629
+ }
630
+ lines.push('', 'No share link came back for this private case — do not hand out the URL above; fetch the shareUrl with tribeunal_get_case before sharing.');
631
+ }
632
+ else if (url) {
633
+ lines.push(`URL: ${url}`, '', `You can view and share the case at: ${url}`);
634
+ }
635
+ lines.push('', 'Full response:', JSON.stringify(createdCase, null, 2));
636
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
637
+ }
638
+ case 'tribeunal_search_cases': {
639
+ const p = SearchCasesSchema.parse(params);
640
+ const results = caseWithUuidOnly(await apiClient.searchCases(p));
641
+ return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
642
+ }
643
+ case 'tribeunal_get_case': {
644
+ const p = GetCaseSchema.parse(params);
645
+ const found = caseWithUuidOnly(await apiClient.getCase(p.id));
646
+ return { content: [{ type: 'text', text: JSON.stringify(found, null, 2) }] };
647
+ }
648
+ case 'tribeunal_close_case': {
649
+ const p = CloseCaseSchema.parse(params);
650
+ const result = await apiClient.closeCase(p.caseId);
651
+ return {
652
+ content: [
653
+ {
654
+ type: 'text',
655
+ text: `Case closed — the verdict is being determined.\n${JSON.stringify(result, null, 2)}`,
656
+ },
657
+ ],
658
+ };
659
+ }
660
+ case 'tribeunal_set_side_image': {
661
+ const p = SetSideImageSchema.parse(params);
662
+ // Confirm the side belongs to the named case so the caller gets a clear message
663
+ // instead of a bare 404 when they mix up ids.
664
+ const parentCase = await apiClient.getCase(p.caseId);
665
+ const sides = Array.isArray(parentCase?.sides) ? parentCase.sides : [];
666
+ const match = sides.find((s) => s?.uuid === p.sideId);
667
+ if (!match) {
668
+ return { content: [{ type: 'text', text: `Side ${p.sideId} is not part of case ${p.caseId}. Use tribeunal_get_case to find the correct side uuid.` }] };
669
+ }
670
+ const updated = caseWithUuidOnly(await apiClient.setSideImage(p.sideId, p.imageUrl));
671
+ return { content: [{ type: 'text', text: `Side image set successfully.\n\n${JSON.stringify(updated, null, 2)}` }] };
672
+ }
673
+ case 'tribeunal_list_evidence': {
674
+ const p = ListEvidenceSchema.parse(params);
675
+ const evidence = await apiClient.getCaseEvidence(p.caseId);
676
+ return { content: [{ type: 'text', text: JSON.stringify(evidence, null, 2) }] };
677
+ }
678
+ // Vote tools
679
+ case 'tribeunal_cast_vote': {
680
+ const p = CastVoteSchema.parse(params);
681
+ const result = await apiClient.castVote(p.caseId, p.sideId, p.comment);
682
+ return {
683
+ content: [
684
+ { type: 'text', text: `Vote cast successfully!\n${JSON.stringify(result, null, 2)}` },
685
+ ],
686
+ };
687
+ }
688
+ case 'tribeunal_revoke_vote': {
689
+ const p = RevokeVoteSchema.parse(params);
690
+ const result = await apiClient.revokeVote(p.caseId, p.sideId);
691
+ return {
692
+ content: [
693
+ {
694
+ type: 'text',
695
+ text: `Vote revoked successfully. Note: Penalties may apply.\n${JSON.stringify(result, null, 2)}`,
696
+ },
697
+ ],
698
+ };
699
+ }
700
+ case 'tribeunal_get_vote_stats': {
701
+ const p = GetVoteStatsSchema.parse(params);
702
+ const stats = await apiClient.getVoteStats(p.caseId);
703
+ return { content: [{ type: 'text', text: JSON.stringify(stats, null, 2) }] };
704
+ }
705
+ // Comment & evidence-mark tools
706
+ case 'tribeunal_post_comment': {
707
+ const p = PostCommentSchema.parse(params);
708
+ const comment = await apiClient.postComment(p.caseId, p.text);
709
+ return {
710
+ content: [
711
+ { type: 'text', text: `Comment posted successfully!\n${JSON.stringify(comment, null, 2)}` },
712
+ ],
713
+ };
714
+ }
715
+ case 'tribeunal_list_comments': {
716
+ const p = ListCommentsSchema.parse(params);
717
+ const comments = await apiClient.listComments(p.caseId);
718
+ return { content: [{ type: 'text', text: JSON.stringify(comments, null, 2) }] };
719
+ }
720
+ // Activity feed & agent-await tools
721
+ case 'tribeunal_get_case_activity': {
722
+ const p = GetCaseActivitySchema.parse(params);
723
+ const page = await apiClient.getCaseActivity(p.caseId, { after: p.after, types: p.types, limit: p.limit });
724
+ return { content: [{ type: 'text', text: JSON.stringify(page, null, 2) }] };
725
+ }
726
+ case 'tribeunal_await_case_activity': {
727
+ const p = AwaitCaseActivitySchema.parse(params);
728
+ const result = await awaitCaseActivity(apiClient, p, ctx);
729
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
730
+ }
731
+ case 'tribeunal_await_verdict': {
732
+ const p = AwaitVerdictSchema.parse(params);
733
+ const result = await awaitVerdict(apiClient, p, ctx);
734
+ const headline = verdictHeadline(result);
735
+ const notice = awaitVerdictNotice(result);
736
+ const body = JSON.stringify(result, null, 2);
737
+ const text = [headline, notice, body].filter(Boolean).join('\n\n');
738
+ return { content: [{ type: 'text', text }] };
739
+ }
740
+ case 'tribeunal_mark_evidence': {
741
+ const p = MarkEvidenceSchema.parse(params);
742
+ const result = await apiClient.markEvidence(p.kind, p.id);
743
+ return {
744
+ content: [
745
+ { type: 'text', text: `Marked as evidence!\n${JSON.stringify(result, null, 2)}` },
746
+ ],
747
+ };
748
+ }
749
+ case 'tribeunal_unmark_evidence': {
750
+ const p = MarkEvidenceSchema.parse(params);
751
+ const result = await apiClient.unmarkEvidence(p.kind, p.id);
752
+ return {
753
+ content: [
754
+ { type: 'text', text: `Evidence mark removed.\n${JSON.stringify(result, null, 2)}` },
755
+ ],
756
+ };
757
+ }
758
+ case 'tribeunal_rate_evidence': {
759
+ const p = RateEvidenceSchema.parse(params);
760
+ const result = await apiClient.rateEvidence(p.evidenceId, p.rating, p.sideId);
761
+ return {
762
+ content: [
763
+ { type: 'text', text: `Evidence rated successfully!\n${JSON.stringify(result, null, 2)}` },
764
+ ],
765
+ };
766
+ }
767
+ // Tribe tools
768
+ case 'tribeunal_list_tribes': {
769
+ const p = ListTribesSchema.parse(params);
770
+ const tribes = await apiClient.listTribes(p);
771
+ return { content: [{ type: 'text', text: JSON.stringify(tribes, null, 2) }] };
772
+ }
773
+ case 'tribeunal_get_tribe': {
774
+ const p = GetTribeSchema.parse(params);
775
+ const tribe = await apiClient.getTribe(p.id);
776
+ return { content: [{ type: 'text', text: JSON.stringify(tribe, null, 2) }] };
777
+ }
778
+ case 'tribeunal_list_tribe_members': {
779
+ const p = ListTribeMembersSchema.parse(params);
780
+ const roster = await apiClient.listTribeMembers(p.tribeId, { page: p.page, limit: p.limit });
781
+ const lines = [];
782
+ const chief = roster.chieftain;
783
+ if (chief?.username) {
784
+ lines.push(`Chieftain: ${chief.username}${chief.isAi ? ' (AI)' : ''}`);
785
+ }
786
+ const members = roster.members ?? [];
787
+ for (const m of members) {
788
+ lines.push(`- ${m.username ?? '(unknown user)'}${m.isAi ? ' (AI)' : ''} — role ${m.role ?? '?'}, joined ${m.joinedAt ?? '?'}`);
789
+ }
790
+ if (members.length === 0) {
791
+ lines.push('(no members have joined yet)');
792
+ }
793
+ const total = roster.total ?? members.length;
794
+ const page = roster.page ?? p.page;
795
+ lines.push(`Total members: ${total} (page ${page})`);
796
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
797
+ }
798
+ case 'tribeunal_join_tribe': {
799
+ const p = JoinTribeSchema.parse(params);
800
+ const result = await apiClient.joinTribe(p.tribeId);
801
+ return {
802
+ content: [
803
+ { type: 'text', text: `Successfully joined tribe!\n${JSON.stringify(result, null, 2)}` },
804
+ ],
805
+ };
806
+ }
807
+ case 'tribeunal_leave_tribe': {
808
+ const p = LeaveTribeSchema.parse(params);
809
+ const result = await apiClient.leaveTribe(p.tribeId);
810
+ return {
811
+ content: [
812
+ { type: 'text', text: `Successfully left tribe.\n${JSON.stringify(result, null, 2)}` },
813
+ ],
814
+ };
815
+ }
816
+ case 'tribeunal_create_tribe': {
817
+ const p = CreateTribeSchema.parse(params);
818
+ const tribe = await apiClient.createTribe({
819
+ name: p.name,
820
+ description: p.description,
821
+ tags: p.tags,
822
+ isPublic: p.isPublic,
823
+ });
824
+ // A private tribe answers with a shareUrl: a view-only link that opens the tribe
825
+ // for whoever holds it (the bare page 404s a logged-out visitor). A public tribe
826
+ // has none, so the line is only added when present.
827
+ const shareLine = tribe.shareUrl ? `\nShare link (view-only): ${tribe.shareUrl}` : '';
828
+ return {
829
+ content: [
830
+ {
831
+ type: 'text',
832
+ text: `Tribe created successfully!${shareLine}\n${JSON.stringify(tribe, null, 2)}`,
833
+ },
834
+ ],
835
+ };
836
+ }
837
+ // Webhook tools
838
+ case 'tribeunal_create_webhook': {
839
+ const p = CreateWebhookSchema.parse(params);
840
+ const endpoint = await apiClient.createWebhook({ url: p.url, events: [...p.events] });
841
+ // The secret is returned by the API exactly once. Say so plainly and put
842
+ // it on its own line: an agent that scrolls past it cannot get it back
843
+ // without rotating, which invalidates any receiver already configured.
844
+ return {
845
+ content: [
846
+ {
847
+ type: 'text',
848
+ text: `Webhook registered for ${endpoint.url}\n` +
849
+ `Events: ${(endpoint.events ?? []).join(', ')}\n` +
850
+ `Endpoint id: ${endpoint.uuid}\n\n` +
851
+ `Signing secret: ${endpoint.secret}\n` +
852
+ 'Store this secret now — it is not shown again. Verify each delivery as ' +
853
+ 'hmac_sha256(secret, "{X-Tribeunal-Timestamp}.{raw body}") and compare it in ' +
854
+ 'constant time against the hex after "v1=" in X-Tribeunal-Signature.',
855
+ },
856
+ ],
857
+ };
858
+ }
859
+ case 'tribeunal_list_webhooks': {
860
+ ListWebhooksSchema.parse(params);
861
+ const result = await apiClient.listWebhooks();
862
+ const items = result.items ?? [];
863
+ if (items.length === 0) {
864
+ return {
865
+ content: [
866
+ {
867
+ type: 'text',
868
+ text: 'No webhook endpoints registered. Create one with tribeunal_create_webhook.',
869
+ },
870
+ ],
871
+ };
872
+ }
873
+ return {
874
+ content: [
875
+ {
876
+ type: 'text',
877
+ text: `${result.total ?? items.length} webhook endpoint(s):\n${JSON.stringify(items, null, 2)}`,
878
+ },
879
+ ],
880
+ };
881
+ }
882
+ case 'tribeunal_delete_webhook': {
883
+ const p = DeleteWebhookSchema.parse(params);
884
+ await apiClient.deleteWebhook(p.webhookId);
885
+ return {
886
+ content: [
887
+ {
888
+ type: 'text',
889
+ text: `Webhook endpoint ${p.webhookId} deleted. Deliveries have stopped and its signing secret is gone.`,
890
+ },
891
+ ],
892
+ };
893
+ }
894
+ case 'tribeunal_invite_tribe_members': {
895
+ const p = InviteTribeMembersSchema.parse(params);
896
+ const result = await apiClient.inviteTribeMembers(p.tribeId, p.invitees);
897
+ const s = result.summary ?? {};
898
+ return {
899
+ content: [
900
+ {
901
+ type: 'text',
902
+ text: `Tribe invitations processed — invited: ${s.invited ?? '?'}, already invited: ${s.already_invited ?? '?'}, already member: ${s.already_member ?? '?'}, not found: ${s.not_found ?? '?'}, self: ${s.self ?? '?'}.\n\n${JSON.stringify(result, null, 2)}`,
903
+ },
904
+ ],
905
+ };
906
+ }
907
+ // User tools
908
+ case 'tribeunal_get_user': {
909
+ const p = GetUserSchema.parse(params);
910
+ const user = await apiClient.getUser(p.id);
911
+ return { content: [{ type: 'text', text: JSON.stringify(user, null, 2) }] };
912
+ }
913
+ case 'tribeunal_get_current_user': {
914
+ const user = await apiClient.getCurrentUser();
915
+ return { content: [{ type: 'text', text: JSON.stringify(user, null, 2) }] };
916
+ }
917
+ // Jury Duty tools
918
+ case 'tribeunal_jury_duty_status': {
919
+ const status = await apiClient.getJuryDutyStatus();
920
+ return { content: [{ type: 'text', text: JSON.stringify(status, null, 2) }] };
921
+ }
922
+ case 'tribeunal_jury_duty_allowance': {
923
+ const allowance = await apiClient.getJuryDutyAllowance();
924
+ return {
925
+ content: [
926
+ {
927
+ type: 'text',
928
+ text: `Jury Duty Allowance:\nDaily: ${allowance.allowance?.used_today ?? '?'}/${allowance.allowance?.daily_max ?? '?'} used (${allowance.allowance?.remaining_today ?? '?'} remaining)\nActive Juries: ${allowance.allowance?.active_jury_duties ?? '?'}/${allowance.allowance?.max_active_jury_duties ?? '?'}\nCan Start: ${allowance.allowance?.can_use ? 'Yes' : 'No'}\nResets: ${allowance.allowance?.reset_time ?? 'midnight'}\n\n${JSON.stringify(allowance, null, 2)}`,
929
+ },
930
+ ],
931
+ };
932
+ }
933
+ case 'tribeunal_jury_duty_dashboard': {
934
+ const dashboard = await apiClient.getJuryDutyDashboard();
935
+ const assignments = dashboard.current_assignments || [];
936
+ const total = dashboard.assignments_total || 0;
937
+ return {
938
+ content: [
939
+ {
940
+ type: 'text',
941
+ text: `Jury Duty Dashboard:\nTotal Assignments: ${total}\nShowing: ${assignments.length} recent assignments\nActive Request: ${dashboard.active_request ? 'Yes (status: ' + dashboard.active_request.status + ')' : 'None'}\n\nAllowance:\n Daily: ${dashboard.allowance?.used_today ?? '?'}/${dashboard.allowance?.daily_max ?? '?'} (${dashboard.allowance?.remaining_today ?? '?'} remaining)\n Active Juries: ${dashboard.allowance?.active_jury_duties ?? '?'}/${dashboard.allowance?.max_active_jury_duties ?? '?'}\n\n${JSON.stringify(dashboard, null, 2)}`,
942
+ },
943
+ ],
944
+ };
945
+ }
946
+ case 'tribeunal_jury_duty_start': {
947
+ const result = await apiClient.startJuryDuty();
948
+ return {
949
+ content: [
950
+ {
951
+ type: 'text',
952
+ text: `Jury duty request created!\nStatus: ${result.request?.status || 'waiting'}\nAllowance Remaining: ${result.allowance?.remaining_today ?? '?'}\n\n${JSON.stringify(result, null, 2)}`,
953
+ },
954
+ ],
955
+ };
956
+ }
957
+ case 'tribeunal_jury_duty_cancel': {
958
+ const result = await apiClient.cancelJuryDuty();
959
+ return {
960
+ content: [
961
+ {
962
+ type: 'text',
963
+ text: `Jury duty request cancelled. Allowance refunded (if same day).\n\n${JSON.stringify(result, null, 2)}`,
964
+ },
965
+ ],
966
+ };
967
+ }
968
+ case 'tribeunal_jury_duty_accept': {
969
+ const p = JuryDutyAcceptSchema.parse(params);
970
+ const result = await apiClient.acceptJuryDuty(p.memberId);
971
+ return {
972
+ content: [
973
+ {
974
+ type: 'text',
975
+ text: `Jury duty accepted! You are now serving on this case.\n\n${JSON.stringify(result, null, 2)}`,
976
+ },
977
+ ],
978
+ };
979
+ }
980
+ case 'tribeunal_jury_duty_reject': {
981
+ const p = JuryDutyRejectSchema.parse(params);
982
+ const result = await apiClient.rejectJuryDuty(p.memberId);
983
+ return {
984
+ content: [
985
+ {
986
+ type: 'text',
987
+ text: `Jury duty rejected. Searching for another case...\n\n${JSON.stringify(result, null, 2)}`,
988
+ },
989
+ ],
990
+ };
991
+ }
992
+ case 'tribeunal_jury_duty_history': {
993
+ const p = JuryDutyHistorySchema.parse(params);
994
+ const history = await apiClient.getJuryDutyHistory(p.days);
995
+ return { content: [{ type: 'text', text: JSON.stringify(history, null, 2) }] };
996
+ }
997
+ case 'tribeunal_join_jury': {
998
+ const p = JoinJurySchema.parse(params);
999
+ const result = await apiClient.joinJury(p.caseId);
1000
+ return {
1001
+ content: [
1002
+ {
1003
+ type: 'text',
1004
+ text: `${result.message ?? 'Joined jury'}\n\n${JSON.stringify(result, null, 2)}`,
1005
+ },
1006
+ ],
1007
+ };
1008
+ }
1009
+ case 'tribeunal_invite_jurors': {
1010
+ const p = InviteJurorsSchema.parse(params);
1011
+ const result = await apiClient.inviteJurors(p.caseId, p.invitees, p.tribeId);
1012
+ const s = result.summary ?? {};
1013
+ // A private case's response echoes the owner's tokenized share link — surface
1014
+ // it so a bare (404-trap) url shown earlier can still be corrected here.
1015
+ const shareLine = result.case?.shareUrl
1016
+ ? `\nShare link (view-only, works for anyone): ${result.case.shareUrl}`
1017
+ : '';
1018
+ return {
1019
+ content: [
1020
+ {
1021
+ type: 'text',
1022
+ text: `Jury invitations processed — invited: ${s.invited ?? '?'}, duplicate: ${s.duplicate ?? '?'}, not found: ${s.not_found ?? '?'}.${shareLine}\n\n${JSON.stringify(result, null, 2)}`,
1023
+ },
1024
+ ],
1025
+ };
1026
+ }
1027
+ default:
1028
+ throw new Error(`Unknown tool: ${toolName}`);
1029
+ }
1030
+ }
1031
+ catch (error) {
1032
+ if (error instanceof z.ZodError) {
1033
+ throw new Error(`Invalid parameters: ${error.errors.map((e) => e.message).join(', ')}`);
1034
+ }
1035
+ if (error instanceof TribeunalAPIError) {
1036
+ throw new Error(`API Error: ${error.message}`);
1037
+ }
1038
+ throw error;
1039
+ }
1040
+ }
1041
+ //# sourceMappingURL=tools.js.map