@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 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, marking the active one. *"Which practices can I access?"*
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-BRf2g74U.mjs";
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 sessions = /* @__PURE__ */ new Map();
144
- const SESSION_IDLE_MS = Number(process.env.MCP_SESSION_IDLE_MS ?? 30 * 6e4);
145
- const SWEEP_INTERVAL_MS = 6e4;
146
- function touch(sessionId) {
147
- const session = sessions.get(sessionId);
148
- if (session) session.lastActivity = Date.now();
149
- }
150
- async function sweepIdleSessions() {
151
- const cutoff = Date.now() - SESSION_IDLE_MS;
152
- for (const [sessionId, session] of sessions) {
153
- if (session.lastActivity > cutoff) continue;
154
- sessions.delete(sessionId);
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 createSession(req) {
186
- const authHeader = req.headers.authorization;
187
- if (!authHeader?.startsWith("Bearer ")) throw new HttpError(401, "Authorization: Bearer <token> header is required.");
188
- const bearerToken = authHeader.slice(7).trim();
189
- let writesAllowed = WRITES_ENABLED;
190
- let apiToken = bearerToken;
191
- if (AUTH) {
192
- const verified = await verifyAccessToken(AUTH, bearerToken);
193
- writesAllowed = WRITES_ENABLED && verified.scopes.includes(AUTH.scopes.write);
194
- if (OBO) apiToken = await getDownstreamToken(OBO, bearerToken);
195
- }
196
- const tenantHeader = req.headers["x-sodium-tenant"];
197
- const tenant = (Array.isArray(tenantHeader) ? tenantHeader[0] : tenantHeader) ?? process.env.SODIUM_TENANT;
198
- const server = await buildServer({
199
- context: {
200
- bearerToken: apiToken,
201
- tenant,
202
- baseUrl: BASE_URL,
203
- writesEnabled: writesAllowed
204
- },
205
- serverName: "Sodium Practice Management",
206
- serverVersion: VERSION
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
- await server.connect(transport);
225
- return transport;
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
- sendJsonError(res, 400, "No valid MCP session. Send an initialize request first.");
260
- return;
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 (await createSession(req)).handleRequest(req, res, body);
376
+ await handleMcpPost(req, res, await readJsonBody(req));
268
377
  }
269
- const httpServer = createServer((req, res) => {
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-BRf2g74U.mjs";
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";