@orboto/mcp 0.178.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 (159) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +123 -0
  3. package/dist/event-bridge.js +250 -0
  4. package/dist/event-bridge.test.js +165 -0
  5. package/dist/http-transport-binding.test.js +151 -0
  6. package/dist/http-transport.js +608 -0
  7. package/dist/http-transport.test.js +408 -0
  8. package/dist/index.js +128 -0
  9. package/dist/input-schema.js +293 -0
  10. package/dist/input-schema.test.js +198 -0
  11. package/dist/instructions-budget.test.js +34 -0
  12. package/dist/manifest-size.test.js +192 -0
  13. package/dist/mcp-instrument.js +24 -0
  14. package/dist/oauth-bootstrap.js +413 -0
  15. package/dist/oauth-bootstrap.test.js +212 -0
  16. package/dist/orboto-client.js +250 -0
  17. package/dist/orboto-client.test.js +141 -0
  18. package/dist/prompts.js +219 -0
  19. package/dist/prompts.test.js +79 -0
  20. package/dist/protect-text-usage.test.js +38 -0
  21. package/dist/resources.js +260 -0
  22. package/dist/resources.test.js +191 -0
  23. package/dist/response-budget.js +515 -0
  24. package/dist/response-budget.test.js +343 -0
  25. package/dist/server.js +547 -0
  26. package/dist/session-nudge.js +81 -0
  27. package/dist/session-nudge.test.js +100 -0
  28. package/dist/session-nudge.transport.test.js +100 -0
  29. package/dist/tool-annotations.test.js +97 -0
  30. package/dist/tool-docs.js +64 -0
  31. package/dist/tool-docs.test.js +90 -0
  32. package/dist/tool-parameters.test.js +145 -0
  33. package/dist/tools/absence-writes.js +93 -0
  34. package/dist/tools/absence-writes.test.js +106 -0
  35. package/dist/tools/admin-translations.js +83 -0
  36. package/dist/tools/admin-writes.js +174 -0
  37. package/dist/tools/admin-writes.test.js +120 -0
  38. package/dist/tools/agent-coordination.js +160 -0
  39. package/dist/tools/agent-coordination.test.js +156 -0
  40. package/dist/tools/agent-drift.js +79 -0
  41. package/dist/tools/agent-drift.test.js +87 -0
  42. package/dist/tools/agent-instructions.js +172 -0
  43. package/dist/tools/agent-instructions.test.js +124 -0
  44. package/dist/tools/agent-messages.js +75 -0
  45. package/dist/tools/ai-status.js +55 -0
  46. package/dist/tools/ai-status.test.js +43 -0
  47. package/dist/tools/ai-usage.js +68 -0
  48. package/dist/tools/analytics.js +72 -0
  49. package/dist/tools/analytics.test.js +71 -0
  50. package/dist/tools/api-call.js +72 -0
  51. package/dist/tools/api-call.test.js +104 -0
  52. package/dist/tools/api-search.js +77 -0
  53. package/dist/tools/api-search.test.js +90 -0
  54. package/dist/tools/approvals.js +68 -0
  55. package/dist/tools/approvals.test.js +48 -0
  56. package/dist/tools/attach.js +108 -0
  57. package/dist/tools/attach.test.js +117 -0
  58. package/dist/tools/backup.js +93 -0
  59. package/dist/tools/backup.test.js +108 -0
  60. package/dist/tools/bulk-create.js +213 -0
  61. package/dist/tools/bulk-create.test.js +135 -0
  62. package/dist/tools/bulk-writes.js +339 -0
  63. package/dist/tools/bulk-writes.test.js +239 -0
  64. package/dist/tools/check-similar.js +84 -0
  65. package/dist/tools/check-similar.test.js +101 -0
  66. package/dist/tools/checklist-writes.js +202 -0
  67. package/dist/tools/checklist-writes.test.js +184 -0
  68. package/dist/tools/claim.js +236 -0
  69. package/dist/tools/claim.test.js +207 -0
  70. package/dist/tools/critical-path.js +83 -0
  71. package/dist/tools/critical-path.test.js +114 -0
  72. package/dist/tools/cross-project-links.ee.js +124 -0
  73. package/dist/tools/cross-project-links.ee.test.js +163 -0
  74. package/dist/tools/customer-report.js +88 -0
  75. package/dist/tools/customer-report.test.js +55 -0
  76. package/dist/tools/doc-attachments.js +184 -0
  77. package/dist/tools/doc-attachments.test.js +136 -0
  78. package/dist/tools/doc-comments.js +228 -0
  79. package/dist/tools/doc-comments.test.js +192 -0
  80. package/dist/tools/doc-edits.js +229 -0
  81. package/dist/tools/doc-edits.test.js +218 -0
  82. package/dist/tools/doc-export.js +87 -0
  83. package/dist/tools/doc-export.test.js +109 -0
  84. package/dist/tools/doc-revisions.js +133 -0
  85. package/dist/tools/doc-revisions.test.js +103 -0
  86. package/dist/tools/docs-ai.js +171 -0
  87. package/dist/tools/docs-ai.test.js +160 -0
  88. package/dist/tools/docs.js +557 -0
  89. package/dist/tools/docs.test.js +365 -0
  90. package/dist/tools/embedding-status.js +100 -0
  91. package/dist/tools/embedding-status.test.js +86 -0
  92. package/dist/tools/free-busy.js +49 -0
  93. package/dist/tools/free-busy.test.js +46 -0
  94. package/dist/tools/get-checklists.js +72 -0
  95. package/dist/tools/get-project-primer.js +58 -0
  96. package/dist/tools/get-project.js +77 -0
  97. package/dist/tools/get-ticket.js +321 -0
  98. package/dist/tools/get-timer.js +62 -0
  99. package/dist/tools/help.js +44 -0
  100. package/dist/tools/identity.js +60 -0
  101. package/dist/tools/identity.test.js +67 -0
  102. package/dist/tools/list-git-app-installations.js +45 -0
  103. package/dist/tools/list-projects.js +72 -0
  104. package/dist/tools/list-projects.test.js +67 -0
  105. package/dist/tools/list-tickets.js +86 -0
  106. package/dist/tools/milestones-crud.test.js +154 -0
  107. package/dist/tools/milestones.js +279 -0
  108. package/dist/tools/milestones.test.js +60 -0
  109. package/dist/tools/my-tickets.js +65 -0
  110. package/dist/tools/personal-facts.js +72 -0
  111. package/dist/tools/personal-facts.test.js +45 -0
  112. package/dist/tools/phase-b-tools.test.js +530 -0
  113. package/dist/tools/portfolio.js +56 -0
  114. package/dist/tools/primer-facts.js +346 -0
  115. package/dist/tools/primer-facts.test.js +353 -0
  116. package/dist/tools/project-listings.js +116 -0
  117. package/dist/tools/project-listings.test.js +106 -0
  118. package/dist/tools/query.js +65 -0
  119. package/dist/tools/query.test.js +128 -0
  120. package/dist/tools/raci.js +108 -0
  121. package/dist/tools/raci.test.js +70 -0
  122. package/dist/tools/requirements-spec.js +89 -0
  123. package/dist/tools/requirements-spec.test.js +62 -0
  124. package/dist/tools/response-expand.js +104 -0
  125. package/dist/tools/review-policy.js +105 -0
  126. package/dist/tools/review-policy.test.js +71 -0
  127. package/dist/tools/search.js +78 -0
  128. package/dist/tools/session-start.js +435 -0
  129. package/dist/tools/session-start.test.js +568 -0
  130. package/dist/tools/set-parent.js +76 -0
  131. package/dist/tools/set-parent.test.js +115 -0
  132. package/dist/tools/shared.js +226 -0
  133. package/dist/tools/shared.test.js +169 -0
  134. package/dist/tools/similar-projection.js +30 -0
  135. package/dist/tools/ticket-attachments.js +131 -0
  136. package/dist/tools/ticket-attachments.test.js +106 -0
  137. package/dist/tools/ticket-schedules.js +112 -0
  138. package/dist/tools/ticket-schedules.test.js +102 -0
  139. package/dist/tools/ticket-writes.js +778 -0
  140. package/dist/tools/ticket-writes.test.js +643 -0
  141. package/dist/tools/time-writes.js +238 -0
  142. package/dist/tools/time-writes.test.js +138 -0
  143. package/dist/tools/update-project.js +165 -0
  144. package/dist/tools/update-project.test.js +172 -0
  145. package/dist/tools/wiki.js +185 -0
  146. package/dist/tools/wiki.test.js +104 -0
  147. package/dist/tools/work-sessions-next.test.js +150 -0
  148. package/dist/tools/work-sessions-start.test.js +174 -0
  149. package/dist/tools/work-sessions.js +709 -0
  150. package/dist/tools/work-sessions.test.js +358 -0
  151. package/dist/toolset.js +149 -0
  152. package/dist/toolset.test.js +150 -0
  153. package/dist/version-guard.test.js +51 -0
  154. package/dist/version.js +28 -0
  155. package/dist/with-metrics-mail-nudge.test.js +41 -0
  156. package/dist/with-metrics-output-schema.test.js +91 -0
  157. package/dist/with-metrics.js +211 -0
  158. package/dist/with-metrics.test.js +230 -0
  159. package/package.json +66 -0
@@ -0,0 +1,120 @@
1
+ /**
2
+ * ORB-244 Phase C Group 4 - admin-only tool tests.
3
+ */
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { OrbotoClient } from '../orboto-client.js';
6
+ import { makeListUsersHandler, makeGetAuditLogHandler, makeTriggerBackupHandler, } from './admin-writes.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: 'OK',
23
+ json: async () => ('json' in r ? r.json : {}),
24
+ text: async () => '',
25
+ };
26
+ });
27
+ return calls;
28
+ }
29
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
30
+ describe('orboto_list_users', () => {
31
+ it('passes search + limit, surfaces user tags in text output', async () => {
32
+ const calls = stub([{
33
+ json: {
34
+ items: [
35
+ { id: 'u1', email: 'ada@acme', fullName: 'Ada', isActive: true, isExternal: false, isBot: false, createdAt: 'now' },
36
+ { id: 'u2', email: 'bot@acme', fullName: 'CI Bot', isActive: true, isExternal: false, isBot: true, createdAt: 'now' },
37
+ { id: 'u3', email: 'inactive@acme', fullName: 'Old', isActive: false, isExternal: false, isBot: false, createdAt: 'now' },
38
+ ],
39
+ nextCursor: null,
40
+ },
41
+ }]);
42
+ const res = await makeListUsersHandler(client)({ search: 'acme', limit: 10 });
43
+ expect(calls[0].url).toContain('search=acme');
44
+ expect(calls[0].url).toContain('limit=10');
45
+ const text = res.content[0].text;
46
+ expect(text).toContain('[bot]');
47
+ expect(text).toContain('[disabled]');
48
+ });
49
+ it('rewrites a 403 into a "super-admin required" message', async () => {
50
+ stub([{ ok: false, status: 403, json: { error: 'Forbidden' } }]);
51
+ await expect(makeListUsersHandler(client)({})).rejects.toThrow(/super-admin permissions/);
52
+ });
53
+ });
54
+ describe('orboto_get_audit_log', () => {
55
+ it('plain entityType filter just queries with entityType', async () => {
56
+ const calls = stub([{ json: { items: [], nextCursor: null } }]);
57
+ await makeGetAuditLogHandler(client)({ entityType: 'user', limit: 25 });
58
+ expect(calls[0].url).toContain('/admin/audit-log?');
59
+ expect(calls[0].url).toContain('entityType=user');
60
+ expect(calls[0].url).toContain('limit=25');
61
+ });
62
+ it('actorEmail resolves through admin-users to actorId', async () => {
63
+ const calls = stub([
64
+ { json: { items: [{ id: 'u1', email: 'ada@acme', fullName: 'Ada', isActive: true, isExternal: false, isBot: false, createdAt: 'now' }], nextCursor: null } },
65
+ { json: { items: [], nextCursor: null } },
66
+ ]);
67
+ await makeGetAuditLogHandler(client)({ actorEmail: 'ada@acme' });
68
+ // Call 0 = users-search lookup, Call 1 = audit-log with actorId=u1
69
+ expect(calls[0].url).toContain('/admin/users');
70
+ expect(calls[1].url).toContain('actorId=u1');
71
+ });
72
+ it('throws when actorEmail does not match a workspace user', async () => {
73
+ stub([
74
+ { json: { items: [], nextCursor: null } },
75
+ ]);
76
+ await expect(makeGetAuditLogHandler(client)({ actorEmail: 'ghost@acme' })).rejects.toThrow(/No workspace user with email/);
77
+ });
78
+ it('renders entries with actor + action + entity hint in text', async () => {
79
+ stub([{
80
+ json: {
81
+ items: [
82
+ { id: 'a1', actorId: 'u1', actorEmail: 'ada@acme', actorName: 'Ada', action: 'user.deactivated', entityType: 'user', entityId: 'u9-aaaaaaaa-...', details: {}, createdAt: '2026-04-25T07:00:00Z' },
83
+ ],
84
+ nextCursor: null,
85
+ },
86
+ }]);
87
+ const res = await makeGetAuditLogHandler(client)({});
88
+ const text = res.content[0].text;
89
+ expect(text).toContain('Ada → user.deactivated user:u9-aaaaa');
90
+ });
91
+ });
92
+ describe('orboto_trigger_backup', () => {
93
+ it('resolves jobName → jobId, then POSTs the run', async () => {
94
+ const calls = stub([
95
+ { json: [{ id: 'j1', name: 'nightly', scope: 'full', schedule: '0 3 * * *', isActive: true }] },
96
+ { json: { id: 'r1', jobId: 'j1', startedAt: 'now', finishedAt: null, status: 'running', storagePath: null, error: null } },
97
+ ]);
98
+ await makeTriggerBackupHandler(client)({ jobName: 'nightly' });
99
+ expect(calls[1].method).toBe('POST');
100
+ expect(calls[1].url).toContain('/admin/backup/jobs/j1/run');
101
+ });
102
+ it('throws when jobName does not match', async () => {
103
+ stub([
104
+ { json: [{ id: 'j1', name: 'nightly', scope: 'full', schedule: null, isActive: true }] },
105
+ ]);
106
+ await expect(makeTriggerBackupHandler(client)({ jobName: 'weekly' })).rejects.toThrow(/"weekly"/);
107
+ });
108
+ it('throws on a disabled job (don\'t silently no-op)', async () => {
109
+ stub([
110
+ { json: [{ id: 'j1', name: 'paused', scope: 'full', schedule: null, isActive: false }] },
111
+ ]);
112
+ await expect(makeTriggerBackupHandler(client)({ jobName: 'paused' })).rejects.toThrow(/currently disabled/);
113
+ });
114
+ it('rewrites a 403 from the API into a permission-clarifying error', async () => {
115
+ stub([
116
+ { ok: false, status: 403, json: { error: 'Forbidden' } },
117
+ ]);
118
+ await expect(makeTriggerBackupHandler(client)({ jobName: 'nightly' })).rejects.toThrow(/super-admin/);
119
+ });
120
+ });
@@ -0,0 +1,160 @@
1
+ /**
2
+ * ORB-705 - MCP coordination tools.
3
+ *
4
+ * Three tools layered on the ORB-704 REST surface:
5
+ * orboto_agent_heartbeat - wraps POST /v1/agent/heartbeat. Returns
6
+ * the sessionToken the agent persists for next call.
7
+ * orboto_agent_presence - wraps GET /v1/agent/presence. Returns
8
+ * active sessions visible to the caller (own only for non-admin,
9
+ * workspace-wide for super-admin).
10
+ * orboto_agent_notify - wraps the existing notification surface
11
+ * to dispatch a directed message to another agent / user.
12
+ * Routing target is identified by email (works for both bots and
13
+ * humans). Fire-and-forget; the recipient sees it via the
14
+ * standard notifications channel (ORB-706 wires the bridge).
15
+ */
16
+ import { z } from 'zod';
17
+ import { mcpInstanceToken } from './shared.js';
18
+ export const agentHeartbeatToolConfig = {
19
+ title: 'Agent heartbeat (Multi-Agent Coordination)',
20
+ description: 'Register or refresh this agent\'s presence in the workspace. Call on startup AND every ~30 s thereafter; rows missing for >90 s count as offline. Returns the `sessionToken` to persist + send on subsequent heartbeats so the same row is bumped instead of churning new ones. `status` can be `idle` (default), `working` (set `workingOnTicketId` too if relevant), or `blocked`. `capabilities` is a free-form list of strings the operator can use to filter (e.g. `["read-only", "writes-tickets", "writes-code"]`). `clientInfo` describes the runtime - fill `name` with the agent runtime (`claude-desktop`, `claude-code`, `dispatcher-daemon`, `cursor`, ...).',
21
+ inputSchema: z.object({
22
+ sessionToken: z.string().nullable().optional(),
23
+ status: z.enum(['idle', 'working', 'blocked']).optional(),
24
+ workingOnTicketId: z.string().uuid().nullable().optional(),
25
+ capabilities: z.array(z.string()).optional(),
26
+ clientInfo: z.object({
27
+ name: z.string().optional(),
28
+ version: z.string().optional(),
29
+ host: z.string().optional(),
30
+ user_agent: z.string().optional(),
31
+ }).optional(),
32
+ }).shape,
33
+ outputSchema: z.object({
34
+ sessionToken: z.string(),
35
+ sessionId: z.string().uuid(),
36
+ }).shape,
37
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
38
+ };
39
+ export function makeAgentHeartbeatHandler(client) {
40
+ return async (args) => {
41
+ const res = await client.post('/v1/agent/heartbeat', args);
42
+ return {
43
+ content: [{ type: 'text', text: `heartbeat ack - sessionToken=${res.sessionToken.slice(0, 8)}…` }],
44
+ structuredContent: { sessionToken: res.sessionToken, sessionId: res.sessionId },
45
+ };
46
+ };
47
+ }
48
+ export const agentPresenceToolConfig = {
49
+ title: 'Workspace agent presence',
50
+ description: 'Returns currently-active agent sessions in the workspace. Active = heartbeat within the last 90 s. Super-admins see every agent; regular users see only their own sessions (useful for "is my dispatcher daemon alive?" checks). Each row exposes `userId`, `userEmail`, the agent runtime (`clientInfo.name`), declared `capabilities`, current `status`, and the ticket the agent is working on if any. Use this to plan multi-agent work - e.g. before dispatching a sub-task, look up which other agents are active and what they\'re working on so you don\'t step on a parallel run.',
51
+ inputSchema: z.object({}).shape,
52
+ outputSchema: z.object({
53
+ sessions: z.array(z.object({
54
+ userId: z.string().uuid(),
55
+ userEmail: z.string(),
56
+ userFullName: z.string().nullable(),
57
+ sessionId: z.string().uuid(),
58
+ status: z.string(),
59
+ workingOnTicket: z.object({
60
+ id: z.string().uuid(),
61
+ key: z.string().nullable(),
62
+ title: z.string(),
63
+ projectKey: z.string().nullable(),
64
+ }).nullable(),
65
+ capabilities: z.array(z.string()),
66
+ clientInfo: z.record(z.string(), z.string()),
67
+ lastSeenAt: z.string(),
68
+ createdAt: z.string(),
69
+ })),
70
+ }).shape,
71
+ annotations: { readOnlyHint: true, idempotentHint: true },
72
+ };
73
+ export function makeAgentPresenceHandler(client) {
74
+ return async () => {
75
+ const sessions = await client.get('/v1/agent/presence');
76
+ const lines = [];
77
+ if (sessions.length === 0) {
78
+ lines.push('No active agent sessions in the workspace.');
79
+ }
80
+ else {
81
+ lines.push(`${sessions.length} active session(s):`);
82
+ for (const s of sessions) {
83
+ const name = s.userFullName ?? s.userEmail;
84
+ const runtime = s.clientInfo.name ?? 'unknown';
85
+ const work = s.workingOnTicket
86
+ ? ` · working on [${s.workingOnTicket.projectKey ?? '?'}] ${s.workingOnTicket.key ?? s.workingOnTicket.id} (${s.workingOnTicket.title})`
87
+ : '';
88
+ lines.push(`- ${name} (${runtime}) - ${s.status}${work}`);
89
+ }
90
+ }
91
+ return {
92
+ content: [{ type: 'text', text: lines.join('\n') }],
93
+ structuredContent: { sessions },
94
+ };
95
+ };
96
+ }
97
+ export const agentNotifyToolConfig = {
98
+ title: 'Notify another agent / user',
99
+ description: 'Dispatch a fire-and-forget message to a target user (bot or human) identified by email. The recipient sees it via the standard notification channel - in-app for human users, MCP push for subscribed agents (ORB-706). Use cases: lead agent telling a worker a sub-task is ready; a worker reporting back to the orchestrator; a CI agent pinging a reviewer when a PR is staged. The `kind` field tags the semantic intent so the recipient can filter (`info` for general updates, `request` when a response is expected, `complete` when reporting a finished sub-task). `payload` is a free-form jsonb blob.',
100
+ inputSchema: z.object({
101
+ targetEmail: z.string().email(),
102
+ kind: z.enum(['info', 'request', 'complete', 'error']).default('info'),
103
+ subject: z.string().min(1).max(200),
104
+ payload: z.record(z.string(), z.unknown()).optional(),
105
+ // ORB-1727 - reply chaining: the id of the inbox message being answered.
106
+ threadId: z.string().uuid().optional(),
107
+ // ORB-1732 - optional project scope: address "the agent working project
108
+ // X" when the recipient identity runs multiple sessions.
109
+ project: z.string().min(1).max(64).optional().describe('Project key (ORB) or UUID: scope the message to the recipient session working that project. Recipients fetching with a project filter see scoped messages for their project plus unscoped ones.'),
110
+ // ORB-1742 - defaults to this MCP session's instance token so a shared
111
+ // identity never wakes itself with its own outbound mail.
112
+ senderRef: z.string().min(1).max(128).optional().describe('Sender-session ref for self-echo exclusion. Defaults to this MCP session automatically - only override when sending on behalf of another session.'),
113
+ }).shape,
114
+ outputSchema: z.object({
115
+ ok: z.literal(true),
116
+ messageId: z.string().uuid(),
117
+ }).shape,
118
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
119
+ };
120
+ // ---------------------------------------------------------------------------
121
+ // orboto_agent_broadcast (ORB-964)
122
+ // ---------------------------------------------------------------------------
123
+ export const agentBroadcastToolConfig = {
124
+ title: 'Scoped broadcast to other agents',
125
+ description: 'Fan-out a message to every subscribed agent in a scope. `scopeType=workspace` reaches every internal member; `scopeType=project` reaches members of `scopeId` (project UUID); `scopeType=topic` reaches everyone subscribed to the topic string. The recipient sees it via a `notifications/resources/updated` push on `orboto://broadcast/<scope>/<scope_id>`. Use for lead-agent → workers (workspace), project-team status updates (project), or ad-hoc cross-cutting coordination (topic). Retention is 24 h - late subscribers can replay via the resource read.',
126
+ inputSchema: z.object({
127
+ scopeType: z.enum(['workspace', 'project', 'topic']),
128
+ scopeId: z.string().default(''),
129
+ message: z.record(z.string(), z.unknown()),
130
+ }).shape,
131
+ outputSchema: z.object({ id: z.string().uuid() }).shape,
132
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
133
+ };
134
+ export function makeAgentBroadcastHandler(client) {
135
+ return async (args) => {
136
+ const res = await client.post('/v1/agent/broadcast', {
137
+ scopeType: args.scopeType,
138
+ scopeId: args.scopeId ?? '',
139
+ message: args.message,
140
+ });
141
+ return {
142
+ content: [{ type: 'text', text: `broadcast posted - id=${res.id}` }],
143
+ structuredContent: { id: res.id },
144
+ };
145
+ };
146
+ }
147
+ export function makeAgentNotifyHandler(client) {
148
+ return async (args, extra) => {
149
+ // ORB-1742 - stamp the sender session automatically.
150
+ const senderRef = mcpInstanceToken(args.senderRef, extra);
151
+ const res = await client.post('/v1/agent/notify', { ...args, senderRef });
152
+ return {
153
+ // ORB-1727 - the message is durable now: it reaches the recipient's
154
+ // inbox even when they are offline (delivery via the pending-mail
155
+ // pointer on their next tool call).
156
+ content: [{ type: 'text', text: `notified ${args.targetEmail} (message ${res.messageId} - delivered live if connected, waits in their inbox otherwise)` }],
157
+ structuredContent: { ok: true, messageId: res.messageId },
158
+ };
159
+ };
160
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * ORB-705 - Multi-Agent Coordination MCP tools.
3
+ *
4
+ * Pins the contract that each tool wraps the right REST endpoint
5
+ * with the right body shape and surfaces the response. The actual
6
+ * end-to-end push behaviour is covered by the API-side tests in
7
+ * agent-sessions.test.ts.
8
+ */
9
+ import { afterEach, describe, expect, it, vi } from 'vitest';
10
+ import { OrbotoClient } from '../orboto-client.js';
11
+ import { makeAgentHeartbeatHandler, makeAgentPresenceHandler, makeAgentNotifyHandler, } from './agent-coordination.js';
12
+ import { makeAgentMessagesHandler } from './agent-messages.js';
13
+ afterEach(() => { vi.restoreAllMocks(); });
14
+ function stub(responses) {
15
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
16
+ const r = responses.shift();
17
+ if (!r)
18
+ throw new Error('unexpected fetch');
19
+ return {
20
+ ok: r.ok ?? true,
21
+ status: r.status ?? 200,
22
+ statusText: 'OK',
23
+ json: async () => ('json' in r ? r.json : {}),
24
+ text: async () => '',
25
+ };
26
+ });
27
+ }
28
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_test' });
29
+ describe('orboto_agent_heartbeat', () => {
30
+ it('POSTs to /v1/agent/heartbeat with the merged body + returns the session token', async () => {
31
+ stub([{ json: { sessionToken: 'abc-123-token', sessionId: '00000000-0000-0000-0000-000000000001' } }]);
32
+ const handler = makeAgentHeartbeatHandler(client);
33
+ const result = await handler({
34
+ status: 'working',
35
+ capabilities: ['writes-tickets'],
36
+ clientInfo: { name: 'claude-code' },
37
+ });
38
+ expect(result.structuredContent).toMatchObject({
39
+ sessionToken: 'abc-123-token',
40
+ sessionId: '00000000-0000-0000-0000-000000000001',
41
+ });
42
+ expect(result.content[0].text).toContain('heartbeat ack');
43
+ });
44
+ });
45
+ describe('orboto_agent_presence', () => {
46
+ it('GETs /v1/agent/presence and renders one line per session', async () => {
47
+ stub([{
48
+ json: [
49
+ {
50
+ userId: '00000000-0000-0000-0000-000000000001',
51
+ userEmail: 'alice@x.test',
52
+ userFullName: 'Alice',
53
+ sessionId: '00000000-0000-0000-0000-000000000010',
54
+ status: 'working',
55
+ workingOnTicket: { id: 't1', key: 'ORB-42', title: 'Test', projectKey: 'ORB' },
56
+ capabilities: ['writes-tickets'],
57
+ clientInfo: { name: 'claude-code' },
58
+ lastSeenAt: new Date().toISOString(),
59
+ createdAt: new Date().toISOString(),
60
+ },
61
+ {
62
+ userId: '00000000-0000-0000-0000-000000000002',
63
+ userEmail: 'bot@x.test',
64
+ userFullName: null,
65
+ sessionId: '00000000-0000-0000-0000-000000000011',
66
+ status: 'idle',
67
+ workingOnTicket: null,
68
+ capabilities: [],
69
+ clientInfo: { name: 'dispatcher-daemon' },
70
+ lastSeenAt: new Date().toISOString(),
71
+ createdAt: new Date().toISOString(),
72
+ },
73
+ ],
74
+ }]);
75
+ const handler = makeAgentPresenceHandler(client);
76
+ const result = await handler();
77
+ const text = result.content[0].text;
78
+ expect(text).toContain('2 active session(s)');
79
+ expect(text).toContain('Alice (claude-code) - working · working on [ORB] ORB-42');
80
+ expect(text).toContain('bot@x.test (dispatcher-daemon) - idle');
81
+ expect(result.structuredContent.sessions).toHaveLength(2);
82
+ });
83
+ it('handles an empty workspace gracefully', async () => {
84
+ stub([{ json: [] }]);
85
+ const handler = makeAgentPresenceHandler(client);
86
+ const result = await handler();
87
+ expect(result.content[0].text).toBe('No active agent sessions in the workspace.');
88
+ });
89
+ });
90
+ describe('orboto_agent_notify', () => {
91
+ it('POSTs to /v1/agent/notify with the body the user supplied', async () => {
92
+ const capturedBody = [];
93
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
94
+ capturedBody.push(JSON.parse(init?.body ?? '{}'));
95
+ return {
96
+ ok: true,
97
+ status: 200,
98
+ statusText: 'OK',
99
+ json: async () => ({ ok: true }),
100
+ text: async () => '',
101
+ };
102
+ });
103
+ const handler = makeAgentNotifyHandler(client);
104
+ const result = await handler({
105
+ targetEmail: 'bob@example.com',
106
+ kind: 'request',
107
+ subject: 'Please review ORB-42',
108
+ payload: { ticketKey: 'ORB-42' },
109
+ });
110
+ expect(capturedBody[0]).toMatchObject({
111
+ targetEmail: 'bob@example.com',
112
+ kind: 'request',
113
+ subject: 'Please review ORB-42',
114
+ payload: { ticketKey: 'ORB-42' },
115
+ });
116
+ expect(result.content[0].text).toContain('notified bob@example.com');
117
+ expect(result.structuredContent).toMatchObject({ ok: true });
118
+ // ORB-1742 - the sender session's ref is stamped automatically (per-
119
+ // process fallback here, since no MCP sessionId rides along).
120
+ expect(capturedBody[0].senderRef).toMatch(/^mcp-/);
121
+ });
122
+ it('ORB-1742: senderRef prefers the per-connection MCP session id, and an explicit ref wins outright', async () => {
123
+ const capturedBody = [];
124
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
125
+ capturedBody.push(JSON.parse(init?.body ?? '{}'));
126
+ return {
127
+ ok: true, status: 200, statusText: 'OK',
128
+ json: async () => ({ ok: true, messageId: '00000000-0000-4000-8000-000000000000' }),
129
+ text: async () => '',
130
+ };
131
+ });
132
+ const handler = makeAgentNotifyHandler(client);
133
+ await handler({ targetEmail: 'bob@example.com', subject: 'hi' }, { sessionId: 'abc123' });
134
+ expect(capturedBody[0].senderRef).toBe('mcp-abc123');
135
+ await handler({ targetEmail: 'bob@example.com', subject: 'hi', senderRef: 'runner:custom' }, { sessionId: 'abc123' });
136
+ expect(capturedBody[1].senderRef).toBe('runner:custom');
137
+ });
138
+ });
139
+ describe('orboto_messages (ORB-1742 self-echo exclusion)', () => {
140
+ it('fetches with excludeRef = the session ref by default; includeOwnSends drops the filter', async () => {
141
+ const urls = [];
142
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => {
143
+ urls.push(String(url));
144
+ return {
145
+ ok: true, status: 200, statusText: 'OK',
146
+ json: async () => ({ messages: [] }),
147
+ text: async () => '',
148
+ };
149
+ });
150
+ const handler = makeAgentMessagesHandler(client);
151
+ await handler({}, { sessionId: 'abc123' });
152
+ expect(urls[0]).toContain('excludeRef=mcp-abc123');
153
+ await handler({ includeOwnSends: true }, { sessionId: 'abc123' });
154
+ expect(urls[1]).not.toContain('excludeRef');
155
+ });
156
+ });
@@ -0,0 +1,79 @@
1
+ /**
2
+ * ORB-543 / ORB-1344 - agent-drift admin MCP tools.
3
+ *
4
+ * - `orboto_admin_agent_drift_list` - paginated drift log with filters
5
+ * (user, drift type, date range, resolved status) + aggregate metrics.
6
+ * - `orboto_admin_agent_drift_resolve` - mark one drift event handled.
7
+ *
8
+ * Both wrap `/admin/agent-drift*` and require `admin:agent_drift:read` /
9
+ * `admin:agent_drift:write` (super-admin holds both).
10
+ */
11
+ import { z } from 'zod';
12
+ export const listAgentDriftToolConfig = {
13
+ title: 'List agent drift events',
14
+ description: 'List agent drift events (commits with no ticket key + no timer, or ticket transitions without a summary comment), newest first, with aggregate metrics. Filter by `userId`, `driftType` (untracked_commit | transition_without_summary), `from`/`to` ISO dates, and `resolved`. The response `enabled` flag is false when the operator has not turned drift detection on. Requires `admin:agent_drift:read`.',
15
+ inputSchema: z.object({
16
+ userId: z.string().uuid().optional().describe('Filter to one user.'),
17
+ driftType: z.enum(['untracked_commit', 'transition_without_summary']).optional(),
18
+ from: z.string().optional().describe('ISO timestamp lower bound (detected_at).'),
19
+ to: z.string().optional().describe('ISO timestamp upper bound (detected_at).'),
20
+ resolved: z.boolean().optional().describe('true = only resolved, false = only open.'),
21
+ limit: z.number().int().min(1).max(200).optional().describe('Default: 50.'),
22
+ cursor: z.string().optional().describe('Opaque cursor from a previous response.'),
23
+ }).shape,
24
+ annotations: { readOnlyHint: true, idempotentHint: true },
25
+ };
26
+ export function makeListAgentDriftHandler(client) {
27
+ return async (args) => {
28
+ const qs = new URLSearchParams();
29
+ if (args.userId)
30
+ qs.set('userId', args.userId);
31
+ if (args.driftType)
32
+ qs.set('driftType', args.driftType);
33
+ if (args.from)
34
+ qs.set('from', args.from);
35
+ if (args.to)
36
+ qs.set('to', args.to);
37
+ if (args.resolved !== undefined)
38
+ qs.set('resolved', String(args.resolved));
39
+ if (args.limit)
40
+ qs.set('limit', String(args.limit));
41
+ if (args.cursor)
42
+ qs.set('cursor', args.cursor);
43
+ const path = `/admin/agent-drift${qs.toString() ? `?${qs.toString()}` : ''}`;
44
+ const res = await client.get(path);
45
+ const header = res.enabled ? '' : '(drift detection is currently DISABLED - showing historical rows only)\n';
46
+ const lines = res.items.length === 0
47
+ ? '(no drift events)'
48
+ : res.items.map((e) => {
49
+ const who = e.userName ?? e.userEmail ?? e.userId.slice(0, 8);
50
+ const where = e.driftType === 'untracked_commit'
51
+ ? `${e.commitSha?.slice(0, 8) ?? '?'} on ${e.repoUrl ?? '?'}`
52
+ : `transition on ${e.ticketKey ?? e.ticketId?.slice(0, 8) ?? '?'}`;
53
+ const state = e.resolvedAt ? 'resolved' : (e.retroTicketKey ? `retro ${e.retroTicketKey}` : 'open');
54
+ return `- [${e.id.slice(0, 8)}] ${e.driftType} - ${who} - ${where} (${state}, ${e.detectedAt})`;
55
+ }).join('\n');
56
+ const metrics = `\n\nTotals: ${res.metrics.total} | ${Object.entries(res.metrics.byType).map(([k, v]) => `${k}=${v}`).join(', ')}`;
57
+ return {
58
+ content: [{ type: 'text', text: header + lines + metrics + (res.nextCursor ? `\n\n(next cursor: ${res.nextCursor})` : '') }],
59
+ structuredContent: res,
60
+ };
61
+ };
62
+ }
63
+ export const resolveAgentDriftToolConfig = {
64
+ title: 'Resolve an agent drift event',
65
+ description: 'Mark one agent drift event handled (stamps resolved_at). Idempotent. 404 if the id is unknown. Requires `admin:agent_drift:write`.',
66
+ inputSchema: z.object({
67
+ id: z.string().uuid().describe('UUID of the drift event to resolve.'),
68
+ }).shape,
69
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
70
+ };
71
+ export function makeResolveAgentDriftHandler(client) {
72
+ return async ({ id }) => {
73
+ const res = await client.post(`/admin/agent-drift/${id}/resolve`, {});
74
+ return {
75
+ content: [{ type: 'text', text: `Resolved drift event ${id} at ${res.resolvedAt}.` }],
76
+ structuredContent: res,
77
+ };
78
+ };
79
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * ORB-543 / ORB-1344 - unit tests for the agent-drift admin MCP tools.
3
+ */
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { OrbotoApiError, OrbotoClient } from '../orboto-client.js';
6
+ import { makeListAgentDriftHandler, makeResolveAgentDriftHandler } from './agent-drift.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
+ calls.push({
13
+ url: url.toString(),
14
+ method: init?.method ?? 'GET',
15
+ body: init?.body ? JSON.parse(init.body) : undefined,
16
+ });
17
+ const r = responses.shift();
18
+ if (!r)
19
+ throw new Error('unexpected extra fetch');
20
+ return {
21
+ ok: r.ok ?? true,
22
+ status: r.status ?? 200,
23
+ statusText: 'OK',
24
+ json: async () => ('json' in r ? r.json : {}),
25
+ text: async () => '',
26
+ };
27
+ });
28
+ return calls;
29
+ }
30
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
31
+ const EVENT = {
32
+ id: '11111111-1111-1111-1111-111111111111',
33
+ userId: '22222222-2222-2222-2222-222222222222',
34
+ userName: 'Claude Bot', userEmail: 'claude@orboto.io', userIsBot: true,
35
+ projectId: '33333333-3333-3333-3333-333333333333', projectKey: 'ORB',
36
+ driftType: 'untracked_commit',
37
+ commitSha: 'deadbeefcafe', repoUrl: 'https://github.com/x/y', commitMessage: 'wip',
38
+ pushId: 'abc', ticketId: null, ticketKey: null,
39
+ detectedAt: '2026-07-02T10:00:00.000Z', resolvedAt: null,
40
+ retroTicketId: null, retroTicketKey: null,
41
+ };
42
+ describe('orboto_admin_agent_drift_list', () => {
43
+ it('renders events + metrics and forwards filters to the querystring', async () => {
44
+ const calls = stub([
45
+ { json: { items: [EVENT], nextCursor: 'cur1', metrics: { total: 1, byType: { untracked_commit: 1 }, byUser: [] }, enabled: true } },
46
+ ]);
47
+ const res = await makeListAgentDriftHandler(client)({
48
+ userId: EVENT.userId, driftType: 'untracked_commit', resolved: false, limit: 10,
49
+ });
50
+ expect(calls[0].url).toContain('/admin/agent-drift?');
51
+ expect(calls[0].url).toContain('userId=22222222');
52
+ expect(calls[0].url).toContain('driftType=untracked_commit');
53
+ expect(calls[0].url).toContain('resolved=false');
54
+ expect(calls[0].url).toContain('limit=10');
55
+ const text = res.content[0].type === 'text' ? res.content[0].text : '';
56
+ expect(text).toContain('untracked_commit');
57
+ expect(text).toContain('Claude Bot');
58
+ expect(text).toContain('next cursor: cur1');
59
+ });
60
+ it('flags a disabled workspace in the header', async () => {
61
+ stub([
62
+ { json: { items: [], nextCursor: null, metrics: { total: 0, byType: {}, byUser: [] }, enabled: false } },
63
+ ]);
64
+ const res = await makeListAgentDriftHandler(client)({});
65
+ const text = res.content[0].type === 'text' ? res.content[0].text : '';
66
+ expect(text).toMatch(/DISABLED/);
67
+ expect(text).toContain('(no drift events)');
68
+ });
69
+ it('surfaces a 403 as an OrbotoApiError', async () => {
70
+ stub([{ ok: false, status: 403, json: { error: 'Forbidden' } }]);
71
+ await expect(makeListAgentDriftHandler(client)({})).rejects.toBeInstanceOf(OrbotoApiError);
72
+ });
73
+ });
74
+ describe('orboto_admin_agent_drift_resolve', () => {
75
+ it('POSTs to the resolve endpoint', async () => {
76
+ const calls = stub([{ json: { id: EVENT.id, resolvedAt: '2026-07-02T11:00:00.000Z' } }]);
77
+ const res = await makeResolveAgentDriftHandler(client)({ id: EVENT.id });
78
+ expect(calls[0].method).toBe('POST');
79
+ expect(calls[0].url).toContain(`/admin/agent-drift/${EVENT.id}/resolve`);
80
+ const text = res.content[0].type === 'text' ? res.content[0].text : '';
81
+ expect(text).toContain('Resolved drift event');
82
+ });
83
+ it('surfaces a 404 as an OrbotoApiError', async () => {
84
+ stub([{ ok: false, status: 404, json: { error: 'Drift event not found' } }]);
85
+ await expect(makeResolveAgentDriftHandler(client)({ id: EVENT.id })).rejects.toBeInstanceOf(OrbotoApiError);
86
+ });
87
+ });