@sodiumhq/mcp-pm 0.1.0-beta.4445 → 0.1.0-beta.4457
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/README.md +1 -2
- package/dist/http.js +185 -80
- package/dist/index.js +1 -1
- package/dist/{src-BRf2g74U.mjs → src-uJ9liBF-.mjs} +180 -143
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -62,8 +62,7 @@ Only enable write mode with an AI client you trust — it hands the client the a
|
|
|
62
62
|
- **`get_practice_dashboard`** — one-call "state of the practice right now": overdue/blocked/due-soon work, what clients are sitting on, unassigned steps, unbilled time, open proposal pipeline. *"Give me my morning briefing."*
|
|
63
63
|
- **`get_practice_details`** — consolidated practice overview (counts, connections, settings)
|
|
64
64
|
- **`whoami`** — show the authenticated user (name, email, code), tenant (name, code, status), and practice name. Useful for verifying which account is connected.
|
|
65
|
-
- **`list_practices`** — list the practices you belong to
|
|
66
|
-
- **`set_active_practice`** — choose which practice to work in, or switch mid-session. *"Switch to Oceans."* Needed when you belong to more than one practice.
|
|
65
|
+
- **`list_practices`** — list the practices you belong to. *"Which practices can I access?"* If you belong to more than one, tools take an optional `practice` code to say which one a request is about; with a single practice it is selected automatically.
|
|
67
66
|
|
|
68
67
|
**Clients**
|
|
69
68
|
- **`list_clients`** — list and filter clients by search, status, type, assignee, services, saved filters
|
package/dist/http.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { t as buildServer } from "./src-
|
|
2
|
+
import { t as buildServer } from "./src-uJ9liBF-.mjs";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import { createHash, randomUUID } from "node:crypto";
|
|
5
5
|
import { createServer } from "node:http";
|
|
6
6
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
7
7
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
8
8
|
import { createRemoteJWKSet, decodeJwt, jwtVerify } from "jose";
|
|
9
|
+
import { hostname } from "node:os";
|
|
9
10
|
//#region src/obo.ts
|
|
10
11
|
const EXPIRY_MARGIN_MS = 6e4;
|
|
11
12
|
const cache = /* @__PURE__ */ new Map();
|
|
@@ -62,6 +63,105 @@ function resolveExpiry(accessToken, expiresIn) {
|
|
|
62
63
|
return Date.now() + seconds * 1e3 - EXPIRY_MARGIN_MS;
|
|
63
64
|
}
|
|
64
65
|
//#endregion
|
|
66
|
+
//#region src/telemetry.ts
|
|
67
|
+
const FLUSH_INTERVAL_MS = Number(process.env.MCP_TELEMETRY_FLUSH_MS ?? 1e4);
|
|
68
|
+
const MAX_BATCH = 20;
|
|
69
|
+
const MAX_QUEUE = 1e3;
|
|
70
|
+
const ERROR_LOG_THROTTLE_MS = 6e4;
|
|
71
|
+
function loadTelemetryConfig(options) {
|
|
72
|
+
const raw = process.env.APPLICATIONINSIGHTS_CONNECTION_STRING?.trim();
|
|
73
|
+
if (!raw) return null;
|
|
74
|
+
const parts = new Map(raw.split(";").map((p) => p.trim()).filter(Boolean).map((p) => {
|
|
75
|
+
const eq = p.indexOf("=");
|
|
76
|
+
return [p.slice(0, eq).toLowerCase(), p.slice(eq + 1)];
|
|
77
|
+
}));
|
|
78
|
+
const instrumentationKey = parts.get("instrumentationkey");
|
|
79
|
+
const ingestionEndpoint = parts.get("ingestionendpoint");
|
|
80
|
+
if (!instrumentationKey || !ingestionEndpoint) return null;
|
|
81
|
+
return {
|
|
82
|
+
instrumentationKey,
|
|
83
|
+
ingestionEndpoint: ingestionEndpoint.replace(/\/+$/, ""),
|
|
84
|
+
roleName: options.roleName,
|
|
85
|
+
applicationVersion: options.applicationVersion
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function formatDuration(ms) {
|
|
89
|
+
const clamped = Math.max(0, Math.round(ms));
|
|
90
|
+
const h = Math.floor(clamped / 36e5);
|
|
91
|
+
const m = Math.floor(clamped % 36e5 / 6e4);
|
|
92
|
+
const s = Math.floor(clamped % 6e4 / 1e3);
|
|
93
|
+
const f = clamped % 1e3;
|
|
94
|
+
const pad = (n, w = 2) => String(n).padStart(w, "0");
|
|
95
|
+
return `${pad(h)}:${pad(m)}:${pad(s)}.${pad(f, 3)}`;
|
|
96
|
+
}
|
|
97
|
+
var TelemetryClient = class {
|
|
98
|
+
queue = [];
|
|
99
|
+
lastErrorLogAt = 0;
|
|
100
|
+
constructor(config) {
|
|
101
|
+
this.config = config;
|
|
102
|
+
setInterval(() => void this.flush(), FLUSH_INTERVAL_MS).unref();
|
|
103
|
+
process.on("SIGTERM", () => void this.flush());
|
|
104
|
+
}
|
|
105
|
+
trackRequest(request) {
|
|
106
|
+
const properties = {};
|
|
107
|
+
if (request.practice) properties.practice = request.practice;
|
|
108
|
+
if (request.tool) properties.tool = request.tool;
|
|
109
|
+
if (request.outcome) properties.outcome = request.outcome;
|
|
110
|
+
if (request.aiClient) properties.aiClient = request.aiClient;
|
|
111
|
+
if (request.error) properties.error = request.error.slice(0, 1024);
|
|
112
|
+
this.enqueue({
|
|
113
|
+
name: `Microsoft.ApplicationInsights.${this.config.instrumentationKey.replace(/-/g, "")}.Request`,
|
|
114
|
+
time: (/* @__PURE__ */ new Date()).toISOString(),
|
|
115
|
+
iKey: this.config.instrumentationKey,
|
|
116
|
+
tags: {
|
|
117
|
+
"ai.cloud.role": this.config.roleName,
|
|
118
|
+
"ai.cloud.roleInstance": hostname(),
|
|
119
|
+
"ai.application.ver": this.config.applicationVersion,
|
|
120
|
+
"ai.operation.id": randomUUID(),
|
|
121
|
+
"ai.operation.name": request.name,
|
|
122
|
+
...request.userSubject ? { "ai.user.authUserId": request.userSubject } : {}
|
|
123
|
+
},
|
|
124
|
+
data: {
|
|
125
|
+
baseType: "RequestData",
|
|
126
|
+
baseData: {
|
|
127
|
+
ver: 2,
|
|
128
|
+
id: randomUUID().replace(/-/g, "").slice(0, 16),
|
|
129
|
+
name: request.name,
|
|
130
|
+
duration: formatDuration(request.durationMs),
|
|
131
|
+
responseCode: String(request.responseCode),
|
|
132
|
+
success: request.success,
|
|
133
|
+
properties
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
enqueue(envelope) {
|
|
139
|
+
this.queue.push(envelope);
|
|
140
|
+
if (this.queue.length > MAX_QUEUE) this.queue.splice(0, this.queue.length - MAX_QUEUE);
|
|
141
|
+
if (this.queue.length >= MAX_BATCH) this.flush();
|
|
142
|
+
}
|
|
143
|
+
async flush() {
|
|
144
|
+
if (this.queue.length === 0) return;
|
|
145
|
+
const batch = this.queue.splice(0, this.queue.length);
|
|
146
|
+
try {
|
|
147
|
+
const response = await fetch(`${this.config.ingestionEndpoint}/v2.1/track`, {
|
|
148
|
+
method: "POST",
|
|
149
|
+
headers: { "Content-Type": "application/x-json-stream" },
|
|
150
|
+
body: batch.map((e) => JSON.stringify(e)).join("\n")
|
|
151
|
+
});
|
|
152
|
+
if (!response.ok) this.logError(`ingestion responded ${response.status}`);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
this.logError(error instanceof Error ? error.message : String(error));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
logError(message) {
|
|
158
|
+
const now = Date.now();
|
|
159
|
+
if (now - this.lastErrorLogAt < ERROR_LOG_THROTTLE_MS) return;
|
|
160
|
+
this.lastErrorLogAt = now;
|
|
161
|
+
console.error(`[sodium-pm-mcp-http] telemetry flush failed: ${message}`);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
//#endregion
|
|
65
165
|
//#region src/auth.ts
|
|
66
166
|
var AuthError = class extends Error {
|
|
67
167
|
constructor(status, code, message) {
|
|
@@ -140,25 +240,22 @@ const WRITES_ENABLED = writesRaw === "true" || writesRaw === "1";
|
|
|
140
240
|
const AUTH = loadAuthConfig();
|
|
141
241
|
const OBO = AUTH ? loadOboConfig(AUTH.issuer) : null;
|
|
142
242
|
const ENDPOINT_PATH = AUTH ? new URL(AUTH.resourceUrl).pathname : process.env.MCP_ENDPOINT_PATH ?? "/mcp";
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
console.error(`[sodium-pm-mcp-http] session ${sessionId} swept (idle)`);
|
|
156
|
-
try {
|
|
157
|
-
await session.transport.close();
|
|
158
|
-
} catch (error) {
|
|
159
|
-
console.error(`[sodium-pm-mcp-http] error closing swept session: ${String(error)}`);
|
|
243
|
+
const TELEMETRY_CONFIG = loadTelemetryConfig({
|
|
244
|
+
roleName: "sodium-pm-mcp",
|
|
245
|
+
applicationVersion: VERSION
|
|
246
|
+
});
|
|
247
|
+
const TELEMETRY = TELEMETRY_CONFIG ? new TelemetryClient(TELEMETRY_CONFIG) : null;
|
|
248
|
+
function describeRequest(body) {
|
|
249
|
+
if (Array.isArray(body)) return "POST batch";
|
|
250
|
+
if (typeof body === "object" && body !== null && "method" in body) {
|
|
251
|
+
const method = String(body.method);
|
|
252
|
+
if (method === "tools/call") {
|
|
253
|
+
const params = body.params;
|
|
254
|
+
return `POST tools/call ${typeof params?.name === "string" ? params.name : "?"}`;
|
|
160
255
|
}
|
|
256
|
+
return `POST ${method}`;
|
|
161
257
|
}
|
|
258
|
+
return "POST unknown";
|
|
162
259
|
}
|
|
163
260
|
const MAX_BODY_BYTES = 4 * 1024 * 1024;
|
|
164
261
|
async function readJsonBody(req) {
|
|
@@ -182,47 +279,71 @@ function sendJsonError(res, status, message) {
|
|
|
182
279
|
id: null
|
|
183
280
|
}));
|
|
184
281
|
}
|
|
185
|
-
async function
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
const
|
|
189
|
-
let
|
|
190
|
-
let
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
});
|
|
208
|
-
const transport = new StreamableHTTPServerTransport({
|
|
209
|
-
sessionIdGenerator: () => randomUUID(),
|
|
210
|
-
onsessioninitialized: (sessionId) => {
|
|
211
|
-
sessions.set(sessionId, {
|
|
212
|
-
transport,
|
|
213
|
-
lastActivity: Date.now()
|
|
214
|
-
});
|
|
215
|
-
console.error(`[sodium-pm-mcp-http] session ${sessionId} started (tenant: ${tenant}, live: ${sessions.size})`);
|
|
216
|
-
}
|
|
217
|
-
});
|
|
218
|
-
transport.onclose = () => {
|
|
219
|
-
if (transport.sessionId) {
|
|
220
|
-
sessions.delete(transport.sessionId);
|
|
221
|
-
console.error(`[sodium-pm-mcp-http] session ${transport.sessionId} closed`);
|
|
222
|
-
}
|
|
282
|
+
async function handleMcpPost(req, res, body) {
|
|
283
|
+
const started = Date.now();
|
|
284
|
+
const requestName = describeRequest(body);
|
|
285
|
+
const userAgent = req.headers["user-agent"];
|
|
286
|
+
let subject;
|
|
287
|
+
let toolCall;
|
|
288
|
+
let tracked = false;
|
|
289
|
+
const track = (responseCode, error) => {
|
|
290
|
+
if (!TELEMETRY || tracked) return;
|
|
291
|
+
tracked = true;
|
|
292
|
+
TELEMETRY.trackRequest({
|
|
293
|
+
name: requestName,
|
|
294
|
+
durationMs: Date.now() - started,
|
|
295
|
+
responseCode,
|
|
296
|
+
success: responseCode < 400 && toolCall?.outcome !== "error",
|
|
297
|
+
userSubject: subject,
|
|
298
|
+
practice: toolCall?.practice || void 0,
|
|
299
|
+
tool: toolCall?.tool,
|
|
300
|
+
outcome: toolCall?.outcome,
|
|
301
|
+
aiClient: typeof userAgent === "string" ? userAgent : void 0,
|
|
302
|
+
error
|
|
303
|
+
});
|
|
223
304
|
};
|
|
224
|
-
|
|
225
|
-
|
|
305
|
+
try {
|
|
306
|
+
const authHeader = req.headers.authorization;
|
|
307
|
+
if (!authHeader?.startsWith("Bearer ")) throw new HttpError(401, "Authorization: Bearer <token> header is required.");
|
|
308
|
+
const bearerToken = authHeader.slice(7).trim();
|
|
309
|
+
let writesAllowed = WRITES_ENABLED;
|
|
310
|
+
let apiToken = bearerToken;
|
|
311
|
+
if (AUTH) {
|
|
312
|
+
const verified = await verifyAccessToken(AUTH, bearerToken);
|
|
313
|
+
subject = verified.subject;
|
|
314
|
+
writesAllowed = WRITES_ENABLED && verified.scopes.includes(AUTH.scopes.write);
|
|
315
|
+
if (OBO) apiToken = await getDownstreamToken(OBO, bearerToken);
|
|
316
|
+
}
|
|
317
|
+
const tenantHeader = req.headers["x-sodium-tenant"];
|
|
318
|
+
const tenant = (Array.isArray(tenantHeader) ? tenantHeader[0] : tenantHeader) ?? process.env.SODIUM_TENANT;
|
|
319
|
+
const isInit = isInitializeRequest(body);
|
|
320
|
+
const server = await buildServer({
|
|
321
|
+
context: {
|
|
322
|
+
bearerToken: apiToken,
|
|
323
|
+
tenant,
|
|
324
|
+
baseUrl: BASE_URL,
|
|
325
|
+
writesEnabled: writesAllowed
|
|
326
|
+
},
|
|
327
|
+
serverName: "Sodium Practice Management",
|
|
328
|
+
serverVersion: VERSION,
|
|
329
|
+
includeStartupContext: isInit,
|
|
330
|
+
onToolCall: (info) => {
|
|
331
|
+
toolCall = info;
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: void 0 });
|
|
335
|
+
res.on("close", () => {
|
|
336
|
+
track(res.statusCode ?? 200);
|
|
337
|
+
transport.close();
|
|
338
|
+
server.close();
|
|
339
|
+
});
|
|
340
|
+
if (isInit) console.error(`[sodium-pm-mcp-http] initialize (tenant: ${tenant || "per-call"}, writes: ${writesAllowed})`);
|
|
341
|
+
await server.connect(transport);
|
|
342
|
+
await transport.handleRequest(req, res, body);
|
|
343
|
+
} catch (error) {
|
|
344
|
+
track(error instanceof HttpError || error instanceof AuthError ? error.status : 500, error instanceof Error ? error.message : String(error));
|
|
345
|
+
throw error;
|
|
346
|
+
}
|
|
226
347
|
}
|
|
227
348
|
var HttpError = class extends Error {
|
|
228
349
|
constructor(status, message) {
|
|
@@ -235,8 +356,7 @@ async function handle(req, res) {
|
|
|
235
356
|
if (req.method === "GET" && url.pathname === "/health") {
|
|
236
357
|
res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({
|
|
237
358
|
status: "ok",
|
|
238
|
-
version: VERSION
|
|
239
|
-
sessions: sessions.size
|
|
359
|
+
version: VERSION
|
|
240
360
|
}));
|
|
241
361
|
return;
|
|
242
362
|
}
|
|
@@ -248,25 +368,14 @@ async function handle(req, res) {
|
|
|
248
368
|
sendJsonError(res, 404, `Not found. MCP endpoint is ${ENDPOINT_PATH}.`);
|
|
249
369
|
return;
|
|
250
370
|
}
|
|
251
|
-
const sessionId = req.headers["mcp-session-id"];
|
|
252
|
-
const existing = typeof sessionId === "string" ? sessions.get(sessionId) : void 0;
|
|
253
|
-
if (existing && typeof sessionId === "string") {
|
|
254
|
-
touch(sessionId);
|
|
255
|
-
await existing.transport.handleRequest(req, res);
|
|
256
|
-
return;
|
|
257
|
-
}
|
|
258
371
|
if (req.method !== "POST") {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
}
|
|
262
|
-
const body = await readJsonBody(req);
|
|
263
|
-
if (!isInitializeRequest(body)) {
|
|
264
|
-
sendJsonError(res, 400, "No valid MCP session. Send an initialize request first.");
|
|
372
|
+
res.setHeader("Allow", "POST");
|
|
373
|
+
sendJsonError(res, 405, "Stateless MCP endpoint. Send requests as POST.");
|
|
265
374
|
return;
|
|
266
375
|
}
|
|
267
|
-
await (
|
|
376
|
+
await handleMcpPost(req, res, await readJsonBody(req));
|
|
268
377
|
}
|
|
269
|
-
|
|
378
|
+
createServer((req, res) => {
|
|
270
379
|
handle(req, res).catch((error) => {
|
|
271
380
|
const message = error instanceof Error ? error.message : String(error);
|
|
272
381
|
console.error(`[sodium-pm-mcp-http] ${req.method} ${req.url} failed: ${message}`);
|
|
@@ -280,11 +389,7 @@ const httpServer = createServer((req, res) => {
|
|
|
280
389
|
if (AUTH && error instanceof HttpError && error.status === 401) res.setHeader("WWW-Authenticate", buildChallenge(AUTH));
|
|
281
390
|
sendJsonError(res, error instanceof HttpError ? error.status : 500, message);
|
|
282
391
|
});
|
|
283
|
-
})
|
|
284
|
-
setInterval(() => {
|
|
285
|
-
sweepIdleSessions();
|
|
286
|
-
}, SWEEP_INTERVAL_MS).unref();
|
|
287
|
-
httpServer.listen(PORT, HOST, () => {
|
|
392
|
+
}).listen(PORT, HOST, () => {
|
|
288
393
|
console.error(`[sodium-pm-mcp-http] v${VERSION} listening on http://${HOST}:${PORT}${ENDPOINT_PATH} (api: ${BASE_URL}, writes: ${WRITES_ENABLED ? "enabled" : "disabled"})`);
|
|
289
394
|
});
|
|
290
395
|
//#endregion
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { t as buildServer } from "./src-
|
|
2
|
+
import { t as buildServer } from "./src-uJ9liBF-.mjs";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
5
|
import { parseArgs } from "node:util";
|