@orboto/mcp 0.195.1 → 0.198.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 (43) hide show
  1. package/dist/event-bridge.js +5 -0
  2. package/dist/event-bridge.test.js +34 -0
  3. package/dist/http-transport.js +6 -3
  4. package/dist/manifest-size.test.js +11 -5
  5. package/dist/orboto-client.js +4 -4
  6. package/dist/server.js +10 -0
  7. package/dist/tool-docs.js +1 -1
  8. package/dist/tools/agent-coordination.js +33 -32
  9. package/dist/tools/agent-coordination.test.js +87 -6
  10. package/dist/tools/agent-message-work.js +22 -0
  11. package/dist/tools/agent-message-work.test.js +33 -0
  12. package/dist/tools/agent-messages.js +32 -15
  13. package/dist/tools/agent-presence-schema.js +40 -0
  14. package/dist/tools/agent-session-scope.js +8 -0
  15. package/dist/tools/claim.js +22 -19
  16. package/dist/tools/claim.test.js +7 -5
  17. package/dist/tools/get-ticket.js +3 -0
  18. package/dist/tools/git-rotate-token.js +61 -0
  19. package/dist/tools/git-rotate-token.test.js +87 -0
  20. package/dist/tools/list-git-app-installations.js +1 -10
  21. package/dist/tools/list-tickets.js +4 -0
  22. package/dist/tools/project-readiness-schema.js +16 -0
  23. package/dist/tools/project-readiness.js +16 -0
  24. package/dist/tools/project-readiness.test.js +19 -0
  25. package/dist/tools/raci.js +4 -2
  26. package/dist/tools/sentry.js +85 -0
  27. package/dist/tools/sentry.test.js +107 -0
  28. package/dist/tools/session-start.js +20 -8
  29. package/dist/tools/session-start.test.js +44 -0
  30. package/dist/tools/shared.js +9 -0
  31. package/dist/tools/spec-check.js +15 -0
  32. package/dist/tools/spec-check.test.js +23 -0
  33. package/dist/tools/spec-readiness.test.js +85 -0
  34. package/dist/tools/spec-schemas.js +34 -0
  35. package/dist/tools/ticket-writes.js +27 -22
  36. package/dist/tools/ticket-writes.test.js +3 -4
  37. package/dist/tools/update-project.js +19 -12
  38. package/dist/tools/update-project.test.js +13 -4
  39. package/dist/tools/work-next.js +1 -1
  40. package/dist/tools/work-sessions-next.test.js +23 -0
  41. package/dist/tools/work-sessions.js +30 -9
  42. package/dist/tools/work-sessions.test.js +14 -0
  43. package/package.json +1 -1
@@ -8,21 +8,29 @@
8
8
  * api's `x-orboto-agent-mail` response header - no polling anywhere).
9
9
  */
10
10
  import { z } from 'zod';
11
+ import { makeAgentMessageWorkHandler } from './agent-message-work.js';
11
12
  import { mcpInstanceToken } from './shared.js';
13
+ const PartySchema = z.object({ userId: z.string(), email: z.string(), sessionId: z.string().nullable(), role: z.string().nullable(), label: z.string() });
12
14
  export const agentMessagesToolConfig = {
13
- title: 'Fetch / acknowledge your agent inbox',
14
- description: 'Your store-and-forward inbox: messages other agents sent you, broadcasts to your scopes (payload.broadcast names the scope) and ticket_ready wake-ups (subject ticket-ready:<KEY>, payload.type ticket_ready with ticketKey, reason assigned | labeled | unblocked: the ticket is yours to claim; claim or answer, then ack). Default = unread, marked delivered on fetch; ackIds marks read. Reply via orboto_agent_notify with threadId = the message id.',
15
+ title: 'Agent inbox and message work',
16
+ description: 'Your inbox as THIS session sees it: mail to this session, account mail inside your declared scope, broadcasts; sibling-session mail and own sends stay out, all:true shows the whole account (ORB-2136). messageWork carries durable work ownership - claim before executing. ackIds is a plain read receipt: no claim needed, finishes nothing. Reply via orboto_agent_notify with threadId (toSessionRef = from.sessionId reaches that instance).',
15
17
  inputSchema: z.object({
16
- all: z.boolean().default(false).describe('true = include already-read messages'),
18
+ messageWork: z.record(z.unknown()).optional()
19
+ .describe('Work envelope {messageId, mutation, cursor, limit, openOnly}; schema: orboto_api_search.'),
20
+ all: z.boolean().default(false),
17
21
  limit: z.number().int().min(1).max(200).default(50),
18
- project: z.string().min(1).max(64).optional().describe('Project key or UUID: scoped messages for this project plus unscoped ones. Ack only messages that are yours.'),
19
- includeOwnSends: z.boolean().default(false).describe('true = also list messages this session sent (hidden by default so a shared identity never wakes itself).'),
20
- ackIds: z.array(z.string().uuid()).max(200).optional().describe('Message ids to mark as read'),
22
+ project: z.string().min(1).max(64).optional().describe('Project key/UUID; includes unscoped mail.'),
23
+ includeOwnSends: z.boolean().default(false),
24
+ ackIds: z.array(z.string().uuid()).max(200).optional().describe('Message ids to mark read.'),
21
25
  }).shape,
22
26
  outputSchema: z.object({
27
+ work: z.record(z.unknown()).optional(),
28
+ sessionId: z.string().nullable().optional(),
23
29
  messages: z.array(z.object({
24
30
  id: z.string(),
25
31
  fromUserId: z.string(),
32
+ from: PartySchema,
33
+ to: PartySchema,
26
34
  kind: z.string(),
27
35
  subject: z.string(),
28
36
  payload: z.record(z.string(), z.unknown()).nullable(),
@@ -33,13 +41,19 @@ export const agentMessagesToolConfig = {
33
41
  })),
34
42
  acked: z.number().int(),
35
43
  }).shape,
36
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
44
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
37
45
  };
38
46
  export function makeAgentMessagesHandler(client) {
39
47
  return async (args, extra) => {
48
+ if (args.messageWork) {
49
+ if (args.ackIds?.length)
50
+ throw new Error('Use a separate explicit ACK call.');
51
+ const result = await makeAgentMessageWorkHandler(client)(args.messageWork, extra);
52
+ return { content: result.content, structuredContent: { messages: [], acked: 0, work: result.structuredContent } };
53
+ }
40
54
  let acked = 0;
41
55
  if (args.ackIds && args.ackIds.length > 0) {
42
- const res = await client.post('/v1/agent/messages/ack', { ids: args.ackIds });
56
+ const res = await client.post('/v1/agent/messages/ack', { ids: args.ackIds, instanceToken: mcpInstanceToken(undefined, extra) });
43
57
  acked = res.acked;
44
58
  }
45
59
  const q = new URLSearchParams();
@@ -49,21 +63,24 @@ export function makeAgentMessagesHandler(client) {
49
63
  q.set('limit', String(args.limit));
50
64
  if (args.project)
51
65
  q.set('project', args.project);
52
- if (!args.includeOwnSends) {
53
- q.set('excludeRef', mcpInstanceToken(undefined, extra));
54
- }
55
- const { messages } = await client.get(`/v1/agent/messages${q.toString() ? `?${q.toString()}` : ''}`);
66
+ const instanceToken = mcpInstanceToken(undefined, extra);
67
+ if (!args.includeOwnSends)
68
+ q.set('excludeRef', instanceToken);
69
+ const { messages, sessionId } = await client.get(`/v1/agent/messages${q.toString() ? `?${q.toString()}` : ''}`, { instanceToken });
56
70
  const lines = messages.length === 0
57
71
  ? [acked > 0 ? `Acknowledged ${acked} message(s). Inbox empty.` : 'Inbox empty.']
58
- : messages.map((m) => `[${m.kind}]${m.projectKey ? ` [${m.projectKey}]` : ''} ${m.subject} (from ${m.fromUserId}, ${m.createdAt}, id ${m.id}${m.threadId ? `, thread ${m.threadId}` : ''})${m.payload ? ` payload: ${JSON.stringify(m.payload)}` : ''}`);
72
+ : messages.map((m) => `[${m.kind}]${m.projectKey ? ` [${m.projectKey}]` : ''} ${m.subject} (from ${m.from?.label ?? m.fromUserId} to ${m.to?.label ?? m.toUserId}, ${m.createdAt}, id ${m.id}${m.threadId ? `, thread ${m.threadId}` : ''})${m.payload ? ` payload: ${JSON.stringify(m.payload)}` : ''}`);
59
73
  if (messages.length > 0) {
60
- lines.push(`Acknowledge with ackIds once handled; reply via orboto_agent_notify with threadId.`);
74
+ lines.push(`Acknowledge with ackIds once handled; reply via orboto_agent_notify with threadId (toSessionRef = the sender's instance short id reaches that session only).`);
61
75
  }
76
+ if (sessionId)
77
+ lines.push(`This session: ${sessionId.slice(0, 8)} (ref ${instanceToken}).`);
62
78
  return {
63
79
  content: [{ type: 'text', text: lines.join('\n') }],
64
80
  structuredContent: {
81
+ sessionId: sessionId ?? null,
65
82
  messages: messages.map((m) => ({
66
- id: m.id, fromUserId: m.fromUserId, kind: m.kind, subject: m.subject,
83
+ id: m.id, fromUserId: m.fromUserId, from: m.from, to: m.to, kind: m.kind, subject: m.subject,
67
84
  payload: m.payload, threadId: m.threadId, projectKey: m.projectKey ?? null,
68
85
  createdAt: m.createdAt, readAt: m.readAt,
69
86
  })),
@@ -0,0 +1,40 @@
1
+ import { z } from 'zod';
2
+ import { AgentSessionScopeSchema } from './agent-session-scope.js';
3
+ const WorkSessionResourceClaimSchema = z.object({
4
+ kind: z.enum(['path', 'named']), value: z.string().min(1).max(500), mode: z.enum(['read', 'write']),
5
+ state: z.enum(['granted', 'waiting']).optional(), requestedAt: z.string().optional(),
6
+ });
7
+ export const AgentInventoryKindSchema = z.enum(['agent', 'human']);
8
+ export const AgentConnectionTypeSchema = z.enum(['api_key', 'oauth', 'lane', 'external_token', 'live_events', 'web']);
9
+ export const AgentInventoryConnectionSchema = z.object({ type: AgentConnectionTypeSchema, label: z.string() });
10
+ export const AgentInventoryProjectSchema = z.object({ id: z.string().uuid(), key: z.string(), name: z.string() });
11
+ export const AgentInventoryEntrySchema = z.object({
12
+ userId: z.string().uuid(),
13
+ userEmail: z.string(),
14
+ userFullName: z.string().nullable(),
15
+ kind: AgentInventoryKindSchema,
16
+ /** Alias of `kind === 'agent'`. */
17
+ isBot: z.boolean(),
18
+ actsAs: z.object({ id: z.string().uuid(), name: z.string().nullable(), email: z.string() }).nullable(),
19
+ owner: z.object({ id: z.string().uuid(), name: z.string().nullable(), email: z.string() }).nullable(),
20
+ autonomyPaused: z.boolean(),
21
+ sessionId: z.string().uuid(),
22
+ status: z.string(),
23
+ lane: z.object({ id: z.string().uuid(), name: z.string(), role: z.string(), paused: z.boolean() }).nullable(),
24
+ projects: z.array(AgentInventoryProjectSchema),
25
+ connections: z.array(AgentInventoryConnectionSchema),
26
+ workingOnTicket: z.object({ id: z.string().uuid(), key: z.string().nullable(), title: z.string(), projectKey: z.string().nullable() }).nullable(),
27
+ workSessions: z.array(z.object({
28
+ ticketId: z.string().uuid(), ticketKey: z.string().nullable(), role: z.string(), leaseUntil: z.string(),
29
+ resourceClaims: z.array(WorkSessionResourceClaimSchema),
30
+ })),
31
+ capabilities: z.array(z.string()),
32
+ clientInfo: z.record(z.string(), z.string()),
33
+ /** ORB-2136 - the scope the session declared; null when it declared none. */
34
+ scope: AgentSessionScopeSchema.nullable(),
35
+ /** ORB-2136 - unread inbox messages this session would list. */
36
+ unreadMessages: z.number().int(),
37
+ lastSeenAt: z.string(),
38
+ createdAt: z.string(),
39
+ });
40
+ export const AgentInventorySchema = z.array(AgentInventoryEntrySchema);
@@ -0,0 +1,8 @@
1
+ import { z } from 'zod';
2
+ /** ORB-2136 - standalone mirror of `@orboto/shared-schema` agent-session-scope (the MCP package does not depend on it). */
3
+ export const AgentSessionRoleSchema = z.enum(['integrator', 'lead', 'worker', 'spec', 'review', 'coordinator']);
4
+ export const AgentSessionScopeSchema = z.object({
5
+ projectKeys: z.array(z.string()).optional(),
6
+ ticketKeys: z.array(z.string()).optional(),
7
+ role: AgentSessionRoleSchema.optional(),
8
+ });
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * @see ORB-179, ORB-181
5
5
  */
6
+ import { specGateFailure, isAlreadyAssigned } from './spec-schemas.js';
6
7
  import { z } from 'zod';
7
8
  import { OrbotoApiError } from '../orboto-client.js';
8
9
  import { mcpInstanceToken, resolveTicketByKey } from './shared.js';
@@ -15,33 +16,44 @@ const CATEGORY_TO_LEGACY = {
15
16
  };
16
17
  export const claimToolConfig = {
17
18
  title: 'Claim a ticket (assign self + in_progress + timer)',
18
- description: 'Composite of `assign self → move to in_progress → start timer` - the canonical "I am picking this up now" move. Idempotent: re-claiming an already-claimed in_progress ticket is a no-op. Set `sole=true` to remove every other assignee first (destructive take-over). Set `force=true` to allow re-claiming a `done` ticket (otherwise refuses, to prevent accidental reopens). Set `noTimer=true` to skip the timer start (e.g. when you only want ownership, not time tracking). If a different ticket has an active timer, it is stopped first (its elapsed time commits a time entry under the previous ticket). '
19
- + '`agentSessionToken` is a stable per-agent-instance token: on a bot/service account it scopes the timer to YOUR instance - concurrent per-instance timers and NO auto-stop, so you own both start and stop. Omit it on human accounts, which keep the single-timer behaviour.',
19
+ description: 'Assign yourself, move to in_progress and start a timer. Repeated claims recheck the specification gate. Worker bots need ready, or override=true with a reason; enabled epics are blocked. sole removes other assignees after the gate passes. force permits reopening done tickets. noTimer skips time tracking. On human accounts a different active timer is stopped first and its elapsed time booked to that ticket. Bot timers are per instance and never auto-stop each other: agentSessionToken defaults to this MCP connection; own both start and stop.',
20
20
  inputSchema: z.object({
21
21
  ticketKey: z.string().min(3),
22
- sole: z.boolean().optional().describe('Take-over: remove every other assignee first.'),
23
- force: z.boolean().optional().describe('Allow re-claiming a `done` ticket.'),
24
- noTimer: z.boolean().optional().describe('Skip the timer start.'),
25
- agentSessionToken: z.string().optional().describe('Per-agent-instance token; scopes the timer on bot accounts.'),
22
+ override: z.boolean().optional().describe("Spec override; reason required."),
23
+ reason: z.string().trim().min(1).max(2000).optional(),
24
+ sole: z.boolean().optional().describe('Remove other assignees after gate.'),
25
+ force: z.boolean().optional().describe('Reopen done.'),
26
+ noTimer: z.boolean().optional().describe('No timer.'),
27
+ agentSessionToken: z.string().optional().describe('Instance token for bot timers.'),
26
28
  }).shape,
27
29
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
28
30
  };
29
31
  export function makeClaimHandler(client) {
30
- return async ({ ticketKey, sole, force, noTimer, agentSessionToken }, extra) => {
32
+ return async ({ ticketKey, sole, force, override, reason, noTimer, agentSessionToken }, extra) => {
31
33
  const me = await client.get('/users/me');
32
34
  const current = await resolveTicketByKey(client, ticketKey);
33
35
  const currentAssignees = current.assignees ?? [];
34
- const alreadyAssigned = currentAssignees.some((a) => a.id === me.id);
36
+ const alreadyAssigned = currentAssignees.some((a) => (a.userId ?? a.id) === me.id);
35
37
  const currentCategory = current.statusCategory;
36
38
  if (currentCategory === 'done' && !force) {
37
39
  throw new Error(`Refusing to claim [${current.ticketKey}]: status is "done". Pass force=true to reopen + claim.`);
38
40
  }
41
+ try {
42
+ await client.post(`/projects/${current.projectId}/tickets/${current.id}/assignees/${me.id}`, { override, reason });
43
+ }
44
+ catch (error) {
45
+ const gate = error instanceof OrbotoApiError && error.status === 409 ? specGateFailure(error.body) : undefined;
46
+ if (gate)
47
+ return gate;
48
+ if (!(error instanceof OrbotoApiError) || error.status !== 409 || !isAlreadyAssigned(error.body))
49
+ throw error;
50
+ }
39
51
  if (sole) {
40
52
  for (const a of currentAssignees) {
41
- if (a.id === me.id)
53
+ if ((a.userId ?? a.id) === me.id)
42
54
  continue;
43
55
  try {
44
- await client.delete(`/projects/${current.projectId}/tickets/${current.id}/assignees/${a.id}`);
56
+ await client.delete(`/projects/${current.projectId}/tickets/${current.id}/assignees/${a.userId ?? a.id}`);
45
57
  }
46
58
  catch (err) {
47
59
  if (!(err instanceof OrbotoApiError) || err.status !== 404)
@@ -49,15 +61,6 @@ export function makeClaimHandler(client) {
49
61
  }
50
62
  }
51
63
  }
52
- if (!alreadyAssigned) {
53
- try {
54
- await client.post(`/projects/${current.projectId}/tickets/${current.id}/assignees/${me.id}`, {});
55
- }
56
- catch (err) {
57
- if (!(err instanceof OrbotoApiError) || err.status !== 409)
58
- throw err;
59
- }
60
- }
61
64
  let finalTicket = current;
62
65
  if (currentCategory !== 'in_progress') {
63
66
  finalTicket = await client.patch(`/projects/${current.projectId}/tickets/${current.id}`, { status: CATEGORY_TO_LEGACY.in_progress });
@@ -75,7 +75,7 @@ describe('orboto_claim', () => {
75
75
  noop: false,
76
76
  });
77
77
  });
78
- it('idempotent: skip assign POST + status PATCH when already-in-progress + already-assigned', async () => {
78
+ it('idempotent re-claim rechecks assignment readiness without changing status', async () => {
79
79
  const calls = stub([
80
80
  { json: ME },
81
81
  { json: PROJ },
@@ -83,10 +83,11 @@ describe('orboto_claim', () => {
83
83
  ...TICKET_TODO, status: 'IN_PROGRESS', statusName: 'In Progress', statusCategory: 'in_progress',
84
84
  assignees: [{ id: 'me1', email: 'agent-e@orboto.io', fullName: 'Agent E' }],
85
85
  } },
86
+ { json: {} },
86
87
  { json: { ticketId: 't1' } }, // GET /time/timer - already on same ticket
87
88
  ]);
88
89
  const res = await makeClaimHandler(client)({ ticketKey: 'ACME-1' });
89
- expect(calls).toHaveLength(4);
90
+ expect(calls).toHaveLength(5);
90
91
  expect(calls.some((c) => c.method === 'PATCH')).toBe(false);
91
92
  expect(calls.some((c) => c.url.includes('/timer/start'))).toBe(false);
92
93
  expect(res.structuredContent).toMatchObject({
@@ -94,7 +95,7 @@ describe('orboto_claim', () => {
94
95
  noop: true,
95
96
  });
96
97
  });
97
- it('sole=true removes every other assignee before adding self', async () => {
98
+ it('sole=true verifies self assignment before removing other assignees', async () => {
98
99
  const calls = stub([
99
100
  { json: ME },
100
101
  { json: PROJ },
@@ -111,8 +112,9 @@ describe('orboto_claim', () => {
111
112
  ]);
112
113
  await makeClaimHandler(client)({ ticketKey: 'ACME-1', sole: true });
113
114
  expect(calls.filter((c) => c.method === 'DELETE')).toHaveLength(2);
114
- expect(calls[3].url).toContain('/assignees/other1');
115
- expect(calls[4].url).toContain('/assignees/other2');
115
+ expect(calls[3].url).toContain('/assignees/me1');
116
+ expect(calls[4].url).toContain('/assignees/other1');
117
+ expect(calls[5].url).toContain('/assignees/other2');
116
118
  });
117
119
  it('refuses to claim a done ticket without force=true', async () => {
118
120
  stub([
@@ -1,3 +1,4 @@
1
+ import { specReleaseInfo } from './shared.js';
1
2
  /**
2
3
  * ORB-244 Phase B - `orboto_get_ticket`.
3
4
  *
@@ -73,6 +74,8 @@ export function makeGetTicketHandler(client) {
73
74
  : null,
74
75
  priority: full.priority,
75
76
  type: full.type,
77
+ specState: full.specState ?? 'none',
78
+ ...specReleaseInfo(full),
76
79
  deliveryMode: full.deliveryMode ?? 'implementation',
77
80
  dueDate: full.dueDate,
78
81
  startDate: full.startDate,
@@ -0,0 +1,61 @@
1
+ /**
2
+ * ORB-2112 - `orboto_git_rotate_token`.
3
+ *
4
+ * Replace the credential a project's git connection uses. orboto probes
5
+ * the provider with the new one first, so a bad paste leaves the working
6
+ * credential in place and the connection id, its activities and issue
7
+ * links untouched.
8
+ */
9
+ import { z } from 'zod';
10
+ import { resolveProjectByKey } from './shared.js';
11
+ export const gitRotateTokenToolConfig = {
12
+ title: 'Rotate a git connection\'s access token',
13
+ description: 'Replace a git connection\'s access token (ssh: private key). The provider is probed with the new credential first; on success it is re-sealed under the same connection id, on failure 422 and the old one stays. reinstallWebhook also reinstalls the hook. App / OAuth connections answer 422.',
14
+ inputSchema: z.object({
15
+ projectKey: z.string().min(1).describe('Project key, e.g. ORB.'),
16
+ connectionId: z.string().uuid().describe('Git connection id.'),
17
+ accessToken: z.string().min(1).optional().describe('New access token (every provider but ssh).'),
18
+ privateKey: z.string().min(1).optional().describe('New SSH private key (ssh only).'),
19
+ reinstallWebhook: z.boolean().optional().describe('Also reinstall the provider hook.'),
20
+ }).shape,
21
+ outputSchema: z.object({
22
+ connectionId: z.string(),
23
+ name: z.string(),
24
+ healthy: z.boolean(),
25
+ tokenState: z.enum(['ok', 'unauthorized', 'unknown']),
26
+ reason: z.string().nullable(),
27
+ webhookUrl: z.string().optional(),
28
+ }).shape,
29
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
30
+ };
31
+ export function makeGitRotateTokenHandler(client) {
32
+ return async (args) => {
33
+ if ((args.accessToken ? 1 : 0) + (args.privateKey ? 1 : 0) !== 1) {
34
+ throw new Error('Pass exactly one of accessToken or privateKey.');
35
+ }
36
+ const project = await resolveProjectByKey(client, args.projectKey);
37
+ const res = await client.post(`/projects/${project.id}/git-connections/${args.connectionId}/rotate-token`, {
38
+ ...(args.accessToken ? { accessToken: args.accessToken } : {}),
39
+ ...(args.privateKey ? { privateKey: args.privateKey } : {}),
40
+ ...(args.reinstallWebhook ? { reinstallWebhook: true } : {}),
41
+ });
42
+ const lines = [
43
+ `Rotated the credential on "${res.health.name}" (${res.health.connectionId}).`,
44
+ `Token state: ${res.health.tokenState}; healthy: ${res.health.healthy}${res.health.reason ? ` (${res.health.reason})` : ''}.`,
45
+ ];
46
+ if (res.webhookUrl) {
47
+ lines.push(`Webhook ${res.webhookAutoInstalled ? 'reinstalled on' : 'install failed for'} ${res.webhookUrl}${res.webhookInstallError ? ` - ${res.webhookInstallError}` : ''}.`);
48
+ }
49
+ return {
50
+ content: [{ type: 'text', text: lines.join('\n') }],
51
+ structuredContent: {
52
+ connectionId: res.health.connectionId,
53
+ name: res.health.name,
54
+ healthy: res.health.healthy,
55
+ tokenState: res.health.tokenState,
56
+ reason: res.health.reason,
57
+ ...(res.webhookUrl ? { webhookUrl: res.webhookUrl } : {}),
58
+ },
59
+ };
60
+ };
61
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * ORB-2114 - `orboto_git_rotate_token` handler tests.
3
+ */
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { OrbotoClient } from '../orboto-client.js';
6
+ import { gitRotateTokenToolConfig, makeGitRotateTokenHandler } from './git-rotate-token.js';
7
+ beforeEach(() => { vi.restoreAllMocks(); });
8
+ afterEach(() => { vi.restoreAllMocks(); });
9
+ function stub(responses) {
10
+ const calls = [];
11
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
12
+ const u = url.toString();
13
+ const m = init?.method ?? 'GET';
14
+ const b = init?.body ? JSON.parse(init.body) : undefined;
15
+ calls.push({ url: u, method: m, body: b });
16
+ const r = responses.shift();
17
+ if (!r)
18
+ throw new Error(`unexpected extra fetch ${m} ${u}`);
19
+ return {
20
+ ok: r.ok ?? true,
21
+ status: r.status ?? 200,
22
+ statusText: r.ok === false ? 'Error' : 'OK',
23
+ json: async () => ('json' in r ? r.json : {}),
24
+ text: async () => JSON.stringify('json' in r ? r.json : {}),
25
+ };
26
+ });
27
+ return calls;
28
+ }
29
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
30
+ const project = { id: '11111111-1111-4111-8111-111111111111', key: 'ORB', name: 'orboto' };
31
+ const connectionId = '22222222-2222-4222-8222-222222222222';
32
+ const health = { connectionId, name: 'Forgejo - orboto/orboto', healthy: true, tokenState: 'ok', unhealthySince: null, reason: null };
33
+ describe('orboto_git_rotate_token', () => {
34
+ it('posts the new access token to the rotate route and returns the health row', async () => {
35
+ const calls = stub([{ json: project }, { json: { rotated: true, health } }]);
36
+ const out = await makeGitRotateTokenHandler(client)({ projectKey: 'ORB', connectionId, accessToken: 'tok_new' });
37
+ expect(calls[0]).toMatchObject({ method: 'GET', url: expect.stringContaining('/projects/by-key/ORB') });
38
+ expect(calls[1]).toMatchObject({
39
+ method: 'POST',
40
+ url: expect.stringContaining(`/projects/${project.id}/git-connections/${connectionId}/rotate-token`),
41
+ body: { accessToken: 'tok_new' },
42
+ });
43
+ expect(out.structuredContent).toEqual({ connectionId, name: health.name, healthy: true, tokenState: 'ok', reason: null });
44
+ const text = out.content[0].text;
45
+ expect(text).toContain('Rotated the credential');
46
+ expect(text).not.toContain('tok_new');
47
+ });
48
+ it('passes privateKey and reinstallWebhook through and reports the reinstalled hook', async () => {
49
+ const calls = stub([
50
+ { json: project },
51
+ { json: { rotated: true, health, webhookUrl: 'https://orboto.example.com/git/webhook/x', webhookAutoInstalled: true, webhookInstallError: null } },
52
+ ]);
53
+ const out = await makeGitRotateTokenHandler(client)({ projectKey: 'ORB', connectionId, privateKey: '-----BEGIN KEY-----', reinstallWebhook: true });
54
+ expect(calls[1].body).toEqual({ privateKey: '-----BEGIN KEY-----', reinstallWebhook: true });
55
+ expect(out.content[0].text).toContain('Webhook reinstalled on https://orboto.example.com/git/webhook/x');
56
+ expect(out.structuredContent).toMatchObject({ webhookUrl: 'https://orboto.example.com/git/webhook/x' });
57
+ });
58
+ it('reports a failed webhook install with its error', async () => {
59
+ stub([
60
+ { json: project },
61
+ { json: { rotated: true, health: { ...health, healthy: false, tokenState: 'unknown', reason: 'outbound_unreachable' }, webhookUrl: 'https://orboto.example.com/git/webhook/x', webhookAutoInstalled: false, webhookInstallError: 'Gitea: 403' } },
62
+ ]);
63
+ const out = await makeGitRotateTokenHandler(client)({ projectKey: 'ORB', connectionId, accessToken: 'tok' });
64
+ const text = out.content[0].text;
65
+ expect(text).toContain('Webhook install failed for https://orboto.example.com/git/webhook/x - Gitea: 403');
66
+ expect(text).toContain('(outbound_unreachable)');
67
+ });
68
+ it('refuses when neither or both credentials are given, before any request', async () => {
69
+ const calls = stub([]);
70
+ await expect(makeGitRotateTokenHandler(client)({ projectKey: 'ORB', connectionId })).rejects.toThrow('exactly one');
71
+ await expect(makeGitRotateTokenHandler(client)({ projectKey: 'ORB', connectionId, accessToken: 'a', privateKey: 'b' })).rejects.toThrow('exactly one');
72
+ expect(calls).toHaveLength(0);
73
+ });
74
+ it('surfaces a rejected token as the API error', async () => {
75
+ stub([{ json: project }, { ok: false, status: 422, json: { error: 'errors.git.token_rejected' } }]);
76
+ await expect(makeGitRotateTokenHandler(client)({ projectKey: 'ORB', connectionId, accessToken: 'bad' })).rejects.toThrow();
77
+ });
78
+ it('names an unknown project key', async () => {
79
+ stub([{ ok: false, status: 404, json: { error: 'errors.projects.not_found' } }]);
80
+ await expect(makeGitRotateTokenHandler(client)({ projectKey: 'NOPE', connectionId, accessToken: 'tok' })).rejects.toThrow('Project "NOPE" not found');
81
+ });
82
+ it('declares a write tool with a title and an output schema', () => {
83
+ expect(gitRotateTokenToolConfig.title).toMatch(/rotate/i);
84
+ expect(gitRotateTokenToolConfig.annotations.readOnlyHint).toBe(false);
85
+ expect(Object.keys(gitRotateTokenToolConfig.outputSchema)).toEqual(expect.arrayContaining(['connectionId', 'tokenState']));
86
+ });
87
+ });
@@ -11,17 +11,8 @@
11
11
  import { z } from 'zod';
12
12
  export const listGitAppInstallationsToolConfig = {
13
13
  title: 'List GitHub App installations',
14
- description: 'Return every GitHub App installation orboto knows about (across all projects). Requires super-admin on the API side. Each row carries the org/user the App is installed on, when it was installed, and whether it is currently suspended.',
14
+ description: 'Every GitHub App installation orboto knows about, across projects (super-admin): the org/user, install time and whether it is suspended.',
15
15
  inputSchema: z.object({}).shape,
16
- outputSchema: z.object({
17
- installations: z.array(z.object({
18
- provider: z.string(),
19
- accountLogin: z.string(),
20
- accountType: z.string(),
21
- installedAt: z.string(),
22
- suspended: z.boolean(),
23
- })),
24
- }).shape,
25
16
  annotations: { readOnlyHint: true, idempotentHint: true },
26
17
  };
27
18
  export function makeListGitAppInstallationsHandler(client) {
@@ -1,3 +1,4 @@
1
+ import { TicketSpecStateSchema } from './spec-schemas.js';
1
2
  /**
2
3
  * ORB-244 Phase B - `orboto_list_tickets`.
3
4
  *
@@ -15,6 +16,7 @@ export const listTicketsToolConfig = {
15
16
  description: 'List tickets in a project, optionally filtered by status category, milestone name, or assignee email. Set unscheduled=true for the product backlog (everything not planned into a milestone yet). Returns up to 50 tickets per call.',
16
17
  inputSchema: z.object({
17
18
  projectKey: z.string().min(1).describe('Project key (e.g. "ACME").'),
19
+ specState: TicketSpecStateSchema.optional(),
18
20
  statusCategory: z
19
21
  .enum(['todo', 'in_progress', 'in_review', 'done', 'wont_fix'])
20
22
  .optional()
@@ -45,6 +47,8 @@ export function makeListTicketsHandler(client) {
45
47
  const project = await resolveProjectByKey(client, input.projectKey);
46
48
  const qs = new URLSearchParams();
47
49
  qs.set('limit', String(input.limit ?? 25));
50
+ if (input.specState)
51
+ qs.set('specState', input.specState);
48
52
  if (input.statusCategory)
49
53
  qs.set('statusCategory', input.statusCategory);
50
54
  if (input.unscheduled && input.milestone) {
@@ -0,0 +1,16 @@
1
+ import { z } from 'zod';
2
+ export const ProjectReadinessStatusSchema = z.enum(['ok', 'missing', 'n/a']);
3
+ export const ProjectReadinessItemIdSchema = z.enum(['project_record', 'human_manager', 'milestones', 'git_connection', 'doc_primer', 'instructions', 'tech_stack', 'commands', 'lane_assignment', 'acceptance', 'spec_settings', 'sentry_intake']);
4
+ export const ProjectReadinessItemSchema = z.object({
5
+ id: ProjectReadinessItemIdSchema,
6
+ status: ProjectReadinessStatusSchema,
7
+ reason: z.string(),
8
+ checked: z.number().int().nonnegative(),
9
+ missing: z.number().int().nonnegative(),
10
+ fix: z.object({ method: z.string(), route: z.string(), mcpTool: z.string(), cliCommand: z.string(), settingsPath: z.string() }),
11
+ });
12
+ export const ProjectReadinessSchema = z.object({
13
+ projectId: z.string().uuid(), projectKey: z.string(), ready: z.boolean(), language: z.string(),
14
+ ticketScope: z.literal('visible'),
15
+ items: z.array(ProjectReadinessItemSchema),
16
+ });
@@ -0,0 +1,16 @@
1
+ import { z } from 'zod';
2
+ import { ProjectReadinessSchema } from './project-readiness-schema.js';
3
+ import { resolveProjectByKey } from './shared.js';
4
+ export const projectReadinessToolConfig = {
5
+ title: 'Check project readiness',
6
+ description: 'Check setup before autonomous work: measured ok/missing/n/a items with reasons and fix actions. Visible tickets only; no external probes.',
7
+ inputSchema: z.object({ projectKey: z.string().min(1).describe('Project key, case-insensitive.') }).shape,
8
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
9
+ };
10
+ export function makeProjectReadinessHandler(client) {
11
+ return async ({ projectKey }) => {
12
+ const project = await resolveProjectByKey(client, projectKey);
13
+ const report = ProjectReadinessSchema.parse(await client.get(`/projects/${project.id}/readiness`));
14
+ return { content: [{ type: 'text', text: `${report.projectKey}: ${report.ready ? 'ready' : 'setup incomplete'}\n${report.items.map(i => `${i.status}: ${i.id} (${i.reason})`).join('\n')}` }], structuredContent: report };
15
+ };
16
+ }
@@ -0,0 +1,19 @@
1
+ import { expect, it, vi } from 'vitest';
2
+ import { readFileSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import { makeProjectReadinessHandler } from './project-readiness.js';
5
+ it('returns measured readiness after project key resolution and preserves fix actions', async () => {
6
+ const report = { projectId: '11111111-2222-4333-8444-555555555555', projectKey: 'TEST', language: 'en', ready: false, ticketScope: 'visible', items: [{ id: 'commands', status: 'missing', reason: 'commands_missing', checked: 1, missing: 1, fix: { method: 'POST', route: '/projects/p/primer-facts', mcpTool: 'orboto_primer_fact_add', cliCommand: 'orboto post /projects/p/primer-facts', settingsPath: '/projects/test/settings?tab=aiPrimer' } }] };
7
+ const get = vi.fn().mockResolvedValueOnce({ id: report.projectId, key: 'TEST', name: 'Test' }).mockResolvedValueOnce(report);
8
+ const result = await makeProjectReadinessHandler({ get })({ projectKey: 'test' });
9
+ expect(get).toHaveBeenLastCalledWith(`/projects/${report.projectId}/readiness`);
10
+ expect(result.structuredContent).toEqual(report);
11
+ });
12
+ it('pins the standalone schema mirror to shared-schema', () => {
13
+ const root = resolve(process.cwd(), '../..');
14
+ expect(readFileSync(resolve(root, 'apps/mcp/src/tools/project-readiness-schema.ts'), 'utf8')).toBe(readFileSync(resolve(root, 'packages/shared-schema/src/project-readiness.ts'), 'utf8').replace("import { ProjectSchema } from './projects.ts';\n", '').replace('\nexport const CreatedProjectSchema = ProjectSchema.extend({ readiness: ProjectReadinessSchema });\n', ''));
15
+ });
16
+ it('propagates denied access instead of reporting readiness', async () => {
17
+ const get = vi.fn().mockRejectedValue(new Error('404'));
18
+ await expect(makeProjectReadinessHandler({ get })({ projectKey: 'private' })).rejects.toThrow();
19
+ });
@@ -65,15 +65,17 @@ export const setRaciToolConfig = {
65
65
  ticketKey: z.string().min(3).describe('Ticket key (e.g. "ORB-42").'),
66
66
  userEmail: z.string().email().describe('Email of a project member.'),
67
67
  role: z.enum(ROLES).describe('R, A, C, or I.'),
68
+ override: z.boolean().optional(),
69
+ reason: z.string().trim().min(1).max(2000).optional(),
68
70
  }).shape,
69
71
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
70
72
  };
71
73
  export function makeSetRaciHandler(client) {
72
- return async ({ ticketKey, userEmail, role }) => {
74
+ return async ({ ticketKey, userEmail, role, override, reason }) => {
73
75
  const ticket = await resolveTicketByKey(client, ticketKey);
74
76
  const userId = await resolveMemberId(client, ticket.projectId, userEmail);
75
77
  try {
76
- await client.put(`/projects/${ticket.projectId}/tickets/${ticket.id}/raci/${userId}`, { role });
78
+ await client.put(`/projects/${ticket.projectId}/tickets/${ticket.id}/raci/${userId}`, { role, override, reason });
77
79
  }
78
80
  catch (err) {
79
81
  if (err instanceof OrbotoApiError && err.status === 409) {