mc8yp 2.0.0 → 2.1.0

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 +109 -28
  2. package/dist/cli.mjs +471 -257
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -154,39 +154,96 @@ async () => {
154
154
 
155
155
  ### Prompts
156
156
 
157
- | Prompt | Description |
158
- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
159
- | `code-mode-guide` | Full reference for the `query` and `execute` tools, including available types, examples, and restriction info for the current connection. |
157
+ | Prompt | Description |
158
+ | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
159
+ | `code-mode-guide` | Full reference for the `query` and `execute` tools, including available types, examples, and access-policy info for the current connection. |
160
160
 
161
- ## API Restrictions
161
+ ## API Access Policy
162
162
 
163
- Restrictions are deny rules that block specific API operations. They can be applied per-connection to limit what an AI agent can access.
163
+ mc8yp supports two per-connection rule types:
164
+
165
+ - **Restrictions** — deny rules that block matching API operations
166
+ - **Allow rules** — allow-list rules that permit matching API operations and block everything else when at least one allow rule is configured
167
+
168
+ If both apply to the same operation, **restrictions take priority**.
169
+
170
+ Example: allowing `/inventory/**` but restricting `/inventory/managedObjects` still blocks `/inventory/managedObjects`.
171
+
172
+ Both rule types use the same syntax.
173
+
174
+ ### Restrictions
175
+
176
+ Restrictions are deny rules that block specific API operations.
177
+
178
+ ### Allow Rules
179
+
180
+ Allow rules are the inverse of restrictions. They define what is permitted. When one or more allow rules are configured, any operation that does not match at least one allow rule is blocked.
164
181
 
165
182
  ### Rule Format
166
183
 
184
+ A restriction or allow rule can be written in either of these forms:
185
+
186
+ ```txt
187
+ <path-pattern>
188
+ <method>:<path-pattern>
167
189
  ```
168
- [METHOD:]<path-pattern>
169
- ```
170
190
 
171
- - **Without a method prefix** — blocks all HTTP methods for matching paths
172
- - **With a method prefix** — blocks only that method (e.g. `GET:`, `DELETE:`, `POST:`)
173
- - **Path patterns** support `*` (single segment wildcard) and `**` (recursive wildcard)
174
- - Query strings and fragments are not allowed in patterns
191
+ - **Without a method prefix** — matches all HTTP methods for matching paths
192
+ - **With a method prefix** — matches only that method (for example `GET:`, `DELETE:`, `POST:`)
193
+ - **The `:` separator is only present when a method prefix is provided**
194
+ - **Supported methods** — `DELETE`, `GET`, `HEAD`, `OPTIONS`, `PATCH`, `POST`, `PUT`, `TRACE`, or `*`
195
+ - Method names are case-insensitive when parsed (`get:/inventory/**` becomes `GET:/inventory/**`)
196
+
197
+ ### Path Pattern Syntax
198
+
199
+ Patterns are matched against the request **pathname**.
200
+
201
+ - Query strings and fragments are **not allowed in rule patterns**
202
+ - Incoming request query strings are ignored for matching, so `/inventory/**` also matches requests such as `/inventory?pageSize=5`
203
+ - Patterns must start with `/`
204
+ - Matching is path-segment aware: `/` separates segments
205
+
206
+ Supported wildcards:
207
+
208
+ - `*` — wildcard **inside a single path segment**. It matches any characters except `/`
209
+ - `**` — recursive wildcard across **zero or more whole path segments**. `**` must be its own complete segment
175
210
 
176
- ### Examples
211
+ ### Path Pattern Examples
177
212
 
178
- | Rule | Effect |
179
- | -------------------------------- | --------------------------------------------------- |
180
- | `/inventory/**` | Block all methods on all inventory paths |
181
- | `DELETE:/inventory/**` | Block only DELETE on inventory paths |
182
- | `/alarm/alarms` | Block all methods on the exact path `/alarm/alarms` |
183
- | `GET:/measurement/measurements` | Block only GET on measurements |
184
- | `POST:/inventory/managedObjects` | Block creating new managed objects |
185
- | `/user/**` | Block all user management |
213
+ | Pattern | Matches | Does Not Match |
214
+ | --------------------- | ----------------------------------------------------------- | ------------------------------------------ |
215
+ | `/inventory` | `/inventory` | `/inventory/managedObjects` |
216
+ | `/inventory/**` | `/inventory`, `/inventory/managedObjects`, `/inventory/x/y` | `/alarm/alarms` |
217
+ | `/i*` | `/inventory`, `/identity`, `/i` | `/inventory/managedObjects` |
218
+ | `/i*/**` | `/inventory`, `/inventory/managedObjects`, `/identity/x` | `/alarm/alarms` |
219
+ | `/inventory/m*` | `/inventory/managedObjects`, `/inventory/measurements` | `/inventory/events`, `/inventory/m/x` |
220
+ | `/inventory/*/child` | `/inventory/device-1/child`, `/inventory/x/child` | `/inventory/child`, `/inventory/a/b/child` |
221
+ | `/inventory/**/child` | `/inventory/child`, `/inventory/a/b/child` | `/inventory/a/b/sibling` |
222
+
223
+ ### Common Rule Examples
224
+
225
+ | Rule | Restriction Effect | Allow-list Effect |
226
+ | -------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------- |
227
+ | `/inventory/**` | Block all methods on `/inventory` and everything below it | Permit all methods on `/inventory` and everything below it |
228
+ | `DELETE:/inventory/**` | Block only DELETE on `/inventory` and everything below it | Permit only DELETE on `/inventory` and everything below it |
229
+ | `/alarm/alarms` | Block all methods on the exact path `/alarm/alarms` | Permit all methods on the exact path `/alarm/alarms` |
230
+ | `GET:/measurement/measurements` | Block only GET on the exact path `/measurement/measurements` | Permit only GET on the exact path `/measurement/measurements` |
231
+ | `POST:/inventory/managedObjects` | Block creating new managed objects | Permit creating new managed objects |
232
+ | `/i*/**` | Block all routes whose first path segment starts with `i` | Permit all routes whose first path segment starts with `i` |
233
+ | `/user/**` | Block all user management paths | Permit all user management paths |
234
+
235
+ ### Important Notes
236
+
237
+ - `/inventory/**` already matches `/inventory` itself, so you do **not** need both `/inventory` and `/inventory/**`
238
+ - `/i**` is **not valid** because `**` must be its own segment. Use `/i*/**` if you want to match a first segment starting with `i` and everything below it
239
+ - `*:/inventory/**` is allowed and means the same thing as `/inventory/**`
240
+ - Rule patterns may not contain empty segments (`//`), `.` or `..` segments, query strings, or fragments
186
241
 
187
242
  ### CLI Mode
188
243
 
189
- Pass restrictions as CLI arguments. Repeat `-r` / `--restriction` for multiple rules:
244
+ Pass restrictions and allow rules as CLI arguments.
245
+
246
+ Repeat `-r`, `--restrict`, or `--restriction` for deny rules:
190
247
 
191
248
  ```sh
192
249
  # Block all inventory access
@@ -197,25 +254,45 @@ mc8yp -r "DELETE:/inventory/**" -r "/alarm/**"
197
254
 
198
255
  # Block everything under user management
199
256
  mc8yp --restriction "/user/**"
257
+
258
+ # Same thing using the long alias
259
+ mc8yp --restrict "/user/**"
260
+ ```
261
+
262
+ Repeat `-a`, `--allow`, or `--allowed` for allow rules:
263
+
264
+ ```sh
265
+ # Only permit inventory access
266
+ mc8yp -a "/inventory/**"
267
+
268
+ # Permit GET inventory access and POST alarms
269
+ mc8yp --allow "GET:/inventory/**" --allowed "POST:/alarm/**"
270
+
271
+ # Allow inventory broadly, but still block one path with a restriction
272
+ mc8yp -a "/inventory/**" -r "/inventory/managedObjects"
200
273
  ```
201
274
 
202
275
  ### Microservice Mode (HTTP)
203
276
 
204
- Pass restrictions as `restriction` query parameters on the MCP endpoint URL:
277
+ Pass restrictions as `restriction`, `restrict`, or `r` query parameters on the MCP endpoint URL.
278
+ Pass allow rules as `allowed`, `allow`, or `a` query parameters.
205
279
 
206
280
  ```
207
- /mcp?restriction=/inventory/**&restriction=DELETE:/alarm/**
281
+ /mcp?restriction=/inventory/**&restrict=DELETE:/alarm/**
282
+ /mcp?r=/inventory/**&r=DELETE:/alarm/**
283
+ /mcp?allow=/inventory/**&allowed=POST:/alarm/**
284
+ /mcp?a=/inventory/**&r=/inventory/managedObjects
208
285
  ```
209
286
 
210
- ### How Restrictions Work
287
+ ### How Access Policy Works
211
288
 
212
- 1. **OpenAPI spec annotation**: The `query` tool annotates blocked operations in the spec with `x-mc8yp-restricted` and related `x-mc8yp-*` metadata fields. The operations remain visible so the agent understands what exists, but they are clearly marked as blocked.
289
+ 1. **Query visibility**: The `query` tool exposes the raw bundled OpenAPI snapshot for the current MCP connection. It does not annotate or filter operations based on restrictions or allow rules.
213
290
 
214
- 2. **Sandbox request enforcement**: The `execute` tool checks restrictions inside the generated sandbox request helper, where the actual HTTP method and normalized path are both available. Matching requests are blocked before any `fetch` is attempted.
291
+ 2. **Sandbox request enforcement**: The `execute` tool checks restrictions and allow rules inside the generated sandbox request helper, where the actual HTTP method and normalized path are both available. Matching deny rules block first. If any allow rules are configured, requests must also match at least one allow rule.
215
292
 
216
293
  3. **Network boundary**: The secure-exec permission layer independently restricts network access to the configured tenant host. Other network operations are denied.
217
294
 
218
- When an `execute` request is blocked by MCP restrictions, the tool returns explanatory text stating that the operation was intentionally denied by MCP connection policy, no request was sent to Cumulocity, and retrying through the same connection will not help.
295
+ When an `execute` request is blocked by MCP connection policy, the tool returns explanatory text stating whether the operation was denied by a restriction or blocked because it is outside the configured allow list, no request was sent to Cumulocity, and retrying through the same connection will not help.
219
296
 
220
297
  ## Build And Packaging
221
298
 
@@ -234,6 +311,10 @@ The versions built are driven by [`openapi-versions.json`](openapi-versions.json
234
311
 
235
312
  Use the dedicated packaging command after `pnpm build` to create Docker-based Cumulocity release zips:
236
313
 
314
+ The packaging step writes a temporary generated Dockerfile under `.c8y/`, copies the selected versioned server bundle into `/app/server/`, and installs production dependencies inside the Linux image with pnpm before copying them into the runtime stage. This avoids cross-platform native optional dependency issues when release artifacts are built on macOS but deployed as `linux/amd64` microservices.
315
+
316
+ The deployed HTTP transport uses POST-only streamable HTTP (`GET /mcp` intentionally returns `405`) because some reverse proxies and microservice ingress layers do not keep the optional long-lived SSE notification channel stable enough for reliable MCP tool calls.
317
+
237
318
  ```sh
238
319
  pnpm package:microservices
239
320
  ```
@@ -251,7 +332,7 @@ The GitHub release workflow uses that packaging command when building tagged rel
251
332
 
252
333
  ### Prerequisites
253
334
 
254
- - Node.js ≥22.0.0
335
+ - Node.js ≥24.0.0
255
336
  - pnpm
256
337
 
257
338
  ### Setup
package/dist/cli.mjs CHANGED
@@ -10,7 +10,6 @@ import { defineTool } from "tmcp/tool";
10
10
  import * as v from "valibot";
11
11
  import { encode } from "@toon-format/toon";
12
12
  import { NodeRuntime, createNodeDriver, createNodeRuntimeDriverFactory } from "secure-exec";
13
- import "ufo";
14
13
  import { Buffer as Buffer$1 } from "node:buffer";
15
14
  import { AsyncLocalStorage } from "node:async_hooks";
16
15
  import { useContext } from "unctx";
@@ -41,7 +40,7 @@ var __require = /* @__PURE__ */ createRequire(import.meta.url);
41
40
  //#endregion
42
41
  //#region package.json
43
42
  var name = "mc8yp";
44
- var version = "2.0.0";
43
+ var version = "2.1.0";
45
44
  var description = "Cumulocity IoT MCP Server - Model Context Protocol integration for IoT device management";
46
45
  //#endregion
47
46
  //#region \0virtual:core-openapi
@@ -85234,7 +85233,9 @@ function createCodeModeGuidePrompt(server) {
85234
85233
  description: "Guide for the two code-mode tools: query and execute, including available shapes and examples."
85235
85234
  }, () => {
85236
85235
  const restrictions = server.ctx.custom?.restrictions ?? [];
85237
- const restrictionSection = restrictions.length > 0 ? `\n## Current Connection Restrictions\nThe current MCP connection blocks matching operations using these deny rules:\n${restrictions.map((rule) => `- \`${rule.source}\``).join("\n")}\n\nRestricted operations stay visible in the spec and are annotated with \`x-mc8yp-restricted\` and related \`x-mc8yp-*\` fields.\n` : "";
85236
+ const allowRules = server.ctx.custom?.allowRules ?? [];
85237
+ const policyLines = [...restrictions.map((rule) => `- deny: \`${rule.source}\``), ...allowRules.map((rule) => `- allow: \`${rule.source}\``)];
85238
+ const restrictionSection = policyLines.length > 0 ? `\n## Current Connection Access Policy\n${policyLines.join("\n")}\n\nThe \`query\` tool still shows the raw bundled OpenAPI spec. These rules are enforced when requests are executed, not by rewriting the spec.\n` : "";
85238
85239
  return prompt.message(`# Cumulocity Code Mode
85239
85240
 
85240
85241
  You have exactly two MCP tools available.
@@ -85247,8 +85248,8 @@ Use \`query\` when you need to inspect the core OpenAPI spec.
85247
85248
  - Return the exact value you want back from that function
85248
85249
  - Sync and async functions are both supported
85249
85250
  - Strings are returned as-is; other results are returned as JSON text
85250
- - Restricted operations stay visible and are annotated with \`x-mc8yp-restricted\` and related \`x-mc8yp-*\` fields
85251
- - Treat operations marked with \`x-mc8yp-restricted\` as intentionally unavailable on this MCP connection; inspect them for context, but do not plan to execute them
85251
+ - The \`query\` tool shows the raw bundled OpenAPI spec for the selected snapshot
85252
+ - The current MCP connection may still block \`execute\` requests through deny rules and/or an allow list even when an operation exists in the spec
85252
85253
 
85253
85254
  ### Available Shape
85254
85255
  \`\`\`ts
@@ -85296,8 +85297,8 @@ Use \`execute\` when you want to call the real Cumulocity API.
85296
85297
  - Return the value you want from that function; async functions are usually the right choice here
85297
85298
  - On success, the returned value is sent back in Toon format
85298
85299
  - If execution is blocked or fails, execute returns a plain text error message
85299
- - The current MCP connection may reject restricted method/path combinations before network access
85300
- - If a request is blocked by MCP connection policy, \`execute\` returns an explanatory text message. That is an intentional connection-level restriction, not a Cumulocity API failure, and retrying through the same connection will not help
85300
+ - The current MCP connection may reject restricted method/path combinations before network access, and it may also reject requests that are outside a configured allow list
85301
+ - If a request is blocked by MCP connection policy, \`execute\` returns an explanatory text message. That is an intentional connection-level access restriction, not a Cumulocity API failure, and retrying through the same connection will not help
85301
85302
 
85302
85303
  ### Available Shape
85303
85304
  \`\`\`ts
@@ -85363,60 +85364,34 @@ function createPrompts(server) {
85363
85364
  return [createCodeModeGuidePrompt(server)];
85364
85365
  }
85365
85366
  //#endregion
85366
- //#region src/utils/restriction-core.ts
85367
- const HTTP_METHODS = [
85368
- "DELETE",
85369
- "GET",
85370
- "HEAD",
85371
- "OPTIONS",
85372
- "PATCH",
85373
- "POST",
85374
- "PUT",
85375
- "TRACE"
85376
- ];
85377
- const HTTP_METHOD_SET$1 = new Set(HTTP_METHODS);
85378
- function normalizeRestrictionMatchPath(value) {
85379
- let raw = value.trim();
85380
- if (!raw) return "/";
85381
- raw = raw.startsWith("/") ? raw : `/${raw}`;
85382
- raw = raw.replace(/\/{2,}/g, "/");
85383
- return raw.length > 1 && raw.endsWith("/") ? raw.slice(0, -1) : raw || "/";
85384
- }
85385
- function normalizeAndValidateRestrictionPath(rawPath) {
85386
- const segmentPattern = /^[A-Za-z0-9._~*-]+$/;
85387
- if (rawPath.includes("?") || rawPath.includes("#")) throw new Error(`Restriction pattern "${rawPath}" must not include query strings or fragments.`);
85388
- if (!rawPath.startsWith("/")) throw new Error("Restriction path pattern must start with \"/\".");
85389
- const pathPattern = normalizeRestrictionMatchPath(rawPath);
85390
- const segments = pathPattern === "/" ? [] : pathPattern.slice(1).split("/");
85391
- for (const segment of segments) {
85392
- if (segment === "." || segment === "..") throw new Error(`Restriction segment "${segment}" is not allowed.`);
85393
- if (segment === "**") continue;
85394
- if (segment.includes("**")) throw new Error(`Invalid restriction segment "${segment}". "**" must be its own path segment.`);
85395
- if (!segmentPattern.test(segment)) throw new Error(`Restriction segment "${segment}" contains unsupported characters.`);
85367
+ //#region src/codemode/semaphore.ts
85368
+ var AsyncSemaphore = class {
85369
+ #maxConcurrency;
85370
+ #activeCount = 0;
85371
+ #waiters = [];
85372
+ constructor(maxConcurrency) {
85373
+ if (!Number.isInteger(maxConcurrency) || maxConcurrency <= 0) throw new Error("maxConcurrency must be a positive integer.");
85374
+ this.#maxConcurrency = maxConcurrency;
85396
85375
  }
85397
- return pathPattern;
85398
- }
85399
- function parseRestrictionRule(input) {
85400
- const source = input.trim();
85401
- if (!source) throw new Error("Restriction value must not be empty.");
85402
- const sep = source.indexOf(":");
85403
- if (sep > 0 && !source.startsWith("/")) {
85404
- const rawMethod = source.slice(0, sep).trim().toUpperCase();
85405
- const rawPath = source.slice(sep + 1).trim();
85406
- if (!rawPath) throw new Error("Restriction path pattern must not be empty.");
85407
- if (rawMethod && rawMethod !== "*" && !HTTP_METHOD_SET$1.has(rawMethod)) throw new Error(`Unsupported restriction method "${source.slice(0, sep)}".`);
85408
- return {
85409
- method: !rawMethod || rawMethod === "*" ? "*" : rawMethod,
85410
- pathPattern: normalizeAndValidateRestrictionPath(rawPath),
85411
- source
85376
+ get activeCount() {
85377
+ return this.#activeCount;
85378
+ }
85379
+ async acquire() {
85380
+ if (this.#activeCount >= this.#maxConcurrency) await new Promise((resolve) => {
85381
+ this.#waiters.push(resolve);
85382
+ });
85383
+ this.#activeCount += 1;
85384
+ let released = false;
85385
+ return () => {
85386
+ if (released) return;
85387
+ released = true;
85388
+ this.#activeCount -= 1;
85389
+ this.#waiters.shift()?.();
85412
85390
  };
85413
85391
  }
85414
- return {
85415
- method: "*",
85416
- pathPattern: normalizeAndValidateRestrictionPath(source),
85417
- source
85418
- };
85419
- }
85392
+ };
85393
+ //#endregion
85394
+ //#region src/utils/restriction-matcher.ts
85420
85395
  function escapeRestrictionRegex(value) {
85421
85396
  let escaped = "";
85422
85397
  for (const char of value) escaped += "\\^$.*+?()[]{}|".includes(char) ? `\\${char}` : char;
@@ -85429,13 +85404,13 @@ function compileRestrictionSegment(segment) {
85429
85404
  }
85430
85405
  function matchCompiledSegments(pattern, path, pi = 0, si = 0) {
85431
85406
  while (pi < pattern.length) {
85432
- const seg = pattern[pi];
85433
- if (seg === "**") {
85407
+ const segment = pattern[pi];
85408
+ if (segment === "**") {
85434
85409
  if (pi === pattern.length - 1) return true;
85435
85410
  for (let i = si; i <= path.length; i++) if (matchCompiledSegments(pattern, path, pi + 1, i)) return true;
85436
85411
  return false;
85437
85412
  }
85438
- if (si >= path.length || !(seg instanceof RegExp) || !seg.test(path[si])) return false;
85413
+ if (si >= path.length || !(segment instanceof RegExp) || !segment.test(path[si])) return false;
85439
85414
  pi++;
85440
85415
  si++;
85441
85416
  }
@@ -85450,51 +85425,35 @@ function compileRestrictionRule(rule) {
85450
85425
  function matchesCompiledRule(rule, method, pathSegments) {
85451
85426
  return (rule.method === "*" || rule.method === method) && matchCompiledSegments(rule.segments, pathSegments);
85452
85427
  }
85453
- function compileRestrictionSources(restrictionSources) {
85454
- return restrictionSources.map(parseRestrictionRule).map(compileRestrictionRule);
85455
- }
85456
- function getBlockedCompiledRestrictions(compiledRestrictions, method, pathname) {
85457
- const normalizedMethod = String(method ?? "GET").trim().toUpperCase() || "GET";
85458
- const normalizedPath = normalizeRestrictionMatchPath(pathname);
85459
- const pathSegments = normalizedPath === "/" ? [] : normalizedPath.slice(1).split("/");
85460
- return compiledRestrictions.filter((rule) => matchesCompiledRule(rule, normalizedMethod, pathSegments));
85461
- }
85462
- //#endregion
85463
- //#region src/utils/restrictions.ts
85464
- const RESTRICTION_EXTENSION_KEY = "x-mc8yp-restrictions";
85465
- const RESTRICTED_OPERATION_FLAG = "x-mc8yp-restricted";
85466
- const RESTRICTED_OPERATION_MESSAGE = "x-mc8yp-restrictionMessage";
85467
- const RESTRICTED_OPERATION_RULES = "x-mc8yp-restrictionRules";
85468
- const RESTRICTED_OPERATION_TYPE = "x-mc8yp-restrictionType";
85469
- const RESTRICTED_AGENT_NOTE = "x-mc8yp-agentNote";
85470
- const HTTP_METHOD_SET = new Set(HTTP_METHODS);
85471
- function normalizePath(value) {
85472
- let raw = value.trim();
85473
- if (!raw) return "/";
85474
- try {
85475
- if (/^[A-Za-z][A-Za-z\d+\-.]*:\/\//.test(raw)) raw = new URL(raw).pathname || "/";
85476
- } catch {}
85477
- raw = (raw.split("#", 1)[0] ?? raw).split("?", 1)[0] ?? raw;
85478
- raw = raw.startsWith("/") ? raw : `/${raw}`;
85479
- raw = raw.replace(/\/{2,}/g, "/");
85480
- return raw.length > 1 && raw.endsWith("/") ? raw.slice(0, -1) : raw || "/";
85428
+ function findMatchingRules(rules, method, pathname) {
85429
+ const normalizedMethod = typeof method === "string" ? method.trim().toUpperCase() : "";
85430
+ const pathSegments = pathname === "/" ? [] : pathname.slice(1).split("/");
85431
+ return rules.filter((rule) => {
85432
+ const compiledRule = compileRestrictionRule(rule);
85433
+ if (!normalizedMethod) return compiledRule.method === "*" && matchCompiledSegments(compiledRule.segments, pathSegments);
85434
+ return matchesCompiledRule(compiledRule, normalizedMethod, pathSegments);
85435
+ });
85481
85436
  }
85482
- function normalizeMethod(value) {
85483
- const upper = value?.trim().toUpperCase() || "GET";
85484
- if (HTTP_METHOD_SET.has(upper)) return upper;
85485
- throw new Error(`Unsupported HTTP method "${value}".`);
85437
+ function findBlockingRestrictions(rules, method, pathname) {
85438
+ return findMatchingRules(rules, method, pathname);
85486
85439
  }
85487
- function evaluateRestrictions(rules, method, path) {
85488
- const m = normalizeMethod(method);
85489
- const p = normalizePath(path);
85490
- const segs = p === "/" ? [] : p.slice(1).split("/");
85440
+ function evaluateAccessPolicy(restrictions, allowRules, method, pathname) {
85441
+ const matchingRestrictions = findMatchingRules(restrictions, method, pathname);
85442
+ if (matchingRestrictions.length > 0) return {
85443
+ blocked: true,
85444
+ blockedBy: "restriction",
85445
+ matchingRestrictions
85446
+ };
85447
+ if (allowRules.length === 0) return { blocked: false };
85448
+ if (findMatchingRules(allowRules, method, pathname).length > 0) return { blocked: false };
85491
85449
  return {
85492
- method: m,
85493
- path: p,
85494
- matchingRules: rules.filter((rule) => matchesCompiledRule(compileRestrictionRule(rule), m, segs))
85450
+ blocked: true,
85451
+ blockedBy: "allow"
85495
85452
  };
85496
85453
  }
85497
- function createNetworkPermissionDecision(tenantUrl, request, rules = []) {
85454
+ //#endregion
85455
+ //#region src/codemode/network-permissions.ts
85456
+ function createNetworkPermissionDecision(tenantUrl, request, restrictions = [], allowRules = []) {
85498
85457
  const tenantHostname = new URL(tenantUrl).hostname;
85499
85458
  if (request.op !== "connect") return {
85500
85459
  allow: false,
@@ -85504,96 +85463,33 @@ function createNetworkPermissionDecision(tenantUrl, request, rules = []) {
85504
85463
  allow: false,
85505
85464
  reason: `Network connect blocked: only ${tenantHostname} is allowed in execute mode.`
85506
85465
  };
85507
- if (typeof request.method === "string") {
85508
- const requestPath = typeof request.url === "string" ? new URL(request.url).pathname : "/";
85509
- const restrictionMatch = evaluateRestrictions(rules, request.method, requestPath);
85510
- if (restrictionMatch.matchingRules.length > 0) return {
85511
- allow: false,
85512
- reason: `Network connect blocked by MCP restrictions: ${restrictionMatch.matchingRules.map((rule) => rule.source).join(", ")}`
85513
- };
85514
- }
85515
- return { allow: true };
85516
- }
85517
- //#endregion
85518
- //#region src/codemode/openapi-restrictions.ts
85519
- const OPENAPI_OPERATION_METHODS = [
85520
- "delete",
85521
- "get",
85522
- "head",
85523
- "options",
85524
- "patch",
85525
- "post",
85526
- "put",
85527
- "trace"
85528
- ];
85529
- function annotateRestrictedOperation(operation, matchingRules) {
85530
- return {
85531
- ...operation,
85532
- [RESTRICTED_OPERATION_FLAG]: true,
85533
- [RESTRICTED_OPERATION_TYPE]: "deny",
85534
- [RESTRICTED_OPERATION_RULES]: matchingRules.map((rule) => rule.source),
85535
- [RESTRICTED_OPERATION_MESSAGE]: "This operation is blocked by the current MCP connection restrictions.",
85536
- [RESTRICTED_AGENT_NOTE]: "The route exists, but it is intentionally restricted for this MCP connection."
85537
- };
85538
- }
85539
- function applyRestrictionsToOpenApiSpec(spec, rules) {
85540
- if (!spec.paths || rules.length === 0) return spec;
85541
- const compiledRules = rules.map(compileRestrictionRule);
85542
- let nextPaths;
85543
- for (const [path, pathItem] of Object.entries(spec.paths)) {
85544
- let nextPathItem;
85545
- const pathSegments = path === "/" ? [] : path.slice(1).split("/");
85546
- for (const method of OPENAPI_OPERATION_METHODS) {
85547
- const operation = pathItem[method];
85548
- if (!operation || typeof operation !== "object") continue;
85549
- const matchingRules = compiledRules.filter((rule) => matchesCompiledRule(rule, method.toUpperCase(), pathSegments));
85550
- if (matchingRules.length === 0) continue;
85551
- nextPathItem ??= { ...pathItem };
85552
- nextPathItem[method] = annotateRestrictedOperation(operation, matchingRules);
85553
- }
85554
- if (nextPathItem) {
85555
- nextPaths ??= { ...spec.paths };
85556
- nextPaths[path] = nextPathItem;
85466
+ if (typeof request.url === "string") {
85467
+ const pathname = new URL(request.url).pathname;
85468
+ const requestMethod = typeof request.method === "string" ? request.method.trim() : void 0;
85469
+ const normalizedMethod = requestMethod?.toUpperCase() ?? "";
85470
+ if (normalizedMethod) {
85471
+ const decision = evaluateAccessPolicy(restrictions, allowRules, normalizedMethod, pathname);
85472
+ if (decision.blocked) {
85473
+ if (decision.blockedBy === "restriction") return {
85474
+ allow: false,
85475
+ reason: `Network connect blocked by MCP restrictions: ${decision.matchingRestrictions.map((rule) => rule.source).join(", ")}`
85476
+ };
85477
+ return {
85478
+ allow: false,
85479
+ reason: `Network connect blocked by MCP allow list: no allow rule matched ${normalizedMethod} ${pathname}. Configured allow rules: ${allowRules.map((rule) => rule.source).join(", ")}`
85480
+ };
85481
+ }
85482
+ } else {
85483
+ const blockingRules = findBlockingRestrictions(restrictions, requestMethod, pathname);
85484
+ if (blockingRules.length > 0) return {
85485
+ allow: false,
85486
+ reason: `Network connect blocked by MCP restrictions: ${blockingRules.map((rule) => rule.source).join(", ")}`
85487
+ };
85557
85488
  }
85558
85489
  }
85559
- return {
85560
- ...spec,
85561
- paths: nextPaths ?? spec.paths,
85562
- [RESTRICTION_EXTENSION_KEY]: {
85563
- mode: "deny",
85564
- rules: rules.map((rule) => rule.source),
85565
- message: "Operations marked with x-mc8yp-restricted are intentionally blocked for the current MCP connection."
85566
- }
85567
- };
85490
+ return { allow: true };
85568
85491
  }
85569
85492
  //#endregion
85570
- //#region src/codemode/semaphore.ts
85571
- var AsyncSemaphore = class {
85572
- #maxConcurrency;
85573
- #activeCount = 0;
85574
- #waiters = [];
85575
- constructor(maxConcurrency) {
85576
- if (!Number.isInteger(maxConcurrency) || maxConcurrency <= 0) throw new Error("maxConcurrency must be a positive integer.");
85577
- this.#maxConcurrency = maxConcurrency;
85578
- }
85579
- get activeCount() {
85580
- return this.#activeCount;
85581
- }
85582
- async acquire() {
85583
- if (this.#activeCount >= this.#maxConcurrency) await new Promise((resolve) => {
85584
- this.#waiters.push(resolve);
85585
- });
85586
- this.#activeCount += 1;
85587
- let released = false;
85588
- return () => {
85589
- if (released) return;
85590
- released = true;
85591
- this.#activeCount -= 1;
85592
- this.#waiters.shift()?.();
85593
- };
85594
- }
85595
- };
85596
- //#endregion
85597
85493
  //#region node_modules/.pnpm/tslib@2.8.1/node_modules/tslib/tslib.es6.mjs
85598
85494
  function __awaiter(thisArg, _arguments, P, generator) {
85599
85495
  function adopt(value) {
@@ -142365,24 +142261,277 @@ function createC8yAuthHeaders(auth) {
142365
142261
  throw new Error("Invalid authentication credentials");
142366
142262
  }
142367
142263
  //#endregion
142264
+ //#region src/utils/restrictions.ts
142265
+ const HTTP_METHODS = [
142266
+ "DELETE",
142267
+ "GET",
142268
+ "HEAD",
142269
+ "OPTIONS",
142270
+ "PATCH",
142271
+ "POST",
142272
+ "PUT",
142273
+ "TRACE"
142274
+ ];
142275
+ const HTTP_METHOD_SET = new Set(HTTP_METHODS);
142276
+ const SEGMENT_PATTERN = /^[A-Za-z0-9._~*-]+$/;
142277
+ function parseRestrictionRule(input) {
142278
+ const inputs = typeof input === "string" ? [input] : input;
142279
+ const parsedRules = [];
142280
+ const failedRules = [];
142281
+ for (const source of inputs) {
142282
+ if (!source) {
142283
+ failedRules.push({
142284
+ rule: source,
142285
+ reason: "Restriction value must not be empty."
142286
+ });
142287
+ continue;
142288
+ }
142289
+ const sep = source.indexOf(":");
142290
+ if (sep > 0 && !source.startsWith("/")) {
142291
+ const rawMethod = source.slice(0, sep).toUpperCase();
142292
+ const rawPath = source.slice(sep + 1);
142293
+ if (!rawPath) {
142294
+ failedRules.push({
142295
+ rule: source,
142296
+ reason: "Restriction path pattern must not be empty."
142297
+ });
142298
+ continue;
142299
+ }
142300
+ if (rawMethod && rawMethod !== "*" && !HTTP_METHOD_SET.has(rawMethod)) {
142301
+ failedRules.push({
142302
+ rule: source,
142303
+ reason: `Unsupported restriction method "${source.slice(0, sep)}".`
142304
+ });
142305
+ continue;
142306
+ }
142307
+ if (rawPath.includes("?") || rawPath.includes("#")) {
142308
+ failedRules.push({
142309
+ rule: source,
142310
+ reason: `Restriction pattern "${rawPath}" must not include query strings or fragments.`
142311
+ });
142312
+ continue;
142313
+ }
142314
+ if (!rawPath.startsWith("/")) {
142315
+ failedRules.push({
142316
+ rule: source,
142317
+ reason: "Restriction path pattern must start with \"/\"."
142318
+ });
142319
+ continue;
142320
+ }
142321
+ const rawPathSegments = rawPath === "/" ? [] : rawPath.slice(1).split("/");
142322
+ if (rawPathSegments.some((segment) => segment.length === 0)) {
142323
+ failedRules.push({
142324
+ rule: source,
142325
+ reason: "Restriction path pattern must not contain empty segments."
142326
+ });
142327
+ continue;
142328
+ }
142329
+ const invalidRawPathSegment = rawPathSegments.find((segment) => {
142330
+ if (segment === "**") return false;
142331
+ return segment.includes("**") || segment === "." || segment === ".." || !SEGMENT_PATTERN.test(segment);
142332
+ });
142333
+ if (invalidRawPathSegment) {
142334
+ failedRules.push({
142335
+ rule: source,
142336
+ reason: invalidRawPathSegment === "." || invalidRawPathSegment === ".." ? `Restriction segment "${invalidRawPathSegment}" is not allowed.` : invalidRawPathSegment.includes("**") ? `Invalid restriction segment "${invalidRawPathSegment}". "**" must be its own path segment.` : `Restriction segment "${invalidRawPathSegment}" contains unsupported characters.`
142337
+ });
142338
+ continue;
142339
+ }
142340
+ parsedRules.push({
142341
+ type: "deny",
142342
+ method: !rawMethod || rawMethod === "*" ? "*" : rawMethod,
142343
+ pathPattern: rawPath,
142344
+ source
142345
+ });
142346
+ continue;
142347
+ }
142348
+ if (source.includes("?") || source.includes("#")) {
142349
+ failedRules.push({
142350
+ rule: source,
142351
+ reason: `Restriction pattern "${source}" must not include query strings or fragments.`
142352
+ });
142353
+ continue;
142354
+ }
142355
+ if (!source.startsWith("/")) {
142356
+ failedRules.push({
142357
+ rule: source,
142358
+ reason: "Restriction path pattern must start with \"/\"."
142359
+ });
142360
+ continue;
142361
+ }
142362
+ const sourceSegments = source === "/" ? [] : source.slice(1).split("/");
142363
+ if (sourceSegments.some((segment) => segment.length === 0)) {
142364
+ failedRules.push({
142365
+ rule: source,
142366
+ reason: "Restriction path pattern must not contain empty segments."
142367
+ });
142368
+ continue;
142369
+ }
142370
+ const invalidSourceSegment = sourceSegments.find((segment) => {
142371
+ if (segment === "**") return false;
142372
+ return segment.includes("**") || segment === "." || segment === ".." || !SEGMENT_PATTERN.test(segment);
142373
+ });
142374
+ if (invalidSourceSegment) {
142375
+ failedRules.push({
142376
+ rule: source,
142377
+ reason: invalidSourceSegment === "." || invalidSourceSegment === ".." ? `Restriction segment "${invalidSourceSegment}" is not allowed.` : invalidSourceSegment.includes("**") ? `Invalid restriction segment "${invalidSourceSegment}". "**" must be its own path segment.` : `Restriction segment "${invalidSourceSegment}" contains unsupported characters.`
142378
+ });
142379
+ continue;
142380
+ }
142381
+ parsedRules.push({
142382
+ type: "deny",
142383
+ method: "*",
142384
+ pathPattern: source,
142385
+ source
142386
+ });
142387
+ }
142388
+ return {
142389
+ parsedRules,
142390
+ failedRules
142391
+ };
142392
+ }
142393
+ function parseAllowRule(input) {
142394
+ const inputs = typeof input === "string" ? [input] : input;
142395
+ const parsedRules = [];
142396
+ const failedRules = [];
142397
+ for (const source of inputs) {
142398
+ if (!source) {
142399
+ failedRules.push({
142400
+ rule: source,
142401
+ reason: "Allow value must not be empty."
142402
+ });
142403
+ continue;
142404
+ }
142405
+ const sep = source.indexOf(":");
142406
+ if (sep > 0 && !source.startsWith("/")) {
142407
+ const rawMethod = source.slice(0, sep).toUpperCase();
142408
+ const rawPath = source.slice(sep + 1);
142409
+ if (!rawPath) {
142410
+ failedRules.push({
142411
+ rule: source,
142412
+ reason: "Allow path pattern must not be empty."
142413
+ });
142414
+ continue;
142415
+ }
142416
+ if (rawMethod && rawMethod !== "*" && !HTTP_METHOD_SET.has(rawMethod)) {
142417
+ failedRules.push({
142418
+ rule: source,
142419
+ reason: `Unsupported allow method "${source.slice(0, sep)}".`
142420
+ });
142421
+ continue;
142422
+ }
142423
+ if (rawPath.includes("?") || rawPath.includes("#")) {
142424
+ failedRules.push({
142425
+ rule: source,
142426
+ reason: `Allow pattern "${rawPath}" must not include query strings or fragments.`
142427
+ });
142428
+ continue;
142429
+ }
142430
+ if (!rawPath.startsWith("/")) {
142431
+ failedRules.push({
142432
+ rule: source,
142433
+ reason: "Allow path pattern must start with \"/\"."
142434
+ });
142435
+ continue;
142436
+ }
142437
+ const rawPathSegments = rawPath === "/" ? [] : rawPath.slice(1).split("/");
142438
+ if (rawPathSegments.some((segment) => segment.length === 0)) {
142439
+ failedRules.push({
142440
+ rule: source,
142441
+ reason: "Allow path pattern must not contain empty segments."
142442
+ });
142443
+ continue;
142444
+ }
142445
+ const invalidRawPathSegment = rawPathSegments.find((segment) => {
142446
+ if (segment === "**") return false;
142447
+ return segment.includes("**") || segment === "." || segment === ".." || !SEGMENT_PATTERN.test(segment);
142448
+ });
142449
+ if (invalidRawPathSegment) {
142450
+ failedRules.push({
142451
+ rule: source,
142452
+ reason: invalidRawPathSegment === "." || invalidRawPathSegment === ".." ? `Allow segment "${invalidRawPathSegment}" is not allowed.` : invalidRawPathSegment.includes("**") ? `Invalid allow segment "${invalidRawPathSegment}". "**" must be its own path segment.` : `Allow segment "${invalidRawPathSegment}" contains unsupported characters.`
142453
+ });
142454
+ continue;
142455
+ }
142456
+ parsedRules.push({
142457
+ type: "allow",
142458
+ method: !rawMethod || rawMethod === "*" ? "*" : rawMethod,
142459
+ pathPattern: rawPath,
142460
+ source
142461
+ });
142462
+ continue;
142463
+ }
142464
+ if (source.includes("?") || source.includes("#")) {
142465
+ failedRules.push({
142466
+ rule: source,
142467
+ reason: `Allow pattern "${source}" must not include query strings or fragments.`
142468
+ });
142469
+ continue;
142470
+ }
142471
+ if (!source.startsWith("/")) {
142472
+ failedRules.push({
142473
+ rule: source,
142474
+ reason: "Allow path pattern must start with \"/\"."
142475
+ });
142476
+ continue;
142477
+ }
142478
+ const sourceSegments = source === "/" ? [] : source.slice(1).split("/");
142479
+ if (sourceSegments.some((segment) => segment.length === 0)) {
142480
+ failedRules.push({
142481
+ rule: source,
142482
+ reason: "Allow path pattern must not contain empty segments."
142483
+ });
142484
+ continue;
142485
+ }
142486
+ const invalidSourceSegment = sourceSegments.find((segment) => {
142487
+ if (segment === "**") return false;
142488
+ return segment.includes("**") || segment === "." || segment === ".." || !SEGMENT_PATTERN.test(segment);
142489
+ });
142490
+ if (invalidSourceSegment) {
142491
+ failedRules.push({
142492
+ rule: source,
142493
+ reason: invalidSourceSegment === "." || invalidSourceSegment === ".." ? `Allow segment "${invalidSourceSegment}" is not allowed.` : invalidSourceSegment.includes("**") ? `Invalid allow segment "${invalidSourceSegment}". "**" must be its own path segment.` : `Allow segment "${invalidSourceSegment}" contains unsupported characters.`
142494
+ });
142495
+ continue;
142496
+ }
142497
+ parsedRules.push({
142498
+ type: "allow",
142499
+ method: "*",
142500
+ pathPattern: source,
142501
+ source
142502
+ });
142503
+ }
142504
+ return {
142505
+ parsedRules,
142506
+ failedRules
142507
+ };
142508
+ }
142509
+ //#endregion
142368
142510
  //#region src/codemode/execute.ts
142369
142511
  const NO_DEFAULT_EXPORT_MESSAGE = "Execution completed without returning a value.";
142370
142512
  const QUERY_ENTRY_PATH = "/codemode-query.mjs";
142371
142513
  const EXECUTE_ENTRY_PATH = "/codemode-execute.mjs";
142372
142514
  const runtimeSemaphore = new AsyncSemaphore(3);
142373
142515
  const BLOCKED_REQUEST_PREFIX = "Request blocked by MCP connection policy.";
142374
- function serializeExecuteConfig(tenantUrl, headers, restrictions) {
142516
+ function serializeExecuteConfig(tenantUrl, headers, restrictions, allowRules) {
142375
142517
  const normalizedTenantUrl = new URL(tenantUrl).toString();
142376
- const restrictionSources = restrictions.map((rule) => {
142377
- const parsedRule = parseRestrictionRule(rule.source);
142378
- if (parsedRule.method !== rule.method || parsedRule.pathPattern !== rule.pathPattern) throw new TypeError(`Restriction source "${rule.source}" does not match its parsed shape.`);
142379
- return parsedRule.source;
142380
- });
142518
+ const serializedRestrictions = restrictions.map(({ type, method, pathPattern, source }) => ({
142519
+ type,
142520
+ method,
142521
+ pathPattern,
142522
+ source
142523
+ }));
142524
+ const serializedAllowRules = allowRules.map(({ type, method, pathPattern, source }) => ({
142525
+ type,
142526
+ method,
142527
+ pathPattern,
142528
+ source
142529
+ }));
142381
142530
  return JSON.stringify({
142382
142531
  tenantUrl: normalizedTenantUrl,
142383
- tenantOrigin: new URL(normalizedTenantUrl).origin,
142384
142532
  authHeaders: headers,
142385
- restrictionSources
142533
+ restrictions: serializedRestrictions,
142534
+ allowRules: serializedAllowRules
142386
142535
  });
142387
142536
  }
142388
142537
  function createQueryRuntime() {
@@ -142399,11 +142548,11 @@ function createQueryRuntime() {
142399
142548
  cpuTimeLimitMs: 5e4
142400
142549
  });
142401
142550
  }
142402
- function createExecuteRuntime(tenantUrl, restrictions) {
142551
+ function createExecuteRuntime(tenantUrl, restrictions, allowRules) {
142403
142552
  return new NodeRuntime({
142404
142553
  systemDriver: createNodeDriver({
142405
142554
  useDefaultNetwork: true,
142406
- permissions: { network: (request) => createNetworkPermissionDecision(tenantUrl, request, restrictions) }
142555
+ permissions: { network: (request) => createNetworkPermissionDecision(tenantUrl, request, restrictions, allowRules) }
142407
142556
  }),
142408
142557
  runtimeDriverFactory: createNodeRuntimeDriverFactory(),
142409
142558
  memoryLimit: 128,
@@ -142415,11 +142564,11 @@ function normalizeCode(functionCode) {
142415
142564
  normalized = normalized.replace(/^```(?:js|javascript|ts|typescript)?\s*/i, "").replace(/\s*```$/, "").trim();
142416
142565
  return normalized;
142417
142566
  }
142418
- function buildQueryScript(sourceCode, restrictions) {
142419
- const restrictedSpec = applyRestrictionsToOpenApiSpec(getCoreOpenApiSpec(), restrictions);
142567
+ function buildQueryScript(sourceCode, _restrictions, _allowRules) {
142568
+ const spec = getCoreOpenApiSpec();
142420
142569
  const functionExpression = normalizeCode(sourceCode);
142421
142570
  return [
142422
- `const spec = ${JSON.stringify(restrictedSpec)};`,
142571
+ `const spec = ${JSON.stringify(spec)};`,
142423
142572
  `const __mc8ypQuery = (${functionExpression});`,
142424
142573
  "",
142425
142574
  "if (typeof __mc8ypQuery !== \"function\") {",
@@ -142429,26 +142578,25 @@ function buildQueryScript(sourceCode, restrictions) {
142429
142578
  "export default await __mc8ypQuery();"
142430
142579
  ].join("\n\n");
142431
142580
  }
142432
- function buildExecutePrelude(tenantUrl, headers, restrictions = []) {
142581
+ function buildExecutePrelude(tenantUrl, headers, restrictions = [], allowRules = []) {
142433
142582
  return [
142434
142583
  "const cumulocity = Object.freeze((() => {",
142435
- ` const config = JSON.parse(${JSON.stringify(serializeExecuteConfig(tenantUrl, headers, restrictions))});`,
142584
+ ` const config = JSON.parse(${JSON.stringify(serializeExecuteConfig(tenantUrl, headers, restrictions, allowRules))});`,
142436
142585
  " if (!config || typeof config !== \"object\") {",
142437
142586
  " throw new TypeError(\"Invalid execute configuration.\");",
142438
142587
  " }",
142439
- " const { tenantUrl, tenantOrigin, authHeaders, restrictionSources } = config;",
142440
- " if (typeof tenantUrl !== \"string\" || typeof tenantOrigin !== \"string\") {",
142441
- " throw new TypeError(\"Execute configuration must contain string tenant values.\");",
142588
+ " const { tenantUrl, authHeaders, restrictions, allowRules } = config;",
142589
+ " if (typeof tenantUrl !== \"string\") {",
142590
+ " throw new TypeError(\"Execute configuration must contain a string tenant URL.\");",
142442
142591
  " }",
142443
- " if (!Array.isArray(restrictionSources) || restrictionSources.some((source) => typeof source !== \"string\")) {",
142444
- " throw new TypeError(\"Execute configuration must contain string restriction sources.\");",
142592
+ " if (!Array.isArray(restrictions) || restrictions.some((rule) => !rule || typeof rule !== \"object\" || typeof rule.type !== \"string\" || typeof rule.method !== \"string\" || typeof rule.pathPattern !== \"string\" || typeof rule.source !== \"string\")) {",
142593
+ " throw new TypeError(\"Execute configuration must contain valid restriction rules.\");",
142594
+ " }",
142595
+ " if (!Array.isArray(allowRules) || allowRules.some((rule) => !rule || typeof rule !== \"object\" || typeof rule.type !== \"string\" || typeof rule.method !== \"string\" || typeof rule.pathPattern !== \"string\" || typeof rule.source !== \"string\")) {",
142596
+ " throw new TypeError(\"Execute configuration must contain valid allow rules.\");",
142445
142597
  " }",
142446
142598
  " const resolveUrl = (descriptor) => {",
142447
- " const resolved = new URL(descriptor, tenantUrl.endsWith(\"/\") ? tenantUrl : tenantUrl + \"/\");",
142448
- " if (resolved.origin !== tenantOrigin) {",
142449
- " throw new Error(\"Cumulocity requests must target the configured tenant origin.\");",
142450
- " }",
142451
- " return resolved;",
142599
+ " return new URL(descriptor, tenantUrl.endsWith(\"/\") ? tenantUrl : tenantUrl + \"/\");",
142452
142600
  " };",
142453
142601
  " const normalizeRequest = (options) => {",
142454
142602
  " if (!options || typeof options !== \"object\") {",
@@ -142457,34 +142605,85 @@ function buildExecutePrelude(tenantUrl, headers, restrictions = []) {
142457
142605
  " const { path, ...rest } = options;",
142458
142606
  " return { path, init: rest };",
142459
142607
  " };",
142460
- ` const HTTP_METHOD_SET = new Set(${JSON.stringify(HTTP_METHODS)});`,
142461
- ` const normalizeRestrictionMatchPath = ${normalizeRestrictionMatchPath.toString()};`,
142462
- ` const normalizeAndValidateRestrictionPath = ${normalizeAndValidateRestrictionPath.toString()};`,
142463
- ` const parseRestrictionRule = ${parseRestrictionRule.toString()};`,
142608
+ ` const HTTP_METHODS = ${JSON.stringify(HTTP_METHODS)};`,
142609
+ " const HTTP_METHOD_SET = new Set(HTTP_METHODS);",
142464
142610
  ` const escapeRestrictionRegex = ${escapeRestrictionRegex.toString()};`,
142465
142611
  ` const compileRestrictionSegment = ${compileRestrictionSegment.toString()};`,
142466
142612
  ` const matchCompiledSegments = ${matchCompiledSegments.toString()};`,
142467
142613
  ` const compileRestrictionRule = ${compileRestrictionRule.toString()};`,
142468
142614
  ` const matchesCompiledRule = ${matchesCompiledRule.toString()};`,
142469
- ` const compileRestrictionSources = ${compileRestrictionSources.toString()};`,
142470
- ` const getBlockedCompiledRestrictions = ${getBlockedCompiledRestrictions.toString()};`,
142471
- " const compiledRestrictions = compileRestrictionSources(restrictionSources);",
142472
- " const formatBlockedRequestMessage = (method, path, matchingRules) => [",
142473
- " \"Request blocked by MCP connection policy.\",",
142474
- " \"\",",
142475
- " \"This operation is intentionally denied by the current MCP connection configuration.\",",
142476
- " \"It did not fail at the Cumulocity API and it was not executed against the tenant.\",",
142477
- " \"Retrying or trying the same operation again through this connection will not succeed.\",",
142478
- " \"\",",
142479
- " \"Report this to the user as a connection-level access restriction.\",",
142480
- " \"If the operation is needed, the MCP restrictions for this connection must be updated by whoever manages that configuration.\",",
142481
- " \"\",",
142482
- " \"Blocked operation:\",",
142483
- " \"Method: \" + method,",
142484
- " \"Path: \" + path,",
142485
- " \"Matching restrictions:\",",
142486
- " ...matchingRules.map((rule) => \"- \" + rule),",
142487
- " ].join(\"\\n\");",
142615
+ " const compiledRestrictions = restrictions.map(compileRestrictionRule);",
142616
+ " const compiledAllowRules = allowRules.map(compileRestrictionRule);",
142617
+ " const findMatchingRules = (compiledRules, method, pathname) => {",
142618
+ " const normalizedMethod = typeof method === \"string\" ? method.trim().toUpperCase() : \"\";",
142619
+ " const pathSegments = pathname === \"/\" ? [] : pathname.slice(1).split(\"/\");",
142620
+ " return compiledRules.filter((rule) => {",
142621
+ " if (!normalizedMethod) {",
142622
+ " return rule.method === \"*\" && matchCompiledSegments(rule.segments, pathSegments);",
142623
+ " }",
142624
+ " return matchesCompiledRule(rule, normalizedMethod, pathSegments);",
142625
+ " });",
142626
+ " };",
142627
+ " const evaluateAccessPolicy = (method, pathname) => {",
142628
+ " const matchingRestrictions = findMatchingRules(compiledRestrictions, method, pathname);",
142629
+ " if (matchingRestrictions.length > 0) {",
142630
+ " return { blocked: true, blockedBy: \"restriction\", matchingRestrictions };",
142631
+ " }",
142632
+ " if (compiledAllowRules.length === 0) {",
142633
+ " return { blocked: false };",
142634
+ " }",
142635
+ " const matchingAllowRules = findMatchingRules(compiledAllowRules, method, pathname);",
142636
+ " if (matchingAllowRules.length > 0) {",
142637
+ " return { blocked: false };",
142638
+ " }",
142639
+ " return { blocked: true, blockedBy: \"allow\" };",
142640
+ " };",
142641
+ " const validateRequestMethod = (method) => {",
142642
+ " if (typeof method !== \"string\" || method.trim().length === 0) {",
142643
+ " throw new TypeError(\"request method must be a non-empty string\");",
142644
+ " }",
142645
+ " const normalizedMethod = method.trim().toUpperCase();",
142646
+ " if (!HTTP_METHOD_SET.has(normalizedMethod)) {",
142647
+ " throw new TypeError(\"request method must be one of: \" + HTTP_METHODS.join(\", \"));",
142648
+ " }",
142649
+ " return normalizedMethod;",
142650
+ " };",
142651
+ " const formatBlockedRequestMessage = (method, path, accessDecision) => {",
142652
+ " if (accessDecision.blockedBy === \"allow\") {",
142653
+ " return [",
142654
+ " \"Request blocked by MCP connection policy.\",",
142655
+ " \"\",",
142656
+ " \"This operation is intentionally blocked because it is not included in the current MCP connection allow list.\",",
142657
+ " \"It did not fail at the Cumulocity API and it was not executed against the tenant.\",",
142658
+ " \"Retrying or trying the same operation again through this connection will not succeed.\",",
142659
+ " \"\",",
142660
+ " \"Report this to the user as a connection-level access restriction.\",",
142661
+ " \"If the operation is needed, the MCP allow list for this connection must be updated by whoever manages that configuration.\",",
142662
+ " \"\",",
142663
+ " \"Blocked operation:\",",
142664
+ " \"Method: \" + method,",
142665
+ " \"Path: \" + path,",
142666
+ " \"Configured allow rules:\",",
142667
+ " ...(allowRules.length > 0 ? allowRules.map((rule) => \"- \" + rule.source) : [\"- (none)\"]),",
142668
+ " ].join(\"\\n\");",
142669
+ " }",
142670
+ " return [",
142671
+ " \"Request blocked by MCP connection policy.\",",
142672
+ " \"\",",
142673
+ " \"This operation is intentionally denied by the current MCP connection configuration.\",",
142674
+ " \"It did not fail at the Cumulocity API and it was not executed against the tenant.\",",
142675
+ " \"Retrying or trying the same operation again through this connection will not succeed.\",",
142676
+ " \"\",",
142677
+ " \"Report this to the user as a connection-level access restriction.\",",
142678
+ " \"If the operation is needed, the MCP restrictions for this connection must be updated by whoever manages that configuration.\",",
142679
+ " \"\",",
142680
+ " \"Blocked operation:\",",
142681
+ " \"Method: \" + method,",
142682
+ " \"Path: \" + path,",
142683
+ " \"Matching restrictions:\",",
142684
+ " ...accessDecision.matchingRestrictions.map((rule) => \"- \" + rule.source),",
142685
+ " ].join(\"\\n\");",
142686
+ " };",
142488
142687
  " const normalizeBody = (headers, body) => {",
142489
142688
  " if (body == null || typeof body === \"string\") {",
142490
142689
  " return body;",
@@ -142514,11 +142713,11 @@ function buildExecutePrelude(tenantUrl, headers, restrictions = []) {
142514
142713
  " if (typeof path !== \"string\" || path.length === 0) {",
142515
142714
  " throw new TypeError(\"request path must be a non-empty string\");",
142516
142715
  " }",
142716
+ " const method = validateRequestMethod(init.method);",
142517
142717
  " const resolvedUrl = resolveUrl(path);",
142518
- " const blockedRules = getBlockedCompiledRestrictions(compiledRestrictions, init.method, resolvedUrl.pathname);",
142519
- " if (blockedRules.length > 0) {",
142520
- " const method = typeof init.method === \"string\" && init.method.trim() ? init.method.trim().toUpperCase() : \"GET\";",
142521
- " throw new Error(formatBlockedRequestMessage(method, resolvedUrl.pathname, blockedRules.map((rule) => rule.source)));",
142718
+ " const accessDecision = evaluateAccessPolicy(method, resolvedUrl.pathname);",
142719
+ " if (accessDecision.blocked) {",
142720
+ " throw new Error(formatBlockedRequestMessage(method, resolvedUrl.pathname, accessDecision));",
142522
142721
  " }",
142523
142722
  " const headers = new Headers(init.headers ?? {});",
142524
142723
  " for (const [key, value] of Object.entries(authHeaders)) {",
@@ -142528,6 +142727,7 @@ function buildExecutePrelude(tenantUrl, headers, restrictions = []) {
142528
142727
  " const requestHeaders = Object.fromEntries(headers.entries());",
142529
142728
  " const response = await fetch(resolvedUrl.toString(), {",
142530
142729
  " ...init,",
142730
+ " method,",
142531
142731
  " headers: requestHeaders,",
142532
142732
  " body,",
142533
142733
  " });",
@@ -142542,10 +142742,10 @@ function buildExecutePrelude(tenantUrl, headers, restrictions = []) {
142542
142742
  "})());"
142543
142743
  ].join("\n\n");
142544
142744
  }
142545
- function buildExecuteScript(sourceCode, tenantUrl, headers, restrictions = []) {
142745
+ function buildExecuteScript(sourceCode, tenantUrl, headers, restrictions = [], allowRules = []) {
142546
142746
  const functionExpression = normalizeCode(sourceCode);
142547
142747
  return [
142548
- buildExecutePrelude(tenantUrl, headers, restrictions),
142748
+ buildExecutePrelude(tenantUrl, headers, restrictions, allowRules),
142549
142749
  `const __mc8ypExecute = (${functionExpression});`,
142550
142750
  "",
142551
142751
  "const __mc8ypErrorMessage = (error) => error instanceof Error ? error.message : String(error);",
@@ -142579,9 +142779,9 @@ function extractDefaultExport(exportsObject) {
142579
142779
  if (typeof exportsObject !== "undefined") return exportsObject;
142580
142780
  throw new Error(NO_DEFAULT_EXPORT_MESSAGE);
142581
142781
  }
142582
- async function runExecuteScript(code, tenantUrl, restrictions) {
142782
+ async function runExecuteScript(code, tenantUrl, restrictions, allowRules) {
142583
142783
  const release = await runtimeSemaphore.acquire();
142584
- const runtime = createExecuteRuntime(tenantUrl, restrictions);
142784
+ const runtime = createExecuteRuntime(tenantUrl, restrictions, allowRules);
142585
142785
  try {
142586
142786
  const result = await runtime.run(code, EXECUTE_ENTRY_PATH);
142587
142787
  if (result.code !== 0) {
@@ -142611,14 +142811,14 @@ async function runModule(code, entryPath, runtime) {
142611
142811
  release();
142612
142812
  }
142613
142813
  }
142614
- async function query(functionCode, restrictions = []) {
142615
- const result = await runQueryScript(buildQueryScript(functionCode, restrictions));
142814
+ async function query(functionCode, restrictions = [], allowRules = []) {
142815
+ const result = await runQueryScript(buildQueryScript(functionCode, restrictions, allowRules));
142616
142816
  return typeof result === "string" ? result : JSON.stringify(result);
142617
142817
  }
142618
- async function execute(functionCode, input, restrictions = []) {
142818
+ async function execute(functionCode, input, restrictions = [], allowRules = []) {
142619
142819
  const auth = await resolveC8yAuth(input);
142620
142820
  const authHeaders = createC8yAuthHeaders(auth);
142621
- const result = await runExecuteScript(buildExecuteScript(functionCode, auth.tenantUrl, authHeaders, restrictions), auth.tenantUrl, restrictions);
142821
+ const result = await runExecuteScript(buildExecuteScript(functionCode, auth.tenantUrl, authHeaders, restrictions, allowRules), auth.tenantUrl, restrictions, allowRules);
142622
142822
  if (result.status === "success") return encode(result.result);
142623
142823
  return result.error.message;
142624
142824
  }
@@ -142688,8 +142888,8 @@ Recommended shapes:
142688
142888
 
142689
142889
  If your function returns a string, it is returned as-is. Otherwise the result is returned as JSON text.
142690
142890
 
142691
- The current MCP connection may mark blocked operations with \`x-mc8yp-restricted\` and related \`x-mc8yp-*\` fields. These operations are intentionally unavailable even though they still exist in the OpenAPI spec.
142692
- Treat those annotations as a hard connection-level restriction: use them to understand what exists, but do not plan to call those operations with \`execute\`.
142891
+ The spec exposed by \`query\` is the raw bundled OpenAPI snapshot. It is not rewritten for the current MCP connection policy.
142892
+ The current MCP connection may still block \`execute\` calls through restrictions and/or an allow list, so do not assume every operation visible in the spec is executable on this connection.
142693
142893
 
142694
142894
  Examples:
142695
142895
  () => {
@@ -142713,7 +142913,7 @@ Examples:
142713
142913
  schema: v.object({ code: createCodeSchema("A JavaScript function expression. The top-level binding `spec` is available automatically. Return the final result from that function. Async functions are supported.") })
142714
142914
  }, async (input) => {
142715
142915
  try {
142716
- return tool.text(await query(input.code, server.ctx.custom?.restrictions ?? []));
142916
+ return tool.text(await query(input.code, server.ctx.custom?.restrictions ?? [], server.ctx.custom?.allowRules ?? []));
142717
142917
  } catch (error) {
142718
142918
  const message = error instanceof Error ? error.message : String(error);
142719
142919
  return tool.error(message);
@@ -142754,7 +142954,8 @@ Tool output behavior:
142754
142954
  - On success, the actual function result is returned in Toon format.
142755
142955
  - On blocked or failed execution, the tool returns a plain text message.
142756
142956
 
142757
- The current MCP connection may deny certain method/path combinations. Restricted routes remain visible in the spec, and \`cumulocity.request(...)\` will reject blocked calls before sending them.
142957
+ The current MCP connection may deny certain method/path combinations and may also use an allow list.
142958
+ The \`query\` tool does not annotate or filter the OpenAPI spec for that policy, and \`cumulocity.request(...)\` will reject blocked calls before sending them.
142758
142959
  When that happens, the tool returns a plain text connection-policy message. That is not a Cumulocity API failure and retrying the same operation through the same connection will not help.
142759
142960
 
142760
142961
  ${getExecuteEnvironmentNote()}
@@ -142788,7 +142989,7 @@ async () => {
142788
142989
  schema: addTenantURLToSchema(v.object({ code: createCodeSchema("An async JavaScript function expression. The top-level binding `cumulocity` is available automatically. Return the final result from that function. `await` is supported.") }))
142789
142990
  }, async (input) => {
142790
142991
  try {
142791
- return tool.text(await execute(input.code, input, server.ctx.custom?.restrictions ?? []));
142992
+ return tool.text(await execute(input.code, input, server.ctx.custom?.restrictions ?? [], server.ctx.custom?.allowRules ?? []));
142792
142993
  } catch (error) {
142793
142994
  const message = error instanceof Error ? error.message : String(error);
142794
142995
  return tool.error(message);
@@ -142826,7 +143027,7 @@ function createC8YMcpServer() {
142826
143027
  capabilities: {
142827
143028
  tools: { listChanged: true },
142828
143029
  prompts: { listChanged: true },
142829
- resources: { listChanged: false }
143030
+ resources: { listChanged: true }
142830
143031
  }
142831
143032
  }).withContext();
142832
143033
  server.tools(createTools(server));
@@ -142921,7 +143122,12 @@ runMain(defineCommand({
142921
143122
  restriction: {
142922
143123
  type: "string",
142923
143124
  description: "Restriction rule to deny API access (e.g. \"GET:/inventory/**\"). Can be repeated.",
142924
- alias: "r"
143125
+ alias: ["r", "restrict"]
143126
+ },
143127
+ allowed: {
143128
+ type: "string",
143129
+ description: "Allow rule to permit API access (e.g. \"GET:/inventory/**\"). Can be repeated. When present, non-matching operations are blocked unless another allow rule matches them.",
143130
+ alias: ["a", "allow"]
142925
143131
  },
142926
143132
  spec: {
142927
143133
  type: "string",
@@ -142942,12 +143148,20 @@ runMain(defineCommand({
142942
143148
  if (!selected) throw new Error(`Invalid --spec value "${requested}". Available: ${specs.map((s) => s.version).join(", ")}`);
142943
143149
  setCoreOpenApiVersion(selected.version);
142944
143150
  consola.info(`Using core OpenAPI snapshot: ${getCoreOpenApiLabel()}`);
142945
- const raw = args.restriction;
142946
- const restrictions = (Array.isArray(raw) ? raw : raw ? [raw] : []).filter(Boolean).map(parseRestrictionRule);
143151
+ const rawRestrictions = args.restriction;
143152
+ const { parsedRules: restrictions, failedRules: failedRestrictions } = parseRestrictionRule((Array.isArray(rawRestrictions) ? rawRestrictions : rawRestrictions ? [rawRestrictions] : []).filter((value) => typeof value === "string" && value.length > 0));
143153
+ if (failedRestrictions.length > 0) throw new Error(["One or more restriction flags could not be parsed:", ...failedRestrictions.map((rule) => `- ${rule.rule}: ${rule.reason}`)].join("\n"));
143154
+ const rawAllowed = args.allowed;
143155
+ const { parsedRules: allowRules, failedRules: failedAllowRules } = parseAllowRule((Array.isArray(rawAllowed) ? rawAllowed : rawAllowed ? [rawAllowed] : []).filter((value) => typeof value === "string" && value.length > 0));
143156
+ if (failedAllowRules.length > 0) throw new Error(["One or more allow flags could not be parsed:", ...failedAllowRules.map((rule) => `- ${rule.rule}: ${rule.reason}`)].join("\n"));
142947
143157
  if (restrictions.length > 0) consola.info(`Applying ${restrictions.length} restriction rule(s):`, restrictions.map((r) => r.source));
143158
+ if (allowRules.length > 0) consola.info(`Applying ${allowRules.length} allow rule(s):`, allowRules.map((rule) => rule.source));
142948
143159
  const transport = new StdioTransport(createC8YMcpServer());
142949
143160
  consola.info("Starting MCP server over stdio transport...");
142950
- transport.listen({ restrictions });
143161
+ transport.listen({
143162
+ restrictions,
143163
+ allowRules
143164
+ });
142951
143165
  }
142952
143166
  }));
142953
143167
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mc8yp",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "type": "module",
5
5
  "description": "Cumulocity IoT MCP Server - Model Context Protocol integration for IoT device management",
6
6
  "keywords": [
@@ -49,6 +49,7 @@
49
49
  "devDependencies": {
50
50
  "@schplitt/eslint-config": "^1.4.0",
51
51
  "@types/node": "^24.12.2",
52
+ "changelogithub": "^14.0.0",
52
53
  "eslint": "^9.39.4",
53
54
  "tsdown": "^0.21.9",
54
55
  "typescript": "^5.9.3",