@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,608 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-244 Phase A - Streamable HTTP transport for the MCP server.
|
|
3
|
+
*
|
|
4
|
+
* Used by the Self-Hosted-inline delivery variant: a separate
|
|
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.
|
|
31
|
+
*/
|
|
32
|
+
import { createServer } from 'node:http';
|
|
33
|
+
import { randomUUID } from 'node:crypto';
|
|
34
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
35
|
+
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
36
|
+
import { buildOrbotoMcpServer } from './server.js';
|
|
37
|
+
import { resolveToolset } from './toolset.js';
|
|
38
|
+
import { OrbotoClient, preflightMcpSession } from './orboto-client.js';
|
|
39
|
+
import { EventBridge } from './event-bridge.js';
|
|
40
|
+
/** Default store backed by the api's `/system/mcp/sessions` endpoints. Each
|
|
41
|
+
* call builds a short-lived OrbotoClient bound to the request's token so the
|
|
42
|
+
* api scopes every read/write to that identity. */
|
|
43
|
+
export function createApiSessionStore(baseUrl) {
|
|
44
|
+
return {
|
|
45
|
+
async register(token, meta) {
|
|
46
|
+
const client = new OrbotoClient({ baseUrl, apiKey: token });
|
|
47
|
+
await client.post('/system/mcp/sessions', meta);
|
|
48
|
+
},
|
|
49
|
+
async resolve(token, sessionId) {
|
|
50
|
+
const client = new OrbotoClient({ baseUrl, apiKey: token });
|
|
51
|
+
const res = await client.get(`/system/mcp/sessions/${encodeURIComponent(sessionId)}`);
|
|
52
|
+
return res.found === true;
|
|
53
|
+
},
|
|
54
|
+
async remove(token, sessionId) {
|
|
55
|
+
const client = new OrbotoClient({ baseUrl, apiKey: token });
|
|
56
|
+
await client.delete(`/system/mcp/sessions/${encodeURIComponent(sessionId)}`);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/** Wrap a mutable token holder as an OAuthTokenProviderLike so the session's
|
|
61
|
+
* clients resolve the latest presented bearer per request. `forceRefresh`
|
|
62
|
+
* cannot mint a new token here (the CLIENT owns the OAuth refresh), so on a
|
|
63
|
+
* 401 it just re-reads the holder; the client presents its refreshed bearer
|
|
64
|
+
* on the next request, which updates the holder for subsequent calls. */
|
|
65
|
+
function holderTokenProvider(holder) {
|
|
66
|
+
return {
|
|
67
|
+
getAccessToken: async () => holder.current,
|
|
68
|
+
forceRefresh: async () => holder.current,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
export function classifyUnknownSession(input) {
|
|
72
|
+
if (!input.hasValidAuth)
|
|
73
|
+
return 'reinit-404';
|
|
74
|
+
return input.isPersistedForCaller ? 'rehydrate' : 'adopt';
|
|
75
|
+
}
|
|
76
|
+
function forceInitialized(transport, sessionId) {
|
|
77
|
+
const web = transport._webStandardTransport;
|
|
78
|
+
web.sessionId = sessionId;
|
|
79
|
+
web._initialized = true;
|
|
80
|
+
}
|
|
81
|
+
/** Rewrite the `mcp-session-id` on an in-flight Node request so the transport
|
|
82
|
+
* validates it against an adopted (fresh) id. The SDK's transport builds its
|
|
83
|
+
* Web Request from `rawHeaders`, so patching the parsed `headers` object alone
|
|
84
|
+
* is not enough - both must be updated (ORB-1353). */
|
|
85
|
+
function overrideSessionIdHeader(req, sessionId) {
|
|
86
|
+
req.headers['mcp-session-id'] = sessionId;
|
|
87
|
+
const raw = req.rawHeaders;
|
|
88
|
+
let found = false;
|
|
89
|
+
for (let i = 0; i < raw.length; i += 2) {
|
|
90
|
+
if (raw[i]?.toLowerCase() === 'mcp-session-id') {
|
|
91
|
+
raw[i + 1] = sessionId;
|
|
92
|
+
found = true;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (!found)
|
|
96
|
+
raw.push('mcp-session-id', sessionId);
|
|
97
|
+
}
|
|
98
|
+
/** Idle-touch throttle: don't re-persist a live session more than once per
|
|
99
|
+
* minute of activity. The api slides the row's TTL on every touch. */
|
|
100
|
+
const TOUCH_THROTTLE_MS = 60_000;
|
|
101
|
+
/** Extract a "name@version" label from an initialize request's clientInfo, for
|
|
102
|
+
* observability of which adapter owns a session. Returns undefined when the
|
|
103
|
+
* body carries no usable clientInfo. */
|
|
104
|
+
export function clientInfoLabel(body) {
|
|
105
|
+
const params = body?.params;
|
|
106
|
+
const ci = params?.clientInfo;
|
|
107
|
+
if (ci && typeof ci.name === 'string' && ci.name.length > 0) {
|
|
108
|
+
return typeof ci.version === 'string' && ci.version.length > 0 ? `${ci.name}@${ci.version}` : ci.name;
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* ORB-941 - graceful close of every active MCP session when the
|
|
114
|
+
* 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
|
+
*/
|
|
129
|
+
export async function closeAllMcpSessions(sessions, reason) {
|
|
130
|
+
let closed = 0;
|
|
131
|
+
for (const { transport, mcp } of [...sessions]) {
|
|
132
|
+
try {
|
|
133
|
+
await mcp.server.sendLoggingMessage({
|
|
134
|
+
level: 'warning',
|
|
135
|
+
data: `orboto MCP: ${reason} Closing this session.`,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// Client never negotiated the logging capability - skip the
|
|
140
|
+
// notice; the transport close below is what actually enforces it.
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
await transport.close();
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// Already closing / closed - nothing to do.
|
|
147
|
+
}
|
|
148
|
+
closed++;
|
|
149
|
+
}
|
|
150
|
+
return closed;
|
|
151
|
+
}
|
|
152
|
+
/** Read the request body as JSON. Fails hard on empty body for POSTs
|
|
153
|
+
* that need one; MCP clients always send a body on /mcp.
|
|
154
|
+
* ORB-1576 - byte cap: the auth check runs AFTER the body is buffered,
|
|
155
|
+
* so an unauthenticated client could otherwise grow container memory
|
|
156
|
+
* with an endless POST. JSON-RPC envelopes are small (the fat
|
|
157
|
+
* attachment payloads ride the api directly, not /mcp). */
|
|
158
|
+
const MAX_BODY_BYTES = 5 * 1024 * 1024;
|
|
159
|
+
async function readJsonBody(req) {
|
|
160
|
+
return new Promise((resolve, reject) => {
|
|
161
|
+
const chunks = [];
|
|
162
|
+
let received = 0;
|
|
163
|
+
req.on('data', (chunk) => {
|
|
164
|
+
received += chunk.length;
|
|
165
|
+
if (received > MAX_BODY_BYTES) {
|
|
166
|
+
req.destroy();
|
|
167
|
+
reject(new Error(`request body too large (>${MAX_BODY_BYTES} bytes)`));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
chunks.push(chunk);
|
|
171
|
+
});
|
|
172
|
+
req.on('end', () => {
|
|
173
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
174
|
+
if (text.length === 0)
|
|
175
|
+
return resolve(null);
|
|
176
|
+
try {
|
|
177
|
+
resolve(JSON.parse(text));
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
reject(err);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
req.on('error', reject);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
function sendError(res, status, message, extraHeaders) {
|
|
187
|
+
res.writeHead(status, { 'content-type': 'application/json', ...(extraHeaders ?? {}) });
|
|
188
|
+
res.end(JSON.stringify({ error: message }));
|
|
189
|
+
}
|
|
190
|
+
/** ORB-957 - RFC 6750 §3 WWW-Authenticate challenge for /mcp 401s.
|
|
191
|
+
* MCP-aware clients (Claude Desktop, Cursor, VS Code Copilot) follow
|
|
192
|
+
* 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
|
+
*/
|
|
202
|
+
function wwwAuthChallenge(req, baseUrl, error, description) {
|
|
203
|
+
const host = req.headers['x-forwarded-host']
|
|
204
|
+
|| 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
|
+
const hostIsLocal = !!host && /^(localhost|127\.|::1|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/.test(host);
|
|
213
|
+
const protoHeader = req.headers['x-forwarded-proto'];
|
|
214
|
+
const proto = protoHeader || (hostIsLocal ? 'http' : 'https');
|
|
215
|
+
const origin = host
|
|
216
|
+
? `${proto}://${host}`
|
|
217
|
+
: baseUrl.replace(/\/$/, '');
|
|
218
|
+
const resourceMetadata = `${origin}/.well-known/oauth-protected-resource`;
|
|
219
|
+
return `Bearer realm="orboto-mcp", error="${error}", error_description="${description.replace(/"/g, '\\"')}", resource_metadata="${resourceMetadata}"`;
|
|
220
|
+
}
|
|
221
|
+
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
|
+
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
|
+
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
|
+
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
|
+
const tokenProvider = holderTokenProvider(tokenHolder);
|
|
238
|
+
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
|
+
const subscriptions = new Set();
|
|
254
|
+
const mcp = await buildOrbotoMcpServer({ baseUrl, tokenProvider, userAgentSuffix, subscriptions, toolset });
|
|
255
|
+
const bridge = new EventBridge({ baseUrl, tokenProvider, mcp, subscriptions });
|
|
256
|
+
return { sessionClient, subscriptions, mcp, bridge };
|
|
257
|
+
}
|
|
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
|
+
/** ORB-1576 - resolve a bearer's owner identity via the status preflight;
|
|
262
|
+
* null on any failure (invalid token, MCP off, network) = fail closed. */
|
|
263
|
+
async function resolveUserEmail(token) {
|
|
264
|
+
try {
|
|
265
|
+
const client = new OrbotoClient({ baseUrl, apiKey: token });
|
|
266
|
+
const { userEmail } = await preflightMcpSession(client);
|
|
267
|
+
return userEmail;
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
async function establishForcedSession(tokenHolder, userAgentSuffix, chosenSessionId, userEmail, toolset) {
|
|
274
|
+
const { sessionClient, mcp, bridge } = await buildSessionCore(tokenHolder, userAgentSuffix, toolset);
|
|
275
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
|
|
276
|
+
transport.onclose = () => { sessions.delete(chosenSessionId); bridge.close(); };
|
|
277
|
+
await mcp.connect(transport);
|
|
278
|
+
forceInitialized(transport, chosenSessionId);
|
|
279
|
+
const session = {
|
|
280
|
+
transport, mcp, client: sessionClient, bridge, tokenHolder, userEmail, lastTouchAt: Date.now(),
|
|
281
|
+
};
|
|
282
|
+
sessions.set(chosenSessionId, session);
|
|
283
|
+
bridge.start();
|
|
284
|
+
return session;
|
|
285
|
+
}
|
|
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
|
+
const pollMs = Number(process.env.ORBOTO_MCP_KILLSWITCH_POLL_MS ?? 30_000);
|
|
299
|
+
async function pollKillSwitch() {
|
|
300
|
+
if (sessions.size === 0)
|
|
301
|
+
return;
|
|
302
|
+
const userDisabled = [];
|
|
303
|
+
for (const session of sessions.values()) {
|
|
304
|
+
try {
|
|
305
|
+
const status = await session.client.get('/system/mcp/status');
|
|
306
|
+
if (!status.enabled) {
|
|
307
|
+
// Workspace-wide off wins and is authoritative for everyone -
|
|
308
|
+
// close all sessions and stop probing.
|
|
309
|
+
await closeAllMcpSessions(sessions.values(), 'the workspace administrator has disabled MCP access.');
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (!status.userMcpEnabled)
|
|
313
|
+
userDisabled.push(session);
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
// This session's token may be expired/invalid - try the next.
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (userDisabled.length > 0) {
|
|
320
|
+
await closeAllMcpSessions(userDisabled, 'you have disabled MCP access for your account.');
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
const killSwitchTimer = setInterval(() => { void pollKillSwitch(); }, pollMs);
|
|
324
|
+
// Don't let the poll keep the process (or a test) alive on its own.
|
|
325
|
+
killSwitchTimer.unref?.();
|
|
326
|
+
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
|
+
if (req.method === 'GET' && req.url === '/health') {
|
|
330
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
331
|
+
res.end(JSON.stringify({ status: 'ok' }));
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
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
|
+
const requestUrl = new URL(req.url ?? '', 'http://localhost');
|
|
342
|
+
if (requestUrl.pathname !== '/mcp') {
|
|
343
|
+
sendError(res, 404, 'Not found');
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const toolset = resolveToolset(requestUrl.searchParams.get('toolset')
|
|
347
|
+
?? 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
|
+
if (req.method !== 'POST' && req.method !== 'DELETE') {
|
|
361
|
+
const probeAuth = (req.headers.authorization ?? '');
|
|
362
|
+
const hasBearer = probeAuth.startsWith('Bearer ') && probeAuth.slice(7).trim().length > 0;
|
|
363
|
+
if ((req.method === 'GET' || req.method === 'HEAD') && !hasBearer) {
|
|
364
|
+
const challenge = wwwAuthChallenge(req, baseUrl, 'invalid_request', 'Bearer token required');
|
|
365
|
+
if (req.method === 'HEAD') {
|
|
366
|
+
// HEAD carries no body per HTTP semantics.
|
|
367
|
+
res.writeHead(401, { 'content-type': 'application/json', 'WWW-Authenticate': challenge });
|
|
368
|
+
res.end();
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
sendError(res, 401, 'Bearer token required', { 'WWW-Authenticate': challenge });
|
|
372
|
+
}
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
res.writeHead(405, { allow: 'POST, DELETE' });
|
|
376
|
+
res.end();
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
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
|
+
const authHeader = (req.headers.authorization ?? '');
|
|
390
|
+
const token = authHeader.startsWith('Bearer ')
|
|
391
|
+
? authHeader.slice(7).trim()
|
|
392
|
+
: '';
|
|
393
|
+
if (!token) {
|
|
394
|
+
sendError(res, 401, 'Bearer token required', {
|
|
395
|
+
'WWW-Authenticate': wwwAuthChallenge(req, baseUrl, 'invalid_request', 'Bearer token required'),
|
|
396
|
+
});
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
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
|
+
if (req.method === 'DELETE') {
|
|
403
|
+
const existing = sessions.get(sessionId);
|
|
404
|
+
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
|
+
const presented = await resolveUserEmail(token);
|
|
408
|
+
if (!presented || presented !== existing.userEmail) {
|
|
409
|
+
return sendError(res, 404, 'Unknown MCP session');
|
|
410
|
+
}
|
|
411
|
+
}
|
|
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
|
+
void store.remove(token, sessionId).catch(() => { });
|
|
415
|
+
if (existing) {
|
|
416
|
+
await existing.transport.handleRequest(req, res);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
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
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
422
|
+
res.end(JSON.stringify({ ok: true }));
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
// POST path. Parse body so we can route (new-session vs. existing).
|
|
426
|
+
let body;
|
|
427
|
+
try {
|
|
428
|
+
body = await readJsonBody(req);
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
return sendError(res, 400, 'Malformed JSON');
|
|
432
|
+
}
|
|
433
|
+
const userAgentSuffix = req.headers['user-agent']
|
|
434
|
+
?.split('/')[0] || undefined;
|
|
435
|
+
const userAgent = req.headers['user-agent'];
|
|
436
|
+
if (sessionId && sessions.has(sessionId)) {
|
|
437
|
+
// Existing session - hand straight to its transport.
|
|
438
|
+
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
|
+
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
|
+
const presented = await resolveUserEmail(token);
|
|
454
|
+
if (!presented || presented !== session.userEmail) {
|
|
455
|
+
return sendError(res, 401, 'Bearer does not match the session owner');
|
|
456
|
+
}
|
|
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
|
+
process.stderr.write(`[orboto-mcp] session ${sessionId} bearer rotated - adopting client's current access token\n`);
|
|
461
|
+
session.tokenHolder.current = token;
|
|
462
|
+
}
|
|
463
|
+
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
|
+
const now = Date.now();
|
|
468
|
+
if (now - session.lastTouchAt >= TOUCH_THROTTLE_MS) {
|
|
469
|
+
session.lastTouchAt = now;
|
|
470
|
+
void store.register(session.tokenHolder.current, { sessionId }).catch(() => { });
|
|
471
|
+
}
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
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
|
+
if (sessionId && !isInitializeRequest(body)) {
|
|
497
|
+
const preflightClient = new OrbotoClient({ baseUrl, apiKey: token, userAgentSuffix });
|
|
498
|
+
let authValid = false;
|
|
499
|
+
let callerEmail = '';
|
|
500
|
+
try {
|
|
501
|
+
const preflight = await preflightMcpSession(preflightClient);
|
|
502
|
+
authValid = true;
|
|
503
|
+
callerEmail = preflight.userEmail;
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
// Invalid token, MCP disabled, or mcp:use missing → refuse to
|
|
507
|
+
// re-establish; fall back to the re-initialise 404.
|
|
508
|
+
authValid = false;
|
|
509
|
+
}
|
|
510
|
+
let isPersisted = false;
|
|
511
|
+
if (authValid) {
|
|
512
|
+
try {
|
|
513
|
+
isPersisted = await store.resolve(token, sessionId);
|
|
514
|
+
}
|
|
515
|
+
catch {
|
|
516
|
+
// Store hiccup - treat as not-persisted and let adoption handle it
|
|
517
|
+
// (auth is already valid, so re-establishing is safe).
|
|
518
|
+
isPersisted = false;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
const action = classifyUnknownSession({ hasValidAuth: authValid, isPersistedForCaller: isPersisted });
|
|
522
|
+
if (action === 'reinit-404') {
|
|
523
|
+
return sendError(res, 404, 'Unknown or expired MCP session - reinitialize (the server restarted since this session began).');
|
|
524
|
+
}
|
|
525
|
+
if (action === 'rehydrate') {
|
|
526
|
+
const session = await establishForcedSession({ current: token }, userAgentSuffix, sessionId, callerEmail, toolset);
|
|
527
|
+
// Touch persistence so the TTL slides; same id, so no adoptedFrom.
|
|
528
|
+
void store.register(token, { sessionId, userAgent }).catch(() => { });
|
|
529
|
+
await session.transport.handleRequest(req, res, body);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
// action === 'adopt' - mint a fresh id and re-establish under it.
|
|
533
|
+
const newSessionId = randomUUID();
|
|
534
|
+
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
|
+
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
|
+
process.stderr.write(`[orboto-mcp] auto-adopted stale session ${sessionId} → ${newSessionId} (client=${userAgent ?? 'unknown'})\n`);
|
|
543
|
+
void store
|
|
544
|
+
.register(token, { sessionId: newSessionId, userAgent, adoptedFrom: sessionId })
|
|
545
|
+
.catch(() => { });
|
|
546
|
+
await session.transport.handleRequest(req, res, body);
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
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
|
+
const preflightClient = new OrbotoClient({ baseUrl, apiKey: token, userAgentSuffix });
|
|
559
|
+
let ownerEmail;
|
|
560
|
+
try {
|
|
561
|
+
ownerEmail = (await preflightMcpSession(preflightClient)).userEmail;
|
|
562
|
+
}
|
|
563
|
+
catch (err) {
|
|
564
|
+
return sendError(res, 401, err.message, {
|
|
565
|
+
'WWW-Authenticate': wwwAuthChallenge(req, baseUrl, 'invalid_token', err.message),
|
|
566
|
+
});
|
|
567
|
+
}
|
|
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
|
+
const tokenHolder = { current: token };
|
|
571
|
+
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
|
+
const clientInfo = clientInfoLabel(body);
|
|
575
|
+
const transport = new StreamableHTTPServerTransport({
|
|
576
|
+
sessionIdGenerator: () => randomUUID(),
|
|
577
|
+
onsessioninitialized: (sid) => {
|
|
578
|
+
sessions.set(sid, {
|
|
579
|
+
transport, mcp, client: sessionClient, bridge, tokenHolder, userEmail: ownerEmail, lastTouchAt: Date.now(),
|
|
580
|
+
});
|
|
581
|
+
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
|
+
void store
|
|
585
|
+
.register(token, { sessionId: sid, clientInfo, userAgent })
|
|
586
|
+
.catch(() => { });
|
|
587
|
+
},
|
|
588
|
+
});
|
|
589
|
+
transport.onclose = () => {
|
|
590
|
+
if (transport.sessionId)
|
|
591
|
+
sessions.delete(transport.sessionId);
|
|
592
|
+
bridge.close();
|
|
593
|
+
};
|
|
594
|
+
await mcp.connect(transport);
|
|
595
|
+
await transport.handleRequest(req, res, body);
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
// Neither an init nor a known session - the client is confused.
|
|
599
|
+
sendError(res, 400, 'Missing mcp-session-id header or initialize request');
|
|
600
|
+
});
|
|
601
|
+
// Stop the kill-switch poll when the HTTP server is torn down.
|
|
602
|
+
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
|
+
server.__mcp = { sessions, store };
|
|
607
|
+
return server;
|
|
608
|
+
}
|