@sodiumhq/mcp-pm 0.1.0-beta.4448 → 0.1.0-beta.4465

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/http.js CHANGED
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
- import { t as buildServer } from "./src-DEbTNFXg.mjs";
2
+ import { t as buildServer } from "./src-uJ9liBF-.mjs";
3
3
  import { createRequire } from "node:module";
4
- import { createHash } from "node:crypto";
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) {
@@ -71,11 +171,11 @@ var AuthError = class extends Error {
71
171
  }
72
172
  };
73
173
  function loadAuthConfig() {
74
- const resourceUrl = process.env.MCP_RESOURCE_URL;
174
+ const resourceUrl = process.env.MCP_RESOURCE_URL?.trim();
75
175
  const issuer = process.env.AUTH0_ISSUER;
76
176
  if (!resourceUrl || !issuer) return null;
77
177
  return {
78
- resourceUrl: resourceUrl.replace(/\/+$/, ""),
178
+ resourceUrl,
79
179
  issuer: issuer.endsWith("/") ? issuer : `${issuer}/`,
80
180
  scopes: {
81
181
  read: process.env.MCP_SCOPE_READ ?? "mcp:read",
@@ -139,7 +239,25 @@ const writesRaw = (process.env.SODIUM_ENABLE_WRITES ?? "").trim().toLowerCase();
139
239
  const WRITES_ENABLED = writesRaw === "true" || writesRaw === "1";
140
240
  const AUTH = loadAuthConfig();
141
241
  const OBO = AUTH ? loadOboConfig(AUTH.issuer) : null;
142
- const ENDPOINT_PATH = AUTH ? new URL(AUTH.resourceUrl).pathname : process.env.MCP_ENDPOINT_PATH ?? "/mcp";
242
+ const RESOURCE_PATH = AUTH ? new URL(AUTH.resourceUrl).pathname : process.env.MCP_ENDPOINT_PATH ?? "/mcp";
243
+ const ENDPOINT_PATH = RESOURCE_PATH.length > 1 ? RESOURCE_PATH.replace(/\/+$/, "") : RESOURCE_PATH;
244
+ const TELEMETRY_CONFIG = loadTelemetryConfig({
245
+ roleName: "sodium-pm-mcp",
246
+ applicationVersion: VERSION
247
+ });
248
+ const TELEMETRY = TELEMETRY_CONFIG ? new TelemetryClient(TELEMETRY_CONFIG) : null;
249
+ function describeRequest(body) {
250
+ if (Array.isArray(body)) return "POST batch";
251
+ if (typeof body === "object" && body !== null && "method" in body) {
252
+ const method = String(body.method);
253
+ if (method === "tools/call") {
254
+ const params = body.params;
255
+ return `POST tools/call ${typeof params?.name === "string" ? params.name : "?"}`;
256
+ }
257
+ return `POST ${method}`;
258
+ }
259
+ return "POST unknown";
260
+ }
143
261
  const MAX_BODY_BYTES = 4 * 1024 * 1024;
144
262
  async function readJsonBody(req) {
145
263
  const chunks = [];
@@ -163,38 +281,70 @@ function sendJsonError(res, status, message) {
163
281
  }));
164
282
  }
165
283
  async function handleMcpPost(req, res, body) {
166
- const authHeader = req.headers.authorization;
167
- if (!authHeader?.startsWith("Bearer ")) throw new HttpError(401, "Authorization: Bearer <token> header is required.");
168
- const bearerToken = authHeader.slice(7).trim();
169
- let writesAllowed = WRITES_ENABLED;
170
- let apiToken = bearerToken;
171
- if (AUTH) {
172
- const verified = await verifyAccessToken(AUTH, bearerToken);
173
- writesAllowed = WRITES_ENABLED && verified.scopes.includes(AUTH.scopes.write);
174
- if (OBO) apiToken = await getDownstreamToken(OBO, bearerToken);
175
- }
176
- const tenantHeader = req.headers["x-sodium-tenant"];
177
- const tenant = (Array.isArray(tenantHeader) ? tenantHeader[0] : tenantHeader) ?? process.env.SODIUM_TENANT;
178
- const isInit = isInitializeRequest(body);
179
- const server = await buildServer({
180
- context: {
181
- bearerToken: apiToken,
182
- tenant,
183
- baseUrl: BASE_URL,
184
- writesEnabled: writesAllowed
185
- },
186
- serverName: "Sodium Practice Management",
187
- serverVersion: VERSION,
188
- includeStartupContext: isInit
189
- });
190
- const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: void 0 });
191
- res.on("close", () => {
192
- transport.close();
193
- server.close();
194
- });
195
- if (isInit) console.error(`[sodium-pm-mcp-http] initialize (tenant: ${tenant || "per-call"}, writes: ${writesAllowed})`);
196
- await server.connect(transport);
197
- await transport.handleRequest(req, res, body);
284
+ const started = Date.now();
285
+ const requestName = describeRequest(body);
286
+ const userAgent = req.headers["user-agent"];
287
+ let subject;
288
+ let toolCall;
289
+ let tracked = false;
290
+ const track = (responseCode, error) => {
291
+ if (!TELEMETRY || tracked) return;
292
+ tracked = true;
293
+ TELEMETRY.trackRequest({
294
+ name: requestName,
295
+ durationMs: Date.now() - started,
296
+ responseCode,
297
+ success: responseCode < 400 && toolCall?.outcome !== "error",
298
+ userSubject: subject,
299
+ practice: toolCall?.practice || void 0,
300
+ tool: toolCall?.tool,
301
+ outcome: toolCall?.outcome,
302
+ aiClient: typeof userAgent === "string" ? userAgent : void 0,
303
+ error
304
+ });
305
+ };
306
+ try {
307
+ const authHeader = req.headers.authorization;
308
+ if (!authHeader?.startsWith("Bearer ")) throw new HttpError(401, "Authorization: Bearer <token> header is required.");
309
+ const bearerToken = authHeader.slice(7).trim();
310
+ let writesAllowed = WRITES_ENABLED;
311
+ let apiToken = bearerToken;
312
+ if (AUTH) {
313
+ const verified = await verifyAccessToken(AUTH, bearerToken);
314
+ subject = verified.subject;
315
+ writesAllowed = WRITES_ENABLED && verified.scopes.includes(AUTH.scopes.write);
316
+ if (OBO) apiToken = await getDownstreamToken(OBO, bearerToken);
317
+ }
318
+ const tenantHeader = req.headers["x-sodium-tenant"];
319
+ const tenant = (Array.isArray(tenantHeader) ? tenantHeader[0] : tenantHeader) ?? process.env.SODIUM_TENANT;
320
+ const isInit = isInitializeRequest(body);
321
+ const server = await buildServer({
322
+ context: {
323
+ bearerToken: apiToken,
324
+ tenant,
325
+ baseUrl: BASE_URL,
326
+ writesEnabled: writesAllowed
327
+ },
328
+ serverName: "Sodium Practice Management",
329
+ serverVersion: VERSION,
330
+ includeStartupContext: isInit,
331
+ onToolCall: (info) => {
332
+ toolCall = info;
333
+ }
334
+ });
335
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: void 0 });
336
+ res.on("close", () => {
337
+ track(res.statusCode ?? 200);
338
+ transport.close();
339
+ server.close();
340
+ });
341
+ if (isInit) console.error(`[sodium-pm-mcp-http] initialize (tenant: ${tenant || "per-call"}, writes: ${writesAllowed})`);
342
+ await server.connect(transport);
343
+ await transport.handleRequest(req, res, body);
344
+ } catch (error) {
345
+ track(error instanceof HttpError || error instanceof AuthError ? error.status : 500, error instanceof Error ? error.message : String(error));
346
+ throw error;
347
+ }
198
348
  }
199
349
  var HttpError = class extends Error {
200
350
  constructor(status, message) {
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { t as buildServer } from "./src-DEbTNFXg.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";
@@ -2236,6 +2236,15 @@ var SodiumApiClient = class {
2236
2236
  if (!this.activeTenant) throw new Error("No practice specified. Pass a practice code from list_practices in the tool's 'practice' argument.");
2237
2237
  return this.activeTenant;
2238
2238
  }
2239
+ toolCallListener = null;
2240
+ setToolCallListener(listener) {
2241
+ this.toolCallListener = listener;
2242
+ }
2243
+ notifyToolCall(info) {
2244
+ try {
2245
+ this.toolCallListener?.(info);
2246
+ } catch {}
2247
+ }
2239
2248
  hasActiveTenant() {
2240
2249
  return this.activeTenant.length > 0;
2241
2250
  }
@@ -8752,16 +8761,43 @@ function registerTenantTool(server, api, name, config, cb) {
8752
8761
  ...config,
8753
8762
  inputSchema
8754
8763
  }, async (args, extra) => {
8764
+ const started = Date.now();
8755
8765
  const { practice, ...rest } = args;
8756
8766
  const resolution = await resolveTenantForCall(api, typeof practice === "string" ? practice : void 0);
8757
- if (!resolution.ok) return {
8758
- content: [{
8759
- type: "text",
8760
- text: resolution.message
8761
- }],
8762
- isError: true
8763
- };
8764
- return cb(rest, extra);
8767
+ if (!resolution.ok) {
8768
+ api.notifyToolCall({
8769
+ tool: name,
8770
+ practice: "",
8771
+ durationMs: Date.now() - started,
8772
+ outcome: "refused"
8773
+ });
8774
+ return {
8775
+ content: [{
8776
+ type: "text",
8777
+ text: resolution.message
8778
+ }],
8779
+ isError: true
8780
+ };
8781
+ }
8782
+ try {
8783
+ const result = await cb(rest, extra);
8784
+ const isError = typeof result === "object" && result !== null && result.isError === true;
8785
+ api.notifyToolCall({
8786
+ tool: name,
8787
+ practice: api.getActiveTenant(),
8788
+ durationMs: Date.now() - started,
8789
+ outcome: isError ? "error" : "ok"
8790
+ });
8791
+ return result;
8792
+ } catch (error) {
8793
+ api.notifyToolCall({
8794
+ tool: name,
8795
+ practice: api.getActiveTenant(),
8796
+ durationMs: Date.now() - started,
8797
+ outcome: "error"
8798
+ });
8799
+ throw error;
8800
+ }
8765
8801
  });
8766
8802
  }
8767
8803
  function registerWriteTool(server, api, ctx, name, config, cb) {
@@ -8770,6 +8806,7 @@ function registerWriteTool(server, api, ctx, name, config, cb) {
8770
8806
  }
8771
8807
  async function buildServer(config) {
8772
8808
  const api = new SodiumApiClient(config.context, { serverVersion: config.serverVersion });
8809
+ if (config.onToolCall) api.setToolCallListener(config.onToolCall);
8773
8810
  let instructions;
8774
8811
  if (config.includeStartupContext !== false) {
8775
8812
  const selection = api.hasActiveTenant() ? { kind: "explicit" } : await resolveTenant(api);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sodiumhq/mcp-pm",
3
- "version": "0.1.0-beta.4448",
3
+ "version": "0.1.0-beta.4465",
4
4
  "description": "Sodium Practice Management MCP server — lets AI assistants interact with your Sodium tenant",
5
5
  "type": "module",
6
6
  "bin": {