mc8yp 2.0.1 → 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 +104 -27
  2. package/dist/cli.mjs +425 -270
  3. package/package.json +1 -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:
175
207
 
176
- ### Examples
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
177
210
 
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 |
211
+ ### Path Pattern Examples
212
+
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
 
package/dist/cli.mjs CHANGED
@@ -40,7 +40,7 @@ var __require = /* @__PURE__ */ createRequire(import.meta.url);
40
40
  //#endregion
41
41
  //#region package.json
42
42
  var name = "mc8yp";
43
- var version = "2.0.1";
43
+ var version = "2.1.0";
44
44
  var description = "Cumulocity IoT MCP Server - Model Context Protocol integration for IoT device management";
45
45
  //#endregion
46
46
  //#region \0virtual:core-openapi
@@ -85233,7 +85233,9 @@ function createCodeModeGuidePrompt(server) {
85233
85233
  description: "Guide for the two code-mode tools: query and execute, including available shapes and examples."
85234
85234
  }, () => {
85235
85235
  const restrictions = server.ctx.custom?.restrictions ?? [];
85236
- 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` : "";
85237
85239
  return prompt.message(`# Cumulocity Code Mode
85238
85240
 
85239
85241
  You have exactly two MCP tools available.
@@ -85246,8 +85248,8 @@ Use \`query\` when you need to inspect the core OpenAPI spec.
85246
85248
  - Return the exact value you want back from that function
85247
85249
  - Sync and async functions are both supported
85248
85250
  - Strings are returned as-is; other results are returned as JSON text
85249
- - Restricted operations stay visible and are annotated with \`x-mc8yp-restricted\` and related \`x-mc8yp-*\` fields
85250
- - 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
85251
85253
 
85252
85254
  ### Available Shape
85253
85255
  \`\`\`ts
@@ -85295,8 +85297,8 @@ Use \`execute\` when you want to call the real Cumulocity API.
85295
85297
  - Return the value you want from that function; async functions are usually the right choice here
85296
85298
  - On success, the returned value is sent back in Toon format
85297
85299
  - If execution is blocked or fails, execute returns a plain text error message
85298
- - The current MCP connection may reject restricted method/path combinations before network access
85299
- - 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
85300
85302
 
85301
85303
  ### Available Shape
85302
85304
  \`\`\`ts
@@ -85362,139 +85364,32 @@ function createPrompts(server) {
85362
85364
  return [createCodeModeGuidePrompt(server)];
85363
85365
  }
85364
85366
  //#endregion
85365
- //#region src/utils/restrictions.ts
85366
- const HTTP_METHODS = [
85367
- "DELETE",
85368
- "GET",
85369
- "HEAD",
85370
- "OPTIONS",
85371
- "PATCH",
85372
- "POST",
85373
- "PUT",
85374
- "TRACE"
85375
- ];
85376
- const HTTP_METHOD_SET = new Set(HTTP_METHODS);
85377
- const RESTRICTION_EXTENSION_KEY = "x-mc8yp-restrictions";
85378
- const RESTRICTED_OPERATION_FLAG = "x-mc8yp-restricted";
85379
- const RESTRICTED_OPERATION_MESSAGE = "x-mc8yp-restrictionMessage";
85380
- const RESTRICTED_OPERATION_RULES = "x-mc8yp-restrictionRules";
85381
- const RESTRICTED_OPERATION_TYPE = "x-mc8yp-restrictionType";
85382
- const RESTRICTED_AGENT_NOTE = "x-mc8yp-agentNote";
85383
- function parseRestrictionRule(input) {
85384
- const segmentPattern = /^[A-Za-z0-9._~*-]+$/;
85385
- const inputs = typeof input === "string" ? [input] : input;
85386
- const parsedRules = [];
85387
- const failedRules = [];
85388
- for (const source of inputs) {
85389
- if (!source) {
85390
- failedRules.push({
85391
- rule: source,
85392
- reason: "Restriction value must not be empty."
85393
- });
85394
- continue;
85395
- }
85396
- const sep = source.indexOf(":");
85397
- if (sep > 0 && !source.startsWith("/")) {
85398
- const rawMethod = source.slice(0, sep).toUpperCase();
85399
- const rawPath = source.slice(sep + 1);
85400
- if (!rawPath) {
85401
- failedRules.push({
85402
- rule: source,
85403
- reason: "Restriction path pattern must not be empty."
85404
- });
85405
- continue;
85406
- }
85407
- if (rawMethod && rawMethod !== "*" && !HTTP_METHOD_SET.has(rawMethod)) {
85408
- failedRules.push({
85409
- rule: source,
85410
- reason: `Unsupported restriction method "${source.slice(0, sep)}".`
85411
- });
85412
- continue;
85413
- }
85414
- if (rawPath.includes("?") || rawPath.includes("#")) {
85415
- failedRules.push({
85416
- rule: source,
85417
- reason: `Restriction pattern "${rawPath}" must not include query strings or fragments.`
85418
- });
85419
- continue;
85420
- }
85421
- if (!rawPath.startsWith("/")) {
85422
- failedRules.push({
85423
- rule: source,
85424
- reason: "Restriction path pattern must start with \"/\"."
85425
- });
85426
- continue;
85427
- }
85428
- const rawPathSegments = rawPath === "/" ? [] : rawPath.slice(1).split("/");
85429
- if (rawPathSegments.some((segment) => segment.length === 0)) {
85430
- failedRules.push({
85431
- rule: source,
85432
- reason: "Restriction path pattern must not contain empty segments."
85433
- });
85434
- continue;
85435
- }
85436
- const invalidRawPathSegment = rawPathSegments.find((segment) => {
85437
- if (segment === "**") return false;
85438
- return segment.includes("**") || segment === "." || segment === ".." || !segmentPattern.test(segment);
85439
- });
85440
- if (invalidRawPathSegment) {
85441
- failedRules.push({
85442
- rule: source,
85443
- 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.`
85444
- });
85445
- continue;
85446
- }
85447
- parsedRules.push({
85448
- method: !rawMethod || rawMethod === "*" ? "*" : rawMethod,
85449
- pathPattern: rawPath,
85450
- source
85451
- });
85452
- continue;
85453
- }
85454
- if (source.includes("?") || source.includes("#")) {
85455
- failedRules.push({
85456
- rule: source,
85457
- reason: `Restriction pattern "${source}" must not include query strings or fragments.`
85458
- });
85459
- continue;
85460
- }
85461
- if (!source.startsWith("/")) {
85462
- failedRules.push({
85463
- rule: source,
85464
- reason: "Restriction path pattern must start with \"/\"."
85465
- });
85466
- continue;
85467
- }
85468
- const sourceSegments = source === "/" ? [] : source.slice(1).split("/");
85469
- if (sourceSegments.some((segment) => segment.length === 0)) {
85470
- failedRules.push({
85471
- rule: source,
85472
- reason: "Restriction path pattern must not contain empty segments."
85473
- });
85474
- continue;
85475
- }
85476
- const invalidSourceSegment = sourceSegments.find((segment) => {
85477
- if (segment === "**") return false;
85478
- return segment.includes("**") || segment === "." || segment === ".." || !segmentPattern.test(segment);
85479
- });
85480
- if (invalidSourceSegment) {
85481
- failedRules.push({
85482
- rule: source,
85483
- 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.`
85484
- });
85485
- continue;
85486
- }
85487
- parsedRules.push({
85488
- method: "*",
85489
- pathPattern: source,
85490
- source
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;
85375
+ }
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);
85491
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()?.();
85390
+ };
85492
85391
  }
85493
- return {
85494
- parsedRules,
85495
- failedRules
85496
- };
85497
- }
85392
+ };
85498
85393
  //#endregion
85499
85394
  //#region src/utils/restriction-matcher.ts
85500
85395
  function escapeRestrictionRegex(value) {
@@ -85530,7 +85425,7 @@ function compileRestrictionRule(rule) {
85530
85425
  function matchesCompiledRule(rule, method, pathSegments) {
85531
85426
  return (rule.method === "*" || rule.method === method) && matchCompiledSegments(rule.segments, pathSegments);
85532
85427
  }
85533
- function findBlockingRestrictions(rules, method, pathname) {
85428
+ function findMatchingRules(rules, method, pathname) {
85534
85429
  const normalizedMethod = typeof method === "string" ? method.trim().toUpperCase() : "";
85535
85430
  const pathSegments = pathname === "/" ? [] : pathname.slice(1).split("/");
85536
85431
  return rules.filter((rule) => {
@@ -85539,88 +85434,26 @@ function findBlockingRestrictions(rules, method, pathname) {
85539
85434
  return matchesCompiledRule(compiledRule, normalizedMethod, pathSegments);
85540
85435
  });
85541
85436
  }
85542
- //#endregion
85543
- //#region src/codemode/openapi-restrictions.ts
85544
- const OPENAPI_OPERATION_METHODS = [
85545
- "delete",
85546
- "get",
85547
- "head",
85548
- "options",
85549
- "patch",
85550
- "post",
85551
- "put",
85552
- "trace"
85553
- ];
85554
- function annotateRestrictedOperation(operation, matchingRules) {
85555
- return {
85556
- ...operation,
85557
- [RESTRICTED_OPERATION_FLAG]: true,
85558
- [RESTRICTED_OPERATION_TYPE]: "deny",
85559
- [RESTRICTED_OPERATION_RULES]: matchingRules.map((rule) => rule.source),
85560
- [RESTRICTED_OPERATION_MESSAGE]: "This operation is blocked by the current MCP connection restrictions.",
85561
- [RESTRICTED_AGENT_NOTE]: "The route exists, but it is intentionally restricted for this MCP connection."
85562
- };
85437
+ function findBlockingRestrictions(rules, method, pathname) {
85438
+ return findMatchingRules(rules, method, pathname);
85563
85439
  }
85564
- function applyRestrictionsToOpenApiSpec(spec, rules) {
85565
- if (!spec.paths || rules.length === 0) return spec;
85566
- const compiledRules = rules.map(compileRestrictionRule);
85567
- let nextPaths;
85568
- for (const [path, pathItem] of Object.entries(spec.paths)) {
85569
- let nextPathItem;
85570
- const pathSegments = path === "/" ? [] : path.slice(1).split("/");
85571
- for (const method of OPENAPI_OPERATION_METHODS) {
85572
- const operation = pathItem[method];
85573
- if (!operation || typeof operation !== "object") continue;
85574
- const matchingRules = compiledRules.filter((rule) => matchesCompiledRule(rule, method.toUpperCase(), pathSegments));
85575
- if (matchingRules.length === 0) continue;
85576
- nextPathItem ??= { ...pathItem };
85577
- nextPathItem[method] = annotateRestrictedOperation(operation, matchingRules);
85578
- }
85579
- if (nextPathItem) {
85580
- nextPaths ??= { ...spec.paths };
85581
- nextPaths[path] = nextPathItem;
85582
- }
85583
- }
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 };
85584
85449
  return {
85585
- ...spec,
85586
- paths: nextPaths ?? spec.paths,
85587
- [RESTRICTION_EXTENSION_KEY]: {
85588
- mode: "deny",
85589
- rules: rules.map((rule) => rule.source),
85590
- message: "Operations marked with x-mc8yp-restricted are intentionally blocked for the current MCP connection."
85591
- }
85450
+ blocked: true,
85451
+ blockedBy: "allow"
85592
85452
  };
85593
85453
  }
85594
85454
  //#endregion
85595
- //#region src/codemode/semaphore.ts
85596
- var AsyncSemaphore = class {
85597
- #maxConcurrency;
85598
- #activeCount = 0;
85599
- #waiters = [];
85600
- constructor(maxConcurrency) {
85601
- if (!Number.isInteger(maxConcurrency) || maxConcurrency <= 0) throw new Error("maxConcurrency must be a positive integer.");
85602
- this.#maxConcurrency = maxConcurrency;
85603
- }
85604
- get activeCount() {
85605
- return this.#activeCount;
85606
- }
85607
- async acquire() {
85608
- if (this.#activeCount >= this.#maxConcurrency) await new Promise((resolve) => {
85609
- this.#waiters.push(resolve);
85610
- });
85611
- this.#activeCount += 1;
85612
- let released = false;
85613
- return () => {
85614
- if (released) return;
85615
- released = true;
85616
- this.#activeCount -= 1;
85617
- this.#waiters.shift()?.();
85618
- };
85619
- }
85620
- };
85621
- //#endregion
85622
85455
  //#region src/codemode/network-permissions.ts
85623
- function createNetworkPermissionDecision(tenantUrl, request, rules = []) {
85456
+ function createNetworkPermissionDecision(tenantUrl, request, restrictions = [], allowRules = []) {
85624
85457
  const tenantHostname = new URL(tenantUrl).hostname;
85625
85458
  if (request.op !== "connect") return {
85626
85459
  allow: false,
@@ -85631,11 +85464,28 @@ function createNetworkPermissionDecision(tenantUrl, request, rules = []) {
85631
85464
  reason: `Network connect blocked: only ${tenantHostname} is allowed in execute mode.`
85632
85465
  };
85633
85466
  if (typeof request.url === "string") {
85634
- const blockingRules = findBlockingRestrictions(rules, typeof request.method === "string" ? request.method.trim() : void 0, new URL(request.url).pathname);
85635
- if (blockingRules.length > 0) return {
85636
- allow: false,
85637
- reason: `Network connect blocked by MCP restrictions: ${blockingRules.map((rule) => rule.source).join(", ")}`
85638
- };
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
+ };
85488
+ }
85639
85489
  }
85640
85490
  return { allow: true };
85641
85491
  }
@@ -142411,15 +142261,268 @@ function createC8yAuthHeaders(auth) {
142411
142261
  throw new Error("Invalid authentication credentials");
142412
142262
  }
142413
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
142414
142510
  //#region src/codemode/execute.ts
142415
142511
  const NO_DEFAULT_EXPORT_MESSAGE = "Execution completed without returning a value.";
142416
142512
  const QUERY_ENTRY_PATH = "/codemode-query.mjs";
142417
142513
  const EXECUTE_ENTRY_PATH = "/codemode-execute.mjs";
142418
142514
  const runtimeSemaphore = new AsyncSemaphore(3);
142419
142515
  const BLOCKED_REQUEST_PREFIX = "Request blocked by MCP connection policy.";
142420
- function serializeExecuteConfig(tenantUrl, headers, restrictions) {
142516
+ function serializeExecuteConfig(tenantUrl, headers, restrictions, allowRules) {
142421
142517
  const normalizedTenantUrl = new URL(tenantUrl).toString();
142422
- const serializedRestrictions = restrictions.map(({ method, pathPattern, source }) => ({
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,
142423
142526
  method,
142424
142527
  pathPattern,
142425
142528
  source
@@ -142427,7 +142530,8 @@ function serializeExecuteConfig(tenantUrl, headers, restrictions) {
142427
142530
  return JSON.stringify({
142428
142531
  tenantUrl: normalizedTenantUrl,
142429
142532
  authHeaders: headers,
142430
- restrictions: serializedRestrictions
142533
+ restrictions: serializedRestrictions,
142534
+ allowRules: serializedAllowRules
142431
142535
  });
142432
142536
  }
142433
142537
  function createQueryRuntime() {
@@ -142444,11 +142548,11 @@ function createQueryRuntime() {
142444
142548
  cpuTimeLimitMs: 5e4
142445
142549
  });
142446
142550
  }
142447
- function createExecuteRuntime(tenantUrl, restrictions) {
142551
+ function createExecuteRuntime(tenantUrl, restrictions, allowRules) {
142448
142552
  return new NodeRuntime({
142449
142553
  systemDriver: createNodeDriver({
142450
142554
  useDefaultNetwork: true,
142451
- permissions: { network: (request) => createNetworkPermissionDecision(tenantUrl, request, restrictions) }
142555
+ permissions: { network: (request) => createNetworkPermissionDecision(tenantUrl, request, restrictions, allowRules) }
142452
142556
  }),
142453
142557
  runtimeDriverFactory: createNodeRuntimeDriverFactory(),
142454
142558
  memoryLimit: 128,
@@ -142460,11 +142564,11 @@ function normalizeCode(functionCode) {
142460
142564
  normalized = normalized.replace(/^```(?:js|javascript|ts|typescript)?\s*/i, "").replace(/\s*```$/, "").trim();
142461
142565
  return normalized;
142462
142566
  }
142463
- function buildQueryScript(sourceCode, restrictions) {
142464
- const restrictedSpec = applyRestrictionsToOpenApiSpec(getCoreOpenApiSpec(), restrictions);
142567
+ function buildQueryScript(sourceCode, _restrictions, _allowRules) {
142568
+ const spec = getCoreOpenApiSpec();
142465
142569
  const functionExpression = normalizeCode(sourceCode);
142466
142570
  return [
142467
- `const spec = ${JSON.stringify(restrictedSpec)};`,
142571
+ `const spec = ${JSON.stringify(spec)};`,
142468
142572
  `const __mc8ypQuery = (${functionExpression});`,
142469
142573
  "",
142470
142574
  "if (typeof __mc8ypQuery !== \"function\") {",
@@ -142474,20 +142578,23 @@ function buildQueryScript(sourceCode, restrictions) {
142474
142578
  "export default await __mc8ypQuery();"
142475
142579
  ].join("\n\n");
142476
142580
  }
142477
- function buildExecutePrelude(tenantUrl, headers, restrictions = []) {
142581
+ function buildExecutePrelude(tenantUrl, headers, restrictions = [], allowRules = []) {
142478
142582
  return [
142479
142583
  "const cumulocity = Object.freeze((() => {",
142480
- ` const config = JSON.parse(${JSON.stringify(serializeExecuteConfig(tenantUrl, headers, restrictions))});`,
142584
+ ` const config = JSON.parse(${JSON.stringify(serializeExecuteConfig(tenantUrl, headers, restrictions, allowRules))});`,
142481
142585
  " if (!config || typeof config !== \"object\") {",
142482
142586
  " throw new TypeError(\"Invalid execute configuration.\");",
142483
142587
  " }",
142484
- " const { tenantUrl, authHeaders, restrictions } = config;",
142588
+ " const { tenantUrl, authHeaders, restrictions, allowRules } = config;",
142485
142589
  " if (typeof tenantUrl !== \"string\") {",
142486
142590
  " throw new TypeError(\"Execute configuration must contain a string tenant URL.\");",
142487
142591
  " }",
142488
- " if (!Array.isArray(restrictions) || restrictions.some((rule) => !rule || typeof rule !== \"object\" || typeof rule.method !== \"string\" || typeof rule.pathPattern !== \"string\" || typeof rule.source !== \"string\")) {",
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\")) {",
142489
142593
  " throw new TypeError(\"Execute configuration must contain valid restriction rules.\");",
142490
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.\");",
142597
+ " }",
142491
142598
  " const resolveUrl = (descriptor) => {",
142492
142599
  " return new URL(descriptor, tenantUrl.endsWith(\"/\") ? tenantUrl : tenantUrl + \"/\");",
142493
142600
  " };",
@@ -142506,16 +142613,31 @@ function buildExecutePrelude(tenantUrl, headers, restrictions = []) {
142506
142613
  ` const compileRestrictionRule = ${compileRestrictionRule.toString()};`,
142507
142614
  ` const matchesCompiledRule = ${matchesCompiledRule.toString()};`,
142508
142615
  " const compiledRestrictions = restrictions.map(compileRestrictionRule);",
142509
- " const findBlockingRestrictions = (method, pathname) => {",
142616
+ " const compiledAllowRules = allowRules.map(compileRestrictionRule);",
142617
+ " const findMatchingRules = (compiledRules, method, pathname) => {",
142510
142618
  " const normalizedMethod = typeof method === \"string\" ? method.trim().toUpperCase() : \"\";",
142511
142619
  " const pathSegments = pathname === \"/\" ? [] : pathname.slice(1).split(\"/\");",
142512
- " return compiledRestrictions.filter((rule) => {",
142620
+ " return compiledRules.filter((rule) => {",
142513
142621
  " if (!normalizedMethod) {",
142514
142622
  " return rule.method === \"*\" && matchCompiledSegments(rule.segments, pathSegments);",
142515
142623
  " }",
142516
142624
  " return matchesCompiledRule(rule, normalizedMethod, pathSegments);",
142517
142625
  " });",
142518
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
+ " };",
142519
142641
  " const validateRequestMethod = (method) => {",
142520
142642
  " if (typeof method !== \"string\" || method.trim().length === 0) {",
142521
142643
  " throw new TypeError(\"request method must be a non-empty string\");",
@@ -142526,22 +142648,42 @@ function buildExecutePrelude(tenantUrl, headers, restrictions = []) {
142526
142648
  " }",
142527
142649
  " return normalizedMethod;",
142528
142650
  " };",
142529
- " const formatBlockedRequestMessage = (method, path, matchingRules) => [",
142530
- " \"Request blocked by MCP connection policy.\",",
142531
- " \"\",",
142532
- " \"This operation is intentionally denied by the current MCP connection configuration.\",",
142533
- " \"It did not fail at the Cumulocity API and it was not executed against the tenant.\",",
142534
- " \"Retrying or trying the same operation again through this connection will not succeed.\",",
142535
- " \"\",",
142536
- " \"Report this to the user as a connection-level access restriction.\",",
142537
- " \"If the operation is needed, the MCP restrictions for this connection must be updated by whoever manages that configuration.\",",
142538
- " \"\",",
142539
- " \"Blocked operation:\",",
142540
- " \"Method: \" + method,",
142541
- " \"Path: \" + path,",
142542
- " \"Matching restrictions:\",",
142543
- " ...matchingRules.map((rule) => \"- \" + rule),",
142544
- " ].join(\"\\n\");",
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
+ " };",
142545
142687
  " const normalizeBody = (headers, body) => {",
142546
142688
  " if (body == null || typeof body === \"string\") {",
142547
142689
  " return body;",
@@ -142573,9 +142715,9 @@ function buildExecutePrelude(tenantUrl, headers, restrictions = []) {
142573
142715
  " }",
142574
142716
  " const method = validateRequestMethod(init.method);",
142575
142717
  " const resolvedUrl = resolveUrl(path);",
142576
- " const blockedRules = findBlockingRestrictions(method, resolvedUrl.pathname);",
142577
- " if (blockedRules.length > 0) {",
142578
- " 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));",
142579
142721
  " }",
142580
142722
  " const headers = new Headers(init.headers ?? {});",
142581
142723
  " for (const [key, value] of Object.entries(authHeaders)) {",
@@ -142600,10 +142742,10 @@ function buildExecutePrelude(tenantUrl, headers, restrictions = []) {
142600
142742
  "})());"
142601
142743
  ].join("\n\n");
142602
142744
  }
142603
- function buildExecuteScript(sourceCode, tenantUrl, headers, restrictions = []) {
142745
+ function buildExecuteScript(sourceCode, tenantUrl, headers, restrictions = [], allowRules = []) {
142604
142746
  const functionExpression = normalizeCode(sourceCode);
142605
142747
  return [
142606
- buildExecutePrelude(tenantUrl, headers, restrictions),
142748
+ buildExecutePrelude(tenantUrl, headers, restrictions, allowRules),
142607
142749
  `const __mc8ypExecute = (${functionExpression});`,
142608
142750
  "",
142609
142751
  "const __mc8ypErrorMessage = (error) => error instanceof Error ? error.message : String(error);",
@@ -142637,9 +142779,9 @@ function extractDefaultExport(exportsObject) {
142637
142779
  if (typeof exportsObject !== "undefined") return exportsObject;
142638
142780
  throw new Error(NO_DEFAULT_EXPORT_MESSAGE);
142639
142781
  }
142640
- async function runExecuteScript(code, tenantUrl, restrictions) {
142782
+ async function runExecuteScript(code, tenantUrl, restrictions, allowRules) {
142641
142783
  const release = await runtimeSemaphore.acquire();
142642
- const runtime = createExecuteRuntime(tenantUrl, restrictions);
142784
+ const runtime = createExecuteRuntime(tenantUrl, restrictions, allowRules);
142643
142785
  try {
142644
142786
  const result = await runtime.run(code, EXECUTE_ENTRY_PATH);
142645
142787
  if (result.code !== 0) {
@@ -142669,14 +142811,14 @@ async function runModule(code, entryPath, runtime) {
142669
142811
  release();
142670
142812
  }
142671
142813
  }
142672
- async function query(functionCode, restrictions = []) {
142673
- const result = await runQueryScript(buildQueryScript(functionCode, restrictions));
142814
+ async function query(functionCode, restrictions = [], allowRules = []) {
142815
+ const result = await runQueryScript(buildQueryScript(functionCode, restrictions, allowRules));
142674
142816
  return typeof result === "string" ? result : JSON.stringify(result);
142675
142817
  }
142676
- async function execute(functionCode, input, restrictions = []) {
142818
+ async function execute(functionCode, input, restrictions = [], allowRules = []) {
142677
142819
  const auth = await resolveC8yAuth(input);
142678
142820
  const authHeaders = createC8yAuthHeaders(auth);
142679
- 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);
142680
142822
  if (result.status === "success") return encode(result.result);
142681
142823
  return result.error.message;
142682
142824
  }
@@ -142746,8 +142888,8 @@ Recommended shapes:
142746
142888
 
142747
142889
  If your function returns a string, it is returned as-is. Otherwise the result is returned as JSON text.
142748
142890
 
142749
- 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.
142750
- 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.
142751
142893
 
142752
142894
  Examples:
142753
142895
  () => {
@@ -142771,7 +142913,7 @@ Examples:
142771
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.") })
142772
142914
  }, async (input) => {
142773
142915
  try {
142774
- 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 ?? []));
142775
142917
  } catch (error) {
142776
142918
  const message = error instanceof Error ? error.message : String(error);
142777
142919
  return tool.error(message);
@@ -142812,7 +142954,8 @@ Tool output behavior:
142812
142954
  - On success, the actual function result is returned in Toon format.
142813
142955
  - On blocked or failed execution, the tool returns a plain text message.
142814
142956
 
142815
- 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.
142816
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.
142817
142960
 
142818
142961
  ${getExecuteEnvironmentNote()}
@@ -142846,7 +142989,7 @@ async () => {
142846
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.") }))
142847
142990
  }, async (input) => {
142848
142991
  try {
142849
- 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 ?? []));
142850
142993
  } catch (error) {
142851
142994
  const message = error instanceof Error ? error.message : String(error);
142852
142995
  return tool.error(message);
@@ -142979,7 +143122,12 @@ runMain(defineCommand({
142979
143122
  restriction: {
142980
143123
  type: "string",
142981
143124
  description: "Restriction rule to deny API access (e.g. \"GET:/inventory/**\"). Can be repeated.",
142982
- 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"]
142983
143131
  },
142984
143132
  spec: {
142985
143133
  type: "string",
@@ -143000,13 +143148,20 @@ runMain(defineCommand({
143000
143148
  if (!selected) throw new Error(`Invalid --spec value "${requested}". Available: ${specs.map((s) => s.version).join(", ")}`);
143001
143149
  setCoreOpenApiVersion(selected.version);
143002
143150
  consola.info(`Using core OpenAPI snapshot: ${getCoreOpenApiLabel()}`);
143003
- const raw = args.restriction;
143004
- const { parsedRules: restrictions, failedRules } = parseRestrictionRule((Array.isArray(raw) ? raw : raw ? [raw] : []).filter((value) => typeof value === "string" && value.length > 0));
143005
- if (failedRules.length > 0) throw new Error(["One or more restriction flags could not be parsed:", ...failedRules.map((rule) => `- ${rule.rule}: ${rule.reason}`)].join("\n"));
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"));
143006
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));
143007
143159
  const transport = new StdioTransport(createC8YMcpServer());
143008
143160
  consola.info("Starting MCP server over stdio transport...");
143009
- transport.listen({ restrictions });
143161
+ transport.listen({
143162
+ restrictions,
143163
+ allowRules
143164
+ });
143010
143165
  }
143011
143166
  }));
143012
143167
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mc8yp",
3
- "version": "2.0.1",
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": [