@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.
- package/LICENSE.md +21 -0
- package/README.md +123 -0
- package/dist/event-bridge.js +250 -0
- package/dist/event-bridge.test.js +165 -0
- package/dist/http-transport-binding.test.js +151 -0
- package/dist/http-transport.js +608 -0
- package/dist/http-transport.test.js +408 -0
- package/dist/index.js +128 -0
- package/dist/input-schema.js +293 -0
- package/dist/input-schema.test.js +198 -0
- package/dist/instructions-budget.test.js +34 -0
- package/dist/manifest-size.test.js +192 -0
- package/dist/mcp-instrument.js +24 -0
- package/dist/oauth-bootstrap.js +413 -0
- package/dist/oauth-bootstrap.test.js +212 -0
- package/dist/orboto-client.js +250 -0
- package/dist/orboto-client.test.js +141 -0
- package/dist/prompts.js +219 -0
- package/dist/prompts.test.js +79 -0
- package/dist/protect-text-usage.test.js +38 -0
- package/dist/resources.js +260 -0
- package/dist/resources.test.js +191 -0
- package/dist/response-budget.js +515 -0
- package/dist/response-budget.test.js +343 -0
- package/dist/server.js +547 -0
- package/dist/session-nudge.js +81 -0
- package/dist/session-nudge.test.js +100 -0
- package/dist/session-nudge.transport.test.js +100 -0
- package/dist/tool-annotations.test.js +97 -0
- package/dist/tool-docs.js +64 -0
- package/dist/tool-docs.test.js +90 -0
- package/dist/tool-parameters.test.js +145 -0
- package/dist/tools/absence-writes.js +93 -0
- package/dist/tools/absence-writes.test.js +106 -0
- package/dist/tools/admin-translations.js +83 -0
- package/dist/tools/admin-writes.js +174 -0
- package/dist/tools/admin-writes.test.js +120 -0
- package/dist/tools/agent-coordination.js +160 -0
- package/dist/tools/agent-coordination.test.js +156 -0
- package/dist/tools/agent-drift.js +79 -0
- package/dist/tools/agent-drift.test.js +87 -0
- package/dist/tools/agent-instructions.js +172 -0
- package/dist/tools/agent-instructions.test.js +124 -0
- package/dist/tools/agent-messages.js +75 -0
- package/dist/tools/ai-status.js +55 -0
- package/dist/tools/ai-status.test.js +43 -0
- package/dist/tools/ai-usage.js +68 -0
- package/dist/tools/analytics.js +72 -0
- package/dist/tools/analytics.test.js +71 -0
- package/dist/tools/api-call.js +72 -0
- package/dist/tools/api-call.test.js +104 -0
- package/dist/tools/api-search.js +77 -0
- package/dist/tools/api-search.test.js +90 -0
- package/dist/tools/approvals.js +68 -0
- package/dist/tools/approvals.test.js +48 -0
- package/dist/tools/attach.js +108 -0
- package/dist/tools/attach.test.js +117 -0
- package/dist/tools/backup.js +93 -0
- package/dist/tools/backup.test.js +108 -0
- package/dist/tools/bulk-create.js +213 -0
- package/dist/tools/bulk-create.test.js +135 -0
- package/dist/tools/bulk-writes.js +339 -0
- package/dist/tools/bulk-writes.test.js +239 -0
- package/dist/tools/check-similar.js +84 -0
- package/dist/tools/check-similar.test.js +101 -0
- package/dist/tools/checklist-writes.js +202 -0
- package/dist/tools/checklist-writes.test.js +184 -0
- package/dist/tools/claim.js +236 -0
- package/dist/tools/claim.test.js +207 -0
- package/dist/tools/critical-path.js +83 -0
- package/dist/tools/critical-path.test.js +114 -0
- package/dist/tools/cross-project-links.ee.js +124 -0
- package/dist/tools/cross-project-links.ee.test.js +163 -0
- package/dist/tools/customer-report.js +88 -0
- package/dist/tools/customer-report.test.js +55 -0
- package/dist/tools/doc-attachments.js +184 -0
- package/dist/tools/doc-attachments.test.js +136 -0
- package/dist/tools/doc-comments.js +228 -0
- package/dist/tools/doc-comments.test.js +192 -0
- package/dist/tools/doc-edits.js +229 -0
- package/dist/tools/doc-edits.test.js +218 -0
- package/dist/tools/doc-export.js +87 -0
- package/dist/tools/doc-export.test.js +109 -0
- package/dist/tools/doc-revisions.js +133 -0
- package/dist/tools/doc-revisions.test.js +103 -0
- package/dist/tools/docs-ai.js +171 -0
- package/dist/tools/docs-ai.test.js +160 -0
- package/dist/tools/docs.js +557 -0
- package/dist/tools/docs.test.js +365 -0
- package/dist/tools/embedding-status.js +100 -0
- package/dist/tools/embedding-status.test.js +86 -0
- package/dist/tools/free-busy.js +49 -0
- package/dist/tools/free-busy.test.js +46 -0
- package/dist/tools/get-checklists.js +72 -0
- package/dist/tools/get-project-primer.js +58 -0
- package/dist/tools/get-project.js +77 -0
- package/dist/tools/get-ticket.js +321 -0
- package/dist/tools/get-timer.js +62 -0
- package/dist/tools/help.js +44 -0
- package/dist/tools/identity.js +60 -0
- package/dist/tools/identity.test.js +67 -0
- package/dist/tools/list-git-app-installations.js +45 -0
- package/dist/tools/list-projects.js +72 -0
- package/dist/tools/list-projects.test.js +67 -0
- package/dist/tools/list-tickets.js +86 -0
- package/dist/tools/milestones-crud.test.js +154 -0
- package/dist/tools/milestones.js +279 -0
- package/dist/tools/milestones.test.js +60 -0
- package/dist/tools/my-tickets.js +65 -0
- package/dist/tools/personal-facts.js +72 -0
- package/dist/tools/personal-facts.test.js +45 -0
- package/dist/tools/phase-b-tools.test.js +530 -0
- package/dist/tools/portfolio.js +56 -0
- package/dist/tools/primer-facts.js +346 -0
- package/dist/tools/primer-facts.test.js +353 -0
- package/dist/tools/project-listings.js +116 -0
- package/dist/tools/project-listings.test.js +106 -0
- package/dist/tools/query.js +65 -0
- package/dist/tools/query.test.js +128 -0
- package/dist/tools/raci.js +108 -0
- package/dist/tools/raci.test.js +70 -0
- package/dist/tools/requirements-spec.js +89 -0
- package/dist/tools/requirements-spec.test.js +62 -0
- package/dist/tools/response-expand.js +104 -0
- package/dist/tools/review-policy.js +105 -0
- package/dist/tools/review-policy.test.js +71 -0
- package/dist/tools/search.js +78 -0
- package/dist/tools/session-start.js +435 -0
- package/dist/tools/session-start.test.js +568 -0
- package/dist/tools/set-parent.js +76 -0
- package/dist/tools/set-parent.test.js +115 -0
- package/dist/tools/shared.js +226 -0
- package/dist/tools/shared.test.js +169 -0
- package/dist/tools/similar-projection.js +30 -0
- package/dist/tools/ticket-attachments.js +131 -0
- package/dist/tools/ticket-attachments.test.js +106 -0
- package/dist/tools/ticket-schedules.js +112 -0
- package/dist/tools/ticket-schedules.test.js +102 -0
- package/dist/tools/ticket-writes.js +778 -0
- package/dist/tools/ticket-writes.test.js +643 -0
- package/dist/tools/time-writes.js +238 -0
- package/dist/tools/time-writes.test.js +138 -0
- package/dist/tools/update-project.js +165 -0
- package/dist/tools/update-project.test.js +172 -0
- package/dist/tools/wiki.js +185 -0
- package/dist/tools/wiki.test.js +104 -0
- package/dist/tools/work-sessions-next.test.js +150 -0
- package/dist/tools/work-sessions-start.test.js +174 -0
- package/dist/tools/work-sessions.js +709 -0
- package/dist/tools/work-sessions.test.js +358 -0
- package/dist/toolset.js +149 -0
- package/dist/toolset.test.js +150 -0
- package/dist/version-guard.test.js +51 -0
- package/dist/version.js +28 -0
- package/dist/with-metrics-mail-nudge.test.js +41 -0
- package/dist/with-metrics-output-schema.test.js +91 -0
- package/dist/with-metrics.js +211 -0
- package/dist/with-metrics.test.js +230 -0
- 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
|
+
});
|