@thalovant/mcp-server 0.1.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/.env.example +49 -0
- package/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/README.md +288 -0
- package/SECURITY.md +22 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +1612 -0
- package/examples/claude_desktop_config.json +11 -0
- package/examples/principal-credentials.sample.json +24 -0
- package/package.json +76 -0
- package/server.json +48 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1612 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
5
|
+
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
6
|
+
import { DEFAULT_CONTROL_API_URL, ThalovantClient, ThalovantControlPlane, ThalovantIdentity, buildClientContext, } from "@thalovant/sdk";
|
|
7
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
8
|
+
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
|
9
|
+
import { createServer as createHttpServer } from "node:http";
|
|
10
|
+
import { appendFile, readFile, writeFile } from "node:fs/promises";
|
|
11
|
+
import { dirname, resolve } from "node:path";
|
|
12
|
+
import { mkdir } from "node:fs/promises";
|
|
13
|
+
import { pathToFileURL } from "node:url";
|
|
14
|
+
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
const VERSION = "0.1.0";
|
|
17
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
18
|
+
const MAX_TIMEOUT_MS = 300_000;
|
|
19
|
+
const MAX_LIMIT = 100;
|
|
20
|
+
const DEFAULT_HTTP_BODY_LIMIT_BYTES = 1_048_576;
|
|
21
|
+
const DEFAULT_HTTP_RATE_LIMIT_MAX = 120;
|
|
22
|
+
const DEFAULT_HTTP_RATE_LIMIT_WINDOW_MS = 60_000;
|
|
23
|
+
const DEFAULT_HTTP_SESSION_TTL_MS = 3_600_000;
|
|
24
|
+
const DEFAULT_OAUTH_SCOPE = "mcp:thalovant";
|
|
25
|
+
const SECRET_KEYS = [
|
|
26
|
+
"access_token",
|
|
27
|
+
"accessToken",
|
|
28
|
+
"access_key",
|
|
29
|
+
"accessKey",
|
|
30
|
+
"api_key",
|
|
31
|
+
"apiKey",
|
|
32
|
+
"password",
|
|
33
|
+
"broker_password",
|
|
34
|
+
"brokerPassword",
|
|
35
|
+
"client_secret",
|
|
36
|
+
"clientSecret",
|
|
37
|
+
"crypto_key",
|
|
38
|
+
"cryptoKey",
|
|
39
|
+
"private_key",
|
|
40
|
+
"privateKey",
|
|
41
|
+
"refresh_token",
|
|
42
|
+
"refreshToken",
|
|
43
|
+
"authToken",
|
|
44
|
+
"authorization",
|
|
45
|
+
"bearer",
|
|
46
|
+
"token",
|
|
47
|
+
"secret",
|
|
48
|
+
];
|
|
49
|
+
const protocolSchema = z.enum(["wss", "https", "mqtt"]);
|
|
50
|
+
const limitSchema = z.number().int().min(1).max(MAX_LIMIT).default(25);
|
|
51
|
+
const optionalCursorSchema = z.string().min(1).optional();
|
|
52
|
+
const jsonRecordSchema = z.record(z.unknown());
|
|
53
|
+
const controlPlaneSchema = {
|
|
54
|
+
loginScope: z
|
|
55
|
+
.string()
|
|
56
|
+
.min(1)
|
|
57
|
+
.optional()
|
|
58
|
+
.describe("Optional login scope used with THALOVANT_EMAIL and THALOVANT_PASSWORD."),
|
|
59
|
+
apiUrl: z
|
|
60
|
+
.string()
|
|
61
|
+
.url()
|
|
62
|
+
.optional()
|
|
63
|
+
.describe("Control-plane base URL. Defaults to https://api.thalovant.com."),
|
|
64
|
+
};
|
|
65
|
+
const contextSchema = z
|
|
66
|
+
.object({
|
|
67
|
+
userId: z.string().min(1).optional(),
|
|
68
|
+
userName: z.string().min(1).optional(),
|
|
69
|
+
authProvider: z.string().min(1).optional(),
|
|
70
|
+
authClaims: jsonRecordSchema.optional(),
|
|
71
|
+
roles: z.array(z.string().min(1)).optional(),
|
|
72
|
+
platform: z.string().min(1).optional(),
|
|
73
|
+
source: z.string().min(1).optional(),
|
|
74
|
+
destination: z.string().min(1).optional(),
|
|
75
|
+
channel: z.string().min(1).optional(),
|
|
76
|
+
deviceId: z.string().min(1).optional(),
|
|
77
|
+
locale: z.string().min(1).optional(),
|
|
78
|
+
metadata: jsonRecordSchema.optional(),
|
|
79
|
+
sessionId: z.string().min(1).optional(),
|
|
80
|
+
})
|
|
81
|
+
.optional();
|
|
82
|
+
const runtimeAuthSchema = {
|
|
83
|
+
identityFile: z
|
|
84
|
+
.string()
|
|
85
|
+
.min(1)
|
|
86
|
+
.optional()
|
|
87
|
+
.describe("Path to a Thalovant identity JSON file."),
|
|
88
|
+
configPath: z
|
|
89
|
+
.string()
|
|
90
|
+
.min(1)
|
|
91
|
+
.optional()
|
|
92
|
+
.describe("Path to a Thalovant SDK config file."),
|
|
93
|
+
profile: z.string().min(1).optional().describe("SDK config profile name."),
|
|
94
|
+
fromEnv: z
|
|
95
|
+
.boolean()
|
|
96
|
+
.default(false)
|
|
97
|
+
.describe("Load the identity from Thalovant SDK environment variables."),
|
|
98
|
+
protocol: protocolSchema.default("wss").describe("Runtime transport protocol."),
|
|
99
|
+
};
|
|
100
|
+
class HttpError extends Error {
|
|
101
|
+
status;
|
|
102
|
+
constructor(status, message) {
|
|
103
|
+
super(message);
|
|
104
|
+
this.status = status;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
class InMemoryEventStore {
|
|
108
|
+
maxEventsPerStream;
|
|
109
|
+
eventIdToStreamId = new Map();
|
|
110
|
+
streamEvents = new Map();
|
|
111
|
+
counter = 0;
|
|
112
|
+
constructor(maxEventsPerStream = 200) {
|
|
113
|
+
this.maxEventsPerStream = maxEventsPerStream;
|
|
114
|
+
}
|
|
115
|
+
async storeEvent(streamId, message) {
|
|
116
|
+
const eventId = `${Date.now()}-${++this.counter}`;
|
|
117
|
+
this.addEvent(streamId, eventId, message);
|
|
118
|
+
return eventId;
|
|
119
|
+
}
|
|
120
|
+
addEvent(streamId, eventId, message) {
|
|
121
|
+
this.eventIdToStreamId.set(eventId, streamId);
|
|
122
|
+
const events = this.streamEvents.get(streamId) ?? [];
|
|
123
|
+
events.push({ eventId, message });
|
|
124
|
+
while (events.length > this.maxEventsPerStream) {
|
|
125
|
+
const removed = events.shift();
|
|
126
|
+
if (removed)
|
|
127
|
+
this.eventIdToStreamId.delete(removed.eventId);
|
|
128
|
+
}
|
|
129
|
+
this.streamEvents.set(streamId, events);
|
|
130
|
+
}
|
|
131
|
+
async getStreamIdForEventId(eventId) {
|
|
132
|
+
return this.eventIdToStreamId.get(eventId);
|
|
133
|
+
}
|
|
134
|
+
async replayEventsAfter(lastEventId, { send }) {
|
|
135
|
+
const streamId = this.eventIdToStreamId.get(lastEventId);
|
|
136
|
+
if (!streamId) {
|
|
137
|
+
throw new Error(`Unknown event id: ${lastEventId}`);
|
|
138
|
+
}
|
|
139
|
+
const events = this.streamEvents.get(streamId) ?? [];
|
|
140
|
+
const lastIndex = events.findIndex((event) => event.eventId === lastEventId);
|
|
141
|
+
for (const event of events.slice(lastIndex + 1)) {
|
|
142
|
+
await send(event.eventId, event.message);
|
|
143
|
+
}
|
|
144
|
+
return streamId;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
class FileEventStore extends InMemoryEventStore {
|
|
148
|
+
filePath;
|
|
149
|
+
constructor(filePath) {
|
|
150
|
+
super();
|
|
151
|
+
this.filePath = filePath;
|
|
152
|
+
}
|
|
153
|
+
static async create(filePath) {
|
|
154
|
+
const store = new FileEventStore(resolve(filePath));
|
|
155
|
+
try {
|
|
156
|
+
const data = await readFile(store.filePath, "utf8");
|
|
157
|
+
for (const line of data.split("\n")) {
|
|
158
|
+
if (!line.trim())
|
|
159
|
+
continue;
|
|
160
|
+
const event = JSON.parse(line);
|
|
161
|
+
store.addEvent(event.streamId, event.eventId, event.message);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
if (error.code !== "ENOENT") {
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return store;
|
|
170
|
+
}
|
|
171
|
+
async storeEvent(streamId, message) {
|
|
172
|
+
const eventId = await super.storeEvent(streamId, message);
|
|
173
|
+
await mkdir(dirname(this.filePath), { recursive: true, mode: 0o700 });
|
|
174
|
+
await appendFile(this.filePath, `${stableStringify({ streamId, eventId, message })}\n`, { mode: 0o600 });
|
|
175
|
+
return eventId;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
class FixedWindowRateLimiter {
|
|
179
|
+
maxRequests;
|
|
180
|
+
windowMs;
|
|
181
|
+
entries = new Map();
|
|
182
|
+
constructor(maxRequests, windowMs) {
|
|
183
|
+
this.maxRequests = maxRequests;
|
|
184
|
+
this.windowMs = windowMs;
|
|
185
|
+
}
|
|
186
|
+
check(key) {
|
|
187
|
+
const now = Date.now();
|
|
188
|
+
const entry = this.entries.get(key);
|
|
189
|
+
if (!entry || entry.resetAt <= now) {
|
|
190
|
+
const resetAt = now + this.windowMs;
|
|
191
|
+
this.entries.set(key, { count: 1, resetAt });
|
|
192
|
+
return { allowed: true, remaining: Math.max(this.maxRequests - 1, 0), resetAt };
|
|
193
|
+
}
|
|
194
|
+
entry.count += 1;
|
|
195
|
+
const allowed = entry.count <= this.maxRequests;
|
|
196
|
+
return {
|
|
197
|
+
allowed,
|
|
198
|
+
remaining: Math.max(this.maxRequests - entry.count, 0),
|
|
199
|
+
resetAt: entry.resetAt,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
cleanup() {
|
|
203
|
+
const now = Date.now();
|
|
204
|
+
for (const [key, entry] of this.entries) {
|
|
205
|
+
if (entry.resetAt <= now)
|
|
206
|
+
this.entries.delete(key);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const requestContext = new AsyncLocalStorage();
|
|
211
|
+
let credentialsFileCache;
|
|
212
|
+
function textContent(text, data) {
|
|
213
|
+
return {
|
|
214
|
+
content: [
|
|
215
|
+
{
|
|
216
|
+
type: "text",
|
|
217
|
+
text: data === undefined ? text : `${text}\n\n${stableStringify(data)}`,
|
|
218
|
+
},
|
|
219
|
+
],
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
function jsonContent(data) {
|
|
223
|
+
return {
|
|
224
|
+
content: [
|
|
225
|
+
{
|
|
226
|
+
type: "text",
|
|
227
|
+
text: stableStringify(data),
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function stableStringify(value) {
|
|
233
|
+
return JSON.stringify(value, null, 2);
|
|
234
|
+
}
|
|
235
|
+
function currentPrincipal() {
|
|
236
|
+
return requestContext.getStore()?.principal ?? {
|
|
237
|
+
id: "local",
|
|
238
|
+
clientId: "local",
|
|
239
|
+
scopes: ["local"],
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
function isRemotePrincipal(principal = currentPrincipal()) {
|
|
243
|
+
return principal.id !== "local";
|
|
244
|
+
}
|
|
245
|
+
function principalFromExtra(extra) {
|
|
246
|
+
const authInfo = extra?.authInfo;
|
|
247
|
+
if (!authInfo) {
|
|
248
|
+
return { id: "local", clientId: "local", scopes: ["local"] };
|
|
249
|
+
}
|
|
250
|
+
const claims = authInfo.extra?.claims && typeof authInfo.extra.claims === "object" ? authInfo.extra.claims : undefined;
|
|
251
|
+
const principalId = (typeof authInfo.extra?.principalId === "string" && authInfo.extra.principalId) ||
|
|
252
|
+
(typeof claims?.sub === "string" && claims.sub) ||
|
|
253
|
+
authInfo.clientId;
|
|
254
|
+
return {
|
|
255
|
+
id: principalId,
|
|
256
|
+
clientId: authInfo.clientId,
|
|
257
|
+
subject: typeof claims?.sub === "string" ? claims.sub : undefined,
|
|
258
|
+
scopes: authInfo.scopes,
|
|
259
|
+
claims,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
function principalCredentialKeys(principal) {
|
|
263
|
+
return Array.from(new Set([principal.id, principal.subject, principal.clientId].filter((value) => Boolean(value))));
|
|
264
|
+
}
|
|
265
|
+
function hashedPrincipalFileName(principalId) {
|
|
266
|
+
return `${createHash("sha256").update(principalId).digest("hex")}.json`;
|
|
267
|
+
}
|
|
268
|
+
async function loadCredentialFile() {
|
|
269
|
+
const path = process.env.THALOVANT_PRINCIPAL_CREDENTIALS_FILE;
|
|
270
|
+
if (!path)
|
|
271
|
+
return undefined;
|
|
272
|
+
try {
|
|
273
|
+
const data = JSON.parse(await readFile(path, "utf8"));
|
|
274
|
+
return data;
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
throw new Error(`Unable to read THALOVANT_PRINCIPAL_CREDENTIALS_FILE: ${path}: ${String(error)}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
async function loadCredentialFromDir(principal) {
|
|
281
|
+
const dir = process.env.THALOVANT_PRINCIPAL_CREDENTIALS_DIR;
|
|
282
|
+
if (!dir)
|
|
283
|
+
return undefined;
|
|
284
|
+
for (const key of principalCredentialKeys(principal)) {
|
|
285
|
+
const filePath = resolve(dir, hashedPrincipalFileName(key));
|
|
286
|
+
try {
|
|
287
|
+
return JSON.parse(await readFile(filePath, "utf8"));
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
if (error.code !== "ENOENT") {
|
|
291
|
+
throw new Error(`Unable to read principal credential file: ${filePath}: ${String(error)}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return undefined;
|
|
296
|
+
}
|
|
297
|
+
async function credentialForPrincipal(principal = currentPrincipal()) {
|
|
298
|
+
const fromDir = await loadCredentialFromDir(principal);
|
|
299
|
+
if (fromDir)
|
|
300
|
+
return fromDir;
|
|
301
|
+
credentialsFileCache ??= loadCredentialFile();
|
|
302
|
+
const file = await credentialsFileCache;
|
|
303
|
+
if (!file)
|
|
304
|
+
return undefined;
|
|
305
|
+
for (const key of principalCredentialKeys(principal)) {
|
|
306
|
+
const credential = file.principals?.[key];
|
|
307
|
+
if (credential)
|
|
308
|
+
return credential;
|
|
309
|
+
}
|
|
310
|
+
return parseBool(process.env.THALOVANT_ALLOW_DEFAULT_PRINCIPAL_CREDENTIALS, false) ? file.default : undefined;
|
|
311
|
+
}
|
|
312
|
+
function allowSharedThalovantCredentials(principal = currentPrincipal()) {
|
|
313
|
+
return !isRemotePrincipal(principal) || parseBool(process.env.THALOVANT_ALLOW_SHARED_CREDENTIALS, false);
|
|
314
|
+
}
|
|
315
|
+
function toolListFromEnv(...names) {
|
|
316
|
+
return names.flatMap((name) => parseCsv(process.env[name]));
|
|
317
|
+
}
|
|
318
|
+
function toolPatternMatches(pattern, toolName) {
|
|
319
|
+
if (pattern === "*")
|
|
320
|
+
return true;
|
|
321
|
+
if (pattern.endsWith("*"))
|
|
322
|
+
return toolName.startsWith(pattern.slice(0, -1));
|
|
323
|
+
return pattern === toolName;
|
|
324
|
+
}
|
|
325
|
+
function listAllowsTool(patterns, toolName) {
|
|
326
|
+
if (!patterns || patterns.length === 0)
|
|
327
|
+
return true;
|
|
328
|
+
return patterns.some((pattern) => toolPatternMatches(pattern, toolName));
|
|
329
|
+
}
|
|
330
|
+
function listDeniesTool(patterns, toolName) {
|
|
331
|
+
return Boolean(patterns?.some((pattern) => toolPatternMatches(pattern, toolName)));
|
|
332
|
+
}
|
|
333
|
+
async function authorizeTool(toolName, principal) {
|
|
334
|
+
const globalAllow = toolListFromEnv("MCP_TOOL_ALLOWLIST", "THALOVANT_MCP_TOOL_ALLOWLIST");
|
|
335
|
+
const globalDeny = toolListFromEnv("MCP_TOOL_DENYLIST", "THALOVANT_MCP_TOOL_DENYLIST");
|
|
336
|
+
if (!listAllowsTool(globalAllow, toolName) || listDeniesTool(globalDeny, toolName)) {
|
|
337
|
+
throw new Error(`Tool ${toolName} is disabled by server policy.`);
|
|
338
|
+
}
|
|
339
|
+
const credential = await credentialForPrincipal(principal);
|
|
340
|
+
if (!listAllowsTool(credential?.allowedTools, toolName) || listDeniesTool(credential?.deniedTools, toolName)) {
|
|
341
|
+
throw new Error(`Tool ${toolName} is disabled for this principal.`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
async function auditLog(event) {
|
|
345
|
+
const mode = process.env.MCP_AUDIT_LOG ?? "off";
|
|
346
|
+
if (mode === "off")
|
|
347
|
+
return;
|
|
348
|
+
const payload = `${JSON.stringify(redactSecrets(event))}\n`;
|
|
349
|
+
if (mode === "stderr" || mode === "both") {
|
|
350
|
+
console.error(payload.trimEnd());
|
|
351
|
+
}
|
|
352
|
+
const file = process.env.MCP_AUDIT_LOG_FILE;
|
|
353
|
+
if ((mode === "file" || mode === "both" || file) && file) {
|
|
354
|
+
await mkdir(dirname(resolve(file)), { recursive: true, mode: 0o700 });
|
|
355
|
+
await appendFile(file, payload, { mode: 0o600 });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
function registerThalovantTool(server, name, config, handler) {
|
|
359
|
+
server.registerTool(name, config, async (args, extra) => {
|
|
360
|
+
const principal = principalFromExtra(extra);
|
|
361
|
+
const start = Date.now();
|
|
362
|
+
try {
|
|
363
|
+
await authorizeTool(name, principal);
|
|
364
|
+
const result = await requestContext.run({ toolName: name, principal }, () => handler(args, extra));
|
|
365
|
+
await auditLog({
|
|
366
|
+
ts: new Date().toISOString(),
|
|
367
|
+
event: "mcp.tool",
|
|
368
|
+
tool: name,
|
|
369
|
+
principalId: principal.id,
|
|
370
|
+
clientId: principal.clientId,
|
|
371
|
+
status: "ok",
|
|
372
|
+
durationMs: Date.now() - start,
|
|
373
|
+
args: parseBool(process.env.MCP_AUDIT_INCLUDE_ARGS, false) ? args : undefined,
|
|
374
|
+
});
|
|
375
|
+
return result;
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
await auditLog({
|
|
379
|
+
ts: new Date().toISOString(),
|
|
380
|
+
event: "mcp.tool",
|
|
381
|
+
tool: name,
|
|
382
|
+
principalId: principal.id,
|
|
383
|
+
clientId: principal.clientId,
|
|
384
|
+
status: "error",
|
|
385
|
+
durationMs: Date.now() - start,
|
|
386
|
+
error: error instanceof Error ? error.message : String(error),
|
|
387
|
+
});
|
|
388
|
+
throw error;
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
function clampTimeout(timeoutMs) {
|
|
393
|
+
if (timeoutMs === undefined)
|
|
394
|
+
return DEFAULT_TIMEOUT_MS;
|
|
395
|
+
return Math.min(Math.max(timeoutMs, 1_000), MAX_TIMEOUT_MS);
|
|
396
|
+
}
|
|
397
|
+
function redactSecrets(value) {
|
|
398
|
+
if (Array.isArray(value)) {
|
|
399
|
+
return value.map((item) => redactSecrets(item));
|
|
400
|
+
}
|
|
401
|
+
if (!value || typeof value !== "object") {
|
|
402
|
+
return value;
|
|
403
|
+
}
|
|
404
|
+
const output = {};
|
|
405
|
+
for (const [key, nestedValue] of Object.entries(value)) {
|
|
406
|
+
const normalized = key.toLowerCase();
|
|
407
|
+
const isSecret = SECRET_KEYS.some((secretKey) => normalized.includes(secretKey.toLowerCase()));
|
|
408
|
+
output[key] = isSecret ? "[redacted]" : redactSecrets(nestedValue);
|
|
409
|
+
}
|
|
410
|
+
return output;
|
|
411
|
+
}
|
|
412
|
+
async function createControlPlane(options) {
|
|
413
|
+
const principal = currentPrincipal();
|
|
414
|
+
const credential = await credentialForPrincipal(principal);
|
|
415
|
+
const canUseShared = allowSharedThalovantCredentials(principal);
|
|
416
|
+
const apiUrl = options.apiUrl ?? credential?.control?.apiUrl ?? (canUseShared ? process.env.THALOVANT_API_URL : undefined) ?? DEFAULT_CONTROL_API_URL;
|
|
417
|
+
const accessToken = credential?.control?.accessToken ?? (canUseShared ? process.env.THALOVANT_ACCESS_TOKEN : undefined);
|
|
418
|
+
const api = new ThalovantControlPlane(apiUrl, {
|
|
419
|
+
accessToken,
|
|
420
|
+
userAgent: `thalovant-mcp/${VERSION}`,
|
|
421
|
+
});
|
|
422
|
+
if (!accessToken && canUseShared) {
|
|
423
|
+
const email = process.env.THALOVANT_EMAIL;
|
|
424
|
+
const password = process.env.THALOVANT_PASSWORD;
|
|
425
|
+
if (email && password) {
|
|
426
|
+
await api.login(email, password, { scope: options.loginScope ?? process.env.THALOVANT_SCOPE });
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return api;
|
|
430
|
+
}
|
|
431
|
+
function ensureAuthenticated(api) {
|
|
432
|
+
if (!api.accessToken) {
|
|
433
|
+
throw new Error("This tool requires Thalovant API auth. Configure THALOVANT_ACCESS_TOKEN, THALOVANT_EMAIL/THALOVANT_PASSWORD, or per-principal Thalovant credentials for this MCP principal.");
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
async function createRuntimeClient(options) {
|
|
437
|
+
const protocol = options.protocol ?? "wss";
|
|
438
|
+
const principal = currentPrincipal();
|
|
439
|
+
const credential = await credentialForPrincipal(principal);
|
|
440
|
+
const runtimeCredential = credential?.runtime;
|
|
441
|
+
const canUseShared = allowSharedThalovantCredentials(principal);
|
|
442
|
+
const canUseToolPaths = canUseShared || parseBool(process.env.MCP_HTTP_ALLOW_CLIENT_CREDENTIAL_PATHS, false);
|
|
443
|
+
if (runtimeCredential?.identity) {
|
|
444
|
+
return new ThalovantClient(new ThalovantIdentity(runtimeCredential.identity), { protocol });
|
|
445
|
+
}
|
|
446
|
+
if (runtimeCredential?.identityFile) {
|
|
447
|
+
return ThalovantClient.fromIdentityFile(runtimeCredential.identityFile, { protocol });
|
|
448
|
+
}
|
|
449
|
+
if (runtimeCredential?.configPath || runtimeCredential?.profile) {
|
|
450
|
+
return ThalovantClient.fromConfig({
|
|
451
|
+
path: runtimeCredential.configPath,
|
|
452
|
+
profile: runtimeCredential.profile,
|
|
453
|
+
protocol,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
if (runtimeCredential?.fromEnv && canUseShared) {
|
|
457
|
+
return ThalovantClient.fromEnv({ protocol });
|
|
458
|
+
}
|
|
459
|
+
if (isRemotePrincipal(principal) && !canUseShared && !canUseToolPaths) {
|
|
460
|
+
throw new Error("No per-principal Thalovant runtime identity is configured for this authenticated MCP principal.");
|
|
461
|
+
}
|
|
462
|
+
if (!canUseToolPaths && (options.identityFile || options.configPath || options.profile || options.fromEnv)) {
|
|
463
|
+
throw new Error("Runtime identity path/config arguments are disabled for remote MCP principals.");
|
|
464
|
+
}
|
|
465
|
+
if (options.identityFile) {
|
|
466
|
+
return ThalovantClient.fromIdentityFile(options.identityFile, { protocol });
|
|
467
|
+
}
|
|
468
|
+
if (options.configPath || options.profile) {
|
|
469
|
+
return ThalovantClient.fromConfig({
|
|
470
|
+
path: options.configPath,
|
|
471
|
+
profile: options.profile ?? process.env.THALOVANT_PROFILE,
|
|
472
|
+
protocol,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
if (!canUseShared) {
|
|
476
|
+
throw new Error("No per-principal Thalovant runtime identity is configured for this authenticated MCP principal.");
|
|
477
|
+
}
|
|
478
|
+
if (options.fromEnv || process.env.THALOVANT_ACCESS_KEY) {
|
|
479
|
+
return ThalovantClient.fromEnv({ protocol });
|
|
480
|
+
}
|
|
481
|
+
return ThalovantClient.fromConfig({
|
|
482
|
+
profile: process.env.THALOVANT_PROFILE,
|
|
483
|
+
protocol,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
function summarizeReply(reply) {
|
|
487
|
+
const displayItems = reply.displayItems({ maxTextChars: 1_000 });
|
|
488
|
+
return {
|
|
489
|
+
text: reply.text,
|
|
490
|
+
displayText: reply.displayText,
|
|
491
|
+
utterances: reply.utterances,
|
|
492
|
+
handled: reply.handled,
|
|
493
|
+
ok: reply.ok,
|
|
494
|
+
sessionId: reply.sessionId,
|
|
495
|
+
requestId: reply.requestId,
|
|
496
|
+
displayItems,
|
|
497
|
+
events: reply.events.map((event) => redactSecrets(event.asObject())),
|
|
498
|
+
failureEvent: reply.failureEvent ? redactSecrets(reply.failureEvent.asObject()) : undefined,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
async function saveIdentity(path, identity) {
|
|
502
|
+
const resolvedPath = resolve(path);
|
|
503
|
+
await mkdir(dirname(resolvedPath), { recursive: true, mode: 0o700 });
|
|
504
|
+
await writeFile(resolvedPath, `${stableStringify(identity.asObject(true))}\n`, { mode: 0o600 });
|
|
505
|
+
return resolvedPath;
|
|
506
|
+
}
|
|
507
|
+
function parseCsv(value) {
|
|
508
|
+
return (value ?? "")
|
|
509
|
+
.split(",")
|
|
510
|
+
.map((item) => item.trim())
|
|
511
|
+
.filter(Boolean);
|
|
512
|
+
}
|
|
513
|
+
function parseBool(value, defaultValue = false) {
|
|
514
|
+
if (value === undefined)
|
|
515
|
+
return defaultValue;
|
|
516
|
+
return ["1", "true", "yes", "on"].includes(value.toLowerCase());
|
|
517
|
+
}
|
|
518
|
+
function parseInteger(value, defaultValue, options) {
|
|
519
|
+
if (value === undefined || value.trim() === "")
|
|
520
|
+
return defaultValue;
|
|
521
|
+
const parsed = Number.parseInt(value, 10);
|
|
522
|
+
if (!Number.isFinite(parsed))
|
|
523
|
+
return defaultValue;
|
|
524
|
+
const min = options?.min ?? Number.MIN_SAFE_INTEGER;
|
|
525
|
+
const max = options?.max ?? Number.MAX_SAFE_INTEGER;
|
|
526
|
+
return Math.min(Math.max(parsed, min), max);
|
|
527
|
+
}
|
|
528
|
+
function normalizeHttpPath(path) {
|
|
529
|
+
const raw = path?.trim() || "/mcp";
|
|
530
|
+
return raw.startsWith("/") ? raw : `/${raw}`;
|
|
531
|
+
}
|
|
532
|
+
function originForHost(host, port) {
|
|
533
|
+
const displayHost = host === "0.0.0.0" ? "localhost" : host;
|
|
534
|
+
const bracketedHost = displayHost.includes(":") && !displayHost.startsWith("[") ? `[${displayHost}]` : displayHost;
|
|
535
|
+
return `http://${bracketedHost}:${port}`;
|
|
536
|
+
}
|
|
537
|
+
function isLoopbackBindHost(host) {
|
|
538
|
+
return ["127.0.0.1", "localhost", "::1", "[::1]"].includes(host);
|
|
539
|
+
}
|
|
540
|
+
function defaultAllowedHosts(host, port) {
|
|
541
|
+
if (isLoopbackBindHost(host)) {
|
|
542
|
+
return Array.from(new Set([
|
|
543
|
+
`${host}:${port}`,
|
|
544
|
+
host,
|
|
545
|
+
`127.0.0.1:${port}`,
|
|
546
|
+
"127.0.0.1",
|
|
547
|
+
`localhost:${port}`,
|
|
548
|
+
"localhost",
|
|
549
|
+
`[::1]:${port}`,
|
|
550
|
+
"[::1]",
|
|
551
|
+
]));
|
|
552
|
+
}
|
|
553
|
+
return [];
|
|
554
|
+
}
|
|
555
|
+
function getHttpConfig() {
|
|
556
|
+
const host = process.env.MCP_HTTP_HOST ?? "127.0.0.1";
|
|
557
|
+
const port = parseInteger(process.env.MCP_HTTP_PORT, 3000, { min: 1, max: 65_535 });
|
|
558
|
+
const path = normalizeHttpPath(process.env.MCP_HTTP_PATH);
|
|
559
|
+
const publicUrl = new URL(process.env.MCP_PUBLIC_URL ?? process.env.MCP_HTTP_PUBLIC_URL ?? originForHost(host, port));
|
|
560
|
+
const resourceUrl = new URL(process.env.MCP_RESOURCE_URL ?? path, publicUrl);
|
|
561
|
+
const resourceMetadataUrl = new URL("/.well-known/oauth-protected-resource", publicUrl);
|
|
562
|
+
const allowedHosts = parseCsv(process.env.MCP_HTTP_ALLOWED_HOSTS ?? process.env.MCP_ALLOWED_HOSTS).length > 0
|
|
563
|
+
? parseCsv(process.env.MCP_HTTP_ALLOWED_HOSTS ?? process.env.MCP_ALLOWED_HOSTS)
|
|
564
|
+
: defaultAllowedHosts(host, port);
|
|
565
|
+
const authTokens = parseCsv(process.env.MCP_HTTP_AUTH_TOKENS ?? process.env.MCP_HTTP_AUTH_TOKEN ?? process.env.MCP_AUTH_TOKEN);
|
|
566
|
+
const allowUnauthenticated = parseBool(process.env.MCP_HTTP_ALLOW_UNAUTHENTICATED, false);
|
|
567
|
+
const oauthJwksUrl = process.env.MCP_OAUTH_JWKS_URL;
|
|
568
|
+
const oauthIntrospectionUrl = process.env.MCP_OAUTH_INTROSPECTION_URL;
|
|
569
|
+
const authMode = process.env.MCP_HTTP_AUTH_MODE === "jwt" || oauthJwksUrl
|
|
570
|
+
? "jwt"
|
|
571
|
+
: process.env.MCP_HTTP_AUTH_MODE === "introspection" || oauthIntrospectionUrl
|
|
572
|
+
? "introspection"
|
|
573
|
+
: "static";
|
|
574
|
+
if (!allowUnauthenticated && authMode === "static" && authTokens.length === 0) {
|
|
575
|
+
throw new Error("HTTP mode requires MCP_HTTP_AUTH_TOKEN or MCP_HTTP_AUTH_TOKENS. Set MCP_HTTP_ALLOW_UNAUTHENTICATED=true only for local development.");
|
|
576
|
+
}
|
|
577
|
+
if (authMode === "jwt" && !oauthJwksUrl) {
|
|
578
|
+
throw new Error("JWT auth mode requires MCP_OAUTH_JWKS_URL.");
|
|
579
|
+
}
|
|
580
|
+
if (authMode === "introspection" && !oauthIntrospectionUrl) {
|
|
581
|
+
throw new Error("Introspection auth mode requires MCP_OAUTH_INTROSPECTION_URL.");
|
|
582
|
+
}
|
|
583
|
+
if (allowedHosts.length === 0) {
|
|
584
|
+
throw new Error("HTTP mode requires MCP_HTTP_ALLOWED_HOSTS when binding to a non-loopback host.");
|
|
585
|
+
}
|
|
586
|
+
return {
|
|
587
|
+
host,
|
|
588
|
+
port,
|
|
589
|
+
path,
|
|
590
|
+
publicUrl,
|
|
591
|
+
resourceUrl,
|
|
592
|
+
resourceMetadataUrl,
|
|
593
|
+
authTokens,
|
|
594
|
+
allowUnauthenticated,
|
|
595
|
+
authMode,
|
|
596
|
+
oauthIssuer: process.env.MCP_OAUTH_ISSUER,
|
|
597
|
+
oauthJwksUrl,
|
|
598
|
+
oauthAudience: process.env.MCP_OAUTH_AUDIENCE ?? resourceUrl.toString(),
|
|
599
|
+
oauthAuthorizationServers: parseCsv(process.env.MCP_OAUTH_AUTHORIZATION_SERVERS ?? process.env.MCP_OAUTH_ISSUER),
|
|
600
|
+
oauthRequiredScopes: parseCsv(process.env.MCP_OAUTH_REQUIRED_SCOPES ?? DEFAULT_OAUTH_SCOPE),
|
|
601
|
+
oauthScopesSupported: parseCsv(process.env.MCP_OAUTH_SCOPES_SUPPORTED ?? DEFAULT_OAUTH_SCOPE),
|
|
602
|
+
oauthIntrospectionUrl,
|
|
603
|
+
oauthClientId: process.env.MCP_OAUTH_CLIENT_ID,
|
|
604
|
+
oauthClientSecret: process.env.MCP_OAUTH_CLIENT_SECRET,
|
|
605
|
+
allowedHosts,
|
|
606
|
+
allowedOrigins: parseCsv(process.env.MCP_HTTP_ALLOWED_ORIGINS ?? process.env.MCP_ALLOWED_ORIGINS),
|
|
607
|
+
maxBodyBytes: parseInteger(process.env.MCP_HTTP_MAX_BODY_BYTES, DEFAULT_HTTP_BODY_LIMIT_BYTES, {
|
|
608
|
+
min: 1_024,
|
|
609
|
+
max: 20 * 1_048_576,
|
|
610
|
+
}),
|
|
611
|
+
rateLimitMax: parseInteger(process.env.MCP_HTTP_RATE_LIMIT_MAX, DEFAULT_HTTP_RATE_LIMIT_MAX, { min: 1 }),
|
|
612
|
+
rateLimitWindowMs: parseInteger(process.env.MCP_HTTP_RATE_LIMIT_WINDOW_MS, DEFAULT_HTTP_RATE_LIMIT_WINDOW_MS, {
|
|
613
|
+
min: 1_000,
|
|
614
|
+
}),
|
|
615
|
+
sessionTtlMs: parseInteger(process.env.MCP_HTTP_SESSION_TTL_MS, DEFAULT_HTTP_SESSION_TTL_MS, { min: 10_000 }),
|
|
616
|
+
eventStoreFile: process.env.MCP_EVENT_STORE_FILE,
|
|
617
|
+
enableJsonResponse: parseBool(process.env.MCP_HTTP_ENABLE_JSON_RESPONSE, false),
|
|
618
|
+
trustProxy: parseBool(process.env.MCP_HTTP_TRUST_PROXY, false),
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
function setSecurityHeaders(res) {
|
|
622
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
623
|
+
res.setHeader("X-Frame-Options", "DENY");
|
|
624
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
625
|
+
res.setHeader("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'");
|
|
626
|
+
}
|
|
627
|
+
function sendJson(res, status, body, headers) {
|
|
628
|
+
if (res.headersSent)
|
|
629
|
+
return;
|
|
630
|
+
for (const [key, value] of Object.entries(headers ?? {})) {
|
|
631
|
+
res.setHeader(key, value);
|
|
632
|
+
}
|
|
633
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
634
|
+
res.end(`${stableStringify(body)}\n`);
|
|
635
|
+
}
|
|
636
|
+
function sendMcpError(res, status, code, message) {
|
|
637
|
+
sendJson(res, status, {
|
|
638
|
+
jsonrpc: "2.0",
|
|
639
|
+
error: { code, message },
|
|
640
|
+
id: null,
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
function sendAuthRequired(res, config, message) {
|
|
644
|
+
sendJson(res, 401, { error: message }, {
|
|
645
|
+
"WWW-Authenticate": `Bearer realm="thalovant-mcp", resource_metadata="${config.resourceMetadataUrl.toString()}"`,
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
function protectedResourceMetadata(config) {
|
|
649
|
+
return {
|
|
650
|
+
resource: config.resourceUrl.toString(),
|
|
651
|
+
authorization_servers: config.oauthAuthorizationServers,
|
|
652
|
+
scopes_supported: config.oauthScopesSupported,
|
|
653
|
+
bearer_methods_supported: ["header"],
|
|
654
|
+
resource_name: "Thalovant MCP Server",
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
function validateHost(req, config) {
|
|
658
|
+
const host = req.headers.host?.toLowerCase();
|
|
659
|
+
return Boolean(host && config.allowedHosts.map((allowed) => allowed.toLowerCase()).includes(host));
|
|
660
|
+
}
|
|
661
|
+
function applyCors(req, res, config) {
|
|
662
|
+
const origin = req.headers.origin;
|
|
663
|
+
if (!origin)
|
|
664
|
+
return true;
|
|
665
|
+
if (!config.allowedOrigins.includes(origin))
|
|
666
|
+
return false;
|
|
667
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
668
|
+
res.setHeader("Vary", "Origin");
|
|
669
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
670
|
+
res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, Accept, Mcp-Session-Id, MCP-Protocol-Version, Last-Event-ID");
|
|
671
|
+
res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id, MCP-Protocol-Version");
|
|
672
|
+
res.setHeader("Access-Control-Max-Age", "600");
|
|
673
|
+
return true;
|
|
674
|
+
}
|
|
675
|
+
function safeEquals(a, b) {
|
|
676
|
+
const left = Buffer.from(a);
|
|
677
|
+
const right = Buffer.from(b);
|
|
678
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
679
|
+
}
|
|
680
|
+
function tokenFingerprint(token) {
|
|
681
|
+
return createHash("sha256").update(token).digest("hex").slice(0, 32);
|
|
682
|
+
}
|
|
683
|
+
const jwksCache = new Map();
|
|
684
|
+
function getJwks(url) {
|
|
685
|
+
const existing = jwksCache.get(url);
|
|
686
|
+
if (existing)
|
|
687
|
+
return existing;
|
|
688
|
+
const jwks = createRemoteJWKSet(new URL(url));
|
|
689
|
+
jwksCache.set(url, jwks);
|
|
690
|
+
return jwks;
|
|
691
|
+
}
|
|
692
|
+
function scopesFromClaim(value) {
|
|
693
|
+
if (Array.isArray(value)) {
|
|
694
|
+
return value.filter((item) => typeof item === "string");
|
|
695
|
+
}
|
|
696
|
+
if (typeof value === "string") {
|
|
697
|
+
return value.split(/[,\s]+/).filter(Boolean);
|
|
698
|
+
}
|
|
699
|
+
return [];
|
|
700
|
+
}
|
|
701
|
+
function stringArray(value) {
|
|
702
|
+
if (Array.isArray(value)) {
|
|
703
|
+
return value.filter((item) => typeof item === "string");
|
|
704
|
+
}
|
|
705
|
+
return typeof value === "string" ? [value] : [];
|
|
706
|
+
}
|
|
707
|
+
function assertRequiredScopes(scopes, requiredScopes) {
|
|
708
|
+
const missing = requiredScopes.filter((scope) => !scopes.includes(scope));
|
|
709
|
+
if (missing.length > 0) {
|
|
710
|
+
throw new HttpError(403, `Missing required OAuth scope: ${missing.join(", ")}`);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
function assertAudience(audiences, expectedAudience) {
|
|
714
|
+
if (!expectedAudience)
|
|
715
|
+
return;
|
|
716
|
+
if (!audiences.includes(expectedAudience)) {
|
|
717
|
+
throw new HttpError(401, "Bearer token audience does not match this MCP resource server.");
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
function authInfoFromAuthResult(auth) {
|
|
721
|
+
return {
|
|
722
|
+
token: auth.token ?? "",
|
|
723
|
+
clientId: auth.clientId,
|
|
724
|
+
scopes: auth.scopes,
|
|
725
|
+
expiresAt: auth.expiresAt,
|
|
726
|
+
resource: undefined,
|
|
727
|
+
extra: {
|
|
728
|
+
principalId: auth.userId,
|
|
729
|
+
claims: auth.claims,
|
|
730
|
+
},
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
async function verifyJwtToken(token, config) {
|
|
734
|
+
if (!config.oauthJwksUrl) {
|
|
735
|
+
throw new HttpError(500, "JWT verifier is not configured.");
|
|
736
|
+
}
|
|
737
|
+
try {
|
|
738
|
+
const verifyOptions = {
|
|
739
|
+
issuer: config.oauthIssuer,
|
|
740
|
+
audience: config.oauthAudience,
|
|
741
|
+
};
|
|
742
|
+
const { payload } = await jwtVerify(token, getJwks(config.oauthJwksUrl), verifyOptions);
|
|
743
|
+
const scopes = Array.from(new Set([...scopesFromClaim(payload.scope), ...scopesFromClaim(payload.scp)]));
|
|
744
|
+
assertRequiredScopes(scopes, config.oauthRequiredScopes);
|
|
745
|
+
const clientId = (typeof payload.client_id === "string" && payload.client_id) ||
|
|
746
|
+
(typeof payload.azp === "string" && payload.azp) ||
|
|
747
|
+
"oauth-client";
|
|
748
|
+
const userId = payload.sub ?? clientId;
|
|
749
|
+
return {
|
|
750
|
+
userId,
|
|
751
|
+
clientId,
|
|
752
|
+
scopes,
|
|
753
|
+
token,
|
|
754
|
+
expiresAt: payload.exp,
|
|
755
|
+
claims: payload,
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
catch (error) {
|
|
759
|
+
if (error instanceof HttpError)
|
|
760
|
+
throw error;
|
|
761
|
+
throw new HttpError(401, "Invalid OAuth JWT bearer token.");
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
async function verifyIntrospectionToken(token, config) {
|
|
765
|
+
if (!config.oauthIntrospectionUrl) {
|
|
766
|
+
throw new HttpError(500, "OAuth introspection verifier is not configured.");
|
|
767
|
+
}
|
|
768
|
+
const params = new URLSearchParams({ token });
|
|
769
|
+
const headers = {
|
|
770
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
771
|
+
Accept: "application/json",
|
|
772
|
+
};
|
|
773
|
+
if (config.oauthClientId && config.oauthClientSecret) {
|
|
774
|
+
headers.Authorization = `Basic ${Buffer.from(`${config.oauthClientId}:${config.oauthClientSecret}`).toString("base64")}`;
|
|
775
|
+
}
|
|
776
|
+
else if (config.oauthClientId) {
|
|
777
|
+
params.set("client_id", config.oauthClientId);
|
|
778
|
+
}
|
|
779
|
+
const response = await fetch(config.oauthIntrospectionUrl, {
|
|
780
|
+
method: "POST",
|
|
781
|
+
headers,
|
|
782
|
+
body: params,
|
|
783
|
+
});
|
|
784
|
+
if (!response.ok) {
|
|
785
|
+
throw new HttpError(401, "OAuth token introspection failed.");
|
|
786
|
+
}
|
|
787
|
+
const data = (await response.json());
|
|
788
|
+
if (data.active !== true) {
|
|
789
|
+
throw new HttpError(401, "Inactive OAuth bearer token.");
|
|
790
|
+
}
|
|
791
|
+
const scopes = scopesFromClaim(data.scope);
|
|
792
|
+
assertRequiredScopes(scopes, config.oauthRequiredScopes);
|
|
793
|
+
assertAudience(stringArray(data.aud), config.oauthAudience);
|
|
794
|
+
const clientId = (typeof data.client_id === "string" && data.client_id) ||
|
|
795
|
+
(typeof data.azp === "string" && data.azp) ||
|
|
796
|
+
"oauth-client";
|
|
797
|
+
const userId = (typeof data.sub === "string" && data.sub) || clientId;
|
|
798
|
+
return {
|
|
799
|
+
userId,
|
|
800
|
+
clientId,
|
|
801
|
+
scopes,
|
|
802
|
+
token,
|
|
803
|
+
expiresAt: typeof data.exp === "number" ? data.exp : undefined,
|
|
804
|
+
claims: data,
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
async function authenticate(req, config) {
|
|
808
|
+
const authorization = req.headers.authorization;
|
|
809
|
+
const match = typeof authorization === "string" ? /^Bearer\s+(.+)$/i.exec(authorization) : null;
|
|
810
|
+
const providedToken = match?.[1];
|
|
811
|
+
if (providedToken) {
|
|
812
|
+
if (config.authMode === "static") {
|
|
813
|
+
for (const token of config.authTokens) {
|
|
814
|
+
if (safeEquals(providedToken, token)) {
|
|
815
|
+
const userId = tokenFingerprint(token);
|
|
816
|
+
return {
|
|
817
|
+
userId,
|
|
818
|
+
clientId: userId,
|
|
819
|
+
scopes: config.oauthScopesSupported,
|
|
820
|
+
token,
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
throw new HttpError(401, "Invalid bearer token.");
|
|
825
|
+
}
|
|
826
|
+
if (config.authMode === "jwt") {
|
|
827
|
+
return verifyJwtToken(providedToken, config);
|
|
828
|
+
}
|
|
829
|
+
return verifyIntrospectionToken(providedToken, config);
|
|
830
|
+
}
|
|
831
|
+
if (config.allowUnauthenticated) {
|
|
832
|
+
return { userId: "anonymous", clientId: "anonymous", scopes: ["anonymous"] };
|
|
833
|
+
}
|
|
834
|
+
throw new HttpError(401, "Missing bearer token.");
|
|
835
|
+
}
|
|
836
|
+
function clientAddress(req, config) {
|
|
837
|
+
if (config.trustProxy) {
|
|
838
|
+
const forwardedFor = req.headers["x-forwarded-for"];
|
|
839
|
+
if (typeof forwardedFor === "string" && forwardedFor.trim()) {
|
|
840
|
+
return forwardedFor.split(",")[0]?.trim() ?? "unknown";
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
return req.socket.remoteAddress ?? "unknown";
|
|
844
|
+
}
|
|
845
|
+
function parseRequestUrl(req) {
|
|
846
|
+
return new URL(req.url ?? "/", "http://127.0.0.1");
|
|
847
|
+
}
|
|
848
|
+
async function readJsonBody(req, maxBytes) {
|
|
849
|
+
const chunks = [];
|
|
850
|
+
let total = 0;
|
|
851
|
+
for await (const chunk of req) {
|
|
852
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
853
|
+
total += buffer.length;
|
|
854
|
+
if (total > maxBytes) {
|
|
855
|
+
throw new HttpError(413, "Request body too large.");
|
|
856
|
+
}
|
|
857
|
+
chunks.push(buffer);
|
|
858
|
+
}
|
|
859
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
860
|
+
if (!text.trim())
|
|
861
|
+
return undefined;
|
|
862
|
+
try {
|
|
863
|
+
return JSON.parse(text);
|
|
864
|
+
}
|
|
865
|
+
catch {
|
|
866
|
+
throw new HttpError(400, "Request body must be valid JSON.");
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
function isInitializationBody(body) {
|
|
870
|
+
return isInitializeRequest(body) || (Array.isArray(body) && body.some((item) => isInitializeRequest(item)));
|
|
871
|
+
}
|
|
872
|
+
async function createEventStore(config) {
|
|
873
|
+
return config.eventStoreFile ? FileEventStore.create(config.eventStoreFile) : new InMemoryEventStore();
|
|
874
|
+
}
|
|
875
|
+
export function createServer() {
|
|
876
|
+
const server = new McpServer({
|
|
877
|
+
name: "thalovant",
|
|
878
|
+
version: VERSION,
|
|
879
|
+
});
|
|
880
|
+
registerThalovantTool(server, "thalovant_config_status", {
|
|
881
|
+
title: "Thalovant Config Status",
|
|
882
|
+
description: "Inspect local Thalovant MCP configuration without revealing secret values.",
|
|
883
|
+
inputSchema: {},
|
|
884
|
+
annotations: {
|
|
885
|
+
readOnlyHint: true,
|
|
886
|
+
openWorldHint: false,
|
|
887
|
+
},
|
|
888
|
+
}, async () => jsonContent({
|
|
889
|
+
apiUrl: process.env.THALOVANT_API_URL ?? DEFAULT_CONTROL_API_URL,
|
|
890
|
+
hasAccessToken: Boolean(process.env.THALOVANT_ACCESS_TOKEN),
|
|
891
|
+
hasEmailPassword: Boolean(process.env.THALOVANT_EMAIL && process.env.THALOVANT_PASSWORD),
|
|
892
|
+
profile: process.env.THALOVANT_PROFILE,
|
|
893
|
+
hasRuntimeEnvIdentity: Boolean(process.env.THALOVANT_ACCESS_KEY || process.env.THALOVANT_IDENTITY),
|
|
894
|
+
defaultProtocol: "wss",
|
|
895
|
+
secretHandling: "Secrets are redacted in tool output. Identity files are only written when savePath is provided.",
|
|
896
|
+
}));
|
|
897
|
+
registerThalovantTool(server, "thalovant_list_public_hubs", {
|
|
898
|
+
title: "List Public Hubs",
|
|
899
|
+
description: "List Thalovant public hubs. This read-only discovery call does not require authentication.",
|
|
900
|
+
inputSchema: {
|
|
901
|
+
limit: limitSchema,
|
|
902
|
+
cursor: optionalCursorSchema,
|
|
903
|
+
apiUrl: controlPlaneSchema.apiUrl,
|
|
904
|
+
},
|
|
905
|
+
annotations: {
|
|
906
|
+
readOnlyHint: true,
|
|
907
|
+
openWorldHint: true,
|
|
908
|
+
},
|
|
909
|
+
}, async ({ limit, cursor, apiUrl }) => {
|
|
910
|
+
const api = await createControlPlane({ apiUrl });
|
|
911
|
+
return jsonContent(redactSecrets(await api.listPublicHubs({ limit, cursor })));
|
|
912
|
+
});
|
|
913
|
+
registerThalovantTool(server, "thalovant_get_public_hub", {
|
|
914
|
+
title: "Get Public Hub",
|
|
915
|
+
description: "Fetch one public Thalovant hub by id or slug.",
|
|
916
|
+
inputSchema: {
|
|
917
|
+
hubRef: z.string().min(1).describe("Public hub id or slug."),
|
|
918
|
+
apiUrl: controlPlaneSchema.apiUrl,
|
|
919
|
+
},
|
|
920
|
+
annotations: {
|
|
921
|
+
readOnlyHint: true,
|
|
922
|
+
openWorldHint: true,
|
|
923
|
+
},
|
|
924
|
+
}, async ({ hubRef, apiUrl }) => {
|
|
925
|
+
const api = await createControlPlane({ apiUrl });
|
|
926
|
+
return jsonContent(redactSecrets(await api.getPublicHub(hubRef)));
|
|
927
|
+
});
|
|
928
|
+
registerThalovantTool(server, "thalovant_list_hubs", {
|
|
929
|
+
title: "List Visible Hubs",
|
|
930
|
+
description: "List authenticated Thalovant hubs visible to the account.",
|
|
931
|
+
inputSchema: {
|
|
932
|
+
...controlPlaneSchema,
|
|
933
|
+
limit: limitSchema,
|
|
934
|
+
cursor: optionalCursorSchema,
|
|
935
|
+
ownerId: z.string().min(1).optional(),
|
|
936
|
+
},
|
|
937
|
+
annotations: {
|
|
938
|
+
readOnlyHint: true,
|
|
939
|
+
openWorldHint: true,
|
|
940
|
+
},
|
|
941
|
+
}, async ({ limit, cursor, ownerId, ...auth }) => {
|
|
942
|
+
const api = await createControlPlane(auth);
|
|
943
|
+
ensureAuthenticated(api);
|
|
944
|
+
return jsonContent(redactSecrets(await api.listHubs({ limit, cursor, ownerId })));
|
|
945
|
+
});
|
|
946
|
+
registerThalovantTool(server, "thalovant_get_hub", {
|
|
947
|
+
title: "Get Hub",
|
|
948
|
+
description: "Fetch one authenticated Thalovant hub by id.",
|
|
949
|
+
inputSchema: {
|
|
950
|
+
...controlPlaneSchema,
|
|
951
|
+
hubId: z.string().min(1),
|
|
952
|
+
},
|
|
953
|
+
annotations: {
|
|
954
|
+
readOnlyHint: true,
|
|
955
|
+
openWorldHint: true,
|
|
956
|
+
},
|
|
957
|
+
}, async ({ hubId, ...auth }) => {
|
|
958
|
+
const api = await createControlPlane(auth);
|
|
959
|
+
ensureAuthenticated(api);
|
|
960
|
+
return jsonContent(redactSecrets(await api.getHub(hubId)));
|
|
961
|
+
});
|
|
962
|
+
registerThalovantTool(server, "thalovant_create_client_identity", {
|
|
963
|
+
title: "Create Client Identity",
|
|
964
|
+
description: "Create a Thalovant client identity for a hub. The identity is secret; output is redacted unless savePath is used.",
|
|
965
|
+
inputSchema: {
|
|
966
|
+
...controlPlaneSchema,
|
|
967
|
+
hubId: z.string().min(1).describe("Hub id or slug accepted by the Thalovant SDK."),
|
|
968
|
+
name: z.string().min(1).max(128),
|
|
969
|
+
siteId: z.string().min(1).optional(),
|
|
970
|
+
ownerId: z.string().min(1).optional(),
|
|
971
|
+
active: z.boolean().optional(),
|
|
972
|
+
preferredProtocols: z.array(protocolSchema).min(1).default(["wss", "https"]),
|
|
973
|
+
idempotencyKey: z.string().min(1).optional(),
|
|
974
|
+
spec: jsonRecordSchema.optional(),
|
|
975
|
+
savePath: z
|
|
976
|
+
.string()
|
|
977
|
+
.min(1)
|
|
978
|
+
.optional()
|
|
979
|
+
.describe("Optional local file path for the full secret identity JSON. File mode is set to 0600."),
|
|
980
|
+
},
|
|
981
|
+
annotations: {
|
|
982
|
+
readOnlyHint: false,
|
|
983
|
+
destructiveHint: false,
|
|
984
|
+
idempotentHint: false,
|
|
985
|
+
openWorldHint: true,
|
|
986
|
+
},
|
|
987
|
+
}, async ({ hubId, name, siteId, ownerId, active, preferredProtocols, idempotencyKey, spec, savePath, ...auth }) => {
|
|
988
|
+
const api = await createControlPlane(auth);
|
|
989
|
+
ensureAuthenticated(api);
|
|
990
|
+
const result = await api.createClientIdentity(hubId, {
|
|
991
|
+
name,
|
|
992
|
+
siteId,
|
|
993
|
+
ownerId,
|
|
994
|
+
active,
|
|
995
|
+
preferredProtocols,
|
|
996
|
+
idempotencyKey,
|
|
997
|
+
spec,
|
|
998
|
+
});
|
|
999
|
+
const selectedEndpoint = api.requireRuntimeProtocol(result, preferredProtocols[0]);
|
|
1000
|
+
const savedIdentityPath = savePath ? await saveIdentity(savePath, result.identity) : undefined;
|
|
1001
|
+
return jsonContent({
|
|
1002
|
+
result: redactSecrets(result.asObject({ includeSecrets: false })),
|
|
1003
|
+
selectedEndpoint,
|
|
1004
|
+
enabledProtocols: result.identity.enabledProtocols(),
|
|
1005
|
+
savedIdentityPath,
|
|
1006
|
+
secretNotice: savedIdentityPath
|
|
1007
|
+
? "Full identity was written to the requested local path with mode 0600."
|
|
1008
|
+
: "Secret identity fields were not returned. Pass savePath to write a protected local identity file.",
|
|
1009
|
+
});
|
|
1010
|
+
});
|
|
1011
|
+
registerThalovantTool(server, "thalovant_identity_status", {
|
|
1012
|
+
title: "Identity Status",
|
|
1013
|
+
description: "Load a Thalovant identity and return redacted protocol and endpoint status.",
|
|
1014
|
+
inputSchema: {
|
|
1015
|
+
...runtimeAuthSchema,
|
|
1016
|
+
},
|
|
1017
|
+
annotations: {
|
|
1018
|
+
readOnlyHint: true,
|
|
1019
|
+
openWorldHint: false,
|
|
1020
|
+
},
|
|
1021
|
+
}, async (args) => {
|
|
1022
|
+
const client = await createRuntimeClient(args);
|
|
1023
|
+
try {
|
|
1024
|
+
const identity = client.identity;
|
|
1025
|
+
return jsonContent({
|
|
1026
|
+
identity: redactSecrets(identity.asObject(false)),
|
|
1027
|
+
enabledProtocols: identity.enabledProtocols(),
|
|
1028
|
+
endpoints: redactSecrets(identity.dataPlaneEndpoints.asObject({ redactCredentials: true })),
|
|
1029
|
+
supportsRequestedProtocol: identity.supportsProtocol(args.protocol),
|
|
1030
|
+
health: client.healthcheck(),
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
finally {
|
|
1034
|
+
await client.close();
|
|
1035
|
+
}
|
|
1036
|
+
});
|
|
1037
|
+
registerThalovantTool(server, "thalovant_healthcheck", {
|
|
1038
|
+
title: "Runtime Healthcheck",
|
|
1039
|
+
description: "Connect to a Thalovant hub with a saved identity and return runtime transport health.",
|
|
1040
|
+
inputSchema: {
|
|
1041
|
+
...runtimeAuthSchema,
|
|
1042
|
+
timeoutMs: z.number().int().min(1_000).max(MAX_TIMEOUT_MS).default(10_000),
|
|
1043
|
+
},
|
|
1044
|
+
annotations: {
|
|
1045
|
+
readOnlyHint: true,
|
|
1046
|
+
openWorldHint: true,
|
|
1047
|
+
},
|
|
1048
|
+
}, async ({ timeoutMs, ...runtime }) => {
|
|
1049
|
+
const client = await createRuntimeClient(runtime);
|
|
1050
|
+
try {
|
|
1051
|
+
await client.connect(clampTimeout(timeoutMs));
|
|
1052
|
+
return jsonContent(client.healthcheck());
|
|
1053
|
+
}
|
|
1054
|
+
finally {
|
|
1055
|
+
await client.close();
|
|
1056
|
+
}
|
|
1057
|
+
});
|
|
1058
|
+
registerThalovantTool(server, "thalovant_ask", {
|
|
1059
|
+
title: "Ask Hub",
|
|
1060
|
+
description: "Send one text request to a Thalovant hub using a saved identity and return the normalized reply.",
|
|
1061
|
+
inputSchema: {
|
|
1062
|
+
...runtimeAuthSchema,
|
|
1063
|
+
text: z.string().min(1).max(20_000),
|
|
1064
|
+
timeoutMs: z.number().int().min(1_000).max(MAX_TIMEOUT_MS).default(DEFAULT_TIMEOUT_MS),
|
|
1065
|
+
lang: z.string().min(1).optional(),
|
|
1066
|
+
sessionId: z.string().min(1).optional(),
|
|
1067
|
+
requestId: z.string().min(1).optional(),
|
|
1068
|
+
context: contextSchema,
|
|
1069
|
+
replySettleMs: z.number().int().min(0).max(10_000).optional(),
|
|
1070
|
+
emptyReplyWaitMs: z.number().int().min(0).max(10_000).optional(),
|
|
1071
|
+
},
|
|
1072
|
+
annotations: {
|
|
1073
|
+
readOnlyHint: false,
|
|
1074
|
+
destructiveHint: false,
|
|
1075
|
+
idempotentHint: false,
|
|
1076
|
+
openWorldHint: true,
|
|
1077
|
+
},
|
|
1078
|
+
}, async ({ text, timeoutMs, lang, sessionId, requestId, context, replySettleMs, emptyReplyWaitMs, ...runtime }) => {
|
|
1079
|
+
const client = await createRuntimeClient(runtime);
|
|
1080
|
+
try {
|
|
1081
|
+
const builtContext = context ? buildClientContext({}, context) : undefined;
|
|
1082
|
+
const reply = await client.ask(text, {
|
|
1083
|
+
timeoutMs: clampTimeout(timeoutMs),
|
|
1084
|
+
lang,
|
|
1085
|
+
sessionId,
|
|
1086
|
+
requestId,
|
|
1087
|
+
context: builtContext,
|
|
1088
|
+
replySettleMs,
|
|
1089
|
+
emptyReplyWaitMs,
|
|
1090
|
+
});
|
|
1091
|
+
return jsonContent(redactSecrets(summarizeReply(reply)));
|
|
1092
|
+
}
|
|
1093
|
+
finally {
|
|
1094
|
+
await client.close();
|
|
1095
|
+
}
|
|
1096
|
+
});
|
|
1097
|
+
registerThalovantTool(server, "thalovant_send_action", {
|
|
1098
|
+
title: "Send Action",
|
|
1099
|
+
description: "Send a structured action payload to a Thalovant hub.",
|
|
1100
|
+
inputSchema: {
|
|
1101
|
+
...runtimeAuthSchema,
|
|
1102
|
+
payload: z.string().min(1).max(20_000),
|
|
1103
|
+
title: z.string().min(1).max(512).optional(),
|
|
1104
|
+
lang: z.string().min(1).optional(),
|
|
1105
|
+
sessionId: z.string().min(1).optional(),
|
|
1106
|
+
requestId: z.string().min(1).optional(),
|
|
1107
|
+
context: contextSchema,
|
|
1108
|
+
},
|
|
1109
|
+
annotations: {
|
|
1110
|
+
readOnlyHint: false,
|
|
1111
|
+
destructiveHint: false,
|
|
1112
|
+
idempotentHint: false,
|
|
1113
|
+
openWorldHint: true,
|
|
1114
|
+
},
|
|
1115
|
+
}, async ({ payload, title, lang, sessionId, requestId, context, ...runtime }) => {
|
|
1116
|
+
const client = await createRuntimeClient(runtime);
|
|
1117
|
+
try {
|
|
1118
|
+
await client.sendAction(payload, {
|
|
1119
|
+
title,
|
|
1120
|
+
lang,
|
|
1121
|
+
sessionId,
|
|
1122
|
+
requestId,
|
|
1123
|
+
context: context ? buildClientContext({}, context) : undefined,
|
|
1124
|
+
});
|
|
1125
|
+
return textContent("Action sent.");
|
|
1126
|
+
}
|
|
1127
|
+
finally {
|
|
1128
|
+
await client.close();
|
|
1129
|
+
}
|
|
1130
|
+
});
|
|
1131
|
+
registerThalovantTool(server, "thalovant_send_code", {
|
|
1132
|
+
title: "Send Code",
|
|
1133
|
+
description: "Send an exact typed, scanned, barcode, QR, or serial value to a Thalovant hub.",
|
|
1134
|
+
inputSchema: {
|
|
1135
|
+
...runtimeAuthSchema,
|
|
1136
|
+
value: z.string().min(1).max(20_000),
|
|
1137
|
+
kind: z.string().min(1).max(64).optional(),
|
|
1138
|
+
label: z.string().min(1).max(128).optional(),
|
|
1139
|
+
lang: z.string().min(1).optional(),
|
|
1140
|
+
sessionId: z.string().min(1).optional(),
|
|
1141
|
+
requestId: z.string().min(1).optional(),
|
|
1142
|
+
context: contextSchema,
|
|
1143
|
+
},
|
|
1144
|
+
annotations: {
|
|
1145
|
+
readOnlyHint: false,
|
|
1146
|
+
destructiveHint: false,
|
|
1147
|
+
idempotentHint: false,
|
|
1148
|
+
openWorldHint: true,
|
|
1149
|
+
},
|
|
1150
|
+
}, async ({ value, kind, label, lang, sessionId, requestId, context, ...runtime }) => {
|
|
1151
|
+
const client = await createRuntimeClient(runtime);
|
|
1152
|
+
try {
|
|
1153
|
+
await client.sendCode(value, {
|
|
1154
|
+
kind,
|
|
1155
|
+
label,
|
|
1156
|
+
lang,
|
|
1157
|
+
sessionId,
|
|
1158
|
+
requestId,
|
|
1159
|
+
context: context ? buildClientContext({}, context) : undefined,
|
|
1160
|
+
});
|
|
1161
|
+
return textContent("Code sent.");
|
|
1162
|
+
}
|
|
1163
|
+
finally {
|
|
1164
|
+
await client.close();
|
|
1165
|
+
}
|
|
1166
|
+
});
|
|
1167
|
+
registerThalovantTool(server, "thalovant_emit_event", {
|
|
1168
|
+
title: "Emit Event",
|
|
1169
|
+
description: "Emit a raw Thalovant event to a hub. Use only when a more specific runtime tool is not enough.",
|
|
1170
|
+
inputSchema: {
|
|
1171
|
+
...runtimeAuthSchema,
|
|
1172
|
+
eventType: z.string().min(1).max(256),
|
|
1173
|
+
data: jsonRecordSchema.default({}),
|
|
1174
|
+
context: contextSchema,
|
|
1175
|
+
},
|
|
1176
|
+
annotations: {
|
|
1177
|
+
readOnlyHint: false,
|
|
1178
|
+
destructiveHint: false,
|
|
1179
|
+
idempotentHint: false,
|
|
1180
|
+
openWorldHint: true,
|
|
1181
|
+
},
|
|
1182
|
+
}, async ({ eventType, data, context, ...runtime }) => {
|
|
1183
|
+
const client = await createRuntimeClient(runtime);
|
|
1184
|
+
try {
|
|
1185
|
+
await client.emit(eventType, data, context ? buildClientContext({}, context) : undefined);
|
|
1186
|
+
return textContent("Event emitted.");
|
|
1187
|
+
}
|
|
1188
|
+
finally {
|
|
1189
|
+
await client.close();
|
|
1190
|
+
}
|
|
1191
|
+
});
|
|
1192
|
+
registerThalovantTool(server, "thalovant_wait_for_event", {
|
|
1193
|
+
title: "Wait For Event",
|
|
1194
|
+
description: "Wait for one named hub event using a saved identity.",
|
|
1195
|
+
inputSchema: {
|
|
1196
|
+
...runtimeAuthSchema,
|
|
1197
|
+
eventName: z.string().min(1).max(256),
|
|
1198
|
+
timeoutMs: z.number().int().min(1_000).max(MAX_TIMEOUT_MS).default(DEFAULT_TIMEOUT_MS),
|
|
1199
|
+
sessionId: z.string().min(1).optional(),
|
|
1200
|
+
requestId: z.string().min(1).optional(),
|
|
1201
|
+
context: contextSchema,
|
|
1202
|
+
},
|
|
1203
|
+
annotations: {
|
|
1204
|
+
readOnlyHint: true,
|
|
1205
|
+
openWorldHint: true,
|
|
1206
|
+
},
|
|
1207
|
+
}, async ({ eventName, timeoutMs, sessionId, requestId, context, ...runtime }) => {
|
|
1208
|
+
const client = await createRuntimeClient(runtime);
|
|
1209
|
+
try {
|
|
1210
|
+
const event = await client.waitForEvent(eventName, {
|
|
1211
|
+
timeoutMs: clampTimeout(timeoutMs),
|
|
1212
|
+
sessionId,
|
|
1213
|
+
requestId,
|
|
1214
|
+
context: context ? buildClientContext({}, context) : undefined,
|
|
1215
|
+
});
|
|
1216
|
+
return jsonContent(redactSecrets(event.asObject()));
|
|
1217
|
+
}
|
|
1218
|
+
finally {
|
|
1219
|
+
await client.close();
|
|
1220
|
+
}
|
|
1221
|
+
});
|
|
1222
|
+
registerThalovantTool(server, "thalovant_get_analytics_overview", {
|
|
1223
|
+
title: "Analytics Overview",
|
|
1224
|
+
description: "Read authenticated Thalovant analytics overview data.",
|
|
1225
|
+
inputSchema: {
|
|
1226
|
+
...controlPlaneSchema,
|
|
1227
|
+
admin: z.boolean().optional(),
|
|
1228
|
+
range: z.string().min(1).optional(),
|
|
1229
|
+
bucket: z.string().min(1).optional(),
|
|
1230
|
+
ownerId: z.string().min(1).optional(),
|
|
1231
|
+
hubId: z.string().min(1).optional(),
|
|
1232
|
+
clientId: z.string().min(1).optional(),
|
|
1233
|
+
country: z.string().min(1).optional(),
|
|
1234
|
+
message: z.string().min(1).optional(),
|
|
1235
|
+
utterance: z.string().min(1).optional(),
|
|
1236
|
+
intent: z.string().min(1).optional(),
|
|
1237
|
+
timeStart: z.string().min(1).optional(),
|
|
1238
|
+
timeEnd: z.string().min(1).optional(),
|
|
1239
|
+
weekday: z.number().int().min(0).max(6).optional(),
|
|
1240
|
+
hour: z.number().int().min(0).max(23).optional(),
|
|
1241
|
+
},
|
|
1242
|
+
annotations: {
|
|
1243
|
+
readOnlyHint: true,
|
|
1244
|
+
openWorldHint: true,
|
|
1245
|
+
},
|
|
1246
|
+
}, async (args) => {
|
|
1247
|
+
const { loginScope, apiUrl, ...filters } = args;
|
|
1248
|
+
const api = await createControlPlane({ loginScope, apiUrl });
|
|
1249
|
+
ensureAuthenticated(api);
|
|
1250
|
+
return jsonContent(redactSecrets(await api.getAnalyticsOverview(filters)));
|
|
1251
|
+
});
|
|
1252
|
+
registerThalovantTool(server, "thalovant_list_memory_items", {
|
|
1253
|
+
title: "List Memory Items",
|
|
1254
|
+
description: "List authenticated Thalovant memory items.",
|
|
1255
|
+
inputSchema: {
|
|
1256
|
+
...controlPlaneSchema,
|
|
1257
|
+
scope: z.enum(["personal", "workspace", "hub"]).optional(),
|
|
1258
|
+
kind: z.enum(["note", "preference", "fact"]).optional(),
|
|
1259
|
+
ownerId: z.string().min(1).optional(),
|
|
1260
|
+
hubId: z.string().min(1).optional(),
|
|
1261
|
+
query: z.string().min(1).optional(),
|
|
1262
|
+
includeDeleted: z.boolean().optional(),
|
|
1263
|
+
includeExpired: z.boolean().optional(),
|
|
1264
|
+
limit: limitSchema,
|
|
1265
|
+
offset: z.number().int().min(0).optional(),
|
|
1266
|
+
},
|
|
1267
|
+
annotations: {
|
|
1268
|
+
readOnlyHint: true,
|
|
1269
|
+
openWorldHint: true,
|
|
1270
|
+
},
|
|
1271
|
+
}, async (args) => {
|
|
1272
|
+
const { loginScope, apiUrl, ...filters } = args;
|
|
1273
|
+
const api = await createControlPlane({ loginScope, apiUrl });
|
|
1274
|
+
ensureAuthenticated(api);
|
|
1275
|
+
return jsonContent(redactSecrets(await api.listMemoryItems(filters)));
|
|
1276
|
+
});
|
|
1277
|
+
registerThalovantTool(server, "thalovant_get_memory_summary", {
|
|
1278
|
+
title: "Memory Summary",
|
|
1279
|
+
description: "Read authenticated Thalovant memory summary data.",
|
|
1280
|
+
inputSchema: {
|
|
1281
|
+
...controlPlaneSchema,
|
|
1282
|
+
ownerId: z.string().min(1).optional(),
|
|
1283
|
+
},
|
|
1284
|
+
annotations: {
|
|
1285
|
+
readOnlyHint: true,
|
|
1286
|
+
openWorldHint: true,
|
|
1287
|
+
},
|
|
1288
|
+
}, async ({ ownerId, ...auth }) => {
|
|
1289
|
+
const api = await createControlPlane(auth);
|
|
1290
|
+
ensureAuthenticated(api);
|
|
1291
|
+
return jsonContent(redactSecrets(await api.getMemorySummary({ ownerId })));
|
|
1292
|
+
});
|
|
1293
|
+
registerThalovantTool(server, "thalovant_get_memory_item", {
|
|
1294
|
+
title: "Get Memory Item",
|
|
1295
|
+
description: "Read one authenticated Thalovant memory item.",
|
|
1296
|
+
inputSchema: {
|
|
1297
|
+
...controlPlaneSchema,
|
|
1298
|
+
memoryId: z.string().min(1),
|
|
1299
|
+
},
|
|
1300
|
+
annotations: {
|
|
1301
|
+
readOnlyHint: true,
|
|
1302
|
+
openWorldHint: true,
|
|
1303
|
+
},
|
|
1304
|
+
}, async ({ memoryId, ...auth }) => {
|
|
1305
|
+
const api = await createControlPlane(auth);
|
|
1306
|
+
ensureAuthenticated(api);
|
|
1307
|
+
return jsonContent(redactSecrets(await api.getMemoryItem(memoryId)));
|
|
1308
|
+
});
|
|
1309
|
+
registerThalovantTool(server, "thalovant_create_memory_item", {
|
|
1310
|
+
title: "Create Memory Item",
|
|
1311
|
+
description: "Create an explicit Thalovant memory item.",
|
|
1312
|
+
inputSchema: {
|
|
1313
|
+
...controlPlaneSchema,
|
|
1314
|
+
scope: z.enum(["personal", "workspace", "hub"]).optional(),
|
|
1315
|
+
kind: z.enum(["note", "preference", "fact"]).optional(),
|
|
1316
|
+
title: z.string().max(512).nullable().optional(),
|
|
1317
|
+
content: z.string().min(1).max(20_000),
|
|
1318
|
+
tags: z.array(z.string().min(1)).optional(),
|
|
1319
|
+
ownerId: z.string().min(1).optional(),
|
|
1320
|
+
hubId: z.string().min(1).optional(),
|
|
1321
|
+
source: z.string().min(1).optional(),
|
|
1322
|
+
metadata: jsonRecordSchema.optional(),
|
|
1323
|
+
consentScope: z.string().min(1).optional(),
|
|
1324
|
+
consentVersion: z.string().nullable().optional(),
|
|
1325
|
+
retentionPolicy: z.string().min(1).optional(),
|
|
1326
|
+
expiresAt: z.string().nullable().optional(),
|
|
1327
|
+
},
|
|
1328
|
+
annotations: {
|
|
1329
|
+
readOnlyHint: false,
|
|
1330
|
+
destructiveHint: false,
|
|
1331
|
+
idempotentHint: false,
|
|
1332
|
+
openWorldHint: true,
|
|
1333
|
+
},
|
|
1334
|
+
}, async (args) => {
|
|
1335
|
+
const { loginScope, apiUrl, ...payload } = args;
|
|
1336
|
+
const api = await createControlPlane({ loginScope, apiUrl });
|
|
1337
|
+
ensureAuthenticated(api);
|
|
1338
|
+
return jsonContent(redactSecrets(await api.createMemoryItem(payload)));
|
|
1339
|
+
});
|
|
1340
|
+
registerThalovantTool(server, "thalovant_update_memory_item", {
|
|
1341
|
+
title: "Update Memory Item",
|
|
1342
|
+
description: "Update an explicit Thalovant memory item.",
|
|
1343
|
+
inputSchema: {
|
|
1344
|
+
...controlPlaneSchema,
|
|
1345
|
+
memoryId: z.string().min(1),
|
|
1346
|
+
kind: z.enum(["note", "preference", "fact"]).optional(),
|
|
1347
|
+
title: z.string().max(512).nullable().optional(),
|
|
1348
|
+
content: z.string().min(1).max(20_000).optional(),
|
|
1349
|
+
tags: z.array(z.string().min(1)).optional(),
|
|
1350
|
+
metadata: jsonRecordSchema.optional(),
|
|
1351
|
+
consentScope: z.string().min(1).optional(),
|
|
1352
|
+
consentVersion: z.string().nullable().optional(),
|
|
1353
|
+
retentionPolicy: z.string().min(1).optional(),
|
|
1354
|
+
expiresAt: z.string().nullable().optional(),
|
|
1355
|
+
clearExpiresAt: z.boolean().optional(),
|
|
1356
|
+
},
|
|
1357
|
+
annotations: {
|
|
1358
|
+
readOnlyHint: false,
|
|
1359
|
+
destructiveHint: false,
|
|
1360
|
+
idempotentHint: false,
|
|
1361
|
+
openWorldHint: true,
|
|
1362
|
+
},
|
|
1363
|
+
}, async (args) => {
|
|
1364
|
+
const { loginScope, apiUrl, memoryId, ...payload } = args;
|
|
1365
|
+
const api = await createControlPlane({ loginScope, apiUrl });
|
|
1366
|
+
ensureAuthenticated(api);
|
|
1367
|
+
return jsonContent(redactSecrets(await api.updateMemoryItem(memoryId, payload)));
|
|
1368
|
+
});
|
|
1369
|
+
registerThalovantTool(server, "thalovant_delete_memory_item", {
|
|
1370
|
+
title: "Delete Memory Item",
|
|
1371
|
+
description: "Delete one Thalovant memory item.",
|
|
1372
|
+
inputSchema: {
|
|
1373
|
+
...controlPlaneSchema,
|
|
1374
|
+
memoryId: z.string().min(1),
|
|
1375
|
+
},
|
|
1376
|
+
annotations: {
|
|
1377
|
+
readOnlyHint: false,
|
|
1378
|
+
destructiveHint: true,
|
|
1379
|
+
idempotentHint: false,
|
|
1380
|
+
openWorldHint: true,
|
|
1381
|
+
},
|
|
1382
|
+
}, async ({ memoryId, ...auth }) => {
|
|
1383
|
+
const api = await createControlPlane(auth);
|
|
1384
|
+
ensureAuthenticated(api);
|
|
1385
|
+
await api.deleteMemoryItem(memoryId);
|
|
1386
|
+
return textContent("Memory item deleted.");
|
|
1387
|
+
});
|
|
1388
|
+
return server;
|
|
1389
|
+
}
|
|
1390
|
+
export async function startHttpServer(config = getHttpConfig()) {
|
|
1391
|
+
const sessions = new Map();
|
|
1392
|
+
const rateLimiter = new FixedWindowRateLimiter(config.rateLimitMax, config.rateLimitWindowMs);
|
|
1393
|
+
const eventStore = await createEventStore(config);
|
|
1394
|
+
const closeSession = async (sessionId) => {
|
|
1395
|
+
const session = sessions.get(sessionId);
|
|
1396
|
+
if (!session)
|
|
1397
|
+
return;
|
|
1398
|
+
sessions.delete(sessionId);
|
|
1399
|
+
await session.transport.close().catch((error) => {
|
|
1400
|
+
console.error("Error closing MCP HTTP transport:", error);
|
|
1401
|
+
});
|
|
1402
|
+
await session.server.close().catch((error) => {
|
|
1403
|
+
console.error("Error closing MCP HTTP server session:", error);
|
|
1404
|
+
});
|
|
1405
|
+
};
|
|
1406
|
+
const cleanupTimer = setInterval(() => {
|
|
1407
|
+
rateLimiter.cleanup();
|
|
1408
|
+
const now = Date.now();
|
|
1409
|
+
for (const [sessionId, session] of sessions) {
|
|
1410
|
+
if (now - session.lastSeen > config.sessionTtlMs) {
|
|
1411
|
+
void closeSession(sessionId);
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
}, Math.min(config.sessionTtlMs, 60_000));
|
|
1415
|
+
cleanupTimer.unref();
|
|
1416
|
+
const httpServer = createHttpServer(async (req, res) => {
|
|
1417
|
+
setSecurityHeaders(res);
|
|
1418
|
+
try {
|
|
1419
|
+
if (!validateHost(req, config)) {
|
|
1420
|
+
sendJson(res, 403, { error: "Forbidden host." });
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
if (!applyCors(req, res, config)) {
|
|
1424
|
+
sendJson(res, 403, { error: "Forbidden origin." });
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
if (req.method === "OPTIONS") {
|
|
1428
|
+
res.writeHead(204);
|
|
1429
|
+
res.end();
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
const url = parseRequestUrl(req);
|
|
1433
|
+
if (url.pathname === "/healthz" || url.pathname === "/readyz") {
|
|
1434
|
+
sendJson(res, 200, {
|
|
1435
|
+
ok: true,
|
|
1436
|
+
name: "thalovant",
|
|
1437
|
+
version: VERSION,
|
|
1438
|
+
transport: "streamable-http",
|
|
1439
|
+
});
|
|
1440
|
+
return;
|
|
1441
|
+
}
|
|
1442
|
+
if (url.pathname === config.resourceMetadataUrl.pathname) {
|
|
1443
|
+
sendJson(res, 200, protectedResourceMetadata(config));
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
if (url.pathname !== config.path) {
|
|
1447
|
+
sendJson(res, 404, { error: "Not found." });
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
const rateKey = clientAddress(req, config);
|
|
1451
|
+
const rate = rateLimiter.check(rateKey);
|
|
1452
|
+
res.setHeader("RateLimit-Limit", String(config.rateLimitMax));
|
|
1453
|
+
res.setHeader("RateLimit-Remaining", String(rate.remaining));
|
|
1454
|
+
res.setHeader("RateLimit-Reset", String(Math.ceil(rate.resetAt / 1_000)));
|
|
1455
|
+
if (!rate.allowed) {
|
|
1456
|
+
sendJson(res, 429, { error: "Rate limit exceeded." }, { "Retry-After": String(Math.ceil(config.rateLimitWindowMs / 1_000)) });
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
const auth = await authenticate(req, config);
|
|
1460
|
+
const authenticatedReq = req;
|
|
1461
|
+
authenticatedReq.auth = authInfoFromAuthResult(auth);
|
|
1462
|
+
authenticatedReq.auth.resource = config.resourceUrl;
|
|
1463
|
+
if (req.method === "POST") {
|
|
1464
|
+
const body = await readJsonBody(req, config.maxBodyBytes);
|
|
1465
|
+
const sessionId = typeof req.headers["mcp-session-id"] === "string" ? req.headers["mcp-session-id"] : undefined;
|
|
1466
|
+
if (sessionId) {
|
|
1467
|
+
const session = sessions.get(sessionId);
|
|
1468
|
+
if (!session) {
|
|
1469
|
+
sendMcpError(res, 404, -32001, "Unknown MCP session.");
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
if (session.userId !== auth.userId) {
|
|
1473
|
+
sendMcpError(res, 403, -32003, "MCP session is bound to a different authenticated principal.");
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
session.lastSeen = Date.now();
|
|
1477
|
+
await session.transport.handleRequest(authenticatedReq, res, body);
|
|
1478
|
+
return;
|
|
1479
|
+
}
|
|
1480
|
+
if (!isInitializationBody(body)) {
|
|
1481
|
+
sendMcpError(res, 400, -32000, "Bad Request: initialize is required before session requests.");
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
const mcpServer = createServer();
|
|
1485
|
+
let transport;
|
|
1486
|
+
transport = new StreamableHTTPServerTransport({
|
|
1487
|
+
sessionIdGenerator: () => randomUUID(),
|
|
1488
|
+
eventStore,
|
|
1489
|
+
enableJsonResponse: config.enableJsonResponse,
|
|
1490
|
+
enableDnsRebindingProtection: true,
|
|
1491
|
+
allowedHosts: config.allowedHosts,
|
|
1492
|
+
allowedOrigins: config.allowedOrigins,
|
|
1493
|
+
onsessioninitialized: (newSessionId) => {
|
|
1494
|
+
sessions.set(newSessionId, {
|
|
1495
|
+
transport,
|
|
1496
|
+
server: mcpServer,
|
|
1497
|
+
userId: auth.userId,
|
|
1498
|
+
createdAt: Date.now(),
|
|
1499
|
+
lastSeen: Date.now(),
|
|
1500
|
+
});
|
|
1501
|
+
},
|
|
1502
|
+
onsessionclosed: (closedSessionId) => {
|
|
1503
|
+
sessions.delete(closedSessionId);
|
|
1504
|
+
},
|
|
1505
|
+
});
|
|
1506
|
+
transport.onerror = (error) => {
|
|
1507
|
+
console.error("MCP HTTP transport error:", error);
|
|
1508
|
+
};
|
|
1509
|
+
transport.onclose = () => {
|
|
1510
|
+
const closedSessionId = transport.sessionId;
|
|
1511
|
+
if (closedSessionId) {
|
|
1512
|
+
sessions.delete(closedSessionId);
|
|
1513
|
+
}
|
|
1514
|
+
void mcpServer.close().catch((error) => {
|
|
1515
|
+
console.error("Error closing MCP session server:", error);
|
|
1516
|
+
});
|
|
1517
|
+
};
|
|
1518
|
+
await mcpServer.connect(transport);
|
|
1519
|
+
await transport.handleRequest(authenticatedReq, res, body);
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
if (req.method === "GET" || req.method === "DELETE") {
|
|
1523
|
+
const sessionId = typeof req.headers["mcp-session-id"] === "string" ? req.headers["mcp-session-id"] : undefined;
|
|
1524
|
+
if (!sessionId) {
|
|
1525
|
+
sendMcpError(res, 400, -32000, "Missing MCP session id.");
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
const session = sessions.get(sessionId);
|
|
1529
|
+
if (!session) {
|
|
1530
|
+
sendMcpError(res, 404, -32001, "Unknown MCP session.");
|
|
1531
|
+
return;
|
|
1532
|
+
}
|
|
1533
|
+
if (session.userId !== auth.userId) {
|
|
1534
|
+
sendMcpError(res, 403, -32003, "MCP session is bound to a different authenticated principal.");
|
|
1535
|
+
return;
|
|
1536
|
+
}
|
|
1537
|
+
session.lastSeen = Date.now();
|
|
1538
|
+
await session.transport.handleRequest(authenticatedReq, res);
|
|
1539
|
+
if (req.method === "DELETE") {
|
|
1540
|
+
await closeSession(sessionId);
|
|
1541
|
+
}
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1544
|
+
res.setHeader("Allow", "GET, POST, DELETE, OPTIONS");
|
|
1545
|
+
sendMcpError(res, 405, -32000, "Method not allowed.");
|
|
1546
|
+
}
|
|
1547
|
+
catch (error) {
|
|
1548
|
+
if (error instanceof HttpError) {
|
|
1549
|
+
if (error.status === 401) {
|
|
1550
|
+
sendAuthRequired(res, config, error.message);
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
sendJson(res, error.status, { error: error.message });
|
|
1554
|
+
return;
|
|
1555
|
+
}
|
|
1556
|
+
console.error("Error handling MCP HTTP request:", error);
|
|
1557
|
+
sendMcpError(res, 500, -32603, "Internal server error.");
|
|
1558
|
+
}
|
|
1559
|
+
});
|
|
1560
|
+
await new Promise((resolveListen, rejectListen) => {
|
|
1561
|
+
const onError = (error) => {
|
|
1562
|
+
httpServer.off("listening", onListening);
|
|
1563
|
+
rejectListen(error);
|
|
1564
|
+
};
|
|
1565
|
+
const onListening = () => {
|
|
1566
|
+
httpServer.off("error", onError);
|
|
1567
|
+
resolveListen();
|
|
1568
|
+
};
|
|
1569
|
+
httpServer.once("error", onError);
|
|
1570
|
+
httpServer.once("listening", onListening);
|
|
1571
|
+
httpServer.listen(config.port, config.host);
|
|
1572
|
+
});
|
|
1573
|
+
const url = `http://${config.host.includes(":") && !config.host.startsWith("[") ? `[${config.host}]` : config.host}:${config.port}${config.path}`;
|
|
1574
|
+
console.error(`Thalovant MCP Streamable HTTP listening at ${url}`);
|
|
1575
|
+
return {
|
|
1576
|
+
url,
|
|
1577
|
+
close: async () => {
|
|
1578
|
+
clearInterval(cleanupTimer);
|
|
1579
|
+
for (const sessionId of Array.from(sessions.keys())) {
|
|
1580
|
+
await closeSession(sessionId);
|
|
1581
|
+
}
|
|
1582
|
+
await new Promise((resolveClose, rejectClose) => {
|
|
1583
|
+
httpServer.close((error) => {
|
|
1584
|
+
if (error)
|
|
1585
|
+
rejectClose(error);
|
|
1586
|
+
else
|
|
1587
|
+
resolveClose();
|
|
1588
|
+
});
|
|
1589
|
+
});
|
|
1590
|
+
},
|
|
1591
|
+
};
|
|
1592
|
+
}
|
|
1593
|
+
export async function main() {
|
|
1594
|
+
const requestedTransport = process.argv.includes("--http")
|
|
1595
|
+
? "http"
|
|
1596
|
+
: process.argv.includes("--stdio")
|
|
1597
|
+
? "stdio"
|
|
1598
|
+
: (process.env.MCP_TRANSPORT ?? "stdio");
|
|
1599
|
+
if (requestedTransport === "http" || requestedTransport === "streamable-http") {
|
|
1600
|
+
await startHttpServer();
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
const transport = new StdioServerTransport();
|
|
1604
|
+
const server = createServer();
|
|
1605
|
+
await server.connect(transport);
|
|
1606
|
+
}
|
|
1607
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
1608
|
+
main().catch((error) => {
|
|
1609
|
+
console.error("Fatal error in thalovant-mcp:", error);
|
|
1610
|
+
process.exit(1);
|
|
1611
|
+
});
|
|
1612
|
+
}
|