@orboto/mcp 0.192.0 → 0.194.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/dist/event-bridge.js +1 -47
- package/dist/event-bridge.test.js +0 -19
- package/dist/http-transport-binding.test.js +1 -4
- package/dist/http-transport.js +1 -213
- package/dist/http-transport.test.js +0 -56
- package/dist/index.js +1 -48
- package/dist/input-schema.js +2 -76
- package/dist/input-schema.test.js +1 -15
- package/dist/instructions-budget.test.js +0 -2
- package/dist/manifest-size.test.js +3 -72
- package/dist/oauth-bootstrap.js +0 -47
- package/dist/oauth-bootstrap.test.js +4 -16
- package/dist/orboto-client.js +0 -30
- package/dist/orboto-client.test.js +0 -12
- package/dist/prompts.js +0 -39
- package/dist/protect-text-usage.test.js +1 -9
- package/dist/required-rules-wrapper.test.js +0 -5
- package/dist/required-rules.js +0 -2
- package/dist/resources.js +0 -62
- package/dist/resources.test.js +0 -16
- package/dist/response-budget.js +2 -150
- package/dist/response-budget.test.js +1 -32
- package/dist/server.js +4 -180
- package/dist/session-nudge.js +0 -9
- package/dist/session-nudge.test.js +2 -10
- package/dist/session-nudge.transport.test.js +0 -23
- package/dist/tool-annotations.test.js +0 -44
- package/dist/tool-docs.js +1 -12
- package/dist/tool-docs.test.js +2 -8
- package/dist/tool-parameters.test.js +0 -19
- package/dist/tools/absence-writes.js +1 -17
- package/dist/tools/admin-translations.js +0 -11
- package/dist/tools/admin-writes.js +0 -28
- package/dist/tools/admin-writes.test.js +0 -1
- package/dist/tools/agent-coordination.js +2 -24
- package/dist/tools/agent-coordination.test.js +0 -2
- package/dist/tools/agent-instructions.js +0 -10
- package/dist/tools/agent-instructions.test.js +0 -2
- package/dist/tools/agent-messages.js +0 -1
- package/dist/tools/ai-status.js +0 -14
- package/dist/tools/ai-usage.js +0 -10
- package/dist/tools/analytics.js +0 -2
- package/dist/tools/api-call.js +0 -12
- package/dist/tools/api-call.test.js +1 -2
- package/dist/tools/api-search.js +1 -6
- package/dist/tools/approvals.js +0 -6
- package/dist/tools/attach.js +0 -25
- package/dist/tools/backup.js +0 -13
- package/dist/tools/backup.test.js +1 -2
- package/dist/tools/bulk-create.js +1 -25
- package/dist/tools/bulk-create.test.js +0 -4
- package/dist/tools/bulk-writes.js +69 -61
- package/dist/tools/bulk-writes.test.js +34 -4
- package/dist/tools/check-similar.js +0 -17
- package/dist/tools/checklist-writes.js +82 -27
- package/dist/tools/checklist-writes.test.js +69 -1
- package/dist/tools/claim.js +1 -71
- package/dist/tools/claim.test.js +0 -2
- package/dist/tools/critical-path.js +0 -5
- package/dist/tools/critical-path.test.js +0 -3
- package/dist/tools/cross-project-links.ee.js +1 -24
- package/dist/tools/doc-attachments.js +0 -28
- package/dist/tools/doc-attachments.test.js +1 -4
- package/dist/tools/doc-comments.js +1 -37
- package/dist/tools/doc-comments.test.js +1 -2
- package/dist/tools/doc-edits.js +1 -31
- package/dist/tools/doc-edits.test.js +0 -9
- package/dist/tools/doc-export.js +0 -24
- package/dist/tools/doc-export.test.js +0 -12
- package/dist/tools/doc-revisions.js +0 -23
- package/dist/tools/docs-ai.js +0 -34
- package/dist/tools/docs-ai.test.js +0 -11
- package/dist/tools/docs.js +1 -75
- package/dist/tools/docs.test.js +1 -3
- package/dist/tools/embedding-status.js +0 -13
- package/dist/tools/embedding-status.test.js +0 -1
- package/dist/tools/feedback.js +1 -15
- package/dist/tools/feedback.test.js +0 -1
- package/dist/tools/get-checklists.js +6 -14
- package/dist/tools/get-project-primer.js +1 -25
- package/dist/tools/get-project.js +0 -17
- package/dist/tools/get-ticket.js +1 -61
- package/dist/tools/get-timer.js +0 -11
- package/dist/tools/git-health-reasons.js +0 -1
- package/dist/tools/identity.js +0 -13
- package/dist/tools/list-projects.js +0 -20
- package/dist/tools/list-projects.test.js +0 -1
- package/dist/tools/list-tickets.js +10 -9
- package/dist/tools/milestones-crud.test.js +0 -4
- package/dist/tools/milestones.js +0 -43
- package/dist/tools/my-tickets.js +0 -7
- package/dist/tools/phase-b-tools.test.js +14 -21
- package/dist/tools/primer-facts.js +1 -49
- package/dist/tools/primer-facts.test.js +0 -20
- package/dist/tools/project-listings.js +0 -25
- package/dist/tools/query.js +1 -12
- package/dist/tools/query.test.js +0 -8
- package/dist/tools/raci.js +0 -11
- package/dist/tools/requirements-spec.js +1 -12
- package/dist/tools/response-expand.js +0 -19
- package/dist/tools/review-policy.js +0 -24
- package/dist/tools/review-policy.test.js +0 -2
- package/dist/tools/search.js +1 -13
- package/dist/tools/session-start.js +1 -136
- package/dist/tools/session-start.test.js +1 -44
- package/dist/tools/set-parent.js +1 -19
- package/dist/tools/shared.js +0 -41
- package/dist/tools/shared.test.js +0 -4
- package/dist/tools/similar-projection.js +0 -15
- package/dist/tools/ticket-attachments.js +1 -25
- package/dist/tools/ticket-schedules.js +0 -9
- package/dist/tools/ticket-schedules.test.js +0 -1
- package/dist/tools/ticket-writes.js +1 -110
- package/dist/tools/ticket-writes.test.js +1 -15
- package/dist/tools/time-writes.js +26 -46
- package/dist/tools/time-writes.test.js +18 -0
- package/dist/tools/update-project.js +1 -27
- package/dist/tools/update-project.test.js +1 -1
- package/dist/tools/wiki.js +0 -8
- package/dist/tools/work-next.js +0 -5
- package/dist/tools/work-sessions-start.test.js +0 -2
- package/dist/tools/work-sessions.js +0 -48
- package/dist/tools/work-sessions.test.js +0 -5
- package/dist/toolset.js +2 -81
- package/dist/toolset.test.js +0 -20
- package/dist/version-guard.test.js +0 -4
- package/dist/version.js +0 -12
- package/dist/with-metrics-output-schema.test.js +1 -14
- package/dist/with-metrics.js +2 -74
- package/dist/with-metrics.test.js +7 -29
- package/package.json +1 -1
package/dist/event-bridge.js
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
|
-
const HEARTBEAT_GRACE_MS = 90_000;
|
|
1
|
+
const HEARTBEAT_GRACE_MS = 90_000;
|
|
2
2
|
const OVERFLOW_THRESHOLD = 100;
|
|
3
3
|
const RECONNECT_DELAY_MS = 2_000;
|
|
4
4
|
/** Map a raw event to the set of `orboto://` URIs that should be
|
|
5
5
|
* notified. Returns an empty array for events that don't map to any
|
|
6
6
|
* subscribed resource shape (no-op). */
|
|
7
7
|
export function eventToUris(event) {
|
|
8
|
-
// Ticket-level events. Include the ticket URI (when the payload
|
|
9
|
-
// carries a ticketKey) AND the project URI so a project-scoped
|
|
10
|
-
// subscriber learns about activity inside it.
|
|
11
8
|
if (event.type === 'ticket:created' || event.type === 'ticket:updated' || event.type === 'ticket:deleted'
|
|
12
9
|
|| event.type === 'ticket:activity'
|
|
13
10
|
|| event.type === 'comment:created' || event.type === 'comment:updated' || event.type === 'comment:deleted'
|
|
@@ -17,16 +14,8 @@ export function eventToUris(event) {
|
|
|
17
14
|
const key = event.payload?.ticketKey;
|
|
18
15
|
if (key)
|
|
19
16
|
uris.push(`orboto://ticket/${key}`);
|
|
20
|
-
// The MCP bridge doesn't know each project's key; the project URI
|
|
21
|
-
// form uses the projectKey which isn't on the wire. We fall back
|
|
22
|
-
// to projectId, which the resource handler accepts via
|
|
23
|
-
// resolveProjectByKey's lookup path.
|
|
24
17
|
if (event.projectId)
|
|
25
18
|
uris.push(`orboto://project/${event.projectId}`);
|
|
26
|
-
// ORB-962 - hand-off "wake on close". When a ticket:updated
|
|
27
|
-
// payload's statusCategory is `done`, emit an additional URI
|
|
28
|
-
// dedicated to close-events so subscribers don't have to filter
|
|
29
|
-
// every ticket:updated client-side.
|
|
30
19
|
if (event.type === 'ticket:updated' && key && event.payload?.statusCategory === 'done') {
|
|
31
20
|
uris.push(`orboto://handoff/closed/${key}`);
|
|
32
21
|
}
|
|
@@ -35,19 +24,9 @@ export function eventToUris(event) {
|
|
|
35
24
|
if (event.type === 'project:bulk-activity' || event.type === 'member:joined' || event.type === 'member:removed') {
|
|
36
25
|
return event.projectId ? [`orboto://project/${event.projectId}`] : [];
|
|
37
26
|
}
|
|
38
|
-
// ORB-1616 - dispatcher wake-on-unblock. Project-scoped (not per-ticket
|
|
39
|
-
// like `handoff/closed`) because a worker pool doesn't know in advance
|
|
40
|
-
// which ticket will become ready next - it subscribes to the PROJECT's
|
|
41
|
-
// ready channel and pulls via `orboto_work_next` on push. The
|
|
42
|
-
// notification carries no payload (the resource-subscription protocol
|
|
43
|
-
// never does) - `ticket.ready` is the webhook channel that carries the
|
|
44
|
-
// actual ticket key for external consumers.
|
|
45
27
|
if (event.type === 'ticket:ready') {
|
|
46
28
|
return event.projectId ? [`orboto://ready/${event.projectId}`] : [];
|
|
47
29
|
}
|
|
48
|
-
// ORB-1616 - escalation push: collision / failed checks / review
|
|
49
|
-
// rejection / missing decision / git failure. Project-scoped, same
|
|
50
|
-
// reasoning as `ready` above.
|
|
51
30
|
if (event.type === 'agent_escalation:raised') {
|
|
52
31
|
return event.projectId ? [`orboto://escalation/${event.projectId}`] : [];
|
|
53
32
|
}
|
|
@@ -57,9 +36,6 @@ export function eventToUris(event) {
|
|
|
57
36
|
if (event.type === 'timer:changed') {
|
|
58
37
|
return [`orboto://timer`];
|
|
59
38
|
}
|
|
60
|
-
// ORB-964 - agent broadcast. Map to the scope-specific URI so
|
|
61
|
-
// subscribers can register interest in just the scope they care
|
|
62
|
-
// about. For workspace scope, scopeId is the empty string.
|
|
63
39
|
if (event.type === 'agent_broadcast:posted') {
|
|
64
40
|
const p = event.payload;
|
|
65
41
|
if (p?.scopeType) {
|
|
@@ -68,22 +44,13 @@ export function eventToUris(event) {
|
|
|
68
44
|
}
|
|
69
45
|
return [];
|
|
70
46
|
}
|
|
71
|
-
// ORB-970 - quorum lifecycle.
|
|
72
47
|
if (event.type === 'agent_quorum:opened' || event.type === 'agent_quorum:approved') {
|
|
73
48
|
const p = event.payload;
|
|
74
49
|
return p?.topicKey ? [`orboto://quorum/${p.topicKey}`] : [];
|
|
75
50
|
}
|
|
76
|
-
// ORB-706 - mention real-time push. Every notification row firing
|
|
77
|
-
// for the calling user surfaces on `orboto://user/me/notifications`.
|
|
78
|
-
// The API-side SSE bridge already filters notification:new events
|
|
79
|
-
// to only deliver them to the matching user's session, so this
|
|
80
|
-
// URI is naturally per-user-scoped.
|
|
81
51
|
if (event.type === 'notification:new') {
|
|
82
52
|
return [`orboto://user/me/notifications`];
|
|
83
53
|
}
|
|
84
|
-
// restore:progress / system-task:* are user-scoped infrastructure
|
|
85
|
-
// events that don't map to a public resource URI today. The MCP
|
|
86
|
-
// bridge ignores them - the user gets them via the in-app UI.
|
|
87
54
|
return [];
|
|
88
55
|
}
|
|
89
56
|
export class EventBridge {
|
|
@@ -137,13 +104,8 @@ export class EventBridge {
|
|
|
137
104
|
}
|
|
138
105
|
async consume() {
|
|
139
106
|
const fetchFn = this.opts.fetchFn ?? fetch;
|
|
140
|
-
// Fresh abort controller per attempt so close() during a retry
|
|
141
|
-
// window doesn't leak the previous signal.
|
|
142
107
|
this.abort = new AbortController();
|
|
143
108
|
const url = `${this.opts.baseUrl.replace(/\/$/, '')}/sse/mcp-events`;
|
|
144
|
-
// ORB-1470 - resolve the current bearer per connect so a rotated OAuth
|
|
145
|
-
// access token is picked up on the next reconnect instead of pinning the
|
|
146
|
-
// stream to the session's creation-time token.
|
|
147
109
|
const bearer = this.opts.tokenProvider
|
|
148
110
|
? await this.opts.tokenProvider.getAccessToken()
|
|
149
111
|
: (this.opts.apiKey ?? '');
|
|
@@ -164,9 +126,6 @@ export class EventBridge {
|
|
|
164
126
|
let buf = '';
|
|
165
127
|
let lastFrame = Date.now();
|
|
166
128
|
const heartbeat = setInterval(() => {
|
|
167
|
-
// If the server's ping stops arriving the underlying TCP
|
|
168
|
-
// connection might be dead silent (Coolify / NAT timeout). Force
|
|
169
|
-
// a reconnect so we don't sit forever on a zombie stream.
|
|
170
129
|
if (Date.now() - lastFrame > HEARTBEAT_GRACE_MS) {
|
|
171
130
|
try {
|
|
172
131
|
this.abort.abort();
|
|
@@ -175,8 +134,6 @@ export class EventBridge {
|
|
|
175
134
|
}
|
|
176
135
|
}, 30_000);
|
|
177
136
|
try {
|
|
178
|
-
// Read until the stream ends, the request was aborted, or the
|
|
179
|
-
// process is shutting down.
|
|
180
137
|
// eslint-disable-next-line no-constant-condition
|
|
181
138
|
while (true) {
|
|
182
139
|
const { done, value } = await reader.read();
|
|
@@ -184,9 +141,6 @@ export class EventBridge {
|
|
|
184
141
|
break;
|
|
185
142
|
lastFrame = Date.now();
|
|
186
143
|
buf += decoder.decode(value, { stream: true });
|
|
187
|
-
// SSE frames are split by '\n\n'. We handle both 'data: ...'
|
|
188
|
-
// and ': ping' (comment / heartbeat) lines; the latter become
|
|
189
|
-
// a no-op event.
|
|
190
144
|
let idx;
|
|
191
145
|
// eslint-disable-next-line no-cond-assign
|
|
192
146
|
while ((idx = buf.indexOf('\n\n')) !== -1) {
|
|
@@ -1,16 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ORB-940 - EventBridge unit tests.
|
|
3
|
-
*
|
|
4
|
-
* Three concerns:
|
|
5
|
-
* 1. Event → URI mapping (pure function, no I/O)
|
|
6
|
-
* 2. Dispatch behaviour - subscribed URIs trigger
|
|
7
|
-
* sendResourceUpdated, unsubscribed do not
|
|
8
|
-
* 3. Backpressure - > 100 forwarded events → one
|
|
9
|
-
* sendResourceListChanged instead of more updates
|
|
10
|
-
*
|
|
11
|
-
* The actual SSE consumption path is exercised via a streaming
|
|
12
|
-
* mock fetch; the response body is a ReadableStream that yields
|
|
13
|
-
* one frame per chunk, then closes.
|
|
14
3
|
*/
|
|
15
4
|
import { describe, expect, it, vi } from 'vitest';
|
|
16
5
|
import { EventBridge, eventToUris } from './event-bridge.js';
|
|
@@ -64,9 +53,6 @@ describe('eventToUris', () => {
|
|
|
64
53
|
expect(eventToUris({ type: 'timer:changed', userId: 'u1' })).toEqual(['orboto://timer']);
|
|
65
54
|
});
|
|
66
55
|
it('maps notification:new to the calling user\'s notifications resource (ORB-706)', () => {
|
|
67
|
-
// The mcp-events SSE handler already filters notification:new
|
|
68
|
-
// to only deliver to the matching user; the URI is per-user
|
|
69
|
-
// scoped by construction.
|
|
70
56
|
expect(eventToUris({ type: 'notification:new', userId: 'u1' })).toEqual(['orboto://user/me/notifications']);
|
|
71
57
|
});
|
|
72
58
|
it('returns [] for restore-progress / system-task events (infrastructure-only)', () => {
|
|
@@ -107,7 +93,6 @@ describe('EventBridge.dispatch', () => {
|
|
|
107
93
|
log: () => { },
|
|
108
94
|
});
|
|
109
95
|
bridge.start();
|
|
110
|
-
// Give the microtask loop a couple of ticks to drain.
|
|
111
96
|
await new Promise((r) => setTimeout(r, 50));
|
|
112
97
|
bridge.close();
|
|
113
98
|
expect(mcp.sendResourceUpdated).toHaveBeenCalledTimes(1);
|
|
@@ -116,8 +101,6 @@ describe('EventBridge.dispatch', () => {
|
|
|
116
101
|
it('falls back to sendResourceListChanged after the overflow threshold', async () => {
|
|
117
102
|
const mcp = mockMcp();
|
|
118
103
|
const subscriptions = new Set(['orboto://timer']);
|
|
119
|
-
// 101 timer events - one more than the threshold so the 101st
|
|
120
|
-
// round trips through the list_changed path.
|
|
121
104
|
const frames = Array.from({ length: 101 }, () => ({ type: 'timer:changed', userId: 'u1' }));
|
|
122
105
|
const fetchFn = vi.fn().mockResolvedValueOnce(mockSseResponse(frames));
|
|
123
106
|
const bridge = new EventBridge({
|
|
@@ -137,8 +120,6 @@ describe('EventBridge.dispatch', () => {
|
|
|
137
120
|
it('survives malformed frames without aborting the stream', async () => {
|
|
138
121
|
const mcp = mockMcp();
|
|
139
122
|
const subscriptions = new Set(['orboto://ticket/ORB-42']);
|
|
140
|
-
// Inject one bad frame between two good ones - the bad frame is
|
|
141
|
-
// a non-JSON 'data:' line, which the bridge should skip.
|
|
142
123
|
const encoder = new TextEncoder();
|
|
143
124
|
const stream = new ReadableStream({
|
|
144
125
|
start(controller) {
|
|
@@ -102,11 +102,9 @@ describe('ORB-1576 - session binding', () => {
|
|
|
102
102
|
it('rotation adopted for the SAME user, rejected for a different user', async () => {
|
|
103
103
|
const base = await start();
|
|
104
104
|
const sid = await initSession(base, 'orb_alice');
|
|
105
|
-
// Same user, rotated token -> adopted (200).
|
|
106
105
|
const rotated = await post(base, NON_INIT, { authorization: 'Bearer orb_alice_rot', 'mcp-session-id': sid });
|
|
107
106
|
expect(rotated.status).toBe(200);
|
|
108
107
|
await rotated.text();
|
|
109
|
-
// Different user with a VALID token -> 401, holder not adopted.
|
|
110
108
|
const hijack = await post(base, NON_INIT, { authorization: 'Bearer orb_mallory', 'mcp-session-id': sid });
|
|
111
109
|
expect(hijack.status).toBe(401);
|
|
112
110
|
await hijack.text();
|
|
@@ -142,10 +140,9 @@ describe('ORB-1576 - request body cap', () => {
|
|
|
142
140
|
await res.text();
|
|
143
141
|
}
|
|
144
142
|
catch {
|
|
145
|
-
errored = true;
|
|
143
|
+
errored = true;
|
|
146
144
|
}
|
|
147
145
|
expect(errored || status === 400).toBe(true);
|
|
148
146
|
});
|
|
149
147
|
});
|
|
150
|
-
// vitest fake-timer hygiene for the bridge internals
|
|
151
148
|
void vi;
|
package/dist/http-transport.js
CHANGED
|
@@ -1,33 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ORB-244 Phase A - Streamable HTTP transport for the MCP server.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* container alongside the API, listening on `ORBOTO_MCP_PORT` (default
|
|
6
|
-
* 3100). The reverse proxy routes `/mcp` to this port.
|
|
7
|
-
*
|
|
8
|
-
* Per-request auth: every POST must carry `Authorization: Bearer
|
|
9
|
-
* orb_*` in the header. We build one `McpServer` per session so each
|
|
10
|
-
* session gets its own OrbotoClient bound to that session's token -
|
|
11
|
-
* no mid-session token mutation, no cross-session leakage.
|
|
12
|
-
*
|
|
13
|
-
* Sessions are indexed by the MCP-spec-mandated `mcp-session-id`
|
|
14
|
-
* header the transport generates on initialise. Closing the session
|
|
15
|
-
* deletes the transport; a fresh initialise spins up a new one.
|
|
16
|
-
*
|
|
17
|
-
* ORB-1353 - session resilience. The in-memory session map is wiped on every
|
|
18
|
-
* api restart/deploy, which used to invalidate every connected client at once
|
|
19
|
-
* (a real ZCode adapter then dead-looped on the resulting 404 for hours). Two
|
|
20
|
-
* server-side layers fix this: (1) the session registry is PERSISTED to the db
|
|
21
|
-
* via `/system/mcp/sessions`, so a request carrying a session id we no longer
|
|
22
|
-
* hold in memory is rehydrated under the SAME id after a restart; (2) an
|
|
23
|
-
* unknown session id under otherwise-valid auth is transparently AUTO-ADOPTED
|
|
24
|
-
* under a fresh id (MCP tool calls are stateless per-call, so re-establishing
|
|
25
|
-
* loses nothing). The ORB-1324 404 re-initialise contract stays ONLY for
|
|
26
|
-
* requests without valid auth; the kill-switch + mcp:use preflight still fires
|
|
27
|
-
* on every (re)hydration.
|
|
28
|
-
*
|
|
29
|
-
* No Express dep - Node's built-in http server is enough for a
|
|
30
|
-
* single-route MCP endpoint and keeps the production image small.
|
|
4
|
+
* @see ORB-1353, ORB-1324
|
|
31
5
|
*/
|
|
32
6
|
import { createServer } from 'node:http';
|
|
33
7
|
import { randomUUID } from 'node:crypto';
|
|
@@ -112,19 +86,6 @@ export function clientInfoLabel(body) {
|
|
|
112
86
|
/**
|
|
113
87
|
* ORB-941 - graceful close of every active MCP session when the
|
|
114
88
|
* workspace kill-switch (`system_config.mcp_enabled`) flips to disabled.
|
|
115
|
-
*
|
|
116
|
-
* The MCP spec has no standard `notifications/server/closing` method, so
|
|
117
|
-
* the closest correct behaviour is: emit a best-effort logging
|
|
118
|
-
* notification (visible to clients that negotiated the logging
|
|
119
|
-
* capability) explaining WHY, then close the transport. Closing the
|
|
120
|
-
* transport ends the SSE stream; the client's next request lands on the
|
|
121
|
-
* unknown-session 404 branch, which per the Streamable-HTTP spec
|
|
122
|
-
* triggers a transparent re-initialise - and our per-session preflight
|
|
123
|
-
* then refuses that re-init with the clear "administrator has disabled
|
|
124
|
-
* MCP access" error. So an in-flight session is dropped promptly and the
|
|
125
|
-
* client gets an actionable reason rather than an opaque hang.
|
|
126
|
-
*
|
|
127
|
-
* Exported for unit testing with fake sessions.
|
|
128
89
|
*/
|
|
129
90
|
export async function closeAllMcpSessions(sessions, reason) {
|
|
130
91
|
let closed = 0;
|
|
@@ -190,25 +151,10 @@ function sendError(res, status, message, extraHeaders) {
|
|
|
190
151
|
/** ORB-957 - RFC 6750 §3 WWW-Authenticate challenge for /mcp 401s.
|
|
191
152
|
* MCP-aware clients (Claude Desktop, Cursor, VS Code Copilot) follow
|
|
192
153
|
* the resource_metadata URL to auto-discover the OAuth flow.
|
|
193
|
-
*
|
|
194
|
-
* The resource_metadata URL must point at the PUBLIC host the AI
|
|
195
|
-
* client can reach - NOT the internal `http://api:3000` baseUrl the
|
|
196
|
-
* MCP container uses to talk to the API. We derive the public URL
|
|
197
|
-
* from the incoming request's Host + X-Forwarded-Proto headers,
|
|
198
|
-
* which the reverse proxy (nginx → web container) sets for us.
|
|
199
|
-
* Falls back to baseUrl only when the headers are missing (local
|
|
200
|
-
* dev / direct connections).
|
|
201
154
|
*/
|
|
202
155
|
function wwwAuthChallenge(req, baseUrl, error, description) {
|
|
203
156
|
const host = req.headers['x-forwarded-host']
|
|
204
157
|
|| req.headers.host;
|
|
205
|
-
// Multi-layer proxies (Coolify's outer Caddy → web container's nginx
|
|
206
|
-
// → mcp container) sometimes lose the original scheme. Trust an
|
|
207
|
-
// explicit X-Forwarded-Proto header if present; otherwise default to
|
|
208
|
-
// https UNLESS the host is obviously localhost / a private IP (local
|
|
209
|
-
// dev). The MCP container only runs in TLS-terminated deployments,
|
|
210
|
-
// so https-by-default is right for production + lets local dev
|
|
211
|
-
// explicitly send X-Forwarded-Proto=http if needed.
|
|
212
158
|
const hostIsLocal = !!host && /^(localhost|127\.|::1|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/.test(host);
|
|
213
159
|
const protoHeader = req.headers['x-forwarded-proto'];
|
|
214
160
|
const proto = protoHeader || (hostIsLocal ? 'http' : 'https');
|
|
@@ -219,45 +165,16 @@ function wwwAuthChallenge(req, baseUrl, error, description) {
|
|
|
219
165
|
return `Bearer realm="orboto-mcp", error="${error}", error_description="${description.replace(/"/g, '\\"')}", resource_metadata="${resourceMetadata}"`;
|
|
220
166
|
}
|
|
221
167
|
export function createHttpServer({ baseUrl, sessionStore }) {
|
|
222
|
-
// One transport + server per MCP session. The session-id comes from
|
|
223
|
-
// the transport's `onsessioninitialized` hook, which fires after
|
|
224
|
-
// the `initialize` request lands and we've minted a fresh id.
|
|
225
168
|
const sessions = new Map();
|
|
226
|
-
// ORB-1353 - persisted-session store (survives api restarts). Defaults to
|
|
227
|
-
// the api-backed store; tests inject a fake.
|
|
228
169
|
const store = sessionStore ?? createApiSessionStore(baseUrl);
|
|
229
|
-
// Build the per-session core (client + subscription set + MCP server +
|
|
230
|
-
// event bridge) bound to a token. Shared by the new-session, rehydrate, and
|
|
231
|
-
// adopt paths so they can't drift apart.
|
|
232
170
|
async function buildSessionCore(tokenHolder, userAgentSuffix, toolset) {
|
|
233
|
-
// ORB-1470 - bind every long-lived per-session consumer (kill-switch
|
|
234
|
-
// client, tool-handler client, SSE bridge) to the SAME mutable holder so
|
|
235
|
-
// one `holder.current = <request bearer>` update rotates the bearer for
|
|
236
|
-
// all of them at once.
|
|
237
171
|
const tokenProvider = holderTokenProvider(tokenHolder);
|
|
238
172
|
const sessionClient = new OrbotoClient({ baseUrl, tokenProvider, userAgentSuffix });
|
|
239
|
-
// ORB-940 - per-session subscription set + live-event bridge. The set is
|
|
240
|
-
// mutated by resources/subscribe + resources/unsubscribe handlers inside
|
|
241
|
-
// the McpServer; the bridge reads it to decide which incoming API events
|
|
242
|
-
// deserve a push.
|
|
243
|
-
//
|
|
244
|
-
// ORB-1353 subscription boundary: a rehydrated (layer 1) or adopted
|
|
245
|
-
// (layer 2) session ALWAYS starts with an empty subscription set + a fresh
|
|
246
|
-
// bridge. Live subscription state is per-session in-memory state tied to a
|
|
247
|
-
// now-dead SSE socket, so it cannot be carried across an api restart even
|
|
248
|
-
// for the same session id - per the MCP spec, re-subscribing after a
|
|
249
|
-
// session change is the client's job. Rehydrating under the SAME id keeps
|
|
250
|
-
// the deploy case coherent (the client's own session bookkeeping stays
|
|
251
|
-
// valid and its re-subscribe resumes pushes); adoption gives a new id so
|
|
252
|
-
// the client treats it as a fresh session and re-subscribes from scratch.
|
|
253
173
|
const subscriptions = new Set();
|
|
254
174
|
const mcp = await buildOrbotoMcpServer({ baseUrl, tokenProvider, userAgentSuffix, subscriptions, toolset });
|
|
255
175
|
const bridge = new EventBridge({ baseUrl, tokenProvider, mcp, subscriptions });
|
|
256
176
|
return { sessionClient, subscriptions, mcp, bridge };
|
|
257
177
|
}
|
|
258
|
-
// Rehydrate/adopt: stand up a session bound to `token` and force it into the
|
|
259
|
-
// initialized state under `chosenSessionId` WITHOUT replaying the initialize
|
|
260
|
-
// handshake, so the client's in-flight non-init request validates against it.
|
|
261
178
|
/** ORB-1576 - resolve a bearer's owner identity via the status preflight;
|
|
262
179
|
* null on any failure (invalid token, MCP off, network) = fail closed. */
|
|
263
180
|
async function resolveUserEmail(token) {
|
|
@@ -283,18 +200,6 @@ export function createHttpServer({ baseUrl, sessionStore }) {
|
|
|
283
200
|
bridge.start();
|
|
284
201
|
return session;
|
|
285
202
|
}
|
|
286
|
-
// ORB-941 / ORB-942 - MCP kill-switch poll. Two kill-switches ride the
|
|
287
|
-
// same `/system/mcp/status` probe while sessions are live:
|
|
288
|
-
// - workspace-wide (`system_config.mcp_enabled`, ORB-941): an admin
|
|
289
|
-
// flip closes EVERY in-flight session (any session's response is
|
|
290
|
-
// authoritative for the whole workspace).
|
|
291
|
-
// - per-user (`users.mcp_enabled`, ORB-942): a user flipping their own
|
|
292
|
-
// opt-out closes only THAT user's sessions. Since the flag is
|
|
293
|
-
// per-identity, each session is probed with its own token and the
|
|
294
|
-
// `userMcpEnabled` field is read from the same response.
|
|
295
|
-
// New sessions (and rehydrate/adopt) are already refused at preflight, so
|
|
296
|
-
// the poll only needs to reap existing sessions. Only polls while sessions
|
|
297
|
-
// exist → zero cost when idle. Interval is env-tunable for tests.
|
|
298
203
|
const pollMs = Number(process.env.ORBOTO_MCP_KILLSWITCH_POLL_MS ?? 30_000);
|
|
299
204
|
async function pollKillSwitch() {
|
|
300
205
|
if (sessions.size === 0)
|
|
@@ -304,8 +209,6 @@ export function createHttpServer({ baseUrl, sessionStore }) {
|
|
|
304
209
|
try {
|
|
305
210
|
const status = await session.client.get('/system/mcp/status');
|
|
306
211
|
if (!status.enabled) {
|
|
307
|
-
// Workspace-wide off wins and is authoritative for everyone -
|
|
308
|
-
// close all sessions and stop probing.
|
|
309
212
|
await closeAllMcpSessions(sessions.values(), 'the workspace administrator has disabled MCP access.');
|
|
310
213
|
return;
|
|
311
214
|
}
|
|
@@ -321,23 +224,13 @@ export function createHttpServer({ baseUrl, sessionStore }) {
|
|
|
321
224
|
}
|
|
322
225
|
}
|
|
323
226
|
const killSwitchTimer = setInterval(() => { void pollKillSwitch(); }, pollMs);
|
|
324
|
-
// Don't let the poll keep the process (or a test) alive on its own.
|
|
325
227
|
killSwitchTimer.unref?.();
|
|
326
228
|
const server = createServer(async (req, res) => {
|
|
327
|
-
// Health probe - stays cheap, zero-auth so docker healthchecks
|
|
328
|
-
// don't need a token. Doesn't reveal anything about config.
|
|
329
229
|
if (req.method === 'GET' && req.url === '/health') {
|
|
330
230
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
331
231
|
res.end(JSON.stringify({ status: 'ok' }));
|
|
332
232
|
return;
|
|
333
233
|
}
|
|
334
|
-
// Route: all MCP traffic lands on /mcp per spec convention.
|
|
335
|
-
// Anything else is 404 so an accidental probe at / doesn't leak
|
|
336
|
-
// the transport. ORB-1520 - a query string is allowed (and used):
|
|
337
|
-
// `?toolset=full` opts a connection into the full named-tool
|
|
338
|
-
// manifest without server-side env access. The param rides on EVERY
|
|
339
|
-
// request from a client configured with that URL, so it needs no
|
|
340
|
-
// session persistence.
|
|
341
234
|
const requestUrl = new URL(req.url ?? '', 'http://localhost');
|
|
342
235
|
if (requestUrl.pathname !== '/mcp') {
|
|
343
236
|
sendError(res, 404, 'Not found');
|
|
@@ -345,25 +238,12 @@ export function createHttpServer({ baseUrl, sessionStore }) {
|
|
|
345
238
|
}
|
|
346
239
|
const toolset = resolveToolset(requestUrl.searchParams.get('toolset')
|
|
347
240
|
?? req.headers['x-orboto-toolset'], process.env.ORBOTO_MCP_TOOLSET);
|
|
348
|
-
// Reject non-POST + non-DELETE. GET on /mcp is legal per the spec
|
|
349
|
-
// (SSE resumption) but that is unimplemented here.
|
|
350
|
-
//
|
|
351
|
-
// ORB-1424 - an UNAUTHENTICATED GET/HEAD on /mcp is almost always an
|
|
352
|
-
// OAuth-discovery probe (rmcp/Codex hit the resource to read the
|
|
353
|
-
// WWW-Authenticate challenge). Answering a bare 405 there left the
|
|
354
|
-
// client without the resource_metadata pointer and logged a noisy
|
|
355
|
-
// "405 Method Not Allowed". Return 401 + WWW-Authenticate instead so
|
|
356
|
-
// the probe discovers the OAuth flow cleanly. An AUTHENTICATED GET
|
|
357
|
-
// (a real client attempting SSE resumption) still 405s - it already
|
|
358
|
-
// holds a token and needs no discovery pointer, so spec-conform
|
|
359
|
-
// clients (Claude Desktop / Cursor) do not regress.
|
|
360
241
|
if (req.method !== 'POST' && req.method !== 'DELETE') {
|
|
361
242
|
const probeAuth = (req.headers.authorization ?? '');
|
|
362
243
|
const hasBearer = probeAuth.startsWith('Bearer ') && probeAuth.slice(7).trim().length > 0;
|
|
363
244
|
if ((req.method === 'GET' || req.method === 'HEAD') && !hasBearer) {
|
|
364
245
|
const challenge = wwwAuthChallenge(req, baseUrl, 'invalid_request', 'Bearer token required');
|
|
365
246
|
if (req.method === 'HEAD') {
|
|
366
|
-
// HEAD carries no body per HTTP semantics.
|
|
367
247
|
res.writeHead(401, { 'content-type': 'application/json', 'WWW-Authenticate': challenge });
|
|
368
248
|
res.end();
|
|
369
249
|
}
|
|
@@ -376,16 +256,6 @@ export function createHttpServer({ baseUrl, sessionStore }) {
|
|
|
376
256
|
res.end();
|
|
377
257
|
return;
|
|
378
258
|
}
|
|
379
|
-
// Extract the bearer token. Per-session auth - the same token
|
|
380
|
-
// is used for the session's whole lifetime (reconnect without
|
|
381
|
-
// re-init would land on a new session id and fresh auth anyway).
|
|
382
|
-
//
|
|
383
|
-
// Two acceptable token shapes:
|
|
384
|
-
// orb_* - service-account API key (operator-minted)
|
|
385
|
-
// JWT (3 segs) - OAuth-issued access token from the /oauth/
|
|
386
|
-
// authorize + /oauth/token flow (ORB-957)
|
|
387
|
-
// Both go to the API unchanged; the API's authenticate decorator
|
|
388
|
-
// distinguishes them.
|
|
389
259
|
const authHeader = (req.headers.authorization ?? '');
|
|
390
260
|
const token = authHeader.startsWith('Bearer ')
|
|
391
261
|
? authHeader.slice(7).trim()
|
|
@@ -397,32 +267,23 @@ export function createHttpServer({ baseUrl, sessionStore }) {
|
|
|
397
267
|
return;
|
|
398
268
|
}
|
|
399
269
|
const sessionId = (req.headers['mcp-session-id'] ?? '');
|
|
400
|
-
// DELETE is the client's explicit session-close. We forward it
|
|
401
|
-
// to the transport's handleRequest which tears down cleanly.
|
|
402
270
|
if (req.method === 'DELETE') {
|
|
403
271
|
const existing = sessions.get(sessionId);
|
|
404
272
|
if (existing) {
|
|
405
|
-
// ORB-1576 - only the session's owner may tear it down (a leaked id
|
|
406
|
-
// must not let someone kill or probe another user's session).
|
|
407
273
|
const presented = await resolveUserEmail(token);
|
|
408
274
|
if (!presented || presented !== existing.userEmail) {
|
|
409
275
|
return sendError(res, 404, 'Unknown MCP session');
|
|
410
276
|
}
|
|
411
277
|
}
|
|
412
|
-
// ORB-1353 - drop the persisted row too so a closed session doesn't
|
|
413
|
-
// linger until the retention sweep. Best-effort + scoped to the caller.
|
|
414
278
|
void store.remove(token, sessionId).catch(() => { });
|
|
415
279
|
if (existing) {
|
|
416
280
|
await existing.transport.handleRequest(req, res);
|
|
417
281
|
return;
|
|
418
282
|
}
|
|
419
|
-
// Not in memory - e.g. the client is closing a session we only know from
|
|
420
|
-
// persistence after a restart. Acknowledge the close idempotently.
|
|
421
283
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
422
284
|
res.end(JSON.stringify({ ok: true }));
|
|
423
285
|
return;
|
|
424
286
|
}
|
|
425
|
-
// POST path. Parse body so we can route (new-session vs. existing).
|
|
426
287
|
let body;
|
|
427
288
|
try {
|
|
428
289
|
body = await readJsonBody(req);
|
|
@@ -434,36 +295,16 @@ export function createHttpServer({ baseUrl, sessionStore }) {
|
|
|
434
295
|
?.split('/')[0] || undefined;
|
|
435
296
|
const userAgent = req.headers['user-agent'];
|
|
436
297
|
if (sessionId && sessions.has(sessionId)) {
|
|
437
|
-
// Existing session - hand straight to its transport.
|
|
438
298
|
const session = sessions.get(sessionId);
|
|
439
|
-
// ORB-1470 - adopt the bearer the client presents on THIS request. The
|
|
440
|
-
// client's OAuth layer rotates its short-lived (1h) access token; without
|
|
441
|
-
// this the session stays pinned to the token captured at creation and
|
|
442
|
-
// dead-ends with a 401 "OAuth access token expired" once that token ages
|
|
443
|
-
// out, even though the request carried a valid refreshed bearer. Updating
|
|
444
|
-
// the shared holder rotates the bearer for the tool-handler client, the
|
|
445
|
-
// kill-switch client, and the SSE bridge in one assignment.
|
|
446
299
|
if (session.tokenHolder.current !== token) {
|
|
447
|
-
// The client rotated its access token mid-session (the exact case that
|
|
448
|
-
// used to brick the session). ORB-1576 - adopt the new bearer ONLY
|
|
449
|
-
// after it resolves to the session owner's identity: a session id
|
|
450
|
-
// plus ANY valid token must not hijack the session's subscription set
|
|
451
|
-
// / token holder (the id is a 122-bit UUID, but a leaked id is no
|
|
452
|
-
// longer a capability on its own).
|
|
453
300
|
const presented = await resolveUserEmail(token);
|
|
454
301
|
if (!presented || presented !== session.userEmail) {
|
|
455
302
|
return sendError(res, 401, 'Bearer does not match the session owner');
|
|
456
303
|
}
|
|
457
|
-
// Breadcrumb to stderr - Sentry lives on the api side - so operators
|
|
458
|
-
// can see rotations are being absorbed, not re-hitting the pinned-
|
|
459
|
-
// token 401. Low-noise: only on an actual change.
|
|
460
304
|
process.stderr.write(`[orboto-mcp] session ${sessionId} bearer rotated - adopting client's current access token\n`);
|
|
461
305
|
session.tokenHolder.current = token;
|
|
462
306
|
}
|
|
463
307
|
await session.transport.handleRequest(req, res, body);
|
|
464
|
-
// ORB-1353 - slide the persistence TTL on activity so an actively-used
|
|
465
|
-
// session never idle-expires. Throttled + fire-and-forget so it adds no
|
|
466
|
-
// latency and no per-call write storm.
|
|
467
308
|
const now = Date.now();
|
|
468
309
|
if (now - session.lastTouchAt >= TOUCH_THROTTLE_MS) {
|
|
469
310
|
session.lastTouchAt = now;
|
|
@@ -471,28 +312,6 @@ export function createHttpServer({ baseUrl, sessionStore }) {
|
|
|
471
312
|
}
|
|
472
313
|
return;
|
|
473
314
|
}
|
|
474
|
-
// ORB-1175 / ORB-1324 / ORB-1353 - the client presented a session id we
|
|
475
|
-
// don't hold in memory. The common cause is a deploy: the MCP container
|
|
476
|
-
// restarted and lost its in-memory `sessions` map, so every connected
|
|
477
|
-
// client's session id is now unknown. Three outcomes on a non-init request,
|
|
478
|
-
// decided by whether the token is valid and whether the id is a persisted
|
|
479
|
-
// session owned by that token:
|
|
480
|
-
//
|
|
481
|
-
// - no valid auth (or MCP disabled / mcp:use missing) → keep the ORB-1324
|
|
482
|
-
// 404 so spec-conform clients transparently re-initialise (and hit the
|
|
483
|
-
// clear preflight error on the re-init path). NO WWW-Authenticate: the
|
|
484
|
-
// token, not the session, is the thing to fix only when it's actually
|
|
485
|
-
// invalid - which the re-init preflight then challenges.
|
|
486
|
-
// - valid auth + persisted session owned by caller → REHYDRATE under the
|
|
487
|
-
// SAME id (layer 1): the deploy is invisible to a well-behaved client.
|
|
488
|
-
// - valid auth + unknown id → AUTO-ADOPT under a fresh id (layer 2): a
|
|
489
|
-
// buggy client that keeps replaying a dead id self-heals instead of
|
|
490
|
-
// dead-looping on the 404. MCP tool calls are stateless per-call, so
|
|
491
|
-
// silently re-establishing the session loses nothing.
|
|
492
|
-
//
|
|
493
|
-
// The preflight below enforces the kill-switch (ORB-941) + per-user mcp:use
|
|
494
|
-
// on EVERY rehydrate/adopt, exactly like a fresh session - a disabled
|
|
495
|
-
// workspace refuses adopted sessions (they fall to the 404).
|
|
496
315
|
if (sessionId && !isInitializeRequest(body)) {
|
|
497
316
|
const preflightClient = new OrbotoClient({ baseUrl, apiKey: token, userAgentSuffix });
|
|
498
317
|
let authValid = false;
|
|
@@ -503,8 +322,6 @@ export function createHttpServer({ baseUrl, sessionStore }) {
|
|
|
503
322
|
callerEmail = preflight.userEmail;
|
|
504
323
|
}
|
|
505
324
|
catch {
|
|
506
|
-
// Invalid token, MCP disabled, or mcp:use missing → refuse to
|
|
507
|
-
// re-establish; fall back to the re-initialise 404.
|
|
508
325
|
authValid = false;
|
|
509
326
|
}
|
|
510
327
|
let isPersisted = false;
|
|
@@ -513,8 +330,6 @@ export function createHttpServer({ baseUrl, sessionStore }) {
|
|
|
513
330
|
isPersisted = await store.resolve(token, sessionId);
|
|
514
331
|
}
|
|
515
332
|
catch {
|
|
516
|
-
// Store hiccup - treat as not-persisted and let adoption handle it
|
|
517
|
-
// (auth is already valid, so re-establishing is safe).
|
|
518
333
|
isPersisted = false;
|
|
519
334
|
}
|
|
520
335
|
}
|
|
@@ -524,21 +339,13 @@ export function createHttpServer({ baseUrl, sessionStore }) {
|
|
|
524
339
|
}
|
|
525
340
|
if (action === 'rehydrate') {
|
|
526
341
|
const session = await establishForcedSession({ current: token }, userAgentSuffix, sessionId, callerEmail, toolset);
|
|
527
|
-
// Touch persistence so the TTL slides; same id, so no adoptedFrom.
|
|
528
342
|
void store.register(token, { sessionId, userAgent }).catch(() => { });
|
|
529
343
|
await session.transport.handleRequest(req, res, body);
|
|
530
344
|
return;
|
|
531
345
|
}
|
|
532
|
-
// action === 'adopt' - mint a fresh id and re-establish under it.
|
|
533
346
|
const newSessionId = randomUUID();
|
|
534
347
|
const session = await establishForcedSession({ current: token }, userAgentSuffix, newSessionId, callerEmail, toolset);
|
|
535
|
-
// Rewrite the in-flight request's session header so the transport
|
|
536
|
-
// validates it against the fresh id AND the response advertises the new
|
|
537
|
-
// id. A well-behaved client migrates to it; a client that keeps sending
|
|
538
|
-
// the dead id simply gets re-adopted each call - self-healing either way.
|
|
539
348
|
overrideSessionIdHeader(req, newSessionId);
|
|
540
|
-
// Record the adoption (old id, new id, client). Stderr here; the api
|
|
541
|
-
// upsert emits the Sentry breadcrumb (Sentry lives on the api side).
|
|
542
349
|
process.stderr.write(`[orboto-mcp] auto-adopted stale session ${sessionId} → ${newSessionId} (client=${userAgent ?? 'unknown'})\n`);
|
|
543
350
|
void store
|
|
544
351
|
.register(token, { sessionId: newSessionId, userAgent, adoptedFrom: sessionId })
|
|
@@ -547,14 +354,6 @@ export function createHttpServer({ baseUrl, sessionStore }) {
|
|
|
547
354
|
return;
|
|
548
355
|
}
|
|
549
356
|
if (!sessionId && isInitializeRequest(body)) {
|
|
550
|
-
// New session - mint a transport + server pair bound to this
|
|
551
|
-
// request's token, register with the session map once
|
|
552
|
-
// `onsessioninitialized` fires.
|
|
553
|
-
// Preflight: verify mcp:use + mcp_enabled BEFORE we stand up the server,
|
|
554
|
-
// so a refused session costs nothing. If either fails we refuse at the
|
|
555
|
-
// transport level - much clearer than letting the first tool call 403.
|
|
556
|
-
// WWW-Authenticate included on 401-shape failures so the client can
|
|
557
|
-
// auto-discover OAuth.
|
|
558
357
|
const preflightClient = new OrbotoClient({ baseUrl, apiKey: token, userAgentSuffix });
|
|
559
358
|
let ownerEmail;
|
|
560
359
|
try {
|
|
@@ -565,12 +364,8 @@ export function createHttpServer({ baseUrl, sessionStore }) {
|
|
|
565
364
|
'WWW-Authenticate': wwwAuthChallenge(req, baseUrl, 'invalid_token', err.message),
|
|
566
365
|
});
|
|
567
366
|
}
|
|
568
|
-
// ORB-1470 - the session's mutable bearer holder starts at this request's
|
|
569
|
-
// token and is updated to the client's current bearer on every later call.
|
|
570
367
|
const tokenHolder = { current: token };
|
|
571
368
|
const { sessionClient, mcp, bridge } = await buildSessionCore(tokenHolder, userAgentSuffix, toolset);
|
|
572
|
-
// "name@version" of the client's declared clientInfo, for observability
|
|
573
|
-
// of which adapter owns the session.
|
|
574
369
|
const clientInfo = clientInfoLabel(body);
|
|
575
370
|
const transport = new StreamableHTTPServerTransport({
|
|
576
371
|
sessionIdGenerator: () => randomUUID(),
|
|
@@ -579,8 +374,6 @@ export function createHttpServer({ baseUrl, sessionStore }) {
|
|
|
579
374
|
transport, mcp, client: sessionClient, bridge, tokenHolder, userEmail: ownerEmail, lastTouchAt: Date.now(),
|
|
580
375
|
});
|
|
581
376
|
bridge.start();
|
|
582
|
-
// ORB-1353 - persist the freshly-minted session so it survives an
|
|
583
|
-
// api restart (a later request with this id then rehydrates).
|
|
584
377
|
void store
|
|
585
378
|
.register(token, { sessionId: sid, clientInfo, userAgent })
|
|
586
379
|
.catch(() => { });
|
|
@@ -595,14 +388,9 @@ export function createHttpServer({ baseUrl, sessionStore }) {
|
|
|
595
388
|
await transport.handleRequest(req, res, body);
|
|
596
389
|
return;
|
|
597
390
|
}
|
|
598
|
-
// Neither an init nor a known session - the client is confused.
|
|
599
391
|
sendError(res, 400, 'Missing mcp-session-id header or initialize request');
|
|
600
392
|
});
|
|
601
|
-
// Stop the kill-switch poll when the HTTP server is torn down.
|
|
602
393
|
server.on('close', () => clearInterval(killSwitchTimer));
|
|
603
|
-
// ORB-1353 - expose the in-memory registry + store for tests to drive the
|
|
604
|
-
// restart-simulation (clear `sessions` to mimic a process restart while the
|
|
605
|
-
// injected store keeps its persisted rows) and to close lingering sessions.
|
|
606
394
|
server.__mcp = { sessions, store };
|
|
607
395
|
return server;
|
|
608
396
|
}
|