@sodiumhq/mcp-pm 0.1.0-beta.4424 → 0.1.0-beta.4445

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,6 +62,8 @@ 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
67
 
66
68
  **Clients**
67
69
  - **`list_clients`** — list and filter clients by search, status, type, assignee, services, saved filters
package/dist/http.js ADDED
@@ -0,0 +1,291 @@
1
+ #!/usr/bin/env node
2
+ import { t as buildServer } from "./src-BRf2g74U.mjs";
3
+ import { createRequire } from "node:module";
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import { createServer } from "node:http";
6
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
7
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
8
+ import { createRemoteJWKSet, decodeJwt, jwtVerify } from "jose";
9
+ //#region src/obo.ts
10
+ const EXPIRY_MARGIN_MS = 6e4;
11
+ const cache = /* @__PURE__ */ new Map();
12
+ function loadOboConfig(issuer) {
13
+ const clientId = process.env.AUTH0_OBO_CLIENT_ID;
14
+ const clientSecret = process.env.AUTH0_OBO_CLIENT_SECRET;
15
+ if (!clientId || !clientSecret) return null;
16
+ return {
17
+ tokenEndpoint: `${issuer}oauth/token`,
18
+ clientId,
19
+ clientSecret,
20
+ targetAudience: process.env.SODIUM_API_AUDIENCE ?? "https://api.sodiumhq.com"
21
+ };
22
+ }
23
+ function cacheKey(subjectToken, audience) {
24
+ return createHash("sha256").update(`${audience}:${subjectToken}`).digest("hex");
25
+ }
26
+ async function getDownstreamToken(config, subjectToken) {
27
+ const key = cacheKey(subjectToken, config.targetAudience);
28
+ const cached = cache.get(key);
29
+ if (cached && cached.expiresAt > Date.now()) return cached.accessToken;
30
+ const body = new URLSearchParams({
31
+ grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
32
+ subject_token: subjectToken,
33
+ subject_token_type: "urn:ietf:params:oauth:token-type:access_token",
34
+ requested_token_type: "urn:ietf:params:oauth:token-type:access_token",
35
+ audience: config.targetAudience,
36
+ client_id: config.clientId,
37
+ client_secret: config.clientSecret
38
+ });
39
+ const response = await fetch(config.tokenEndpoint, {
40
+ method: "POST",
41
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
42
+ body
43
+ });
44
+ if (!response.ok) {
45
+ const detail = await response.text();
46
+ throw new Error(`On-behalf-of token exchange failed (HTTP ${response.status}): ${detail.slice(0, 300)}`);
47
+ }
48
+ const payload = await response.json();
49
+ if (!payload.access_token) throw new Error("On-behalf-of token exchange returned no access_token.");
50
+ cache.set(key, {
51
+ accessToken: payload.access_token,
52
+ expiresAt: resolveExpiry(payload.access_token, payload.expires_in)
53
+ });
54
+ return payload.access_token;
55
+ }
56
+ function resolveExpiry(accessToken, expiresIn) {
57
+ try {
58
+ const exp = decodeJwt(accessToken).exp;
59
+ if (exp) return exp * 1e3 - EXPIRY_MARGIN_MS;
60
+ } catch {}
61
+ const seconds = expiresIn ?? 300;
62
+ return Date.now() + seconds * 1e3 - EXPIRY_MARGIN_MS;
63
+ }
64
+ //#endregion
65
+ //#region src/auth.ts
66
+ var AuthError = class extends Error {
67
+ constructor(status, code, message) {
68
+ super(message);
69
+ this.status = status;
70
+ this.code = code;
71
+ }
72
+ };
73
+ function loadAuthConfig() {
74
+ const resourceUrl = process.env.MCP_RESOURCE_URL;
75
+ const issuer = process.env.AUTH0_ISSUER;
76
+ if (!resourceUrl || !issuer) return null;
77
+ return {
78
+ resourceUrl: resourceUrl.replace(/\/+$/, ""),
79
+ issuer: issuer.endsWith("/") ? issuer : `${issuer}/`,
80
+ scopes: {
81
+ read: process.env.MCP_SCOPE_READ ?? "mcp:read",
82
+ write: process.env.MCP_SCOPE_WRITE ?? "mcp:write"
83
+ }
84
+ };
85
+ }
86
+ function protectedResourceMetadata(config) {
87
+ return {
88
+ resource: config.resourceUrl,
89
+ authorization_servers: [config.issuer],
90
+ scopes_supported: [config.scopes.read, config.scopes.write],
91
+ bearer_methods_supported: ["header"]
92
+ };
93
+ }
94
+ function metadataUrl(config) {
95
+ const url = new URL(config.resourceUrl);
96
+ const suffix = url.pathname === "/" ? "" : url.pathname;
97
+ return `${url.origin}/.well-known/oauth-protected-resource${suffix}`;
98
+ }
99
+ function buildChallenge(config, scope) {
100
+ const parts = [`Bearer resource_metadata="${metadataUrl(config)}"`];
101
+ if (scope) parts.push(`scope="${scope}"`);
102
+ return parts.join(", ");
103
+ }
104
+ const jwksCache = /* @__PURE__ */ new Map();
105
+ function getJwks(issuer) {
106
+ let jwks = jwksCache.get(issuer);
107
+ if (!jwks) {
108
+ jwks = createRemoteJWKSet(new URL(`${issuer}.well-known/jwks.json`));
109
+ jwksCache.set(issuer, jwks);
110
+ }
111
+ return jwks;
112
+ }
113
+ async function verifyAccessToken(config, token) {
114
+ let payload;
115
+ try {
116
+ payload = (await jwtVerify(token, getJwks(config.issuer), {
117
+ issuer: config.issuer,
118
+ audience: config.resourceUrl
119
+ })).payload;
120
+ } catch (error) {
121
+ throw new AuthError(401, "invalid_token", `Access token is not valid: ${error instanceof Error ? error.message : String(error)}`);
122
+ }
123
+ if (!payload.sub) throw new AuthError(401, "invalid_token", "Access token has no subject claim.");
124
+ const scopes = (typeof payload.scope === "string" ? payload.scope : "").split(" ").filter(Boolean);
125
+ if (!scopes.includes(config.scopes.read)) throw new AuthError(403, "insufficient_scope", `Access token is missing the ${config.scopes.read} scope.`);
126
+ return {
127
+ token,
128
+ subject: payload.sub,
129
+ scopes
130
+ };
131
+ }
132
+ //#endregion
133
+ //#region src/http.ts
134
+ const VERSION = createRequire(import.meta.url)("../package.json").version;
135
+ const BASE_URL = process.env.SODIUM_API_URL ?? "https://api.sodiumhq.com";
136
+ const PORT = Number(process.env.PORT ?? 3960);
137
+ const HOST = process.env.HOST ?? "127.0.0.1";
138
+ const writesRaw = (process.env.SODIUM_ENABLE_WRITES ?? "").trim().toLowerCase();
139
+ const WRITES_ENABLED = writesRaw === "true" || writesRaw === "1";
140
+ const AUTH = loadAuthConfig();
141
+ const OBO = AUTH ? loadOboConfig(AUTH.issuer) : null;
142
+ 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)}`);
160
+ }
161
+ }
162
+ }
163
+ const MAX_BODY_BYTES = 4 * 1024 * 1024;
164
+ async function readJsonBody(req) {
165
+ const chunks = [];
166
+ let total = 0;
167
+ for await (const chunk of req) {
168
+ total += chunk.length;
169
+ if (total > MAX_BODY_BYTES) throw new Error("Request body too large");
170
+ chunks.push(chunk);
171
+ }
172
+ const raw = Buffer.concat(chunks).toString("utf8");
173
+ return raw.length > 0 ? JSON.parse(raw) : void 0;
174
+ }
175
+ function sendJsonError(res, status, message) {
176
+ res.writeHead(status, { "Content-Type": "application/json" }).end(JSON.stringify({
177
+ jsonrpc: "2.0",
178
+ error: {
179
+ code: -32e3,
180
+ message
181
+ },
182
+ id: null
183
+ }));
184
+ }
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
+ }
223
+ };
224
+ await server.connect(transport);
225
+ return transport;
226
+ }
227
+ var HttpError = class extends Error {
228
+ constructor(status, message) {
229
+ super(message);
230
+ this.status = status;
231
+ }
232
+ };
233
+ async function handle(req, res) {
234
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
235
+ if (req.method === "GET" && url.pathname === "/health") {
236
+ res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({
237
+ status: "ok",
238
+ version: VERSION,
239
+ sessions: sessions.size
240
+ }));
241
+ return;
242
+ }
243
+ if (AUTH && req.method === "GET" && url.pathname.startsWith("/.well-known/oauth-protected-resource")) {
244
+ res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify(protectedResourceMetadata(AUTH)));
245
+ return;
246
+ }
247
+ if (url.pathname !== ENDPOINT_PATH) {
248
+ sendJsonError(res, 404, `Not found. MCP endpoint is ${ENDPOINT_PATH}.`);
249
+ return;
250
+ }
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
+ 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.");
265
+ return;
266
+ }
267
+ await (await createSession(req)).handleRequest(req, res, body);
268
+ }
269
+ const httpServer = createServer((req, res) => {
270
+ handle(req, res).catch((error) => {
271
+ const message = error instanceof Error ? error.message : String(error);
272
+ console.error(`[sodium-pm-mcp-http] ${req.method} ${req.url} failed: ${message}`);
273
+ if (res.headersSent) return;
274
+ if (AUTH && error instanceof AuthError) {
275
+ const scope = error.code === "insufficient_scope" ? AUTH.scopes.write : void 0;
276
+ res.setHeader("WWW-Authenticate", buildChallenge(AUTH, scope));
277
+ sendJsonError(res, error.status, message);
278
+ return;
279
+ }
280
+ if (AUTH && error instanceof HttpError && error.status === 401) res.setHeader("WWW-Authenticate", buildChallenge(AUTH));
281
+ sendJsonError(res, error instanceof HttpError ? error.status : 500, message);
282
+ });
283
+ });
284
+ setInterval(() => {
285
+ sweepIdleSessions();
286
+ }, SWEEP_INTERVAL_MS).unref();
287
+ httpServer.listen(PORT, HOST, () => {
288
+ console.error(`[sodium-pm-mcp-http] v${VERSION} listening on http://${HOST}:${PORT}${ENDPOINT_PATH} (api: ${BASE_URL}, writes: ${WRITES_ENABLED ? "enabled" : "disabled"})`);
289
+ });
290
+ //#endregion
291
+ export {};