@sodiumhq/mcp-pm 0.1.0-beta.2603 → 0.1.0-beta.2619

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.
Files changed (3) hide show
  1. package/README.md +12 -1
  2. package/dist/index.js +178 -5
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -48,6 +48,13 @@ Then ask: *"give me a summary of my practice"*.
48
48
  | `SODIUM_API_KEY` | yes | — | Your Sodium API key |
49
49
  | `SODIUM_TENANT` | yes | — | Your tenant code |
50
50
  | `SODIUM_API_URL` | no | `https://api.sodiumhq.com` | Override for staging/dev |
51
+ | `SODIUM_ENABLE_WRITES` | no | `false` | Set to `true` or `1` to allow write tools (equivalent to `--enable-writes`) |
52
+
53
+ ## Write mode
54
+
55
+ Write tools are **off by default** — the server exposes only read tools unless you opt in. Enable writes by adding `--enable-writes` to `args`, or by setting `SODIUM_ENABLE_WRITES=true` (or `1`) in `env`. Destructive and bulk operations (delete, batch) stay blocked either way.
56
+
57
+ Only enable write mode with an AI client you trust — it hands the client the ability to modify data under your API key. To check whether writes are on, ask your AI assistant *"can you make changes to my Sodium data?"* — it will tell you.
51
58
 
52
59
  ## What it can do today
53
60
 
@@ -73,12 +80,16 @@ Then ask: *"give me a summary of my practice"*.
73
80
  **Team**
74
81
  - **`list_users`** — find team members by name, email, role, or status — supports "who is Jane?", "list all partners", "who has been invited but not joined?"
75
82
 
83
+ **Write tools** (require `--enable-writes` — see [Write mode](#write-mode))
84
+ - **`add_task_note`** — capture a note against a specific task. Attributed to your API user, timestamped to now.
85
+ - **`add_client_note`** — capture a note against a client record. Same shape as `add_task_note` but scoped to the client.
86
+
76
87
  More tools land iteratively as the beta progresses.
77
88
 
78
89
  ## Requirements
79
90
 
80
91
  - Node.js 20 or later
81
- - An active Sodium Practice Management subscription at the Pro tier
92
+ - An active Sodium Practice Management subscription
82
93
  - API key and tenant code from your Sodium account
83
94
 
84
95
  ## Licence
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { z } from "zod";
7
+ import { parseArgs } from "node:util";
7
8
  //#region ../mcp-core/src/generated/core/bodySerializer.gen.ts
8
9
  const jsonBodySerializer = { bodySerializer: (body) => JSON.stringify(body, (_key, value) => typeof value === "bigint" ? value.toString() : value) };
9
10
  Object.entries({
@@ -669,6 +670,26 @@ const getClientDates = (options) => (options.client ?? client).get({
669
670
  ...options
670
671
  });
671
672
  /**
673
+ * Create Note for Client
674
+ *
675
+ * Creates a new Note for the specified client.
676
+ */
677
+ const createClientNoteForClient = (options) => (options.client ?? client).post({
678
+ security: [{
679
+ name: "x-api-key",
680
+ type: "apiKey"
681
+ }, {
682
+ scheme: "bearer",
683
+ type: "http"
684
+ }],
685
+ url: "/tenants/{tenant}/clients/{client}/clientnote",
686
+ ...options,
687
+ headers: {
688
+ "Content-Type": "application/json",
689
+ ...options.headers
690
+ }
691
+ });
692
+ /**
672
693
  * List Client Services for Client
673
694
  *
674
695
  * Lists all Client Services for the specified client.
@@ -835,6 +856,26 @@ const listTaskItemNotes = (options) => (options.client ?? client).get({
835
856
  ...options
836
857
  });
837
858
  /**
859
+ * Create Note
860
+ *
861
+ * Creates a new note for the specified task.
862
+ */
863
+ const createTaskItemNote = (options) => (options.client ?? client).post({
864
+ security: [{
865
+ name: "x-api-key",
866
+ type: "apiKey"
867
+ }, {
868
+ scheme: "bearer",
869
+ type: "http"
870
+ }],
871
+ url: "/tenants/{tenant}/tasks/{taskCode}/taskitemnote",
872
+ ...options,
873
+ headers: {
874
+ "Content-Type": "application/json",
875
+ ...options.headers
876
+ }
877
+ });
878
+ /**
838
879
  * Get Task Workflow Groups
839
880
  *
840
881
  * Retrieves comprehensive workflow progress information for a TaskItem.
@@ -1250,6 +1291,32 @@ var SodiumApiClient = class {
1250
1291
  if (error !== void 0 || !data) throw this.toError(response, error, correlationId, `get workflow groups for task ${taskCode}`);
1251
1292
  return data;
1252
1293
  }
1294
+ async createTaskNote(taskCode, body) {
1295
+ const correlationId = randomUUID();
1296
+ const { data, error, response } = await createTaskItemNote({
1297
+ path: {
1298
+ tenant: this.ctx.tenant,
1299
+ taskCode
1300
+ },
1301
+ body,
1302
+ headers: { "X-Correlation-Id": correlationId }
1303
+ });
1304
+ if (error !== void 0 || !data) throw this.toError(response, error, correlationId, `add note to task ${taskCode}`);
1305
+ return data;
1306
+ }
1307
+ async createClientNote(clientCode, body) {
1308
+ const correlationId = randomUUID();
1309
+ const { data, error, response } = await createClientNoteForClient({
1310
+ path: {
1311
+ tenant: this.ctx.tenant,
1312
+ client: clientCode
1313
+ },
1314
+ body,
1315
+ headers: { "X-Correlation-Id": correlationId }
1316
+ });
1317
+ if (error !== void 0 || !data) throw this.toError(response, error, correlationId, `add note to client ${clientCode}`);
1318
+ return data;
1319
+ }
1253
1320
  toError(response, error, correlationId, operation) {
1254
1321
  const status = response.status;
1255
1322
  let message = `Failed to ${operation} (HTTP ${status})`;
@@ -1261,7 +1328,7 @@ var SodiumApiClient = class {
1261
1328
  //#endregion
1262
1329
  //#region ../mcp-core/src/context/instructions.ts
1263
1330
  const ROSTER_CAP = 20;
1264
- async function buildInstructions(api) {
1331
+ async function buildInstructions(api, writesEnabled) {
1265
1332
  const [user, tenant, practice, team] = await Promise.allSettled([
1266
1333
  api.getCurrentUser(),
1267
1334
  api.getTenantDetails(),
@@ -1273,6 +1340,14 @@ async function buildInstructions(api) {
1273
1340
  })
1274
1341
  ]);
1275
1342
  const now = /* @__PURE__ */ new Date();
1343
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
1344
+ const parts = Object.fromEntries(new Intl.DateTimeFormat("en-GB", {
1345
+ timeZone: tz,
1346
+ year: "numeric",
1347
+ month: "2-digit",
1348
+ day: "2-digit",
1349
+ weekday: "long"
1350
+ }).formatToParts(now).map((p) => [p.type, p.value]));
1276
1351
  const lines = [
1277
1352
  "You are assisting a user of Sodium Practice Management — software used by accountancy practices to run their business.",
1278
1353
  "",
@@ -1286,7 +1361,7 @@ async function buildInstructions(api) {
1286
1361
  "- For prompts like 'brief me on my call with X' or 'summarise X', produce a practice-side view: what services the practice delivers to X, what work is outstanding, what the user should raise or action with the client.",
1287
1362
  "- Never frame output as if the named entity is the one being briefed — it is the practice (the user) being briefed ABOUT the client.",
1288
1363
  "",
1289
- `Today: ${now.toISOString().slice(0, 10)} (${now.toLocaleDateString("en-GB", { weekday: "long" })})`,
1364
+ `Today: ${`${parts.year}-${parts.month}-${parts.day}`} (${parts.weekday}, ${tz})`,
1290
1365
  `Current UTC time: ${now.toISOString()}`
1291
1366
  ];
1292
1367
  if (user.status === "fulfilled") {
@@ -1295,6 +1370,7 @@ async function buildInstructions(api) {
1295
1370
  }
1296
1371
  if (tenant.status === "fulfilled") lines.push(`Tenant: ${tenant.value.name} (${tenant.value.code})`);
1297
1372
  if (practice.status === "fulfilled") lines.push(`Practice: ${practice.value.name}`);
1373
+ lines.push("", writesEnabled ? "Write mode: ENABLED. Create/update tools are available; destructive and bulk operations are not." : "Write mode: DISABLED. Read-only — tell the user to relaunch with --enable-writes if they want changes made.");
1298
1374
  if (team.status === "fulfilled") {
1299
1375
  const members = team.value.data ?? [];
1300
1376
  const total = team.value.totalCount ?? members.length;
@@ -2351,10 +2427,74 @@ function describeFilters(args) {
2351
2427
  return parts.join(", ");
2352
2428
  }
2353
2429
  //#endregion
2430
+ //#region ../mcp-core/src/tools/add-client-note.ts
2431
+ const AddClientNoteInputSchema = {
2432
+ clientCode: z.string().min(1, "Client code is required").describe("The client code (identifier) to attach the note to. Usually discovered via list_clients or get_client_summary."),
2433
+ text: z.string().min(1, "Note text cannot be empty").describe("The note body. Keep it concise and factual — something the user can scan later. The user can edit or delete the note in the UI if the wording isn't right."),
2434
+ pinnedLevel: z.number().int().min(0).max(2).optional().describe("Pin level for the note. 0 = not pinned (default), 1 = pinned to the notes section (surfaces at the top of the client's notes list), 2 = pinned to the client page (surfaces on the main client record, most prominent). Only set above 0 if the user explicitly asks for the note to be pinned; use 2 when they say 'pin to the client' / 'client-level pin' / 'pin at the top of the client page', and 1 when they just say 'pin' or 'pin in notes'.")
2435
+ };
2436
+ async function handleAddClientNote(api, args) {
2437
+ try {
2438
+ const user = await api.getCurrentUser();
2439
+ const note = await api.createClientNote(args.clientCode, {
2440
+ text: args.text,
2441
+ date: (/* @__PURE__ */ new Date()).toISOString(),
2442
+ noteFromUserCode: user.code ?? "",
2443
+ pinnedLevel: args.pinnedLevel ?? 0
2444
+ });
2445
+ return { content: [{
2446
+ type: "text",
2447
+ text: `Added note to client ${args.clientCode} (note code: ${note.code ?? "(no code)"}).`
2448
+ }] };
2449
+ } catch (error) {
2450
+ return {
2451
+ content: [{
2452
+ type: "text",
2453
+ text: error instanceof SodiumApiError ? `Error adding client note: ${error.message} (correlation: ${error.correlationId})` : `Error adding client note: ${error instanceof Error ? error.message : String(error)}`
2454
+ }],
2455
+ isError: true
2456
+ };
2457
+ }
2458
+ }
2459
+ //#endregion
2460
+ //#region ../mcp-core/src/tools/add-task-note.ts
2461
+ const AddTaskNoteInputSchema = {
2462
+ taskCode: z.string().min(1, "Task code is required").describe("The task code (identifier) to attach the note to. Usually discovered via list_tasks or get_task_context."),
2463
+ text: z.string().min(1, "Note text cannot be empty").describe("The note body. Keep it concise and factual — something the user can scan later. The user can edit or delete the note in the UI if the wording isn't right."),
2464
+ pinnedLevel: z.number().int().min(0).max(2).optional().describe("Pin level for the note. 0 = not pinned (default), 1 = pinned to the notes section (surfaces at the top of the task's notes list), 2 = pinned to the task page (surfaces on the main task record, most prominent). Only set above 0 if the user explicitly asks for the note to be pinned; use 2 when they say 'pin to the task' / 'task-level pin' / 'pin at the top of the task page', and 1 when they just say 'pin' or 'pin in notes'.")
2465
+ };
2466
+ async function handleAddTaskNote(api, args) {
2467
+ try {
2468
+ const user = await api.getCurrentUser();
2469
+ const note = await api.createTaskNote(args.taskCode, {
2470
+ text: args.text,
2471
+ date: (/* @__PURE__ */ new Date()).toISOString(),
2472
+ noteFromUserCode: user.code ?? "",
2473
+ pinnedLevel: args.pinnedLevel ?? 0
2474
+ });
2475
+ return { content: [{
2476
+ type: "text",
2477
+ text: `Added note to task ${args.taskCode} (note code: ${note.code ?? "(no code)"}).`
2478
+ }] };
2479
+ } catch (error) {
2480
+ return {
2481
+ content: [{
2482
+ type: "text",
2483
+ text: error instanceof SodiumApiError ? `Error adding task note: ${error.message} (correlation: ${error.correlationId})` : `Error adding task note: ${error instanceof Error ? error.message : String(error)}`
2484
+ }],
2485
+ isError: true
2486
+ };
2487
+ }
2488
+ }
2489
+ //#endregion
2354
2490
  //#region ../mcp-core/src/server.ts
2491
+ function registerWriteTool(server, ctx, name, config, cb) {
2492
+ if (!ctx.writesEnabled) return;
2493
+ server.registerTool(name, config, cb);
2494
+ }
2355
2495
  async function buildServer(config) {
2356
2496
  const api = new SodiumApiClient(config.context, { serverVersion: config.serverVersion });
2357
- const instructions = await buildInstructions(api);
2497
+ const instructions = await buildInstructions(api, config.context.writesEnabled);
2358
2498
  const server = new McpServer({
2359
2499
  name: config.serverName,
2360
2500
  version: config.serverVersion
@@ -2489,6 +2629,28 @@ async function buildServer(config) {
2489
2629
  openWorldHint: true
2490
2630
  }
2491
2631
  }, (args) => handleListUsers(api, args));
2632
+ registerWriteTool(server, config.context, "add_task_note", {
2633
+ title: "Add a note to a task",
2634
+ description: "Create a new note on a task. Additive — does not modify or delete existing notes. The note is attributed to the authenticated API user (the current practice member) and timestamped to 'now'. Use this when the user asks you to capture something on a task: 'add a note on the Greggs year-end task that we're waiting on the rental schedule', 'log on the task that I called John today and got voicemail'. Notes can be pinned; only pin when the user explicitly asks for it. The user can always edit or delete notes in the Sodium UI if the wording isn't right.",
2635
+ inputSchema: AddTaskNoteInputSchema,
2636
+ annotations: {
2637
+ readOnlyHint: false,
2638
+ destructiveHint: false,
2639
+ idempotentHint: false,
2640
+ openWorldHint: true
2641
+ }
2642
+ }, (args) => handleAddTaskNote(api, args));
2643
+ registerWriteTool(server, config.context, "add_client_note", {
2644
+ title: "Add a note to a client",
2645
+ description: "Create a new note on a client. Additive — does not modify or delete existing notes. The note is attributed to the authenticated API user and timestamped to 'now'. Use this when the user asks you to capture something on a client record: 'add a note on ACME that they mentioned expanding into Ireland', 'log on Greggs that they're switching bookkeeping software next quarter'. Client notes are the right place for persistent, client-level context; for task-specific notes use add_task_note. The user can edit or delete notes in the Sodium UI.",
2646
+ inputSchema: AddClientNoteInputSchema,
2647
+ annotations: {
2648
+ readOnlyHint: false,
2649
+ destructiveHint: false,
2650
+ idempotentHint: false,
2651
+ openWorldHint: true
2652
+ }
2653
+ }, (args) => handleAddClientNote(api, args));
2492
2654
  return server;
2493
2655
  }
2494
2656
  //#endregion
@@ -2499,10 +2661,21 @@ function loadContext() {
2499
2661
  const baseUrl = process.env.SODIUM_API_URL ?? "https://api.sodiumhq.com";
2500
2662
  if (!apiKey) throw new Error("SODIUM_API_KEY environment variable is required. Generate one in Sodium → Settings → API Keys.");
2501
2663
  if (!tenant) throw new Error("SODIUM_TENANT environment variable is required. Find your tenant code in Sodium → Settings → Practice.");
2664
+ const { values } = parseArgs({
2665
+ args: process.argv.slice(2),
2666
+ options: { "enable-writes": {
2667
+ type: "boolean",
2668
+ default: false
2669
+ } },
2670
+ strict: false
2671
+ });
2672
+ const cliWrites = values["enable-writes"] === true;
2673
+ const envRaw = (process.env.SODIUM_ENABLE_WRITES ?? "").trim().toLowerCase();
2502
2674
  return {
2503
2675
  apiKey,
2504
2676
  tenant,
2505
- baseUrl
2677
+ baseUrl,
2678
+ writesEnabled: cliWrites || envRaw === "true" || envRaw === "1"
2506
2679
  };
2507
2680
  }
2508
2681
  //#endregion
@@ -2517,7 +2690,7 @@ async function main() {
2517
2690
  });
2518
2691
  const transport = new StdioServerTransport();
2519
2692
  await server.connect(transport);
2520
- console.error(`[sodium-pm-mcp] v${VERSION} ready (tenant: ${context.tenant})`);
2693
+ console.error(`[sodium-pm-mcp] v${VERSION} ready (tenant: ${context.tenant}, writes: ${context.writesEnabled ? "enabled" : "disabled"})`);
2521
2694
  }
2522
2695
  main().catch((error) => {
2523
2696
  const message = error instanceof Error ? error.message : String(error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sodiumhq/mcp-pm",
3
- "version": "0.1.0-beta.2603",
3
+ "version": "0.1.0-beta.2619",
4
4
  "description": "Sodium Practice Management MCP server — lets AI assistants interact with your Sodium tenant",
5
5
  "type": "module",
6
6
  "bin": {