@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,212 @@
1
+ /**
2
+ * ORB-943 - unit tests for the stdio OAuth bootstrap core. No browser, no
3
+ * network, no DB: a fake fetch drives discovery/register/exchange/refresh, and
4
+ * a temp file backs the cache. The interactive loopback shell is exercised via
5
+ * its testable pieces (PKCE, authorize-url, request builders).
6
+ */
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
+ import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { createHash } from 'node:crypto';
12
+ import { generatePkce, buildAuthorizeUrl, deriveOrigin, discoverAuthServer, registerLoopbackClient, exchangeAuthCode, refreshTokens, loadCachedGrant, saveCachedGrant, clearCachedGrant, createTokenProvider, bootstrapOAuth, EXPIRY_SKEW_MS, } from './oauth-bootstrap.js';
13
+ describe('PKCE', () => {
14
+ it('produces an S256 challenge that verifies against the verifier', () => {
15
+ const { verifier, challenge, method } = generatePkce();
16
+ expect(method).toBe('S256');
17
+ const recomputed = createHash('sha256').update(verifier).digest('base64url');
18
+ expect(recomputed).toBe(challenge);
19
+ // base64url, no padding
20
+ expect(challenge).not.toContain('=');
21
+ expect(challenge).not.toContain('+');
22
+ });
23
+ });
24
+ describe('deriveOrigin', () => {
25
+ it('strips a trailing /api (single-host reverse-proxy layout)', () => {
26
+ expect(deriveOrigin('https://orboto.example.com/api')).toBe('https://orboto.example.com');
27
+ expect(deriveOrigin('https://orboto.example.com/api/')).toBe('https://orboto.example.com');
28
+ });
29
+ it('leaves a bare origin unchanged', () => {
30
+ expect(deriveOrigin('http://localhost:3000')).toBe('http://localhost:3000');
31
+ });
32
+ });
33
+ describe('buildAuthorizeUrl', () => {
34
+ it('encodes all PKCE + client params', () => {
35
+ const url = new URL(buildAuthorizeUrl('https://x.test/oauth/authorize', {
36
+ clientId: 'cid', redirectUri: 'http://127.0.0.1:5555/callback', challenge: 'chal', state: 'st', scope: 'mcp offline_access',
37
+ }));
38
+ expect(url.searchParams.get('response_type')).toBe('code');
39
+ expect(url.searchParams.get('client_id')).toBe('cid');
40
+ expect(url.searchParams.get('redirect_uri')).toBe('http://127.0.0.1:5555/callback');
41
+ expect(url.searchParams.get('code_challenge')).toBe('chal');
42
+ expect(url.searchParams.get('code_challenge_method')).toBe('S256');
43
+ expect(url.searchParams.get('state')).toBe('st');
44
+ expect(url.searchParams.get('scope')).toBe('mcp offline_access');
45
+ });
46
+ });
47
+ function jsonResponse(body, status = 200) {
48
+ return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
49
+ }
50
+ describe('discovery / register / exchange / refresh', () => {
51
+ it('discoverAuthServer validates completeness', async () => {
52
+ const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({
53
+ issuer: 'https://x.test',
54
+ authorization_endpoint: 'https://x.test/oauth/authorize',
55
+ token_endpoint: 'https://x.test/oauth/token',
56
+ registration_endpoint: 'https://x.test/oauth/register',
57
+ }));
58
+ const meta = await discoverAuthServer('https://x.test', fetchImpl);
59
+ expect(meta.token_endpoint).toBe('https://x.test/oauth/token');
60
+ });
61
+ it('discoverAuthServer throws on incomplete metadata', async () => {
62
+ const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ issuer: 'https://x.test' }));
63
+ await expect(discoverAuthServer('https://x.test', fetchImpl)).rejects.toThrow(/incomplete metadata/);
64
+ });
65
+ it('registerLoopbackClient returns the client_id', async () => {
66
+ const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ client_id: 'abc123' }, 201));
67
+ const id = await registerLoopbackClient('https://x.test/oauth/register', 'http://127.0.0.1:1/callback', fetchImpl);
68
+ expect(id).toBe('abc123');
69
+ });
70
+ it('exchangeAuthCode parses tokens + computes expiry', async () => {
71
+ const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({
72
+ access_token: 'at', refresh_token: 'rt', expires_in: 3600, scope: 'mcp offline_access',
73
+ }));
74
+ const before = Date.now();
75
+ const tokens = await exchangeAuthCode('https://x.test/oauth/token', {
76
+ clientId: 'cid', code: 'code', redirectUri: 'http://127.0.0.1:1/callback', verifier: 'v',
77
+ }, fetchImpl);
78
+ expect(tokens.accessToken).toBe('at');
79
+ expect(tokens.refreshToken).toBe('rt');
80
+ expect(tokens.expiresAt).toBeGreaterThanOrEqual(before + 3600 * 1000);
81
+ });
82
+ it('refreshTokens surfaces a non-2xx as an error', async () => {
83
+ const fetchImpl = vi.fn().mockResolvedValue(new Response('{"error":"invalid_grant"}', { status: 400 }));
84
+ await expect(refreshTokens('https://x.test/oauth/token', { clientId: 'cid', refreshToken: 'rt' }, fetchImpl))
85
+ .rejects.toThrow(/refresh failed: 400/);
86
+ });
87
+ });
88
+ describe('token cache', () => {
89
+ let dir;
90
+ let path;
91
+ beforeEach(() => {
92
+ dir = mkdtempSync(join(tmpdir(), 'orboto-oauth-'));
93
+ path = join(dir, 'cache.json');
94
+ });
95
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
96
+ it('round-trips a grant keyed by origin and writes 0600', () => {
97
+ saveCachedGrant('https://a.test', { clientId: 'c', refreshToken: 'r', scope: 'mcp', tokenEndpoint: 'https://a.test/oauth/token' }, path);
98
+ saveCachedGrant('https://b.test', { clientId: 'c2', refreshToken: 'r2', scope: 'mcp', tokenEndpoint: 'https://b.test/oauth/token' }, path);
99
+ expect(loadCachedGrant('https://a.test', path)?.clientId).toBe('c');
100
+ expect(loadCachedGrant('https://b.test', path)?.refreshToken).toBe('r2');
101
+ expect(loadCachedGrant('https://missing.test', path)).toBeNull();
102
+ // 0600 perms (owner rw only)
103
+ const mode = statSync(path).mode & 0o777;
104
+ expect(mode).toBe(0o600);
105
+ });
106
+ it('clearCachedGrant removes only the target origin', () => {
107
+ saveCachedGrant('https://a.test', { clientId: 'c', refreshToken: 'r', scope: 'mcp', tokenEndpoint: 'e' }, path);
108
+ saveCachedGrant('https://b.test', { clientId: 'c2', refreshToken: 'r2', scope: 'mcp', tokenEndpoint: 'e' }, path);
109
+ clearCachedGrant('https://a.test', path);
110
+ expect(loadCachedGrant('https://a.test', path)).toBeNull();
111
+ expect(loadCachedGrant('https://b.test', path)).not.toBeNull();
112
+ const file = JSON.parse(readFileSync(path, 'utf8'));
113
+ expect(Object.keys(file)).toEqual(['https://b.test']);
114
+ });
115
+ });
116
+ describe('createTokenProvider', () => {
117
+ // expiresAt comfortably larger than EXPIRY_SKEW_MS so "fresh" and "within
118
+ // skew" are both expressible with a fake clock.
119
+ const base = { accessToken: 'at0', refreshToken: 'rt0', expiresAt: 10_000_000, scope: 'mcp' };
120
+ it('returns the cached access token while fresh', async () => {
121
+ const refresh = vi.fn();
122
+ const now = () => 0; // well before expiry
123
+ const p = createTokenProvider(base, refresh, undefined, now);
124
+ expect(await p.getAccessToken()).toBe('at0');
125
+ expect(refresh).not.toHaveBeenCalled();
126
+ });
127
+ it('refreshes when within the expiry skew and rotates the refresh token', async () => {
128
+ const refresh = vi.fn().mockResolvedValue({ accessToken: 'at1', refreshToken: 'rt1', expiresAt: 10_000_000, scope: 'mcp' });
129
+ const onRefreshed = vi.fn();
130
+ const now = () => base.expiresAt - EXPIRY_SKEW_MS + 1; // inside skew window
131
+ const p = createTokenProvider(base, refresh, onRefreshed, now);
132
+ expect(await p.getAccessToken()).toBe('at1');
133
+ expect(refresh).toHaveBeenCalledWith('rt0');
134
+ expect(onRefreshed).toHaveBeenCalledWith(expect.objectContaining({ refreshToken: 'rt1' }));
135
+ });
136
+ it('forceRefresh renews on demand (401 path)', async () => {
137
+ const refresh = vi.fn().mockResolvedValue({ accessToken: 'at2', refreshToken: 'rt2', expiresAt: 10_000_000, scope: 'mcp' });
138
+ const p = createTokenProvider(base, refresh, undefined, () => 0);
139
+ expect(await p.forceRefresh()).toBe('at2');
140
+ expect(refresh).toHaveBeenCalledOnce();
141
+ });
142
+ it('preserves the prior refresh token if the AS omits a new one', async () => {
143
+ const refresh = vi.fn().mockResolvedValue({ accessToken: 'at3', refreshToken: null, expiresAt: 10_000_000, scope: 'mcp' });
144
+ const onRefreshed = vi.fn();
145
+ const p = createTokenProvider(base, refresh, onRefreshed, () => base.expiresAt);
146
+ await p.getAccessToken();
147
+ expect(onRefreshed).toHaveBeenCalledWith(expect.objectContaining({ refreshToken: 'rt0' }));
148
+ });
149
+ it('throws a clear error when no refresh token is available', async () => {
150
+ const noRefresh = { ...base, refreshToken: null };
151
+ const p = createTokenProvider(noRefresh, vi.fn(), undefined, () => base.expiresAt);
152
+ await expect(p.getAccessToken()).rejects.toThrow(/reconnect the client/);
153
+ });
154
+ it('ORB-1419 - single-flights concurrent getAccessToken calls into ONE refresh', async () => {
155
+ // Two concurrent callers that both see an expired token must share a single
156
+ // in-flight refresh rather than each firing one (which would present the OLD
157
+ // refresh token twice and trip server-side reuse-detection).
158
+ let resolveRefresh = () => { };
159
+ const refresh = vi.fn().mockImplementation(() => new Promise((r) => { resolveRefresh = r; }));
160
+ const p = createTokenProvider(base, refresh, undefined, () => base.expiresAt);
161
+ const a = p.getAccessToken();
162
+ const b = p.getAccessToken();
163
+ // Both callers are now awaiting; only one refresh should have been started.
164
+ expect(refresh).toHaveBeenCalledOnce();
165
+ expect(refresh).toHaveBeenCalledWith('rt0');
166
+ resolveRefresh({ accessToken: 'at-shared', refreshToken: 'rt1', expiresAt: 10_000_000, scope: 'mcp' });
167
+ expect(await a).toBe('at-shared');
168
+ expect(await b).toBe('at-shared');
169
+ expect(refresh).toHaveBeenCalledOnce();
170
+ });
171
+ });
172
+ describe('bootstrapOAuth cached-grant path', () => {
173
+ let dir;
174
+ let path;
175
+ beforeEach(() => {
176
+ dir = mkdtempSync(join(tmpdir(), 'orboto-oauth-'));
177
+ path = join(dir, 'cache.json');
178
+ });
179
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
180
+ it('refreshes silently from a cached grant without opening a browser', async () => {
181
+ saveCachedGrant('https://x.test', {
182
+ clientId: 'cid', refreshToken: 'rt0', scope: 'mcp offline_access', tokenEndpoint: 'https://x.test/oauth/token',
183
+ }, path);
184
+ const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({
185
+ access_token: 'fresh-at', refresh_token: 'rt1', expires_in: 3600, scope: 'mcp offline_access',
186
+ }));
187
+ const openBrowser = vi.fn();
188
+ const provider = await bootstrapOAuth({
189
+ apiBaseUrl: 'https://x.test/api', fetchImpl, openBrowser, cachePath: path, log: () => { },
190
+ });
191
+ expect(await provider.getAccessToken()).toBe('fresh-at');
192
+ expect(openBrowser).not.toHaveBeenCalled();
193
+ // rotation persisted
194
+ expect(loadCachedGrant('https://x.test', path)?.refreshToken).toBe('rt1');
195
+ });
196
+ it('clears a dead cached grant and falls through to discovery', async () => {
197
+ saveCachedGrant('https://x.test', {
198
+ clientId: 'cid', refreshToken: 'dead', scope: 'mcp', tokenEndpoint: 'https://x.test/oauth/token',
199
+ }, path);
200
+ // First call (refresh) 400s; then discovery is attempted (which we fail so
201
+ // we don't need to drive the whole interactive flow) - the point is the
202
+ // dead grant is cleared and discovery is reached.
203
+ const fetchImpl = vi.fn()
204
+ .mockResolvedValueOnce(new Response('{"error":"invalid_grant"}', { status: 400 })) // refresh
205
+ .mockResolvedValueOnce(new Response('nope', { status: 500 })); // discovery
206
+ await expect(bootstrapOAuth({
207
+ apiBaseUrl: 'https://x.test/api', fetchImpl: fetchImpl,
208
+ openBrowser: vi.fn(), cachePath: path, log: () => { },
209
+ })).rejects.toThrow(/discovery failed/);
210
+ expect(loadCachedGrant('https://x.test', path)).toBeNull();
211
+ });
212
+ });
@@ -0,0 +1,250 @@
1
+ /**
2
+ * ORB-244 Phase A - thin HTTP client the MCP server uses to talk to
3
+ * the orboto API.
4
+ *
5
+ * Deliberately speaks HTTPS-REST rather than importing `@orboto/api`'s
6
+ * services directly, for three reasons:
7
+ * 1. One code path covers both delivery variants from the ticket -
8
+ * Local-Proxy (`npx @orboto/mcp-cli`, running on the dev's laptop,
9
+ * pointing at the public orboto URL) AND Self-Hosted-inline
10
+ * (separate container in docker-compose, pointing at
11
+ * `http://api:3000`). The only difference is the env var.
12
+ * 2. The API's `requirePermission` / PBAC cascade runs server-side
13
+ * where it belongs. The MCP server is a pure transport adapter;
14
+ * it never sees the DB or trust boundary.
15
+ * 3. The `orb_*` API-key flow is already wired into the API's
16
+ * `authenticate` decorator - reusing it means the existing
17
+ * `mcp:use` + `api:use` scope checks, rate limits, and audit
18
+ * logs all light up for free.
19
+ *
20
+ * Every request adds `Authorization: Bearer <apiKey>` (from env) and
21
+ * a `User-Agent: orboto-mcp/<version>` header for the admin UI to
22
+ * distinguish MCP traffic from regular API traffic. Non-2xx responses
23
+ * throw `OrbotoApiError` so tool handlers can translate to MCP's
24
+ * `{isError: true}` shape.
25
+ */
26
+ import { VERSION } from './version.js';
27
+ export class OrbotoApiError extends Error {
28
+ status;
29
+ body;
30
+ url;
31
+ constructor(status, body, url) {
32
+ super(`orboto API ${status}: ${body || '(empty body)'}`);
33
+ this.status = status;
34
+ this.body = body;
35
+ this.url = url;
36
+ this.name = 'OrbotoApiError';
37
+ }
38
+ }
39
+ export class OrbotoClient {
40
+ baseUrl;
41
+ /** Headers common to every request EXCEPT Authorization, which is resolved
42
+ * per-request so an OAuth token provider can rotate the bearer. */
43
+ baseHeaders;
44
+ apiKey;
45
+ tokenProvider;
46
+ constructor(config) {
47
+ this.baseUrl = config.baseUrl.replace(/\/+$/, '');
48
+ if (!config.apiKey && !config.tokenProvider) {
49
+ throw new Error('OrbotoClient requires either an apiKey or a tokenProvider');
50
+ }
51
+ this.apiKey = config.apiKey;
52
+ this.tokenProvider = config.tokenProvider;
53
+ // User-Agent shape: `orboto-mcp/<version> (claude-desktop)`. The
54
+ // suffix is optional metadata so the admin's MCP-usage panel
55
+ // (Phase F) can group calls per client family without needing a
56
+ // new DB column.
57
+ const ua = config.userAgentSuffix
58
+ ? `orboto-mcp/${VERSION} (${config.userAgentSuffix})`
59
+ : `orboto-mcp/${VERSION}`;
60
+ this.baseHeaders = {
61
+ 'User-Agent': ua,
62
+ Accept: 'application/json',
63
+ };
64
+ }
65
+ async bearer(forceRefresh = false) {
66
+ if (this.tokenProvider) {
67
+ return forceRefresh ? this.tokenProvider.forceRefresh() : this.tokenProvider.getAccessToken();
68
+ }
69
+ return this.apiKey;
70
+ }
71
+ fullUrl(path) {
72
+ return `${this.baseUrl}${path.startsWith('/') ? '' : '/'}${path}`;
73
+ }
74
+ /**
75
+ * Auth-aware fetch. Injects the resolved bearer and, when an OAuth token
76
+ * provider is in play, retries a single 401 after a forced refresh - so an
77
+ * access token that expired (or was rotated by the api) is renewed inline
78
+ * instead of surfacing a spurious auth error to the tool caller. Throws
79
+ * `OrbotoApiError` on a non-2xx (after the retry). Returns the raw Response
80
+ * so callers parse json/text/binary as they need.
81
+ */
82
+ /** ORB-1727 - unread-inbox count from the latest api response header. */
83
+ pendingAgentMail = 0;
84
+ async authedFetch(url, init, accept) {
85
+ const doFetch = async (token) => {
86
+ const headers = {
87
+ ...this.baseHeaders,
88
+ ...(accept ? { Accept: accept } : {}),
89
+ ...(init.headers ?? {}),
90
+ Authorization: `Bearer ${token}`,
91
+ };
92
+ return fetch(url, { ...init, headers });
93
+ };
94
+ let res = await doFetch(await this.bearer());
95
+ if (res.status === 401 && this.tokenProvider) {
96
+ res = await doFetch(await this.bearer(true));
97
+ }
98
+ // ORB-1727 - the api stamps `x-orboto-agent-mail: <count>` on responses
99
+ // while the caller has unread inbox messages. Capture it centrally so
100
+ // the tool-response wrapper can nudge without any extra request.
101
+ const mail = res.headers?.get?.('x-orboto-agent-mail');
102
+ this.pendingAgentMail = mail ? Number(mail) || 0 : 0;
103
+ if (!res.ok) {
104
+ const body = await res.text().catch(() => '');
105
+ throw new OrbotoApiError(res.status, body, url);
106
+ }
107
+ return res;
108
+ }
109
+ /** GET a JSON endpoint. Throws `OrbotoApiError` on non-2xx. */
110
+ async get(path) {
111
+ const res = await this.authedFetch(this.fullUrl(path), { method: 'GET' });
112
+ return (await res.json());
113
+ }
114
+ /** POST a JSON body. */
115
+ async post(path, body) {
116
+ const res = await this.authedFetch(this.fullUrl(path), {
117
+ method: 'POST',
118
+ headers: { 'Content-Type': 'application/json' },
119
+ body: JSON.stringify(body),
120
+ });
121
+ if (res.status === 204)
122
+ return undefined;
123
+ return (await res.json());
124
+ }
125
+ /** PATCH a JSON body. */
126
+ async patch(path, body) {
127
+ const res = await this.authedFetch(this.fullUrl(path), {
128
+ method: 'PATCH',
129
+ headers: { 'Content-Type': 'application/json' },
130
+ body: JSON.stringify(body),
131
+ });
132
+ return (await res.json());
133
+ }
134
+ async put(path, body) {
135
+ const res = await this.authedFetch(this.fullUrl(path), {
136
+ method: 'PUT',
137
+ headers: { 'Content-Type': 'application/json' },
138
+ body: JSON.stringify(body),
139
+ });
140
+ return (await res.json());
141
+ }
142
+ /** DELETE. Returns the parsed body when the route sends one (some
143
+ * DELETEs 204, others return the mutated row - ORB-626 cancel does).
144
+ *
145
+ * ORB-1610 - `body` is optional and, when present (including `{}`),
146
+ * is sent as a real JSON body with `Content-Type: application/json`.
147
+ * A genuinely bodyless DELETE against a route whose schema declares
148
+ * `body: SomeSchema.optional()` 400s on Fastify's content-type
149
+ * parser (verified against `DELETE /work-sessions/:id/claims`) - a
150
+ * caller that means "release everything" must send `{}`, not omit
151
+ * the body entirely. */
152
+ async delete(path, body) {
153
+ const res = await this.authedFetch(this.fullUrl(path), {
154
+ method: 'DELETE',
155
+ ...(body !== undefined ? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {}),
156
+ });
157
+ if (res.status === 204)
158
+ return undefined;
159
+ const text = await res.text();
160
+ return (text ? JSON.parse(text) : undefined);
161
+ }
162
+ /**
163
+ * POST a `multipart/form-data` payload - used by ingest-file +
164
+ * attachment upload tools (ORB-799). The fetch API picks the boundary
165
+ * automatically when we let it set `Content-Type`, so we deliberately
166
+ * do NOT set `Content-Type: application/json` from the JSON helpers.
167
+ */
168
+ async postMultipart(path, form) {
169
+ const res = await this.authedFetch(this.fullUrl(path), {
170
+ method: 'POST',
171
+ body: form, // no Content-Type - fetch sets the boundary
172
+ });
173
+ if (res.status === 204)
174
+ return undefined;
175
+ return (await res.json());
176
+ }
177
+ /**
178
+ * GET an endpoint that returns plain text (Markdown export, CSV
179
+ * downloads, etc.) - bypasses the `Accept: application/json` header
180
+ * + the JSON-only parsing in `get()`. Returns the raw response body
181
+ * as a string. (ORB-915.)
182
+ */
183
+ async getText(path) {
184
+ const res = await this.authedFetch(this.fullUrl(path), { method: 'GET' }, '*/*');
185
+ return await res.text();
186
+ }
187
+ /**
188
+ * POST an endpoint that returns a binary body (PDF export, ZIP
189
+ * download, etc.). Returns the raw bytes as a Uint8Array - caller
190
+ * decides whether to base64 it for an MCP resource attachment or
191
+ * spill it to disk. (ORB-915.)
192
+ */
193
+ async postBinary(path, body) {
194
+ const res = await this.authedFetch(this.fullUrl(path), {
195
+ method: 'POST',
196
+ headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
197
+ body: body !== undefined ? JSON.stringify(body) : undefined,
198
+ }, '*/*');
199
+ const ab = await res.arrayBuffer();
200
+ return {
201
+ bytes: new Uint8Array(ab),
202
+ contentType: res.headers.get('content-type') ?? 'application/octet-stream',
203
+ };
204
+ }
205
+ /**
206
+ * GET an endpoint that returns a binary body (backup-run ZIP download,
207
+ * etc.). Same shape as postBinary, GET method. (ORB-1301.)
208
+ */
209
+ async getBinary(path) {
210
+ const res = await this.authedFetch(this.fullUrl(path), { method: 'GET' }, '*/*');
211
+ const ab = await res.arrayBuffer();
212
+ return {
213
+ bytes: new Uint8Array(ab),
214
+ contentType: res.headers.get('content-type') ?? 'application/octet-stream',
215
+ };
216
+ }
217
+ }
218
+ /**
219
+ * Preflight a new MCP session: verify the workspace has MCP enabled,
220
+ * the user holds the `mcp:use` permission, AND the user has not flipped
221
+ * their own MCP opt-out (ORB-942). Called once per session (stdio boot /
222
+ * HTTP initialize) and again on every rehydrate/adopt, so all three
223
+ * gates share one enforcement point. Throws a descriptive `Error` on any
224
+ * failure so the caller can translate to an MCP refuse-to-initialize
225
+ * response.
226
+ */
227
+ export async function preflightMcpSession(client) {
228
+ let status;
229
+ try {
230
+ status = await client.get('/system/mcp/status');
231
+ }
232
+ catch (err) {
233
+ if (err instanceof OrbotoApiError && err.status === 401) {
234
+ throw new Error('MCP preflight failed: the provided API key is invalid or expired.');
235
+ }
236
+ throw err;
237
+ }
238
+ if (!status.enabled) {
239
+ throw new Error('MCP preflight failed: the workspace administrator has disabled MCP access.');
240
+ }
241
+ if (!status.mcpUseGranted) {
242
+ throw new Error(`MCP preflight failed: user ${status.userEmail} lacks the mcp:use permission. Ask an admin to grant it.`);
243
+ }
244
+ // ORB-942 - per-user opt-out. Distinct message pointing at the toggle so
245
+ // the user knows this is their own setting, not an admin / permission block.
246
+ if (!status.userMcpEnabled) {
247
+ throw new Error('MCP preflight failed: you have disabled MCP access for your account. Re-enable it in Profile - Connect an AI Client.');
248
+ }
249
+ return { userEmail: status.userEmail };
250
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * ORB-244 Phase A - orboto REST client unit tests.
3
+ *
4
+ * We mock `fetch` and cover the shape we promise to every tool
5
+ * handler: correct base URL, bearer header, User-Agent string,
6
+ * JSON body for write verbs, and the `OrbotoApiError` throw on
7
+ * non-2xx with status + body captured.
8
+ *
9
+ * `preflightMcpSession` is tested too because its three-way
10
+ * failure branching (401 / disabled / permission-denied) is the
11
+ * most likely regression surface when the API contract drifts.
12
+ */
13
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
14
+ import { OrbotoClient, OrbotoApiError, preflightMcpSession } from './orboto-client.js';
15
+ beforeEach(() => { vi.restoreAllMocks(); });
16
+ afterEach(() => { vi.restoreAllMocks(); });
17
+ function mockFetch(response) {
18
+ const fullResponse = {
19
+ ok: response.ok ?? true,
20
+ status: response.status ?? 200,
21
+ statusText: response.statusText ?? 'OK',
22
+ json: response.json ?? (async () => ({})),
23
+ text: response.text ?? (async () => ''),
24
+ };
25
+ return vi.spyOn(globalThis, 'fetch').mockResolvedValue(fullResponse);
26
+ }
27
+ describe('OrbotoClient', () => {
28
+ it('strips trailing slashes from baseUrl', async () => {
29
+ const spy = mockFetch({ json: async () => ({ ok: true }) });
30
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com///', apiKey: 'orb_test' });
31
+ await client.get('/projects');
32
+ expect(spy).toHaveBeenCalledWith('https://orboto.example.com/projects', expect.any(Object));
33
+ });
34
+ it('prepends a missing leading slash on path', async () => {
35
+ const spy = mockFetch({ json: async () => ({}) });
36
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_test' });
37
+ await client.get('projects');
38
+ expect(spy).toHaveBeenCalledWith('https://orboto.example.com/projects', expect.any(Object));
39
+ });
40
+ it('sends Authorization: Bearer and User-Agent with optional client suffix', async () => {
41
+ const spy = mockFetch({ json: async () => ({}) });
42
+ const client = new OrbotoClient({
43
+ baseUrl: 'https://orboto.example.com',
44
+ apiKey: 'orb_test',
45
+ userAgentSuffix: 'claude-desktop',
46
+ });
47
+ await client.get('/projects');
48
+ const [, init] = spy.mock.calls[0];
49
+ const headers = init.headers;
50
+ expect(headers.Authorization).toBe('Bearer orb_test');
51
+ expect(headers['User-Agent']).toMatch(/^orboto-mcp\/[\d.]+ \(claude-desktop\)$/);
52
+ });
53
+ it('omits the UA suffix when userAgentSuffix is not set', async () => {
54
+ const spy = mockFetch({ json: async () => ({}) });
55
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_test' });
56
+ await client.get('/projects');
57
+ const [, init] = spy.mock.calls[0];
58
+ const headers = init.headers;
59
+ expect(headers['User-Agent']).toMatch(/^orboto-mcp\/[\d.]+$/);
60
+ });
61
+ it('serialises POST body as JSON with Content-Type', async () => {
62
+ const spy = mockFetch({ json: async () => ({ created: true }) });
63
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_test' });
64
+ await client.post('/tickets', { title: 'x' });
65
+ const [, init] = spy.mock.calls[0];
66
+ expect(init.method).toBe('POST');
67
+ expect(init.body).toBe('{"title":"x"}');
68
+ expect(init.headers['Content-Type']).toBe('application/json');
69
+ });
70
+ it('throws OrbotoApiError with captured status + body on non-2xx', async () => {
71
+ mockFetch({ ok: false, status: 403, text: async () => '{"error":"forbidden"}' });
72
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_test' });
73
+ await expect(client.get('/forbidden')).rejects.toMatchObject({
74
+ name: 'OrbotoApiError',
75
+ status: 403,
76
+ body: '{"error":"forbidden"}',
77
+ url: 'https://orboto.example.com/forbidden',
78
+ });
79
+ });
80
+ it('returns undefined (not null) for 204', async () => {
81
+ mockFetch({ status: 204 });
82
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_test' });
83
+ const result = await client.post('/action', {});
84
+ expect(result).toBeUndefined();
85
+ });
86
+ // ORB-799 - postMultipart sends FormData with no Content-Type header
87
+ // (fetch chooses the boundary). The bearer + UA headers still apply.
88
+ it('postMultipart sends FormData body without Content-Type (boundary picked by fetch)', async () => {
89
+ const spy = mockFetch({ json: async () => ({ docId: 'd1' }) });
90
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_test' });
91
+ const form = new FormData();
92
+ form.append('file', new Blob([new Uint8Array([0x4d, 0x44])], { type: 'text/markdown' }), 'note.md');
93
+ const result = await client.postMultipart('/spaces/abc/docs/ingest-file', form);
94
+ expect(result).toEqual({ docId: 'd1' });
95
+ const [, init] = spy.mock.calls[0];
96
+ const headers = init.headers;
97
+ expect(headers.Authorization).toBe('Bearer orb_test');
98
+ // No Content-Type header - fetch sets multipart/form-data; boundary=…
99
+ expect(headers['Content-Type']).toBeUndefined();
100
+ expect(init.method).toBe('POST');
101
+ });
102
+ it('postMultipart throws OrbotoApiError on non-2xx', async () => {
103
+ mockFetch({ ok: false, status: 413, text: async () => 'payload too large' });
104
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_test' });
105
+ const form = new FormData();
106
+ form.append('file', new Blob([new Uint8Array([0])], { type: 'application/octet-stream' }), 'x');
107
+ await expect(client.postMultipart('/x', form)).rejects.toBeInstanceOf(OrbotoApiError);
108
+ });
109
+ });
110
+ describe('preflightMcpSession', () => {
111
+ it('resolves {userEmail} when enabled + mcpUseGranted + userMcpEnabled', async () => {
112
+ mockFetch({ json: async () => ({ enabled: true, mcpUseGranted: true, userMcpEnabled: true, userEmail: 'a@b.c' }) });
113
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_test' });
114
+ await expect(preflightMcpSession(client)).resolves.toEqual({ userEmail: 'a@b.c' });
115
+ });
116
+ it('throws the per-user opt-out message when userMcpEnabled=false (ORB-942)', async () => {
117
+ mockFetch({ json: async () => ({ enabled: true, mcpUseGranted: true, userMcpEnabled: false, userEmail: 'a@b.c' }) });
118
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_test' });
119
+ await expect(preflightMcpSession(client)).rejects.toThrow(/you have disabled MCP access for your account/);
120
+ });
121
+ it('throws a helpful message when the API returns 401 (bad token)', async () => {
122
+ mockFetch({ ok: false, status: 401, text: async () => 'Invalid API key' });
123
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_bad' });
124
+ await expect(preflightMcpSession(client)).rejects.toThrow(/API key is invalid or expired/);
125
+ });
126
+ it('throws when the workspace has mcp_enabled=false', async () => {
127
+ mockFetch({ json: async () => ({ enabled: false, mcpUseGranted: true, userEmail: 'a@b.c' }) });
128
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_test' });
129
+ await expect(preflightMcpSession(client)).rejects.toThrow(/administrator has disabled MCP/);
130
+ });
131
+ it('throws when the user lacks mcp:use', async () => {
132
+ mockFetch({ json: async () => ({ enabled: true, mcpUseGranted: false, userEmail: 'a@b.c' }) });
133
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_test' });
134
+ await expect(preflightMcpSession(client)).rejects.toThrow(/lacks the mcp:use permission/);
135
+ });
136
+ it('propagates non-401 errors verbatim', async () => {
137
+ mockFetch({ ok: false, status: 503, text: async () => 'Service unavailable' });
138
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_test' });
139
+ await expect(preflightMcpSession(client)).rejects.toBeInstanceOf(OrbotoApiError);
140
+ });
141
+ });