@thalovant/mcp-server 0.1.10 → 0.1.12

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/.env.example CHANGED
@@ -54,6 +54,22 @@ MCP_HTTP_AUTH_TOKEN=
54
54
  # deployment where a model drives the tools with a long-lived token.
55
55
  # THALOVANT_ENABLE_DESTRUCTIVE_TOOLS=false
56
56
 
57
+ # Non-catalog skill sources (e.g. thalovant_install_runtime_group_skill with
58
+ # sourceType "git" and an arbitrary sourceRef) can pull unvetted code into a
59
+ # production runtime. They are refused unless this is explicitly enabled. Leave
60
+ # it unset whenever a model drives the tools with a long-lived token.
61
+ # THALOVANT_ENABLE_GIT_SKILL_SOURCES=false
62
+
63
+ # Directory that thalovant_create_client_identity may write identity files into.
64
+ # savePath is confined to this directory; absolute paths outside it and ".."
65
+ # traversal are rejected. Defaults to <config-dir>/thalovant/identities.
66
+ # THALOVANT_MCP_IDENTITY_DIR=/var/lib/thalovant-mcp/identities
67
+
68
+ # Read-only mode: register ONLY tools annotated readOnlyHint:true, so a model
69
+ # cannot mutate control-plane or runtime state. Safer default for observe-only
70
+ # agents than hand-writing a denylist.
71
+ # THALOVANT_MCP_READONLY=false
72
+
57
73
  # Optional policy and audit controls.
58
74
  # MCP_TOOL_ALLOWLIST=thalovant_*
59
75
  # MCP_TOOL_DENYLIST=thalovant_delete_memory_item
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ### Security
6
+
7
+ - `thalovant_install_runtime_group_skill` now refuses non-catalog install sources by default. A `sourceType` other than `catalog` (notably `git` with an arbitrary `sourceRef`) can pull unvetted code into a production runtime, and the control-plane validator is format-only with no host allowlist, so it is rejected before any control-plane request unless the operator sets `THALOVANT_ENABLE_GIT_SKILL_SOURCES` (accepting `1`/`true`/`yes`/`on`), mirroring the `THALOVANT_ENABLE_DESTRUCTIVE_TOOLS` gate. `sourceType` is trimmed and case-folded before it is both checked and forwarded, so a variant such as `" Catalog "` or `"CATALOG"` cannot pass the local gate yet reach the control plane — which special-cases only the exact string `"catalog"` — as an unrecognized non-catalog source. The tool is now annotated `destructiveHint: true`, and `thalovant_config_status` reports `gitSkillSourcesEnabled`.
8
+ - `thalovant_create_client_identity` now confines the optional `savePath` to a configurable identity directory (`THALOVANT_MCP_IDENTITY_DIR`, default `<config-dir>/thalovant/identities`). Absolute paths outside the directory and `..` traversal are rejected, and the destination is validated before the identity is created in the control plane, so a model can no longer drop a `0600` credential file into a git working tree or synced folder. `thalovant_config_status` reports `identityDir`.
9
+ - `thalovant_healthcheck` output is now passed through `redactSecrets`, matching every other tool output.
10
+ - `thalovant_get_analytics_overview` no longer advertises the `admin` and (admin-only) `ownerId` arguments to the model, so it no longer teaches an admin mode that only ever 403s for non-admin callers; only the plain overview call remains. Injected `admin`/`ownerId` values are stripped and never reach the control plane.
11
+ - Extended output redaction to additional secret-ish keys: `device_code`, `user_code`, `psk`, `cert`, and `jwt`.
12
+ - Added an opt-in read-only mode (`THALOVANT_MCP_READONLY`): only tools annotated `readOnlyHint: true` are registered, so an observe-only agent needs no hand-written denylist. `thalovant_config_status` reports `readOnly`.
13
+
3
14
  ## 0.1.10
4
15
 
5
16
  - Added hub-provisioning and skill-discovery tools so an agent can discover skills and provision hubs conversationally, all delegating to `@thalovant/sdk` (now `^0.2.28`).
package/README.md CHANGED
@@ -353,7 +353,7 @@ Destructive, **not registered unless explicitly enabled** (see [Destructive Tool
353
353
  - `thalovant_delete_hub`
354
354
  - `thalovant_delete_runtime_group`
355
355
 
356
- Tool outputs redact credential-shaped fields. `thalovant_create_client_identity` does not return secret identity material; pass `savePath` when you want the full identity written to a local file with mode `0600`.
356
+ Tool outputs redact credential-shaped fields. `thalovant_create_client_identity` does not return secret identity material; pass `savePath` when you want the full identity written to a local file with mode `0600`. `savePath` is confined to the server's identity directory (`THALOVANT_MCP_IDENTITY_DIR`, default `<config-dir>/thalovant/identities`): pass a plain filename, since absolute paths outside that directory and `..` traversal are rejected, so a model cannot drop a credential file into a git working tree or synced folder. `thalovant_config_status` reports the active `identityDir`.
357
357
 
358
358
  ## Destructive Tools
359
359
 
@@ -379,6 +379,20 @@ with the trusted principal's credential file granting them back via `allowedTool
379
379
 
380
380
  Deleting a hub still requires a current etag (`412` otherwise), and deleting a runtime group fails with `409` while it is the workspace default or still has hubs attached.
381
381
 
382
+ ## Non-Catalog Skill Sources
383
+
384
+ `thalovant_install_runtime_group_skill` installs from the vetted marketplace catalog by default. Any other source — notably `sourceType: "git"` with an arbitrary `sourceRef` repository URL — pulls code the marketplace never reviewed straight into a production runtime, and the control-plane validator is format-only with no host allowlist. Because the tools are driven by a model holding a long-lived token, non-catalog sources are refused unless an operator opts in:
385
+
386
+ ```bash
387
+ export THALOVANT_ENABLE_GIT_SKILL_SOURCES="true"
388
+ ```
389
+
390
+ With the flag unset, a call with any `sourceType` other than `catalog` fails before any control-plane request is made. Accepted true values are `1`, `true`, `yes`, and `on`. `thalovant_config_status` reports the state as `gitSkillSourcesEnabled`. The tool is annotated `destructiveHint: true`.
391
+
392
+ ## Read-Only Mode
393
+
394
+ Set `THALOVANT_MCP_READONLY=1` to register only tools annotated `readOnlyHint: true`. Write and destructive tools are then never registered and never appear in `tools/list`, so an operator can run an observe-only agent without hand-writing a denylist. Like the other registration-time gates it is read when a server instance is created; `thalovant_config_status` reports the state as `readOnly`.
395
+
382
396
  ## Development
383
397
 
384
398
  ```bash
package/dist/index.js CHANGED
@@ -9,7 +9,8 @@ import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
9
9
  import { createServer as createHttpServer } from "node:http";
10
10
  import { realpathSync } from "node:fs";
11
11
  import { appendFile, readFile, writeFile } from "node:fs/promises";
12
- import { dirname, resolve } from "node:path";
12
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
13
+ import { homedir } from "node:os";
13
14
  import { mkdir } from "node:fs/promises";
14
15
  import { fileURLToPath, pathToFileURL } from "node:url";
15
16
  import { createRemoteJWKSet, jwtVerify } from "jose";
@@ -42,9 +43,16 @@ const SECRET_KEYS = [
42
43
  "privateKey",
43
44
  "refresh_token",
44
45
  "refreshToken",
46
+ "device_code",
47
+ "deviceCode",
48
+ "user_code",
49
+ "userCode",
45
50
  "authToken",
46
51
  "authorization",
47
52
  "bearer",
53
+ "jwt",
54
+ "psk",
55
+ "cert",
48
56
  "token",
49
57
  "secret",
50
58
  ];
@@ -128,6 +136,38 @@ const DESTRUCTIVE_TOOLS = ["thalovant_delete_hub", "thalovant_delete_runtime_gro
128
136
  function destructiveToolsEnabled() {
129
137
  return parseBool(process.env.THALOVANT_ENABLE_DESTRUCTIVE_TOOLS, false);
130
138
  }
139
+ /**
140
+ * Non-catalog skill sources are opt-in.
141
+ *
142
+ * thalovant_install_runtime_group_skill can install a skill from a source other
143
+ * than the vetted marketplace catalog — notably `sourceType: "git"` with an
144
+ * arbitrary `sourceRef` repository URL. The control-plane validator is
145
+ * format-only with no host allowlist, so any non-catalog source is effectively
146
+ * an arbitrary-code-into-the-runtime primitive. Because this server hands its
147
+ * tools to an LLM holding a long-lived token, that primitive is refused by
148
+ * default and only enabled when an operator opts in with this flag, mirroring
149
+ * the THALOVANT_ENABLE_DESTRUCTIVE_TOOLS gate. Installing from the catalog (the
150
+ * default source) is always allowed.
151
+ */
152
+ function gitSkillSourcesEnabled() {
153
+ return parseBool(process.env.THALOVANT_ENABLE_GIT_SKILL_SOURCES, false);
154
+ }
155
+ /** The default install source is the vetted marketplace catalog. */
156
+ function isCatalogSource(sourceType) {
157
+ return !sourceType || sourceType.trim().toLowerCase() === "catalog";
158
+ }
159
+ /**
160
+ * Read-only mode.
161
+ *
162
+ * When THALOVANT_MCP_READONLY is set, only tools annotated readOnlyHint: true
163
+ * are registered, so an operator can run a safe agent without hand-writing a
164
+ * tool denylist. Non-read-only tools are never registered and so never appear
165
+ * in tools/list. This complements, and is independent of, the per-principal
166
+ * allow/deny policy in authorizeTool().
167
+ */
168
+ function readOnlyModeEnabled() {
169
+ return parseBool(process.env.THALOVANT_MCP_READONLY, false);
170
+ }
131
171
  const capacityProfileSchema = z
132
172
  .enum(["standard", "autoscaling"])
133
173
  .describe('Hub capacity profile. Only "standard" (the API default, 1 replica) and "autoscaling" (2-32 replicas) are accepted. Omit the key entirely rather than sending null — an explicit null fails 422 INVALID_HUB_CAPACITY_PROFILE. "autoscaling" is plan-gated: 403 HUB_AUTOSCALING_NOT_INCLUDED when the plan has no autoscaling, 409 AUTOSCALING_HUB_LIMIT_REACHED when its autoscaling slots are already used.');
@@ -467,6 +507,13 @@ async function auditLog(event) {
467
507
  }
468
508
  }
469
509
  function registerThalovantTool(server, name, config, handler) {
510
+ if (readOnlyModeEnabled()) {
511
+ const annotations = (config.annotations ?? {});
512
+ if (annotations.readOnlyHint !== true) {
513
+ // Read-only mode: do not register write/destructive tools at all.
514
+ return;
515
+ }
516
+ }
470
517
  server.registerTool(name, config, async (args, extra) => {
471
518
  const principal = principalFromExtra(extra);
472
519
  const start = Date.now();
@@ -619,8 +666,53 @@ function summarizeReply(reply) {
619
666
  failureEvent: reply.failureEvent ? redactSecrets(reply.failureEvent.asObject()) : undefined,
620
667
  };
621
668
  }
622
- async function saveIdentity(path, identity) {
623
- const resolvedPath = resolve(path);
669
+ /**
670
+ * Base directory that saved client-identity files are confined to.
671
+ *
672
+ * thalovant_create_client_identity takes an LLM-chosen savePath. The file is
673
+ * written 0600, but without a base directory the model controls WHERE the
674
+ * secret lands and could drop a credential file into a git working tree or a
675
+ * synced folder. Files are therefore confined to THALOVANT_MCP_IDENTITY_DIR,
676
+ * defaulting to an `identities` folder under the Thalovant SDK config dir
677
+ * ($XDG_CONFIG_HOME/thalovant, %APPDATA%/Thalovant on Windows, else
678
+ * ~/.config/thalovant), matching the SDK's own convention.
679
+ */
680
+ function identityBaseDir() {
681
+ const override = process.env.THALOVANT_MCP_IDENTITY_DIR?.trim();
682
+ if (override)
683
+ return resolve(override);
684
+ if (process.env.XDG_CONFIG_HOME)
685
+ return join(process.env.XDG_CONFIG_HOME, "thalovant", "identities");
686
+ if (process.platform === "win32" && process.env.APPDATA)
687
+ return join(process.env.APPDATA, "Thalovant", "identities");
688
+ return join(homedir(), ".config", "thalovant", "identities");
689
+ }
690
+ /**
691
+ * Resolve an LLM-supplied savePath against the identity base directory and
692
+ * refuse anything that escapes it. Relative paths resolve inside the base dir;
693
+ * absolute paths and `..` traversal that land outside it are rejected. The base
694
+ * dir is realpath'd so a symlinked base still validates correctly.
695
+ */
696
+ function resolveIdentityPath(savePath) {
697
+ const baseDir = identityBaseDir();
698
+ let canonicalBase = resolve(baseDir);
699
+ try {
700
+ canonicalBase = realpathSync(canonicalBase);
701
+ }
702
+ catch {
703
+ // Base dir does not exist yet; writeIdentityFile creates it. Use the resolved
704
+ // (non-canonical) path for containment — nothing is symlinked yet.
705
+ }
706
+ const candidate = resolve(canonicalBase, savePath);
707
+ const rel = relative(canonicalBase, candidate);
708
+ if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
709
+ throw new Error(`savePath must resolve to a location inside the identity directory (${canonicalBase}). ` +
710
+ `Absolute paths outside it and ".." traversal are rejected; pass a plain filename such as "my-hub.json". ` +
711
+ `Set THALOVANT_MCP_IDENTITY_DIR to change the identity directory.`);
712
+ }
713
+ return candidate;
714
+ }
715
+ async function writeIdentityFile(resolvedPath, identity) {
624
716
  await mkdir(dirname(resolvedPath), { recursive: true, mode: 0o700 });
625
717
  await writeFile(resolvedPath, `${stableStringify(identity.asObject(true))}\n`, { mode: 0o600 });
626
718
  return resolvedPath;
@@ -1017,7 +1109,10 @@ export function createServer() {
1017
1109
  defaultProtocol: "wss",
1018
1110
  destructiveToolsEnabled: destructiveToolsEnabled(),
1019
1111
  destructiveTools: destructiveToolsEnabled() ? [...DESTRUCTIVE_TOOLS] : [],
1020
- secretHandling: "Secrets are redacted in tool output. Identity files are only written when savePath is provided.",
1112
+ gitSkillSourcesEnabled: gitSkillSourcesEnabled(),
1113
+ readOnly: readOnlyModeEnabled(),
1114
+ identityDir: identityBaseDir(),
1115
+ secretHandling: "Secrets are redacted in tool output. Identity files are only written when savePath is provided, and only inside identityDir.",
1021
1116
  }));
1022
1117
  registerThalovantTool(server, "thalovant_list_public_hubs", {
1023
1118
  title: "List Public Hubs",
@@ -1107,7 +1202,7 @@ export function createServer() {
1107
1202
  .string()
1108
1203
  .min(1)
1109
1204
  .optional()
1110
- .describe("Optional local file path for the full secret identity JSON. File mode is set to 0600."),
1205
+ .describe("Optional filename for the full secret identity JSON, written 0600 inside the server's identity directory (see identityDir in thalovant_config_status). Pass a plain filename like \"my-hub.json\"; absolute paths outside the identity directory and \"..\" traversal are rejected."),
1111
1206
  },
1112
1207
  annotations: {
1113
1208
  readOnlyHint: false,
@@ -1116,6 +1211,10 @@ export function createServer() {
1116
1211
  openWorldHint: true,
1117
1212
  },
1118
1213
  }, async ({ hubId, name, siteId, ownerId, active, preferredProtocols, idempotencyKey, spec, savePath, ...auth }) => {
1214
+ // Validate the destination BEFORE creating a cloud identity, so a path
1215
+ // that escapes the identity directory is refused without leaving an
1216
+ // orphaned credential behind in the control plane.
1217
+ const targetIdentityPath = savePath ? resolveIdentityPath(savePath) : undefined;
1119
1218
  const api = await createControlPlane(auth);
1120
1219
  ensureAuthenticated(api);
1121
1220
  const result = await api.createClientIdentity(hubId, {
@@ -1128,7 +1227,7 @@ export function createServer() {
1128
1227
  spec,
1129
1228
  });
1130
1229
  const selectedEndpoint = api.requireRuntimeProtocol(result, preferredProtocols[0]);
1131
- const savedIdentityPath = savePath ? await saveIdentity(savePath, result.identity) : undefined;
1230
+ const savedIdentityPath = targetIdentityPath ? await writeIdentityFile(targetIdentityPath, result.identity) : undefined;
1132
1231
  return jsonContent({
1133
1232
  result: redactSecrets(result.asObject({ includeSecrets: false })),
1134
1233
  selectedEndpoint,
@@ -1180,7 +1279,7 @@ export function createServer() {
1180
1279
  const client = await createRuntimeClient(runtime);
1181
1280
  try {
1182
1281
  await client.connect(clampTimeout(timeoutMs));
1183
- return jsonContent(client.healthcheck());
1282
+ return jsonContent(redactSecrets(client.healthcheck()));
1184
1283
  }
1185
1284
  finally {
1186
1285
  await client.close();
@@ -1355,10 +1454,8 @@ export function createServer() {
1355
1454
  description: "Read authenticated Thalovant analytics overview data.",
1356
1455
  inputSchema: {
1357
1456
  ...controlPlaneSchema,
1358
- admin: z.boolean().optional(),
1359
1457
  range: z.string().min(1).optional(),
1360
1458
  bucket: z.string().min(1).optional(),
1361
- ownerId: z.string().min(1).optional(),
1362
1459
  hubId: z.string().min(1).optional(),
1363
1460
  clientId: z.string().min(1).optional(),
1364
1461
  country: z.string().min(1).optional(),
@@ -1886,7 +1983,7 @@ export function createServer() {
1886
1983
  });
1887
1984
  registerThalovantTool(server, "thalovant_install_runtime_group_skill", {
1888
1985
  title: "Install Runtime Group Skill",
1889
- description: "Install (or re-install) a skill in a Thalovant runtime group. Installing a skill that is already present updates the existing entry rather than failing. Discover skillId with thalovant_list_marketplace_skills, then confirm installable/purchase_required with thalovant_list_runtime_group_marketplace before calling this. Requires the hubs:write scope and a paid plan (402 'API access requires a paid plan'); a paid marketplace skill ALSO needs marketplace access on the tenant plan, which fails with a second, distinct 402 about paid marketplace access.",
1986
+ description: "Install (or re-install) a skill in a Thalovant runtime group from the marketplace catalog. Installing a skill that is already present updates the existing entry rather than failing. Discover skillId with thalovant_list_marketplace_skills, then confirm installable/purchase_required with thalovant_list_runtime_group_marketplace before calling this. Requires the hubs:write scope and a paid plan (402 'API access requires a paid plan'); a paid marketplace skill ALSO needs marketplace access on the tenant plan, which fails with a second, distinct 402 about paid marketplace access. Only catalog sources are allowed by default: installing from a non-catalog source such as sourceType \"git\" runs code the marketplace never vetted and is refused unless the operator sets THALOVANT_ENABLE_GIT_SKILL_SOURCES.",
1890
1987
  inputSchema: {
1891
1988
  ...controlPlaneSchema,
1892
1989
  runtimeGroupId: z.string().min(1).describe("Runtime group UUID."),
@@ -1901,8 +1998,8 @@ export function createServer() {
1901
1998
  .min(1)
1902
1999
  .max(32)
1903
2000
  .optional()
1904
- .describe('Install source, 1-32 characters, defaulting to "catalog". The API accepts any string here rather than a fixed enum, but only "catalog" (requires the skill to exist in the marketplace catalog) and "git" (requires sourceRef) get special handling.'),
1905
- sourceRef: z.string().min(1).max(255).optional().describe("Max 255 characters. Required for git installs — a git install without a valid repository URL fails 422."),
2001
+ .describe('Install source, 1-32 characters, defaulting to "catalog". The API accepts any string here rather than a fixed enum, but only "catalog" (requires the skill to exist in the marketplace catalog) and "git" (requires sourceRef) get special handling. The value is trimmed and lower-cased before it is both checked and forwarded, so "Catalog" and " catalog " are treated exactly as "catalog". Any value that is not "catalog" is refused unless the operator sets THALOVANT_ENABLE_GIT_SKILL_SOURCES, because a non-catalog source can pull unvetted code into the runtime.'),
2002
+ sourceRef: z.string().min(1).max(255).optional().describe("Max 255 characters. Required for git installs — a git install without a valid repository URL fails 422. Only usable when THALOVANT_ENABLE_GIT_SKILL_SOURCES is set."),
1906
2003
  versionPin: z.string().min(1).max(64).optional().describe("Pin the skill to an exact version. Max 64 characters."),
1907
2004
  active: z
1908
2005
  .boolean()
@@ -1911,11 +2008,23 @@ export function createServer() {
1911
2008
  },
1912
2009
  annotations: {
1913
2010
  readOnlyHint: false,
1914
- destructiveHint: false,
2011
+ destructiveHint: true,
1915
2012
  idempotentHint: true,
1916
2013
  openWorldHint: true,
1917
2014
  },
1918
- }, async ({ runtimeGroupId, skillId, marketplaceSkillId, sourceType, sourceRef, versionPin, active, ...auth }) => {
2015
+ }, async ({ runtimeGroupId, skillId, marketplaceSkillId, sourceType: rawSourceType, sourceRef, versionPin, active, ...auth }) => {
2016
+ // Normalize once (trim + case-fold) and forward the SAME value the gate
2017
+ // validates. Otherwise a whitespace/case variant such as " Catalog " or
2018
+ // "CATALOG" passes the local catalog check while the control plane, which
2019
+ // special-cases only the exact string "catalog", treats it as a
2020
+ // non-catalog source — bypassing the gate for the case it exists to block.
2021
+ // A blank/omitted value defaults to the catalog source server-side.
2022
+ const trimmedSource = typeof rawSourceType === "string" ? rawSourceType.trim() : "";
2023
+ const sourceType = trimmedSource ? trimmedSource.toLowerCase() : undefined;
2024
+ if (!isCatalogSource(sourceType) && !gitSkillSourcesEnabled()) {
2025
+ throw new Error(`Refusing to install a skill from non-catalog source "${rawSourceType}". A non-catalog source (for example sourceType "git" with an arbitrary sourceRef) can pull unvetted code into the runtime and is disabled by default. ` +
2026
+ "Set THALOVANT_ENABLE_GIT_SKILL_SOURCES=1 to allow non-catalog skill sources, or install from the marketplace catalog by omitting sourceType (or setting it to \"catalog\").");
2027
+ }
1919
2028
  const api = await createControlPlane(auth);
1920
2029
  ensureAuthenticated(api);
1921
2030
  const options = { marketplaceSkillId, sourceType, sourceRef, versionPin, active };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thalovant/mcp-server",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "Stdio and Streamable HTTP MCP server for Thalovant control-plane and hub runtime APIs.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/server.json CHANGED
@@ -7,19 +7,19 @@
7
7
  "url": "https://github.com/thalovant/thalovant-mcp",
8
8
  "source": "github"
9
9
  },
10
- "version": "0.1.10",
10
+ "version": "0.1.12",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "@thalovant/mcp-server",
15
- "version": "0.1.10",
15
+ "version": "0.1.12",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  }
19
19
  },
20
20
  {
21
21
  "registryType": "oci",
22
- "identifier": "ghcr.io/thalovant/thalovant-mcp:0.1.10",
22
+ "identifier": "ghcr.io/thalovant/thalovant-mcp:0.1.12",
23
23
  "transport": {
24
24
  "type": "streamable-http",
25
25
  "url": "http://127.0.0.1:3000/mcp"