@sodiumhq/mcp-pm 0.1.0-beta.4241 → 0.1.0-beta.4441
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 -1
- package/dist/http.js +292 -0
- package/dist/index.js +4 -9187
- package/dist/src-CTHTAM7g.mjs +9308 -0
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -161,7 +161,7 @@ Only enable write mode with an AI client you trust — it hands the client the a
|
|
|
161
161
|
- **`add_task_note`** — capture a note against a specific task. Attributed to your API user, timestamped to now.
|
|
162
162
|
- **`add_client_note`** — capture a note against a client record. Same shape as `add_task_note` but scoped to the client.
|
|
163
163
|
- **`update_client_custom_fields`** — set or clear custom field values on a client. Accepts a map of field codes to values, validates against the field's data type (Text, Number, Date, Boolean, Select, MultiSelect). Pass null to clear a field. Field codes are discoverable via `get_client_summary`.
|
|
164
|
-
- **`update_contact`** — update a contact's details: name, email, phone, mobile, date of birth, UTR, NI number, address. Requires the contact code (discoverable via `list_contacts`).
|
|
164
|
+
- **`update_contact`** — update a contact's details: name, preferred name (used in email greetings), email, phone, mobile, date of birth, UTR, NI number, address. Requires the contact code (discoverable via `list_contacts`).
|
|
165
165
|
|
|
166
166
|
More tools land iteratively as the beta progresses.
|
|
167
167
|
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { t as buildServer } from "./src-CTHTAM7g.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
|
+
if (!tenant) throw new HttpError(400, "x-sodium-tenant header is required (or set SODIUM_TENANT).");
|
|
199
|
+
const server = await buildServer({
|
|
200
|
+
context: {
|
|
201
|
+
bearerToken: apiToken,
|
|
202
|
+
tenant,
|
|
203
|
+
baseUrl: BASE_URL,
|
|
204
|
+
writesEnabled: writesAllowed
|
|
205
|
+
},
|
|
206
|
+
serverName: "Sodium Practice Management",
|
|
207
|
+
serverVersion: VERSION
|
|
208
|
+
});
|
|
209
|
+
const transport = new StreamableHTTPServerTransport({
|
|
210
|
+
sessionIdGenerator: () => randomUUID(),
|
|
211
|
+
onsessioninitialized: (sessionId) => {
|
|
212
|
+
sessions.set(sessionId, {
|
|
213
|
+
transport,
|
|
214
|
+
lastActivity: Date.now()
|
|
215
|
+
});
|
|
216
|
+
console.error(`[sodium-pm-mcp-http] session ${sessionId} started (tenant: ${tenant}, live: ${sessions.size})`);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
transport.onclose = () => {
|
|
220
|
+
if (transport.sessionId) {
|
|
221
|
+
sessions.delete(transport.sessionId);
|
|
222
|
+
console.error(`[sodium-pm-mcp-http] session ${transport.sessionId} closed`);
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
await server.connect(transport);
|
|
226
|
+
return transport;
|
|
227
|
+
}
|
|
228
|
+
var HttpError = class extends Error {
|
|
229
|
+
constructor(status, message) {
|
|
230
|
+
super(message);
|
|
231
|
+
this.status = status;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
async function handle(req, res) {
|
|
235
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
236
|
+
if (req.method === "GET" && url.pathname === "/health") {
|
|
237
|
+
res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({
|
|
238
|
+
status: "ok",
|
|
239
|
+
version: VERSION,
|
|
240
|
+
sessions: sessions.size
|
|
241
|
+
}));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (AUTH && req.method === "GET" && url.pathname.startsWith("/.well-known/oauth-protected-resource")) {
|
|
245
|
+
res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify(protectedResourceMetadata(AUTH)));
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (url.pathname !== ENDPOINT_PATH) {
|
|
249
|
+
sendJsonError(res, 404, `Not found. MCP endpoint is ${ENDPOINT_PATH}.`);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
253
|
+
const existing = typeof sessionId === "string" ? sessions.get(sessionId) : void 0;
|
|
254
|
+
if (existing && typeof sessionId === "string") {
|
|
255
|
+
touch(sessionId);
|
|
256
|
+
await existing.transport.handleRequest(req, res);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (req.method !== "POST") {
|
|
260
|
+
sendJsonError(res, 400, "No valid MCP session. Send an initialize request first.");
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const body = await readJsonBody(req);
|
|
264
|
+
if (!isInitializeRequest(body)) {
|
|
265
|
+
sendJsonError(res, 400, "No valid MCP session. Send an initialize request first.");
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
await (await createSession(req)).handleRequest(req, res, body);
|
|
269
|
+
}
|
|
270
|
+
const httpServer = createServer((req, res) => {
|
|
271
|
+
handle(req, res).catch((error) => {
|
|
272
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
273
|
+
console.error(`[sodium-pm-mcp-http] ${req.method} ${req.url} failed: ${message}`);
|
|
274
|
+
if (res.headersSent) return;
|
|
275
|
+
if (AUTH && error instanceof AuthError) {
|
|
276
|
+
const scope = error.code === "insufficient_scope" ? AUTH.scopes.write : void 0;
|
|
277
|
+
res.setHeader("WWW-Authenticate", buildChallenge(AUTH, scope));
|
|
278
|
+
sendJsonError(res, error.status, message);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (AUTH && error instanceof HttpError && error.status === 401) res.setHeader("WWW-Authenticate", buildChallenge(AUTH));
|
|
282
|
+
sendJsonError(res, error instanceof HttpError ? error.status : 500, message);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
setInterval(() => {
|
|
286
|
+
sweepIdleSessions();
|
|
287
|
+
}, SWEEP_INTERVAL_MS).unref();
|
|
288
|
+
httpServer.listen(PORT, HOST, () => {
|
|
289
|
+
console.error(`[sodium-pm-mcp-http] v${VERSION} listening on http://${HOST}:${PORT}${ENDPOINT_PATH} (api: ${BASE_URL}, writes: ${WRITES_ENABLED ? "enabled" : "disabled"})`);
|
|
290
|
+
});
|
|
291
|
+
//#endregion
|
|
292
|
+
export {};
|