@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,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-1175 - after a deploy the MCP container loses its in-memory session
|
|
3
|
+
* map, so a client's existing mcp-session-id is unknown. The transport
|
|
4
|
+
* must answer 404 (the Streamable-HTTP spec signal to re-initialise)
|
|
5
|
+
* rather than the old opaque 400, so OAuth-connected clients recover
|
|
6
|
+
* transparently instead of failing with a generic execution error.
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
9
|
+
import { createServer as createNodeServer } from 'node:http';
|
|
10
|
+
import { createHttpServer, closeAllMcpSessions, classifyUnknownSession, clientInfoLabel, } from './http-transport.js';
|
|
11
|
+
let server = null;
|
|
12
|
+
afterEach(() => { server?.close(); server = null; });
|
|
13
|
+
async function start() {
|
|
14
|
+
server = createHttpServer({ baseUrl: 'http://api.invalid' });
|
|
15
|
+
await new Promise((resolve) => server.listen(0, resolve));
|
|
16
|
+
const { port } = server.address();
|
|
17
|
+
return `http://127.0.0.1:${port}`;
|
|
18
|
+
}
|
|
19
|
+
const NON_INIT = JSON.stringify({ jsonrpc: '2.0', method: 'tools/list', id: 1 });
|
|
20
|
+
describe('ORB-1175 - stale MCP session recovery', () => {
|
|
21
|
+
it('returns a clean 404 (re-initialise signal, no auth challenge) for an unknown session id on a non-init request', async () => {
|
|
22
|
+
const base = await start();
|
|
23
|
+
const res = await fetch(`${base}/mcp`, {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: { authorization: 'Bearer orb_dummy', 'content-type': 'application/json', 'mcp-session-id': 'stale-after-deploy' },
|
|
26
|
+
body: NON_INIT,
|
|
27
|
+
});
|
|
28
|
+
expect(res.status).toBe(404);
|
|
29
|
+
// ORB-1324 - NO WWW-Authenticate: the token is fine, only the session is
|
|
30
|
+
// gone. Attaching an auth challenge made clients kick off a manual OAuth
|
|
31
|
+
// re-auth instead of the automatic re-initialise the 404 already signals.
|
|
32
|
+
// (Token expiry is still caught on the re-init path's preflight.)
|
|
33
|
+
expect(res.headers.get('www-authenticate')).toBeNull();
|
|
34
|
+
const body = await res.json();
|
|
35
|
+
expect(body.error).toMatch(/reinitialize/i);
|
|
36
|
+
});
|
|
37
|
+
it('still 401s when no bearer token is present', async () => {
|
|
38
|
+
const base = await start();
|
|
39
|
+
const res = await fetch(`${base}/mcp`, {
|
|
40
|
+
method: 'POST',
|
|
41
|
+
headers: { 'content-type': 'application/json', 'mcp-session-id': 'whatever' },
|
|
42
|
+
body: NON_INIT,
|
|
43
|
+
});
|
|
44
|
+
expect(res.status).toBe(401);
|
|
45
|
+
});
|
|
46
|
+
it('health probe stays open + cheap', async () => {
|
|
47
|
+
const base = await start();
|
|
48
|
+
const res = await fetch(`${base}/health`);
|
|
49
|
+
expect(res.status).toBe(200);
|
|
50
|
+
expect(await res.json()).toEqual({ status: 'ok' });
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
describe('ORB-1424 - GET /mcp OAuth-discovery probe', () => {
|
|
54
|
+
it('an unauthenticated GET /mcp returns 401 + WWW-Authenticate (not 405) so discovery finds the resource metadata', async () => {
|
|
55
|
+
const base = await start();
|
|
56
|
+
const res = await fetch(`${base}/mcp`, { method: 'GET' });
|
|
57
|
+
expect(res.status).toBe(401);
|
|
58
|
+
const challenge = res.headers.get('www-authenticate');
|
|
59
|
+
expect(challenge).toBeTruthy();
|
|
60
|
+
expect(challenge).toContain('resource_metadata=');
|
|
61
|
+
expect(challenge).toContain('/.well-known/oauth-protected-resource');
|
|
62
|
+
});
|
|
63
|
+
it('an authenticated GET /mcp still 405s (SSE resumption unimplemented; no discovery needed)', async () => {
|
|
64
|
+
const base = await start();
|
|
65
|
+
const res = await fetch(`${base}/mcp`, {
|
|
66
|
+
method: 'GET',
|
|
67
|
+
headers: { authorization: 'Bearer orb_dummy' },
|
|
68
|
+
});
|
|
69
|
+
expect(res.status).toBe(405);
|
|
70
|
+
expect(res.headers.get('allow')).toBe('POST, DELETE');
|
|
71
|
+
// A held token needs no auth challenge on the 405.
|
|
72
|
+
expect(res.headers.get('www-authenticate')).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
describe('ORB-941 - graceful close of in-flight MCP sessions on kill-switch', () => {
|
|
76
|
+
function fakeSession() {
|
|
77
|
+
const close = vi.fn().mockResolvedValue(undefined);
|
|
78
|
+
const log = vi.fn().mockResolvedValue(undefined);
|
|
79
|
+
const session = {
|
|
80
|
+
transport: { close },
|
|
81
|
+
mcp: { server: { sendLoggingMessage: log } },
|
|
82
|
+
client: {},
|
|
83
|
+
bridge: { close: vi.fn() },
|
|
84
|
+
tokenHolder: { current: 'orb_dummy' },
|
|
85
|
+
userEmail: 'owner@orboto.test',
|
|
86
|
+
lastTouchAt: Date.now(),
|
|
87
|
+
};
|
|
88
|
+
return { session, close, log };
|
|
89
|
+
}
|
|
90
|
+
it('emits a logging notice then closes every active transport', async () => {
|
|
91
|
+
const a = fakeSession();
|
|
92
|
+
const b = fakeSession();
|
|
93
|
+
const closed = await closeAllMcpSessions([a.session, b.session], 'disabled by admin.');
|
|
94
|
+
expect(closed).toBe(2);
|
|
95
|
+
expect(a.log).toHaveBeenCalledOnce();
|
|
96
|
+
expect(b.log).toHaveBeenCalledOnce();
|
|
97
|
+
expect(a.close).toHaveBeenCalledOnce();
|
|
98
|
+
expect(b.close).toHaveBeenCalledOnce();
|
|
99
|
+
// The reason is surfaced to the client in the notification payload.
|
|
100
|
+
expect(a.log.mock.calls[0][0]).toMatchObject({ level: 'warning' });
|
|
101
|
+
expect(a.log.mock.calls[0][0].data).toContain('disabled by admin.');
|
|
102
|
+
});
|
|
103
|
+
it('still closes the transport when the client never negotiated logging', async () => {
|
|
104
|
+
const s = fakeSession();
|
|
105
|
+
s.log.mockRejectedValueOnce(new Error('logging capability not negotiated'));
|
|
106
|
+
const closed = await closeAllMcpSessions([s.session], 'disabled by admin.');
|
|
107
|
+
expect(closed).toBe(1);
|
|
108
|
+
expect(s.close).toHaveBeenCalledOnce();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// ORB-1353 - session resilience: persist across restarts + auto-adopt.
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
describe('ORB-1353 - classifyUnknownSession (pure decision)', () => {
|
|
115
|
+
it('keeps the 404 re-init contract when auth is invalid', () => {
|
|
116
|
+
expect(classifyUnknownSession({ hasValidAuth: false, isPersistedForCaller: false })).toBe('reinit-404');
|
|
117
|
+
// Even a "persisted" id must not resurrect without valid auth.
|
|
118
|
+
expect(classifyUnknownSession({ hasValidAuth: false, isPersistedForCaller: true })).toBe('reinit-404');
|
|
119
|
+
});
|
|
120
|
+
it('rehydrates a persisted session owned by the caller (layer 1)', () => {
|
|
121
|
+
expect(classifyUnknownSession({ hasValidAuth: true, isPersistedForCaller: true })).toBe('rehydrate');
|
|
122
|
+
});
|
|
123
|
+
it('adopts an unknown id under valid auth (layer 2)', () => {
|
|
124
|
+
expect(classifyUnknownSession({ hasValidAuth: true, isPersistedForCaller: false })).toBe('adopt');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
describe('ORB-1353 - clientInfoLabel', () => {
|
|
128
|
+
it('formats name@version from an initialize body', () => {
|
|
129
|
+
expect(clientInfoLabel({ params: { clientInfo: { name: 'zcode', version: '2.1' } } })).toBe('zcode@2.1');
|
|
130
|
+
});
|
|
131
|
+
it('falls back to name-only and undefined', () => {
|
|
132
|
+
expect(clientInfoLabel({ params: { clientInfo: { name: 'zcode' } } })).toBe('zcode');
|
|
133
|
+
expect(clientInfoLabel({ params: {} })).toBeUndefined();
|
|
134
|
+
expect(clientInfoLabel(null)).toBeUndefined();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
// A controllable in-process fake of the api endpoints the transport touches:
|
|
138
|
+
// preflight (/system/mcp/status), the working-rules fetch, and the event
|
|
139
|
+
// bridge's SSE endpoint. DB-free - the persisted-session store is injected
|
|
140
|
+
// separately so a "restart" is just clearing the in-memory registry.
|
|
141
|
+
function fakeApi(opts = {}) {
|
|
142
|
+
const state = {
|
|
143
|
+
enabled: opts.enabled ?? true,
|
|
144
|
+
mcpUseGranted: opts.mcpUseGranted ?? true,
|
|
145
|
+
// ORB-942 - per-user MCP opt-out. Defaults on so existing flows are unaffected.
|
|
146
|
+
userMcpEnabled: opts.userMcpEnabled ?? true,
|
|
147
|
+
// ORB-1470 - record every Authorization header the api sees so a test can
|
|
148
|
+
// assert which bearer the transport forwarded upstream.
|
|
149
|
+
seenAuth: [],
|
|
150
|
+
};
|
|
151
|
+
const api = createNodeServer((req, res) => {
|
|
152
|
+
const url = req.url ?? '';
|
|
153
|
+
if (url.startsWith('/system/mcp/status')) {
|
|
154
|
+
state.seenAuth.push((req.headers.authorization ?? ''));
|
|
155
|
+
// A token of `Bearer invalid` models an expired/invalid credential.
|
|
156
|
+
if ((req.headers.authorization ?? '') === 'Bearer invalid') {
|
|
157
|
+
res.writeHead(401, { 'content-type': 'application/json' });
|
|
158
|
+
res.end(JSON.stringify({ error: 'invalid token' }));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
162
|
+
res.end(JSON.stringify({
|
|
163
|
+
enabled: state.enabled,
|
|
164
|
+
mcpUseGranted: state.mcpUseGranted,
|
|
165
|
+
userMcpEnabled: state.userMcpEnabled,
|
|
166
|
+
userEmail: 'u@example.com',
|
|
167
|
+
}));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (url.startsWith('/agent-instructions')) {
|
|
171
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
172
|
+
res.end(JSON.stringify({ instructions: '' }));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (url.startsWith('/sse/mcp-events')) {
|
|
176
|
+
// Empty stream - the bridge reconnects on close, cleared by session close.
|
|
177
|
+
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
|
178
|
+
res.end();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
182
|
+
res.end('{}');
|
|
183
|
+
});
|
|
184
|
+
return { api, state };
|
|
185
|
+
}
|
|
186
|
+
/** In-memory persisted-session store. Survives an in-memory-registry "restart";
|
|
187
|
+
* keyed identity by token (1:1 with a user in these tests). */
|
|
188
|
+
function fakeStore() {
|
|
189
|
+
const rows = new Map();
|
|
190
|
+
const register = vi.fn(async (token, meta) => {
|
|
191
|
+
rows.set(meta.sessionId, { userId: token, adoptedFrom: meta.adoptedFrom });
|
|
192
|
+
});
|
|
193
|
+
const resolve = vi.fn(async (token, sessionId) => {
|
|
194
|
+
const r = rows.get(sessionId);
|
|
195
|
+
return !!r && r.userId === token;
|
|
196
|
+
});
|
|
197
|
+
const remove = vi.fn(async (_token, sessionId) => { rows.delete(sessionId); });
|
|
198
|
+
const store = { register, resolve, remove };
|
|
199
|
+
return { rows, register, resolve, remove, store };
|
|
200
|
+
}
|
|
201
|
+
const INIT_BODY = JSON.stringify({
|
|
202
|
+
jsonrpc: '2.0',
|
|
203
|
+
id: 1,
|
|
204
|
+
method: 'initialize',
|
|
205
|
+
params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'test-client', version: '1.0' } },
|
|
206
|
+
});
|
|
207
|
+
describe('ORB-1353 - persisted-session resilience (transport, fake api)', () => {
|
|
208
|
+
let api = null;
|
|
209
|
+
const cleanups = [];
|
|
210
|
+
afterEach(async () => {
|
|
211
|
+
// Close any live MCP sessions so their event-bridge retry timers don't leak.
|
|
212
|
+
for (const c of cleanups.splice(0))
|
|
213
|
+
c();
|
|
214
|
+
if (server) {
|
|
215
|
+
const internals = server.__mcp;
|
|
216
|
+
if (internals) {
|
|
217
|
+
for (const s of internals.sessions.values()) {
|
|
218
|
+
try {
|
|
219
|
+
await s.transport.close();
|
|
220
|
+
}
|
|
221
|
+
catch { /* ignore */ }
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
await new Promise((r) => (api ? api.close(() => r()) : r()));
|
|
226
|
+
api = null;
|
|
227
|
+
});
|
|
228
|
+
async function startWithFakeApi(store, fake = fakeApi()) {
|
|
229
|
+
api = fake.api;
|
|
230
|
+
await new Promise((r) => api.listen(0, r));
|
|
231
|
+
const { port: apiPort } = api.address();
|
|
232
|
+
server = createHttpServer({ baseUrl: `http://127.0.0.1:${apiPort}`, sessionStore: store });
|
|
233
|
+
await new Promise((r) => server.listen(0, r));
|
|
234
|
+
const { port } = server.address();
|
|
235
|
+
const internals = server.__mcp;
|
|
236
|
+
return { base: `http://127.0.0.1:${port}`, internals };
|
|
237
|
+
}
|
|
238
|
+
async function post(base, body, headers) {
|
|
239
|
+
return fetch(`${base}/mcp`, {
|
|
240
|
+
method: 'POST',
|
|
241
|
+
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', ...headers },
|
|
242
|
+
body,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
it('rehydrates the SAME session id after an in-memory restart (layer 1)', async () => {
|
|
246
|
+
const store = fakeStore();
|
|
247
|
+
const { base, internals } = await startWithFakeApi(store.store);
|
|
248
|
+
// 1. Initialise a session and capture its id.
|
|
249
|
+
const initRes = await post(base, INIT_BODY, { authorization: 'Bearer orb_alice' });
|
|
250
|
+
expect(initRes.status).toBe(200);
|
|
251
|
+
const sid = initRes.headers.get('mcp-session-id');
|
|
252
|
+
await initRes.text();
|
|
253
|
+
expect(sid).toBeTruthy();
|
|
254
|
+
// The freshly-minted session was persisted.
|
|
255
|
+
await vi.waitFor(() => expect(store.rows.has(sid)).toBe(true));
|
|
256
|
+
// 2. Simulate an api restart: the in-memory registry is wiped, the store
|
|
257
|
+
// (DB) survives.
|
|
258
|
+
internals.sessions.clear();
|
|
259
|
+
// 3. The next call with the OLD id + valid auth rehydrates under the SAME id.
|
|
260
|
+
const res = await post(base, NON_INIT, { authorization: 'Bearer orb_alice', 'mcp-session-id': sid });
|
|
261
|
+
expect(res.status).toBe(200);
|
|
262
|
+
expect(res.headers.get('mcp-session-id')).toBe(sid);
|
|
263
|
+
await res.text();
|
|
264
|
+
expect(store.resolve).toHaveBeenCalledWith('orb_alice', sid);
|
|
265
|
+
});
|
|
266
|
+
it('auto-adopts an unknown session id under a FRESH id when auth is valid (layer 2)', async () => {
|
|
267
|
+
const store = fakeStore();
|
|
268
|
+
const { base } = await startWithFakeApi(store.store);
|
|
269
|
+
const stale = 'sess_dead_from_zcode';
|
|
270
|
+
const res = await post(base, NON_INIT, { authorization: 'Bearer orb_bob', 'mcp-session-id': stale });
|
|
271
|
+
expect(res.status).toBe(200);
|
|
272
|
+
const newId = res.headers.get('mcp-session-id');
|
|
273
|
+
await res.text();
|
|
274
|
+
expect(newId).toBeTruthy();
|
|
275
|
+
expect(newId).not.toBe(stale);
|
|
276
|
+
// Adoption is recorded old -> new.
|
|
277
|
+
expect(store.register).toHaveBeenCalledWith('orb_bob', expect.objectContaining({ sessionId: newId, adoptedFrom: stale }));
|
|
278
|
+
});
|
|
279
|
+
it('keeps the ORB-1324 404 contract for an unknown id WITHOUT valid auth', async () => {
|
|
280
|
+
const store = fakeStore();
|
|
281
|
+
const { base } = await startWithFakeApi(store.store);
|
|
282
|
+
const res = await post(base, NON_INIT, { authorization: 'Bearer invalid', 'mcp-session-id': 'whatever' });
|
|
283
|
+
expect(res.status).toBe(404);
|
|
284
|
+
expect(res.headers.get('www-authenticate')).toBeNull();
|
|
285
|
+
const bodyText = await res.text();
|
|
286
|
+
expect(bodyText).toMatch(/reinitialize/i);
|
|
287
|
+
// No session was adopted for an unauthenticated request.
|
|
288
|
+
expect(store.register).not.toHaveBeenCalled();
|
|
289
|
+
});
|
|
290
|
+
it('refuses to adopt when the MCP kill-switch is OFF (returns 404, not a session)', async () => {
|
|
291
|
+
const store = fakeStore();
|
|
292
|
+
const { base } = await startWithFakeApi(store.store, fakeApi({ enabled: false }));
|
|
293
|
+
const res = await post(base, NON_INIT, { authorization: 'Bearer orb_carol', 'mcp-session-id': 'sess_stale' });
|
|
294
|
+
expect(res.status).toBe(404);
|
|
295
|
+
await res.text();
|
|
296
|
+
expect(store.register).not.toHaveBeenCalled();
|
|
297
|
+
});
|
|
298
|
+
it('refuses to adopt when the per-user MCP opt-out is OFF (ORB-942, returns 404)', async () => {
|
|
299
|
+
const store = fakeStore();
|
|
300
|
+
// Workspace is enabled but the caller flipped their own users.mcp_enabled
|
|
301
|
+
// off - the shared preflight refuses, so the stale id falls to the 404
|
|
302
|
+
// re-init branch and no session is (re)established.
|
|
303
|
+
const { base } = await startWithFakeApi(store.store, fakeApi({ enabled: true, userMcpEnabled: false }));
|
|
304
|
+
const res = await post(base, NON_INIT, { authorization: 'Bearer orb_dave', 'mcp-session-id': 'sess_stale' });
|
|
305
|
+
expect(res.status).toBe(404);
|
|
306
|
+
await res.text();
|
|
307
|
+
expect(store.register).not.toHaveBeenCalled();
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
// ORB-1470 - a long-lived MCP session must follow the client's OAuth access-
|
|
312
|
+
// token rotation, not pin itself to the token captured at session creation.
|
|
313
|
+
//
|
|
314
|
+
// Root cause of the field 401s: the transport bound each session's OrbotoClient
|
|
315
|
+
// (+ tool-handler client + SSE bridge) to the bearer seen at creation and
|
|
316
|
+
// reused it for the session's whole life. An OAuth access token has a 1h TTL and
|
|
317
|
+
// the client rotates it; once the pinned token aged out the api answered 401
|
|
318
|
+
// "OAuth access token expired" on the NEXT tool call - even though that request
|
|
319
|
+
// carried a valid refreshed bearer. If the session was initialised with an
|
|
320
|
+
// already-aged cached token, that was only minutes after connect. The fix makes
|
|
321
|
+
// the per-request bearer authoritative via a mutable per-session token holder.
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
describe('ORB-1470 - per-session bearer follows the client\'s token rotation', () => {
|
|
324
|
+
let api = null;
|
|
325
|
+
afterEach(async () => {
|
|
326
|
+
if (server) {
|
|
327
|
+
const internals = server.__mcp;
|
|
328
|
+
if (internals) {
|
|
329
|
+
for (const s of internals.sessions.values()) {
|
|
330
|
+
try {
|
|
331
|
+
await s.transport.close();
|
|
332
|
+
}
|
|
333
|
+
catch { /* ignore */ }
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
await new Promise((r) => (api ? api.close(() => r()) : r()));
|
|
338
|
+
api = null;
|
|
339
|
+
});
|
|
340
|
+
async function startWithFakeApi(store, fake = fakeApi()) {
|
|
341
|
+
api = fake.api;
|
|
342
|
+
await new Promise((r) => api.listen(0, r));
|
|
343
|
+
const { port: apiPort } = api.address();
|
|
344
|
+
server = createHttpServer({ baseUrl: `http://127.0.0.1:${apiPort}`, sessionStore: store });
|
|
345
|
+
await new Promise((r) => server.listen(0, r));
|
|
346
|
+
const { port } = server.address();
|
|
347
|
+
const internals = server.__mcp;
|
|
348
|
+
return { base: `http://127.0.0.1:${port}`, internals, state: fake.state };
|
|
349
|
+
}
|
|
350
|
+
async function post(base, body, headers) {
|
|
351
|
+
return fetch(`${base}/mcp`, {
|
|
352
|
+
method: 'POST',
|
|
353
|
+
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', ...headers },
|
|
354
|
+
body,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
it('adopts the bearer presented on each request and forwards the fresh token upstream (no pinned-token 401)', async () => {
|
|
358
|
+
const store = fakeStore();
|
|
359
|
+
const { base, internals, state } = await startWithFakeApi(store.store);
|
|
360
|
+
// 1. Connect: initialise a session with the first access token (v1).
|
|
361
|
+
const initRes = await post(base, INIT_BODY, { authorization: 'Bearer orb_tok_v1' });
|
|
362
|
+
expect(initRes.status).toBe(200);
|
|
363
|
+
const sid = initRes.headers.get('mcp-session-id');
|
|
364
|
+
await initRes.text();
|
|
365
|
+
expect(sid).toBeTruthy();
|
|
366
|
+
// The session's mutable holder starts at the creation-time bearer.
|
|
367
|
+
const session = internals.sessions.get(sid);
|
|
368
|
+
expect(session.tokenHolder.current).toBe('orb_tok_v1');
|
|
369
|
+
// 2. The client's OAuth layer rotates its access token (v1 -> v2) and sends
|
|
370
|
+
// the next request on the SAME session id with the fresh bearer.
|
|
371
|
+
const res2 = await post(base, NON_INIT, { authorization: 'Bearer orb_tok_v2', 'mcp-session-id': sid });
|
|
372
|
+
expect(res2.status).toBe(200);
|
|
373
|
+
await res2.text();
|
|
374
|
+
// The session adopted the rotated bearer. Before the fix the session stayed
|
|
375
|
+
// pinned to v1 and would 401 "expired" once v1 aged out.
|
|
376
|
+
expect(session.tokenHolder.current).toBe('orb_tok_v2');
|
|
377
|
+
// End-to-end: the session's actual OrbotoClient now forwards v2 upstream, so
|
|
378
|
+
// the api never sees the stale v1 token again.
|
|
379
|
+
state.seenAuth.length = 0;
|
|
380
|
+
await session.client.get('/system/mcp/status');
|
|
381
|
+
expect(state.seenAuth).toContain('Bearer orb_tok_v2');
|
|
382
|
+
expect(state.seenAuth).not.toContain('Bearer orb_tok_v1');
|
|
383
|
+
});
|
|
384
|
+
it('adopts the current bearer when rehydrating a session after a restart', async () => {
|
|
385
|
+
const store = fakeStore();
|
|
386
|
+
const { base, internals, state } = await startWithFakeApi(store.store);
|
|
387
|
+
// Connect with v1, then simulate an api restart wiping the in-memory map.
|
|
388
|
+
const initRes = await post(base, INIT_BODY, { authorization: 'Bearer orb_tok_v1' });
|
|
389
|
+
const sid = initRes.headers.get('mcp-session-id');
|
|
390
|
+
await initRes.text();
|
|
391
|
+
internals.sessions.clear();
|
|
392
|
+
// The client (now on a refreshed v2) reconnects under the SAME id.
|
|
393
|
+
const res = await post(base, NON_INIT, { authorization: 'Bearer orb_tok_v1', 'mcp-session-id': sid });
|
|
394
|
+
// ^ rehydration is keyed on the persisted-store owner (token == userId in
|
|
395
|
+
// the fake), so the rehydrate itself uses the presented token. Then a
|
|
396
|
+
// follow-up request rotates to v2.
|
|
397
|
+
expect(res.status).toBe(200);
|
|
398
|
+
await res.text();
|
|
399
|
+
const res2 = await post(base, NON_INIT, { authorization: 'Bearer orb_tok_v2', 'mcp-session-id': sid });
|
|
400
|
+
expect(res2.status).toBe(200);
|
|
401
|
+
await res2.text();
|
|
402
|
+
const session = internals.sessions.get(sid);
|
|
403
|
+
expect(session.tokenHolder.current).toBe('orb_tok_v2');
|
|
404
|
+
state.seenAuth.length = 0;
|
|
405
|
+
await session.client.get('/system/mcp/status');
|
|
406
|
+
expect(state.seenAuth).toContain('Bearer orb_tok_v2');
|
|
407
|
+
});
|
|
408
|
+
});
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ORB-244 Phase A - entry point for `@orboto/mcp`.
|
|
4
|
+
*
|
|
5
|
+
* Two transport modes, picked by env:
|
|
6
|
+
* ORBOTO_MCP_TRANSPORT=stdio (default) - JSON-RPC over stdin/stdout,
|
|
7
|
+
* used by the Local-Proxy delivery variant. Claude Desktop spawns
|
|
8
|
+
* this process via the `claude_desktop_config.json` snippet and
|
|
9
|
+
* talks over its own stdio pair.
|
|
10
|
+
* ORBOTO_MCP_TRANSPORT=http - Streamable HTTP per MCP
|
|
11
|
+
* spec. Used by the Self-Hosted-inline + Cloud-Managed variants
|
|
12
|
+
* (separate container listening on a port the reverse proxy maps
|
|
13
|
+
* to `/mcp`). Sessions carry `mcp-session-id` for server→client
|
|
14
|
+
* notifications.
|
|
15
|
+
*
|
|
16
|
+
* Config (env-only - no config file):
|
|
17
|
+
* ORBOTO_API_URL required - base URL of the orboto API
|
|
18
|
+
* ORBOTO_API_KEY optional - `orb_*` API key with `mcp:use`
|
|
19
|
+
* scope (stdio mode). Service-account fallback;
|
|
20
|
+
* when omitted, stdio mode bootstraps via OAuth
|
|
21
|
+
* (ORB-943) so a desktop user connects through the
|
|
22
|
+
* workspace login without pasting a token. Per-
|
|
23
|
+
* session bearer is read from the Authorization
|
|
24
|
+
* header in http mode.
|
|
25
|
+
* ORBOTO_AUTH optional - `pat` | `oauth`. Defaults to `pat`
|
|
26
|
+
* when ORBOTO_API_KEY is set, else `oauth`. Force
|
|
27
|
+
* `oauth` to run the browser bootstrap even with a
|
|
28
|
+
* key present.
|
|
29
|
+
* ORBOTO_MCP_TRANSPORT optional - `stdio` (default) | `http`
|
|
30
|
+
* ORBOTO_MCP_PORT optional - port for http transport, default 3100
|
|
31
|
+
* ORBOTO_MCP_CLIENT optional - client hint for User-Agent (e.g.
|
|
32
|
+
* `claude-desktop`, `cursor`).
|
|
33
|
+
*/
|
|
34
|
+
import { buildOrbotoMcpServer } from './server.js';
|
|
35
|
+
import { OrbotoClient, preflightMcpSession } from './orboto-client.js';
|
|
36
|
+
import { bootstrapOAuth } from './oauth-bootstrap.js';
|
|
37
|
+
function requireEnv(name) {
|
|
38
|
+
const v = process.env[name];
|
|
39
|
+
if (!v || v.length === 0) {
|
|
40
|
+
process.stderr.write(`[orboto-mcp] missing required env var: ${name}\n`);
|
|
41
|
+
process.exit(2);
|
|
42
|
+
}
|
|
43
|
+
return v;
|
|
44
|
+
}
|
|
45
|
+
async function main() {
|
|
46
|
+
const transport = (process.env.ORBOTO_MCP_TRANSPORT ?? 'stdio').toLowerCase();
|
|
47
|
+
if (transport === 'stdio') {
|
|
48
|
+
// Local-Proxy mode - one MCP client, one process, one identity. The
|
|
49
|
+
// identity is either a static `orb_*` PAT (service-account fallback) OR,
|
|
50
|
+
// when no key is configured, an OAuth session bootstrapped through the
|
|
51
|
+
// workspace login (ORB-943) so a desktop user never pastes a token. All
|
|
52
|
+
// config read at boot; no per-request auth needed because the only user of
|
|
53
|
+
// this stdio pair is the client that spawned us.
|
|
54
|
+
const baseUrl = requireEnv('ORBOTO_API_URL');
|
|
55
|
+
const apiKey = process.env.ORBOTO_API_KEY;
|
|
56
|
+
const userAgentSuffix = process.env.ORBOTO_MCP_CLIENT;
|
|
57
|
+
const authMode = (process.env.ORBOTO_AUTH ?? (apiKey ? 'pat' : 'oauth')).toLowerCase();
|
|
58
|
+
if (authMode === 'pat' && !apiKey) {
|
|
59
|
+
// eslint-disable-next-line no-console
|
|
60
|
+
console.error('[orboto-mcp] ORBOTO_AUTH=pat requires ORBOTO_API_KEY. Unset it to use OAuth, or provide a key.');
|
|
61
|
+
process.exit(2);
|
|
62
|
+
}
|
|
63
|
+
// clientConfig carries either the PAT or the OAuth token provider; both
|
|
64
|
+
// shapes satisfy OrbotoClientConfig.
|
|
65
|
+
let clientConfig;
|
|
66
|
+
if (authMode === 'oauth') {
|
|
67
|
+
try {
|
|
68
|
+
const tokenProvider = await bootstrapOAuth({ apiBaseUrl: baseUrl });
|
|
69
|
+
clientConfig = { baseUrl, userAgentSuffix, tokenProvider };
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
// eslint-disable-next-line no-console
|
|
73
|
+
console.error(`[orboto-mcp] OAuth bootstrap failed: ${err.message}`);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
clientConfig = { baseUrl, userAgentSuffix, apiKey };
|
|
79
|
+
}
|
|
80
|
+
// Preflight BEFORE spinning up the transport - so a
|
|
81
|
+
// mis-configured install fails loudly to stderr instead of
|
|
82
|
+
// silently hanging on stdin waiting for JSON-RPC frames.
|
|
83
|
+
const preflightClient = new OrbotoClient(clientConfig);
|
|
84
|
+
try {
|
|
85
|
+
const { userEmail } = await preflightMcpSession(preflightClient);
|
|
86
|
+
// eslint-disable-next-line no-console
|
|
87
|
+
console.error(`[orboto-mcp] authenticated as ${userEmail} (${authMode}) → ${baseUrl}`);
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
// eslint-disable-next-line no-console
|
|
91
|
+
console.error(`[orboto-mcp] ${err.message}`);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
const server = await buildOrbotoMcpServer(clientConfig);
|
|
95
|
+
const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
|
|
96
|
+
const stdio = new StdioServerTransport();
|
|
97
|
+
await server.connect(stdio);
|
|
98
|
+
// The SDK writes its own "connected" log to stderr; we don't
|
|
99
|
+
// echo anything here - stdout is reserved for JSON-RPC frames
|
|
100
|
+
// and an accidental console.log would corrupt the protocol.
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (transport === 'http') {
|
|
104
|
+
// Self-Hosted + Cloud-Managed mode. The bearer token comes from
|
|
105
|
+
// the caller (Claude Desktop / Cursor) on every POST - a per-
|
|
106
|
+
// session server is built so each session carries its own
|
|
107
|
+
// API-key scoped OrbotoClient.
|
|
108
|
+
const port = Number(process.env.ORBOTO_MCP_PORT ?? '3100');
|
|
109
|
+
const baseUrl = requireEnv('ORBOTO_API_URL');
|
|
110
|
+
const { createHttpServer } = await import('./http-transport.js');
|
|
111
|
+
const httpServer = createHttpServer({ baseUrl });
|
|
112
|
+
httpServer.listen(port, () => {
|
|
113
|
+
// stderr so self-hosted operators can tail the container log
|
|
114
|
+
// without parsing the JSON on stdout (stdio mode).
|
|
115
|
+
// eslint-disable-next-line no-console
|
|
116
|
+
console.error(`[orboto-mcp] http listening on :${port} → ${baseUrl}`);
|
|
117
|
+
});
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// eslint-disable-next-line no-console
|
|
121
|
+
console.error(`[orboto-mcp] unknown ORBOTO_MCP_TRANSPORT=${transport} (expected 'stdio' or 'http')`);
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
main().catch((err) => {
|
|
125
|
+
// eslint-disable-next-line no-console
|
|
126
|
+
console.error('[orboto-mcp] fatal:', err);
|
|
127
|
+
process.exit(1);
|
|
128
|
+
});
|