diff --git a/docs/rest-apis/devportal/README.md b/docs/rest-apis/devportal/README.md
index bb26054b3..2b716329a 100644
--- a/docs/rest-apis/devportal/README.md
+++ b/docs/rest-apis/devportal/README.md
@@ -29,6 +29,7 @@ Base URLs:
- [Get a theme asset](organization-content.md#get-a-theme-asset)
- [Apply a theme](organization-content.md#apply-a-theme)
- [Reset theme to defaults](organization-content.md#reset-theme-to-defaults)
+- [Download the current theme](organization-content.md#download-the-current-theme)
### [APIs](apis.md)
diff --git a/docs/rest-apis/devportal/apis.md b/docs/rest-apis/devportal/apis.md
index 278dc1f86..f6afbbb62 100644
--- a/docs/rest-apis/devportal/apis.md
+++ b/docs/rest-apis/devportal/apis.md
@@ -48,11 +48,11 @@ This operation requires Basic Auth authentication.
|Name|In|Type|Required|Description|
|---|---|---|---|---|
-|body|body|object|true|API metadata upload. Send either `artifact`, or `api` with `apiDefinition`, or `apiMetadata` with `apiDefinition`. `schemaDefinition` is used for MCP APIs and GraphQL schema updates.|
+|body|body|object|true|API metadata upload. Send either `artifact`, or `api` with `apiDefinition`, or `apiMetadata` with `apiDefinition`. `schemaDefinition` carries a GraphQL SDL schema. (MCP servers are created via `/mcp-servers` with the dedicated `McpServerMultipartBody`, not this body.)|
|» api|body|string(binary)|false|API metadata YAML file.|
|» apiDefinition|body|string(binary)|false|API definition file.|
|» artifact|body|string(binary)|false|Full API ZIP artifact containing metadata and definition files.|
-|» schemaDefinition|body|string(binary)|false|Schema definition file, used by MCP APIs.|
+|» schemaDefinition|body|string(binary)|false|GraphQL SDL schema definition file.|
|» apiMetadata|body|string|false|JSON string accepted by the service when the `api` YAML file is not supplied. Accepted top-level fields: `name`, `version`, `description`, `type`, `agentVisibility`, `status`, `referenceId`, `id`, `tags`, `labels`, `owners`, `endPoints` (productionURL, sandboxURL), and `subscriptionPlans` (array of `{ id }` objects — only `id` is read; the plan must already exist in the organization). `id` becomes the API's stored handle; when the API is created from a YAML artifact instead, the handle is always taken from `metadata.name`.|
> Example responses
@@ -594,11 +594,11 @@ This operation requires Basic Auth authentication.
|Name|In|Type|Required|Description|
|---|---|---|---|---|
-|body|body|object|true|API metadata upload. Send either `artifact`, or `api` with `apiDefinition`, or `apiMetadata` with `apiDefinition`. `schemaDefinition` is used for MCP APIs and GraphQL schema updates.|
+|body|body|object|true|API metadata upload. Send either `artifact`, or `api` with `apiDefinition`, or `apiMetadata` with `apiDefinition`. `schemaDefinition` carries a GraphQL SDL schema. (MCP servers are created via `/mcp-servers` with the dedicated `McpServerMultipartBody`, not this body.)|
|» api|body|string(binary)|false|API metadata YAML file.|
|» apiDefinition|body|string(binary)|false|API definition file.|
|» artifact|body|string(binary)|false|Full API ZIP artifact containing metadata and definition files.|
-|» schemaDefinition|body|string(binary)|false|Schema definition file, used by MCP APIs.|
+|» schemaDefinition|body|string(binary)|false|GraphQL SDL schema definition file.|
|» apiMetadata|body|string|false|JSON string accepted by the service when the `api` YAML file is not supplied. Accepted top-level fields: `name`, `version`, `description`, `type`, `agentVisibility`, `status`, `referenceId`, `id`, `tags`, `labels`, `owners`, `endPoints` (productionURL, sandboxURL), and `subscriptionPlans` (array of `{ id }` objects — only `id` is read; the plan must already exist in the organization). `id` becomes the API's stored handle; when the API is created from a YAML artifact instead, the handle is always taken from `metadata.name`.|
|apiId|path|string|true|The API's handle (unique per org). Resolves only to REST/SOAP/WS/WebSub/GraphQL APIs — MCP servers are addressed via `/mcp-servers`.|
diff --git a/docs/rest-apis/devportal/mcp-servers.md b/docs/rest-apis/devportal/mcp-servers.md
index 327397961..35507d20e 100644
--- a/docs/rest-apis/devportal/mcp-servers.md
+++ b/docs/rest-apis/devportal/mcp-servers.md
@@ -19,20 +19,19 @@ curl -X POST https://localhost:3000/api/v0.9/mcp-servers \
```
-Creates Developer Portal MCP server metadata. Mirrors `POST /api/v0.9/apis` — same artifact ZIP, YAML (`api.yaml` / `devportal.yaml` / `mcp.yaml`), and `apiMetadata` JSON input formats — but the created record is always typed `MCP`. Via the JSON `apiMetadata` field, `type` must be explicitly `MCP`; an omitted type or any other value is rejected with a 400 (use `POST /api/v0.9/apis` instead).
+Creates Developer Portal MCP server metadata. Accepts the same metadata input formats as `POST /api/v0.9/apis` (artifact ZIP, `api.yaml` / `devportal.yaml` / `mcp.yaml`, or `apiMetadata` JSON), but the created record is always typed `MCP`. An MCP server's contract is its `schemaDefinition` — the tools, resources, and prompts it exposes — not an OpenAPI-style `apiDefinition`; a `schemaDefinition` is required and any `apiDefinition` sent is ignored. Via the JSON `apiMetadata` field, `type` must be explicitly `MCP`; an omitted type or any other value is rejected with a 400 (use `POST /api/v0.9/apis`).
> Payload
```yaml
api: string
-apiDefinition: string
artifact: string
schemaDefinition: string
-apiMetadata: '{"name":"Weather API","version":"v1","description":"Weather
- forecast API","type":"REST","agentVisibility":"VISIBLE",
- "status":"PUBLISHED","tags":["weather"],"labels":["default"],"endPoints":{
- "productionURL":"https://api.example.com/weather",
- "sandboxURL":"https://sandbox.example.com/weather"},"subscriptionPlans":[{"id":"Gold"}]}'
+apiMetadata: '{"name":"Travel Assistant MCP","version":"v1","description":"MCP
+ server for travel planning
+ tools","type":"MCP","agentVisibility":"VISIBLE","status":"PUBLISHED",
+ "tags":["mcp"],"labels":["default"],"endPoints":{"productionURL":"https://mcp.example.com",
+ "sandboxURL":"https://mcp.example.com"},"subscriptionPlans":[{"id":"Gold"}]}'
```
@@ -47,12 +46,11 @@ This operation requires Basic Auth authentication.
|Name|In|Type|Required|Description|
|---|---|---|---|---|
-|body|body|object|true|API metadata upload. Send either `artifact`, or `api` with `apiDefinition`, or `apiMetadata` with `apiDefinition`. `schemaDefinition` is used for MCP APIs and GraphQL schema updates.|
-|» api|body|string(binary)|false|API metadata YAML file.|
-|» apiDefinition|body|string(binary)|false|API definition file.|
-|» artifact|body|string(binary)|false|Full API ZIP artifact containing metadata and definition files.|
-|» schemaDefinition|body|string(binary)|false|Schema definition file, used by MCP APIs.|
-|» apiMetadata|body|string|false|JSON string accepted by the service when the `api` YAML file is not supplied. Accepted top-level fields: `name`, `version`, `description`, `type`, `agentVisibility`, `status`, `referenceId`, `id`, `tags`, `labels`, `owners`, `endPoints` (productionURL, sandboxURL), and `subscriptionPlans` (array of `{ id }` objects — only `id` is read; the plan must already exist in the organization). `id` becomes the API's stored handle; when the API is created from a YAML artifact instead, the handle is always taken from `metadata.name`.|
+|body|body|object|true|MCP server upload. Provide the server metadata via `apiMetadata` (JSON), an `api` YAML file, or a full `artifact` ZIP, together with its `schemaDefinition` — the tools, resources, and prompts the server exposes. An MCP server has no OpenAPI-style `apiDefinition`; its `schemaDefinition` IS its contract, so it is required on create (and replaces the stored schema when supplied on update).|
+|» api|body|string(binary)|false|MCP server metadata YAML file (kind MCP).|
+|» artifact|body|string(binary)|false|Full MCP server ZIP artifact containing the metadata and the schemaDefinition.|
+|» schemaDefinition|body|string(binary)|false|MCP tools schema (YAML or JSON) listing the tools, resources, and prompts the server exposes. Required on create; when supplied on update it replaces the stored schema.|
+|» apiMetadata|body|string|false|JSON string accepted when the `api` YAML file is not supplied. Accepted top-level fields: `name`, `version`, `description`, `type` (must be `MCP`), `agentVisibility`, `status`, `referenceId`, `id`, `tags`, `labels`, `owners`, `endPoints` (productionURL, sandboxURL), and `subscriptionPlans` (array of `{ id }` objects — only `id` is read; the plan must already exist in the organization). `id` becomes the MCP server's stored handle.|
> Example responses
@@ -561,20 +559,19 @@ curl -X PUT https://localhost:3000/api/v0.9/mcp-servers/{mcpServerId} \
```
-Updates Developer Portal MCP server metadata and its stored definition. Mirrors `PUT /api/v0.9/apis/{apiId}`, including `type` being required and immutable — it must stay `MCP`; any other value is rejected with `400`, via the same resolveTypeOrReject check `POST /mcp-servers` uses.
+Updates Developer Portal MCP server metadata and, when a `schemaDefinition` is supplied, its stored tools schema. `type` is required and immutable — it must stay `MCP`; any other value is rejected with `400` via the same resolveTypeOrReject check `POST /mcp-servers` uses. An MCP server has no `apiDefinition`; any `apiDefinition` sent on update is ignored.
> Payload
```yaml
api: string
-apiDefinition: string
artifact: string
schemaDefinition: string
-apiMetadata: '{"name":"Weather API","version":"v1","description":"Weather
- forecast API","type":"REST","agentVisibility":"VISIBLE",
- "status":"PUBLISHED","tags":["weather"],"labels":["default"],"endPoints":{
- "productionURL":"https://api.example.com/weather",
- "sandboxURL":"https://sandbox.example.com/weather"},"subscriptionPlans":[{"id":"Gold"}]}'
+apiMetadata: '{"name":"Travel Assistant MCP","version":"v1","description":"MCP
+ server for travel planning
+ tools","type":"MCP","agentVisibility":"VISIBLE","status":"PUBLISHED",
+ "tags":["mcp"],"labels":["default"],"endPoints":{"productionURL":"https://mcp.example.com",
+ "sandboxURL":"https://mcp.example.com"},"subscriptionPlans":[{"id":"Gold"}]}'
```
@@ -589,12 +586,11 @@ This operation requires Basic Auth authentication.
|Name|In|Type|Required|Description|
|---|---|---|---|---|
-|body|body|object|true|API metadata upload. Send either `artifact`, or `api` with `apiDefinition`, or `apiMetadata` with `apiDefinition`. `schemaDefinition` is used for MCP APIs and GraphQL schema updates.|
-|» api|body|string(binary)|false|API metadata YAML file.|
-|» apiDefinition|body|string(binary)|false|API definition file.|
-|» artifact|body|string(binary)|false|Full API ZIP artifact containing metadata and definition files.|
-|» schemaDefinition|body|string(binary)|false|Schema definition file, used by MCP APIs.|
-|» apiMetadata|body|string|false|JSON string accepted by the service when the `api` YAML file is not supplied. Accepted top-level fields: `name`, `version`, `description`, `type`, `agentVisibility`, `status`, `referenceId`, `id`, `tags`, `labels`, `owners`, `endPoints` (productionURL, sandboxURL), and `subscriptionPlans` (array of `{ id }` objects — only `id` is read; the plan must already exist in the organization). `id` becomes the API's stored handle; when the API is created from a YAML artifact instead, the handle is always taken from `metadata.name`.|
+|body|body|object|true|MCP server upload. Provide the server metadata via `apiMetadata` (JSON), an `api` YAML file, or a full `artifact` ZIP, together with its `schemaDefinition` — the tools, resources, and prompts the server exposes. An MCP server has no OpenAPI-style `apiDefinition`; its `schemaDefinition` IS its contract, so it is required on create (and replaces the stored schema when supplied on update).|
+|» api|body|string(binary)|false|MCP server metadata YAML file (kind MCP).|
+|» artifact|body|string(binary)|false|Full MCP server ZIP artifact containing the metadata and the schemaDefinition.|
+|» schemaDefinition|body|string(binary)|false|MCP tools schema (YAML or JSON) listing the tools, resources, and prompts the server exposes. Required on create; when supplied on update it replaces the stored schema.|
+|» apiMetadata|body|string|false|JSON string accepted when the `api` YAML file is not supplied. Accepted top-level fields: `name`, `version`, `description`, `type` (must be `MCP`), `agentVisibility`, `status`, `referenceId`, `id`, `tags`, `labels`, `owners`, `endPoints` (productionURL, sandboxURL), and `subscriptionPlans` (array of `{ id }` objects — only `id` is read; the plan must already exist in the organization). `id` becomes the MCP server's stored handle.|
|mcpServerId|path|string|true|The MCP server's handle (unique per org).|
> Example responses
diff --git a/docs/rest-apis/devportal/organization-content.md b/docs/rest-apis/devportal/organization-content.md
index ca4d7d549..feee2e98f 100644
--- a/docs/rest-apis/devportal/organization-content.md
+++ b/docs/rest-apis/devportal/organization-content.md
@@ -206,3 +206,67 @@ This operation requires Basic Auth authentication.
|---|---|---|---|
|204|[No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5)|Theme reset successfully.|None|
|500|[Internal Server Error](https://tools.ietf.org/html/rfc7231#section-6.6.1)|Internal server error.|[ErrorResponse](schemas.md#schemaerrorresponse)|
+
+## Download the current theme
+
+
+
+`GET /views/{viewId}/export-theme`
+
+> Code samples
+
+```shell
+
+curl -X GET https://localhost:3000/api/v0.9/views/{viewId}/export-theme \
+ -u {username}:{password} \
+ -H 'Accept: application/zip' \
+ -H 'Authorization: Bearer {access-token}'
+
+```
+
+Bundles the view's current custom theme assets into a single ZIP archive for download. The archive is wrapped in a top-level folder so it can be re-uploaded via the apply-theme endpoint. Returns 404 when the view has no custom theme.
+
+### Authentication
+
+
+
+
+
+|Status|Meaning|Description|Schema|
+|---|---|---|---|
+|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|Theme archive.|string|
+|404|[Not Found](https://tools.ietf.org/html/rfc7231#section-6.5.4)|Resource not found.|[ErrorResponse](schemas.md#schemaerrorresponse)|
+|500|[Internal Server Error](https://tools.ietf.org/html/rfc7231#section-6.6.1)|Internal server error.|[ErrorResponse](schemas.md#schemaerrorresponse)|
diff --git a/portals/developer-portal/docs/administer/llm-instructions.md b/portals/developer-portal/docs/administer/llm-instructions.md
index b7f7f3ddc..cfcf3b698 100644
--- a/portals/developer-portal/docs/administer/llm-instructions.md
+++ b/portals/developer-portal/docs/administer/llm-instructions.md
@@ -20,13 +20,15 @@ Well-written LLM Instructions reduce agent errors and improve the quality of AI-
## Configuring LLM Instructions
-LLM Instructions are configured at the view level in the portal's admin settings.
+LLM Instructions are configured per view, from the organization's admin settings.
1. Sign in to the Developer Portal as an admin.
-2. Navigate to **Admin Settings** for your view (`//views//admin/settings`).
-3. Select the **LLM Instructions** tab.
-4. Enter your instructions in the text editor.
-5. Click **Publish**.
+2. Navigate to **Settings** (`//settings`).
+3. Select **LLM Instructions** from the sidebar.
+4. If your organization has more than one view, the **View-scoped setting** banner at the top shows which view you're currently editing — use the pill selector to switch views. Each view has its own LLM Instructions.
+5. Use the **Portal is AI-discoverable** toggle to control whether `llms.txt` and the agent-facing content endpoints are served for this view at all. When turned off, those endpoints return `404` to agents — turning it off doesn't just hide the setting, it takes the portal out of agent discovery entirely.
+6. Enter a **Portal name** and **Description** to orient agents.
+7. Click **Publish** — this is always available, even when AI-discoverable is turned off, so you can save that you've intentionally disabled discovery for this view.
Changes take effect immediately — the updated instructions are reflected in `llms.txt` as soon as you save.
diff --git a/portals/developer-portal/docs/devportal-openapi-spec-v0.9.yaml b/portals/developer-portal/docs/devportal-openapi-spec-v0.9.yaml
index a95479978..1d44b7d64 100644
--- a/portals/developer-portal/docs/devportal-openapi-spec-v0.9.yaml
+++ b/portals/developer-portal/docs/devportal-openapi-spec-v0.9.yaml
@@ -242,6 +242,34 @@ paths:
- OAuth2Security:
- dp:org_content_manage
- dp:org_manage
+ /views/{viewId}/export-theme:
+ parameters:
+ - $ref: "#/components/parameters/viewId"
+ get:
+ tags:
+ - Organization Content
+ summary: Download the current theme
+ description: >-
+ Bundles the view's current custom theme assets into a single ZIP archive for download.
+ The archive is wrapped in a top-level folder so it can be re-uploaded via the apply-theme
+ endpoint. Returns 404 when the view has no custom theme.
+ operationId: exportTheme
+ responses:
+ "200":
+ description: Theme archive.
+ content:
+ application/zip:
+ schema:
+ type: string
+ format: binary
+ "404":
+ $ref: "#/components/responses/NotFound"
+ "500":
+ $ref: "#/components/responses/InternalServerError"
+ security:
+ - OAuth2Security:
+ - dp:org_content_read
+ - dp:org_manage
/apis:
post:
tags:
@@ -1252,13 +1280,15 @@ paths:
- MCP Servers
summary: Create MCP server metadata
description: >-
- Creates Developer Portal MCP server metadata. Mirrors `POST /api/v0.9/apis` — same artifact ZIP, YAML
- (`api.yaml` / `devportal.yaml` / `mcp.yaml`), and `apiMetadata` JSON input formats — but the created record is
- always typed `MCP`. Via the JSON `apiMetadata` field, `type` must be explicitly `MCP`; an omitted type or any
- other value is rejected with a 400 (use `POST /api/v0.9/apis` instead).
+ Creates Developer Portal MCP server metadata. Accepts the same metadata input formats as
+ `POST /api/v0.9/apis` (artifact ZIP, `api.yaml` / `devportal.yaml` / `mcp.yaml`, or `apiMetadata` JSON),
+ but the created record is always typed `MCP`. An MCP server's contract is its `schemaDefinition` — the
+ tools, resources, and prompts it exposes — not an OpenAPI-style `apiDefinition`; a `schemaDefinition` is
+ required and any `apiDefinition` sent is ignored. Via the JSON `apiMetadata` field, `type` must be
+ explicitly `MCP`; an omitted type or any other value is rejected with a 400 (use `POST /api/v0.9/apis`).
operationId: createMcpServer
requestBody:
- $ref: "#/components/requestBodies/ApiMetadataMultipartBody"
+ $ref: "#/components/requestBodies/McpServerMultipartBody"
responses:
"201":
$ref: "#/components/responses/McpServerCreateResponse"
@@ -1328,12 +1358,13 @@ paths:
- MCP Servers
summary: Update MCP server metadata
description: >-
- Updates Developer Portal MCP server metadata and its stored definition. Mirrors `PUT /api/v0.9/apis/{apiId}`,
- including `type` being required and immutable — it must stay `MCP`; any other value is rejected with `400`,
- via the same resolveTypeOrReject check `POST /mcp-servers` uses.
+ Updates Developer Portal MCP server metadata and, when a `schemaDefinition` is supplied, its stored
+ tools schema. `type` is required and immutable — it must stay `MCP`; any other value is rejected with
+ `400` via the same resolveTypeOrReject check `POST /mcp-servers` uses. An MCP server has no
+ `apiDefinition`; any `apiDefinition` sent on update is ignored.
operationId: updateMcpServer
requestBody:
- $ref: "#/components/requestBodies/ApiMetadataMultipartBody"
+ $ref: "#/components/requestBodies/McpServerMultipartBody"
responses:
"200":
$ref: "#/components/responses/McpServerResponse"
@@ -3115,7 +3146,8 @@ components:
required: true
description: >-
API metadata upload. Send either `artifact`, or `api` with `apiDefinition`, or `apiMetadata` with
- `apiDefinition`. `schemaDefinition` is used for MCP APIs and GraphQL schema updates.
+ `apiDefinition`. `schemaDefinition` carries a GraphQL SDL schema. (MCP servers are created via
+ `/mcp-servers` with the dedicated `McpServerMultipartBody`, not this body.)
content:
multipart/form-data:
schema:
@@ -3136,7 +3168,7 @@ components:
schemaDefinition:
type: string
format: binary
- description: Schema definition file, used by MCP APIs.
+ description: GraphQL SDL schema definition file.
apiMetadata:
type: string
description: >-
@@ -3153,6 +3185,46 @@ components:
"status":"PUBLISHED","tags":["weather"],"labels":["default"],"endPoints":{
"productionURL":"https://api.example.com/weather",
"sandboxURL":"https://sandbox.example.com/weather"},"subscriptionPlans":[{"id":"Gold"}]}
+ McpServerMultipartBody:
+ required: true
+ description: >-
+ MCP server upload. Provide the server metadata via `apiMetadata` (JSON), an `api` YAML file,
+ or a full `artifact` ZIP, together with its `schemaDefinition` — the tools, resources, and
+ prompts the server exposes. An MCP server has no OpenAPI-style `apiDefinition`; its
+ `schemaDefinition` IS its contract, so it is required on create (and replaces the stored
+ schema when supplied on update).
+ content:
+ multipart/form-data:
+ schema:
+ type: object
+ properties:
+ api:
+ type: string
+ format: binary
+ description: MCP server metadata YAML file (kind MCP).
+ artifact:
+ type: string
+ format: binary
+ description: Full MCP server ZIP artifact containing the metadata and the schemaDefinition.
+ schemaDefinition:
+ type: string
+ format: binary
+ description: >-
+ MCP tools schema (YAML or JSON) listing the tools, resources, and prompts the server
+ exposes. Required on create; when supplied on update it replaces the stored schema.
+ apiMetadata:
+ type: string
+ description: >-
+ JSON string accepted when the `api` YAML file is not supplied. Accepted top-level fields:
+ `name`, `version`, `description`, `type` (must be `MCP`), `agentVisibility`, `status`,
+ `referenceId`, `id`, `tags`, `labels`, `owners`, `endPoints` (productionURL, sandboxURL),
+ and `subscriptionPlans` (array of `{ id }` objects — only `id` is read; the plan must already
+ exist in the organization). `id` becomes the MCP server's stored handle.
+ example: >-
+ {"name":"Travel Assistant MCP","version":"v1","description":"MCP server for travel
+ planning tools","type":"MCP","agentVisibility":"VISIBLE","status":"PUBLISHED",
+ "tags":["mcp"],"labels":["default"],"endPoints":{"productionURL":"https://mcp.example.com",
+ "sandboxURL":"https://mcp.example.com"},"subscriptionPlans":[{"id":"Gold"}]}
ApiContentZipMultipartBody:
required: true
description: |
diff --git a/portals/developer-portal/docs/publish-apis/publishing-apis.md b/portals/developer-portal/docs/publish-apis/publishing-apis.md
index f6e350f31..d69c106a2 100644
--- a/portals/developer-portal/docs/publish-apis/publishing-apis.md
+++ b/portals/developer-portal/docs/publish-apis/publishing-apis.md
@@ -120,11 +120,12 @@ curl -k -X POST "https://localhost:3000/api/v0.9/apis" \
```
```bash
-# MCP server (note: MCP servers are created under /mcp-servers, not /apis)
+# MCP server (note: MCP servers are created under /mcp-servers, not /apis).
+# An MCP server's contract is its tools schema (schemaDefinition) — it has no apiDefinition.
curl -k -X POST "https://localhost:3000/api/v0.9/mcp-servers" \
-H "Authorization: Bearer $TOKEN" \
-F "api=@mcp.yaml" \
- -F "apiDefinition=@mcp-spec.yaml;type=application/yaml"
+ -F "schemaDefinition=@schemaDefinition.yaml;type=application/yaml"
```
| Field | Required | Description |
diff --git a/portals/developer-portal/it/rest-api/mcp-servers/mcp-servers.spec.js b/portals/developer-portal/it/rest-api/mcp-servers/mcp-servers.spec.js
index d94ec202b..c72d8640c 100644
--- a/portals/developer-portal/it/rest-api/mcp-servers/mcp-servers.spec.js
+++ b/portals/developer-portal/it/rest-api/mcp-servers/mcp-servers.spec.js
@@ -25,6 +25,16 @@
const client = require('../support/client');
const { uniqueHandle, createApi } = require('../support/fixtures');
+// An MCP server's contract is its tools schema (schemaDefinition) — it has no apiDefinition.
+const MCP_TOOLS_SCHEMA = [
+ '- type: TOOL',
+ ' name: ping',
+ ' description: Health check tool.',
+ ' inputSchema:',
+ ' type: object',
+ ' properties: {}',
+].join('\n');
+
async function createMcpServer(overrides = {}) {
const id = overrides.id || uniqueHandle('mcp-server');
const metadata = {
@@ -42,7 +52,7 @@ async function createMcpServer(overrides = {}) {
.as('publisher')
.postMultipart('/mcp-servers')
.field('apiMetadata', JSON.stringify(metadata))
- .attach('apiDefinition', Buffer.from(JSON.stringify({ tools: [] })), 'definition.json');
+ .attach('schemaDefinition', Buffer.from(MCP_TOOLS_SCHEMA), 'schemaDefinition.yaml');
if (res.status !== 201) {
throw new Error(`Failed to seed MCP server: ${res.status} ${JSON.stringify(res.body)}`);
}
@@ -73,7 +83,7 @@ describe('MCP servers', () => {
status: 'PUBLISHED',
endPoints: { productionURL: 'https://x.invalid', sandboxURL: 'https://x.invalid' },
}))
- .attach('apiDefinition', Buffer.from(JSON.stringify({ tools: [] })), 'definition.json');
+ .attach('schemaDefinition', Buffer.from(MCP_TOOLS_SCHEMA), 'schemaDefinition.yaml');
expect(res.status).toBe(400);
});
@@ -93,7 +103,7 @@ describe('MCP servers', () => {
status: 'PUBLISHED',
endPoints: { productionURL: 'https://x.invalid', sandboxURL: 'https://x.invalid' },
}))
- .attach('apiDefinition', Buffer.from(JSON.stringify({ tools: [] })), 'definition.json');
+ .attach('schemaDefinition', Buffer.from(MCP_TOOLS_SCHEMA), 'schemaDefinition.yaml');
expect(res.status).toBe(400);
});
@@ -109,7 +119,7 @@ describe('MCP servers', () => {
status: 'PUBLISHED',
endPoints: { productionURL: 'https://updated.example.invalid', sandboxURL: 'https://updated-sandbox.example.invalid' },
}))
- .attach('apiDefinition', Buffer.from(JSON.stringify({ tools: [] })), 'definition.json');
+ .attach('schemaDefinition', Buffer.from(MCP_TOOLS_SCHEMA), 'schemaDefinition.yaml');
expect(put.status).toBe(400);
});
@@ -125,7 +135,7 @@ describe('MCP servers', () => {
status: 'PUBLISHED',
endPoints: { productionURL: 'https://updated.example.invalid', sandboxURL: 'https://updated-sandbox.example.invalid' },
}))
- .attach('apiDefinition', Buffer.from(JSON.stringify({ tools: [] })), 'definition.json');
+ .attach('schemaDefinition', Buffer.from(MCP_TOOLS_SCHEMA), 'schemaDefinition.yaml');
expect(put.status).toBe(200);
expect(put.body.name).toBe('Updated MCP Server');
});
@@ -141,7 +151,7 @@ describe('MCP servers', () => {
status: 'PUBLISHED',
endPoints: { productionURL: 'https://updated.example.invalid', sandboxURL: 'https://updated-sandbox.example.invalid' },
}))
- .attach('apiDefinition', Buffer.from(JSON.stringify({ tools: [] })), 'definition.json');
+ .attach('schemaDefinition', Buffer.from(MCP_TOOLS_SCHEMA), 'schemaDefinition.yaml');
expect(put.status).toBe(400);
});
@@ -176,7 +186,7 @@ describe('MCP servers', () => {
status: 'PUBLISHED',
endPoints: { productionURL: 'https://x.invalid', sandboxURL: 'https://x.invalid' },
}))
- .attach('apiDefinition', Buffer.from(JSON.stringify({ tools: [] })), 'definition.json');
+ .attach('apiDefinition', Buffer.from(JSON.stringify({ openapi: '3.0.0' })), 'definition.json');
expect(res.status).toBe(400);
});
@@ -189,6 +199,120 @@ describe('MCP servers', () => {
expect(res.body.key).toBeDefined();
});
+ // An MCP server's contract is its tools schema (schemaDefinition), analogous to a
+ // GraphQL SDL — not an apiDefinition. It can be created from a schemaDefinition alone
+ // with no apiDefinition, matching how sampleSeeder deploys samples/mcps (api.yaml +
+ // schemaDefinition.yaml, no definition file) and how the admin UI wizard now uploads it.
+ describe('tools schema (schemaDefinition)', () => {
+ function createWithSchema(schema, idPrefix) {
+ const id = uniqueHandle(idPrefix || 'mcp-schema');
+ return client
+ .as('publisher')
+ .postMultipart('/mcp-servers')
+ .field('apiMetadata', JSON.stringify({
+ id,
+ name: `Schema MCP ${id}`,
+ version: 'v1.0',
+ type: 'MCP',
+ status: 'PUBLISHED',
+ endPoints: { productionURL: 'https://x.invalid', sandboxURL: 'https://x.invalid' },
+ }))
+ .attach('schemaDefinition', Buffer.from(schema), 'schemaDefinition.yaml')
+ .then((res) => ({ id, res }));
+ }
+
+ it('creates an MCP server from a schemaDefinition with no apiDefinition, stored as SCHEMA_DEFINITION', async () => {
+ const { id, res } = await createWithSchema(MCP_TOOLS_SCHEMA);
+ expect(res.status).toBe(201);
+
+ // The tools schema is persisted and retrievable as a SCHEMA_DEFINITION asset.
+ const asset = await client.as('publisher')
+ .get(`/mcp-servers/${id}/assets?type=SCHEMA_DEFINITION&fileName=schemaDefinition.yaml`);
+ expect(asset.status).toBe(200);
+ expect(String(asset.text || asset.body)).toContain('name: ping');
+ });
+
+ it('rejects creating an MCP server without a schemaDefinition', async () => {
+ const id = uniqueHandle('mcp-no-contract');
+ const res = await client
+ .as('publisher')
+ .postMultipart('/mcp-servers')
+ .field('apiMetadata', JSON.stringify({
+ id,
+ name: 'No Contract MCP',
+ version: 'v1.0',
+ type: 'MCP',
+ status: 'PUBLISHED',
+ endPoints: { productionURL: 'https://x.invalid', sandboxURL: 'https://x.invalid' },
+ }));
+ expect(res.status).toBe(400);
+ });
+
+ it('rejects creating an MCP server with an apiDefinition but no schemaDefinition (apiDefinition is not an MCP contract)', async () => {
+ const id = uniqueHandle('mcp-apidef-only');
+ const res = await client
+ .as('publisher')
+ .postMultipart('/mcp-servers')
+ .field('apiMetadata', JSON.stringify({
+ id,
+ name: 'ApiDef Only MCP',
+ version: 'v1.0',
+ type: 'MCP',
+ status: 'PUBLISHED',
+ endPoints: { productionURL: 'https://x.invalid', sandboxURL: 'https://x.invalid' },
+ }))
+ .attach('apiDefinition', Buffer.from(JSON.stringify({ openapi: '3.0.0' })), 'definition.json');
+ expect(res.status).toBe(400);
+ });
+
+ it('replaces the tools schema on update via a new schemaDefinition', async () => {
+ const { id, res } = await createWithSchema(MCP_TOOLS_SCHEMA, 'mcp-schema-update');
+ expect(res.status).toBe(201);
+
+ const updatedSchema = MCP_TOOLS_SCHEMA.replace('name: ping', 'name: echo');
+ const put = await client
+ .as('publisher')
+ .putMultipart(`/mcp-servers/${id}`)
+ .field('apiMetadata', JSON.stringify({
+ name: `Schema MCP ${id}`,
+ version: 'v1.0',
+ type: 'MCP',
+ status: 'PUBLISHED',
+ endPoints: { productionURL: 'https://x.invalid', sandboxURL: 'https://x.invalid' },
+ }))
+ .attach('schemaDefinition', Buffer.from(updatedSchema), 'schemaDefinition.yaml');
+ expect(put.status).toBe(200);
+
+ const asset = await client.as('publisher')
+ .get(`/mcp-servers/${id}/assets?type=SCHEMA_DEFINITION&fileName=schemaDefinition.yaml`);
+ expect(asset.status).toBe(200);
+ expect(String(asset.text || asset.body)).toContain('name: echo');
+ });
+ });
+
+ // A server created via the admin /mcp-servers API stores its schema as a flat
+ // `type:`-tagged YAML array (schemaDefinition.yaml). The MCP Registry API must still
+ // surface those capabilities — mcpRegistryService.parseSchema normalizes the flat array
+ // into the grouped { tools, resources, prompts } shape the registry response expects.
+ describe('MCP registry exposure of admin-created servers', () => {
+ it('exposes tools via the registry API for a server created through /mcp-servers', async () => {
+ const name = uniqueHandle('registry-mcp');
+ const mcp = await createMcpServer({ name });
+ const version = mcp.version || 'v1.0';
+
+ // Registry GETs are public discovery routes at the root (not under /api/v0.9);
+ // an admin-created MCP server (ref_id null) is addressable by name + version.
+ const res = await client.page('publisher')
+ .get(`/registry/${client.ORG_HANDLE}/v0.1/servers/${encodeURIComponent(name)}/versions/${encodeURIComponent(version)}`);
+ expect(res.status).toBe(200);
+
+ const caps = res.body?._meta?.['io.api-platform/mcp-capabilities'];
+ expect(caps).toBeDefined();
+ expect(Array.isArray(caps.tools)).toBe(true);
+ expect(caps.tools.some((t) => t.name === 'ping')).toBe(true);
+ });
+ });
+
// /mcp-servers and /apis share the same dp_api_metadata table, distinguished only
// by `type` — resolveScopedApiId (apiMetadataService.js:313) is what's supposed to
// keep the two families from resolving each other's handles. The tests above only
diff --git a/portals/developer-portal/src/controllers/apiContentController.js b/portals/developer-portal/src/controllers/apiContentController.js
index b1d6af8b1..110c0a62a 100644
--- a/portals/developer-portal/src/controllers/apiContentController.js
+++ b/portals/developer-portal/src/controllers/apiContentController.js
@@ -374,6 +374,9 @@ const loadAPIContent = async (req, res, next) => {
let schemaFileName = constants.FILE_NAME.API_DEFINITION_XML;
if (metaData.type === constants.API_TYPE.GRAPHQL) {
schemaFileName = constants.FILE_NAME.API_DEFINITION_GRAPHQL;
+ } else if (metaData.type === constants.API_TYPE.MCP) {
+ // An MCP server's contract is its tools schema, not an apiDefinition.
+ schemaFileName = constants.FILE_NAME.SCHEMA_DEFINITION_YAML_FILE_NAME;
}
let apiDefinitionForNav = null;
diff --git a/portals/developer-portal/src/controllers/viewConfigureController.js b/portals/developer-portal/src/controllers/viewConfigureController.js
index a8e6d0e2f..1c42fc95e 100644
--- a/portals/developer-portal/src/controllers/viewConfigureController.js
+++ b/portals/developer-portal/src/controllers/viewConfigureController.js
@@ -30,43 +30,80 @@ const kmDao = require('../dao/keyManagerDao');
const { KeyManagerDTO } = require('../dto/keyManagerDto');
const { VALID_EVENT_TYPES } = require('../services/webhooks/eventPublisher');
const apiWorkflowService = require('../services/apiWorkflowService');
+const apiMetadataService = require('../services/apiMetadataService');
const util = require('../utils/util');
const { renderGivenTemplate, loadLayoutFromAPI } = require('../utils/util');
const { getSessionCsrfToken } = require('../middlewares/csrfProtection');
const { config } = require('../config/configLoader');
const constants = require('../utils/constants');
-const loadViewSettingsPage = async (req, res) => {
+// Org-scoped settings page. The URL is /:orgName/settings (no view segment) —
+// almost all settings data is keyed by org. The two genuinely view-scoped panels
+// (LLM Instructions, API Workflows) render for an initial view (default, or the
+// first view) and switch client-side via the in-page view selector.
+const loadSettingsPage = async (req, res) => {
let orgId;
- const viewName = req.params.viewName || 'default';
const completeTemplatePath = path.join(require.main.filename, '..', 'pages', 'settings', 'page.hbs');
const layoutPath = path.join(process.cwd(), 'src', 'defaultContent', 'layout', 'main.hbs');
- const baseUrl = '/' + req.params.orgName + '/views/' + viewName;
+ const orgName = req.params.orgName;
+ // Org-scoped self-links (view selector switches the two view-scoped panels client-side).
+ const settingsUrl = '/' + orgName + '/settings';
const csrfToken = getSessionCsrfToken(req);
let templateContent = {
- baseUrl,
- viewName,
+ settingsUrl,
csrfToken,
showApiWorkflowsNav: config.features?.apiWorkflows === true,
demoMode: config.demo?.enabled === true
};
try {
- const orgName = req.params.orgName;
templateContent.loggedOrg = orgName;
orgId = await orgDao.getId(orgName);
const orgDetails = await orgDao.get(orgName);
templateContent.devportalMode = orgDetails.configuration?.devportalMode || constants.DEVPORTAL_MODE.DEFAULT;
templateContent.orgId = orgId;
+ // The Organization tab manages only the current org (no listing/add/delete).
+ const cur = orgDetails.dataValues || orgDetails;
+ templateContent.currentOrg = {
+ id: cur.handle,
+ displayName: cur.display_name,
+ businessOwner: cur.business_owner || '',
+ businessOwnerContact: cur.business_owner_contact || '',
+ businessOwnerEmail: cur.business_owner_email || '',
+ idpRefId: orgDetails.idp_ref_id || '',
+ cpRefId: orgDetails.cp_ref_id || '',
+ };
+
+ // Views for the selector and the merged Views management tab. The in-page
+ // view selector picks which view the LLM + API Workflow panels edit via the
+ // ?view= query param (the path stays org-scoped). Default to 'default', then
+ // fall back to the first view; ignore an unknown ?view value.
+ const views = await apiMetadataService.getViewsFromDB(orgId);
+ templateContent.views = views;
+ const requestedView = typeof req.query.view === 'string' ? req.query.view : '';
+ const viewExists = (name) => views.some(v => v.id === name);
+ let viewName = 'default';
+ if (requestedView && viewExists(requestedView)) {
+ viewName = requestedView;
+ } else if (!viewExists('default') && views.length > 0) {
+ viewName = views[0].id;
+ }
+ templateContent.viewName = viewName;
+ templateContent.selectedView = viewName;
+
+ // Portal chrome (sidebar/header/home link) is inherently view-scoped.
+ const baseUrl = '/' + orgName + '/views/' + viewName;
+ templateContent.baseUrl = baseUrl;
+
const viewId = await viewDao.getId(orgId, viewName);
const apiWorkflows = await apiWorkflowService.getAllAPIWorkflowsFromDB(orgId, viewId);
templateContent.apiWorkflows = apiWorkflows;
const allAPIs = await apiDao.getByCondition({ org_uuid: orgId });
const docNamesByApiId = await apiFileDao.listDocNamesForApis(orgId, allAPIs.map(api => api.uuid));
- templateContent.orgAPIs = allAPIs.map(api => ({
+ const mappedAPIs = allAPIs.map(api => ({
apiId: api.handle,
apiName: api.name,
apiHandle: api.handle,
@@ -81,6 +118,11 @@ const loadViewSettingsPage = async (req, res) => {
subscriptionPlans: (api.dp_subscription_plans || []).map(p => p.display_name),
existingDocs: docNamesByApiId[api.uuid] || [],
}));
+ // MCP servers get their own admin tab; keep REST/WS/GraphQL/SOAP/WebSub in the APIs tab.
+ // orgAllAPIs backs the client-side apiMap (edit/drawer lookups) shared by both tables.
+ templateContent.orgAllAPIs = mappedAPIs;
+ templateContent.orgAPIs = mappedAPIs.filter(api => api.apiType !== constants.API_TYPE.MCP);
+ templateContent.orgMCPs = mappedAPIs.filter(api => api.apiType === constants.API_TYPE.MCP);
let orgLabels = [];
try {
@@ -139,7 +181,11 @@ const loadViewSettingsPage = async (req, res) => {
try { llmsConfig = { ...llmsConfig, ...JSON.parse(configAsset.file_content.toString('utf8')) }; } catch (e) { /* ignore */ }
}
templateContent.llmsConfig = llmsConfig;
- templateContent.llmsConfigContext = { orgId, viewName, csrfToken, baseUrl };
+ // orgName + views let the client rebuild view-scoped URLs when the selector changes.
+ templateContent.llmsConfigContext = { orgId, orgName, viewName, csrfToken, baseUrl, views };
+
+ const hasCustomTheme = await orgDao.hasThemeContent(orgId, viewName);
+ templateContent.themingContext = { orgId, orgName, viewName, csrfToken, baseUrl, views, hasCustomTheme };
templateContent.profile = req.user;
const templateResponse = fs.readFileSync(completeTemplatePath, constants.CHARSET_UTF8);
@@ -153,11 +199,11 @@ const loadViewSettingsPage = async (req, res) => {
}
res.send(html);
} catch (error) {
- logger.error(`Error while loading view settings page`, {
+ logger.error(`Error while loading settings page`, {
error: error.message,
stack: error.stack
});
- res.status(500).send('Error loading view configuration page');
+ res.status(500).send('Error loading settings page');
}
};
@@ -212,7 +258,7 @@ const saveLlmsConfig = async (req, res) => {
};
module.exports = {
- loadViewSettingsPage,
+ loadSettingsPage,
getLlmsConfig,
saveLlmsConfig,
};
diff --git a/portals/developer-portal/src/dao/apiFileDao.js b/portals/developer-portal/src/dao/apiFileDao.js
index f65dcaf60..f3aeb6188 100644
--- a/portals/developer-portal/src/dao/apiFileDao.js
+++ b/portals/developer-portal/src/dao/apiFileDao.js
@@ -670,13 +670,26 @@ const listDocNamesForApis = async (orgId, apiIds) => {
const deleteByFileName = async (fileName, orgId, apiId, t) => {
try {
+ // Scope to document rows only (type LIKE 'DOC_%'), matching listDocNames. Without this,
+ // a non-doc row (image, spec) that happens to share the file_name would also be deleted.
const contentsToDelete = await APIContent.findAll({
- where: { file_name: fileName, api_uuid: apiId },
+ where: {
+ file_name: fileName,
+ api_uuid: apiId,
+ type: { [Op.like]: `${constants.DOC_TYPES.DOC_ID}%` },
+ },
include: [{ model: APIMetadata, required: true, attributes: [], where: { org_uuid: orgId } }],
transaction: t
});
for (const content of contentsToDelete) {
- await APIContent.destroy({ where: { file_name: content.dataValues.file_name, api_uuid: apiId }, transaction: t });
+ await APIContent.destroy({
+ where: {
+ api_uuid: apiId,
+ file_name: content.dataValues.file_name,
+ type: content.dataValues.type,
+ },
+ transaction: t
+ });
}
} catch (error) {
if (error instanceof Sequelize.UniqueConstraintError) throw error;
diff --git a/portals/developer-portal/src/dao/organizationDao.js b/portals/developer-portal/src/dao/organizationDao.js
index 4dc428f70..8878fbc7a 100644
--- a/portals/developer-portal/src/dao/organizationDao.js
+++ b/portals/developer-portal/src/dao/organizationDao.js
@@ -28,6 +28,7 @@ const { WebhookSubscriber } = require('../models/webhookSubscriber');
const View = require('../models/view');
const Labels = require('../models/label');
const Tags = require('../models/tag');
+const constants = require('../utils/constants');
const create = async (orgData, t) => {
let devPortalId = "";
@@ -346,13 +347,17 @@ const deleteContent = async (orgId, viewName, fileName) => {
}
};
-const deleteAllContent = async (orgId, viewName, t) => {
+// Deletes only theme-related content rows (style/layout/partial/markDown/template/image) for
+// the view — scoped so a theme reset/replace never touches unrelated per-view assets like
+// llms-config.json, which shares this same table.
+const deleteThemeContent = async (orgId, viewName, t) => {
const viewId = await viewDao.getId(orgId, viewName);
try {
return await OrgContent.destroy({
where: {
org_uuid: orgId,
- view_uuid: viewId
+ view_uuid: viewId,
+ file_type: constants.THEME_FILE_TYPES
},
transaction: t
});
@@ -361,6 +366,19 @@ const deleteAllContent = async (orgId, viewName, t) => {
}
};
+const hasThemeContent = async (orgId, viewName) => {
+ const viewId = await viewDao.getId(orgId, viewName);
+ if (!viewId) return false;
+ const count = await OrgContent.count({
+ where: {
+ org_uuid: orgId,
+ view_uuid: viewId,
+ file_type: constants.THEME_FILE_TYPES
+ }
+ });
+ return count > 0;
+};
+
module.exports = {
create,
get,
@@ -373,5 +391,6 @@ module.exports = {
updateContent,
getContent,
deleteContent,
- deleteAllContent,
+ deleteThemeContent,
+ hasThemeContent,
};
diff --git a/portals/developer-portal/src/defaultContent/pages/api-workflows/page.hbs b/portals/developer-portal/src/defaultContent/pages/api-workflows/page.hbs
index 44a2490b8..367b27dec 100644
--- a/portals/developer-portal/src/defaultContent/pages/api-workflows/page.hbs
+++ b/portals/developer-portal/src/defaultContent/pages/api-workflows/page.hbs
@@ -12,7 +12,7 @@
{{#if apiWorkflows.length}}
{{#if profile.isAdmin}}
-
+
Manage workflows
{{/if}}
@@ -58,7 +58,7 @@
No workflows yet
Create a workflow to encode a multi-step API sequence your AI agents can follow. Published workflows will appear here for every consumer.
{{#if profile.isAdmin}}
-
+
Create workflow
{{/if}}
diff --git a/portals/developer-portal/src/defaultContent/partials/sidebar.hbs b/portals/developer-portal/src/defaultContent/partials/sidebar.hbs
index 6198d5e1c..bea8c18f8 100644
--- a/portals/developer-portal/src/defaultContent/partials/sidebar.hbs
+++ b/portals/developer-portal/src/defaultContent/partials/sidebar.hbs
@@ -61,7 +61,8 @@
{{#if profile.isAdmin}}
-
+ {{!-- Settings is org-scoped (no view segment); derive /:orgName/settings from the view-scoped baseUrl --}}
+ Settings
diff --git a/portals/developer-portal/src/helpers/handlebarsHelpers.js b/portals/developer-portal/src/helpers/handlebarsHelpers.js
index 0b9c53e69..c027f7755 100644
--- a/portals/developer-portal/src/helpers/handlebarsHelpers.js
+++ b/portals/developer-portal/src/helpers/handlebarsHelpers.js
@@ -48,7 +48,11 @@ const helpers = {
urlEncode: (value) => encodeURIComponent(value ?? ''),
// JSON helpers
- json: (context) => JSON.stringify(context ?? null),
+ // Replace tags and line separators
+ json: (context) => JSON.stringify(context ?? null)
+ .replace(/ new Handlebars.SafeString(Handlebars.escapeExpression(JSON.stringify(context ?? null))),
diff --git a/portals/developer-portal/src/middlewares/ensureAuthenticated.js b/portals/developer-portal/src/middlewares/ensureAuthenticated.js
index 1e05903ba..6334f49d8 100644
--- a/portals/developer-portal/src/middlewares/ensureAuthenticated.js
+++ b/portals/developer-portal/src/middlewares/ensureAuthenticated.js
@@ -97,6 +97,26 @@ function hasRole(roleClaimValue, roleName) {
return String(roleClaimValue).split(/[\s,]+/).includes(roleName);
}
+// Aligns the org-membership check across the local-auth and token/OAuth2 branches of
+// ensureAuthenticated: the caller's org claim must resolve to the target org's
+// idp_ref_id (or be present in authorizedOrgs). Enforced fail-closed once the caller's
+// session carries an org claim — a target org with no resolvable idp_ref_id counts as
+// "no match" and is rejected, never silently skipped (GO-AUTH-005 / JS-AUTH-005
+// multi-tenant isolation; avoids the org check being bypassable just because the
+// looked-up org row happens to have a blank idp_ref_id). Sessions with no org claim at
+// all (e.g. an IDP that doesn't emit one) are left to the role-based ensurePermission
+// gate below, which is the existing, separate authorization mechanism for that case.
+function belongsToTargetOrg(req, orgDetails) {
+ const tokenOrgClaim = req.user?.[constants.ROLES.ORGANIZATION_CLAIM];
+ if (!tokenOrgClaim) return true;
+ const orgIdentifier = orgDetails?.idp_ref_id;
+ const authorizedOrgs = req.user?.authorizedOrgs;
+ return !!orgIdentifier && (
+ tokenOrgClaim === orgIdentifier ||
+ (Array.isArray(authorizedOrgs) && authorizedOrgs.includes(orgIdentifier))
+ );
+}
+
const ensurePermission = (currentPage, role, req) => {
let adminRole, superAdminRole, subscriberRole;
if (req.user) {
@@ -105,7 +125,7 @@ const ensurePermission = (currentPage, role, req) => {
subscriberRole = req.user[constants.ROLES.SUBSCRIBER];
if (constants.ROUTE.DEVPORTAL_CONFIGURE.some(pattern => minimatch.minimatch(currentPage, pattern))) {
return hasRole(role, superAdminRole) || hasRole(role, adminRole);
- } else if (constants.ROUTE.DEVPORTAL_ROOT.some(pattern => minimatch.minimatch(req.originalUrl, pattern))) {
+ } else if (constants.ROUTE.DEVPORTAL_ROOT.some(pattern => minimatch.minimatch(currentPage, pattern))) {
return hasRole(role, superAdminRole);
} else if (AUTHORIZED_PAGES.some(pattern => minimatch.minimatch(currentPage, pattern))) {
return hasRole(role, subscriberRole) || hasRole(role, adminRole) || hasRole(role, superAdminRole);
@@ -170,8 +190,14 @@ const ensureAuthenticated = async (req, res, next) => {
}
}
}
- if (req.originalUrl !== '/favicon.ico' && req.originalUrl !== '/images' &&
- AUTHENTICATED_PAGES.some(pattern => minimatch.minimatch(req.originalUrl, pattern))) {
+ // Glob patterns below (AUTHENTICATED_PAGES/AUTHORIZED_PAGES/DEVPORTAL_ROOT) match the
+ // full string with no implicit query-string handling, so req.originalUrl (which retains
+ // "?...") would silently fail to match any pattern lacking an explicit "?**" suffix —
+ // e.g. "/*/settings" never matches "/org/settings?view=x", which would skip this entire
+ // auth block. Match against the query-stripped pathname instead.
+ const pathname = req.originalUrl.split('?')[0];
+ if (pathname !== '/favicon.ico' && pathname !== '/images' &&
+ AUTHENTICATED_PAGES.some(pattern => minimatch.minimatch(pathname, pattern))) {
const orgId = req.params.orgName;
let orgDetails;
if (orgId !== undefined) {
@@ -184,7 +210,16 @@ const ensureAuthenticated = async (req, res, next) => {
if (req.user && req.user.isLocalAuth && !config.idp?.clientId) {
req.orgId = req.orgId || orgDetails?.uuid;
req[constants.USER_ID] = await resolveUserUuid(req, req.user[constants.USER_ID]);
- if (AUTHORIZED_PAGES.some(pattern => minimatch.minimatch(req.originalUrl, pattern))) {
+ if (AUTHORIZED_PAGES.some(pattern => minimatch.minimatch(pathname, pattern))) {
+ // Reject cross-org access: the URL's :orgName must resolve (via orgDetails.idp_ref_id)
+ // to the org the authenticated (local-auth) user's token claims it belongs to — the
+ // same comparison the token/OAuth2 branch below uses (belongsToTargetOrg).
+ const isDevportalRoot = constants.ROUTE.DEVPORTAL_ROOT.some(pattern => minimatch.minimatch(pathname, pattern));
+ if (!isDevportalRoot && !belongsToTargetOrg(req, orgDetails)) {
+ const err = new Error('Forbidden');
+ err.status = 403;
+ return next(err);
+ }
if (req.user) {
req.user[constants.ROLES.ADMIN] = adminRole;
req.user[constants.ROLES.SUPER_ADMIN] = superAdminRole;
@@ -196,7 +231,7 @@ const ensureAuthenticated = async (req, res, next) => {
}
if (config.security.roleValidation) {
role = req.user[constants.ROLES.ROLE_CLAIM];
- if (ensurePermission(req.originalUrl, role, req)) {
+ if (ensurePermission(pathname, role, req)) {
return next();
} else {
const err = new Error('Forbidden');
@@ -213,7 +248,7 @@ const ensureAuthenticated = async (req, res, next) => {
req.orgId = req.orgId || orgDetails?.uuid;
req[constants.USER_ID] = await resolveUserUuid(req, decodedAccessToken?.[constants.USER_ID]);
}
- if (AUTHORIZED_PAGES.some(pattern => minimatch.minimatch(req.originalUrl, pattern))) {
+ if (AUTHORIZED_PAGES.some(pattern => minimatch.minimatch(pathname, pattern))) {
role = req.user[constants.ROLES.ROLE_CLAIM];
if (req.user) {
req.user[constants.ROLES.ADMIN] = adminRole;
@@ -224,18 +259,14 @@ const ensureAuthenticated = async (req, res, next) => {
req.user[constants.ORG_IDENTIFIER] = orgDetails.idp_ref_id;
}
}
- const isMatch = constants.ROUTE.DEVPORTAL_ROOT.some(pattern => minimatch.minimatch(req.originalUrl, pattern));
- if (!isMatch) {
- const orgIdentifier = orgDetails?.idp_ref_id;
- const tokenOrgClaim = req.user[constants.ROLES.ORGANIZATION_CLAIM];
- if (orgIdentifier && tokenOrgClaim && tokenOrgClaim !== orgIdentifier) {
- const err = new Error('Forbidden');
- err.status = 403;
- return next(err);
- }
+ const isMatch = constants.ROUTE.DEVPORTAL_ROOT.some(pattern => minimatch.minimatch(pathname, pattern));
+ if (!isMatch && !belongsToTargetOrg(req, orgDetails)) {
+ const err = new Error('Forbidden');
+ err.status = 403;
+ return next(err);
}
if (config.security.roleValidation) {
- if (ensurePermission(req.originalUrl, role, req)) {
+ if (ensurePermission(pathname, role, req)) {
return next();
} else {
const err = new Error('Forbidden');
diff --git a/portals/developer-portal/src/middlewares/registerPartials.js b/portals/developer-portal/src/middlewares/registerPartials.js
index 9591f7c0d..21a75949e 100644
--- a/portals/developer-portal/src/middlewares/registerPartials.js
+++ b/portals/developer-portal/src/middlewares/registerPartials.js
@@ -63,13 +63,24 @@ const registerPartials = async (req, res, next) => {
const orgDetails = await orgDao.get(req.params.orgName);
devportalMode = orgDetails.configuration?.devportalMode || devportalMode;
- const isViewConfigure = req.params.orgName && req.params.orgName !== "portal"
- && req.params.viewName && /views\/.+\/settings/i.test(matchURL);
+ // Org-scoped settings page (/:orgName/settings) has no view segment, but still
+ // renders the default-content chrome (sidebar/header/footer) and its own partials.
+ // Register them against the default view's baseUrl.
+ const isOrgSettings = req.params.orgName && req.params.orgName !== "portal"
+ && !req.params.viewName && /^\/[^/]+\/settings(?:[/?#]|$)/i.test(matchURL);
const isNonConfigure = req.params.orgName && req.params.orgName !== "portal"
- && req.params.viewName && (!(/views\/.+\/settings/i.test(matchURL)));
+ && req.params.viewName;
- if (isNonConfigure || isViewConfigure) {
- const baseUrl = config.server.baseUrl + "/" + req.params.orgName + constants.ROUTE.VIEWS_PATH + req.params.viewName;
+ if (isNonConfigure || isOrgSettings) {
+ // The org-scoped settings route carries no view segment. Downstream partial
+ // resolution (registerPartialsFromFile) reads req.params.viewName to look up
+ // per-view custom overrides, so default it to 'default' — the settings page
+ // renders the default view's chrome and its own default-content partials.
+ if (isOrgSettings && !req.params.viewName) {
+ req.params.viewName = 'default';
+ }
+ const viewSegment = req.params.viewName || 'default';
+ const baseUrl = config.server.baseUrl + "/" + req.params.orgName + constants.ROUTE.VIEWS_PATH + viewSegment;
await registerAllPartialsFromFile(baseUrl, req, './src/defaultContent');
if (isNonConfigure) {
diff --git a/portals/developer-portal/src/pages/settings/page.hbs b/portals/developer-portal/src/pages/settings/page.hbs
index fb5afa993..3938c019c 100644
--- a/portals/developer-portal/src/pages/settings/page.hbs
+++ b/portals/developer-portal/src/pages/settings/page.hbs
@@ -10,17 +10,13 @@
{{/pageScripts}}
-
-
-
-
-
-
-
+
+
+
@@ -34,29 +30,56 @@
+
+
+
+ {{> theming}}
+
+
+
@@ -229,6 +259,10 @@
3Documentation
+
+
+ 4Content
+
@@ -399,6 +433,33 @@
+
+
+
Upload a ZIP to set this API's developer-portal content. Include a web/ folder for landing-page assets (Markdown, HTML, CSS, JS, images) and/or a docs/ folder for downloadable documents. Files with a matching name are replaced; others are added. This uploads immediately when you click Upload content — it is independent of the Save changes button.
+
+
+
+
+
+
+
Ready to upload
+
+
+
+
+
+
+
+
@@ -411,7 +472,104 @@
-
+
+
+
+
+
MCP Servers
+
Add, edit, and remove the MCP servers exposed in this developer portal.
No MCP servers found. Click Add MCP Server to get started.
+ {{/unless}}
+
+
+
+
@@ -613,7 +771,164 @@
-
+
+
+
+
+
Organization
+
Manage the details of this organization.
+
+
+
+ {{#if currentOrg}}
+
+
+
+
+
+
+
+
+
Identifier used in URLs. Cannot be changed.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{else}}
+
Organization details are unavailable.
+ {{/if}}
+
+
+
+
+
+
+
Views
+
Views scope which labelled APIs are visible. LLM Instructions and API Workflows are configured per view.
+
+
+
+
+
+
+
+
+
Handle
+
Name
+
Labels
+
+
+
+
+ {{#views}}
+
+
{{id}}
+
+
+
+
+
+ {{#each labels}}{{this}}{{/each}}
+
+
+
+
+
+ {{#unless (eq id "default")}}
+
+ {{/unless}}
+
+
+
+ {{/views}}
+ {{#unless views.length}}
+
No views yet. Add one to get started.
+ {{/unless}}
+
+
+
+
+
+
+
+
+
+
Labels
+
Labels group APIs; views make labelled APIs visible to consumers.
+
+
+
+
+
+
+
+
+
Display name
+
Name
+
+
+
+
+ {{#orgLabels}}
+
+
+
+
+
{{id}}
+
+
+
+
+
+
+
+ {{/orgLabels}}
+ {{#unless orgLabels.length}}
+
No labels yet. Add one to get started.
+ {{/unless}}
+
+
+
+
@@ -734,6 +1049,74 @@
+
+
+
+
+
+
Delete View
+
+
Are you sure you want to delete ? Its LLM Instructions and API Workflows will be removed. This action can't be undone.
+
+
+
+
+
+
+
+
+
+
+
+
+
Reset Theme
+
+
Reset to the built-in default theme? Its custom CSS, layout, partials, and images will be removed. This action can't be undone.
+
+
+
+
+
+
+
+
+
+
+
+
Add view
+
+
+
+
+
+
Lowercase identifier used in URLs. Cannot be changed later.
+
+
+
+
+
+
+
+ {{#if orgLabels.length}}
+
+ {{#orgLabels}}
+
+ {{/orgLabels}}
+
+
Click labels to toggle. APIs with these labels appear in this view.
+ {{else}}
+
No labels yet — create labels first to attach them to this view.
+ {{/if}}
+
+
+
+
+
+
+
+
@@ -928,20 +1311,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
Theming
+
Upload a custom theme (CSS, layout, partials, images) for this view, or reset it back to the built-in default.
+
+
+
+{{> view-combo hash="cfg-theming" label="the theme"}}
+
+{{!-- Current theme status --}}
+
+
+
+
+
+
+
+
Current theme
+
+ {{#if themingContext.hasCustomTheme}}
+ This view is using a custom uploaded theme.
+ {{else}}
+ This view is using the built-in default theme.
+ {{/if}}
+
Accepted: a single ZIP archive containing styles/, layout/, partials/, and/or images/. Uploading replaces this view's current custom theme files entirely.
+
+
+
+
+
+
+
+
+
Ready to upload
+
+
+
+
+
+
+
+
+
diff --git a/portals/developer-portal/src/pages/settings/partials/view-combo.hbs b/portals/developer-portal/src/pages/settings/partials/view-combo.hbs
new file mode 100644
index 000000000..0d79c7653
--- /dev/null
+++ b/portals/developer-portal/src/pages/settings/partials/view-combo.hbs
@@ -0,0 +1,56 @@
+{{!--
+ View-scoped setting banner, used in the LLM Instructions and API Workflows panels.
+ Always rendered — communicates that the panel's content is scoped to a single view.
+ When the org has more than one view, the pill on the right becomes a searchable
+ switcher: each option is a plain link to ?view=# so navigation is native;
+ the JS in page.hbs handles open/close, type-to-filter, and keyboard nav.
+ Params: hash = target panel id (e.g. "cfg-llm"). label = noun phrase describing what's
+ being edited (e.g. "instructions", "workflows", "the theme") — defaults to "settings".
+--}}
+
+
+
+
+
View-scoped setting
+
+ {{#compare views.length ">" 1}}
+ You're editing {{#unless label}}settings{{else}}{{label}}{{/unless}} for one of {{views.length}} views
+ {{else}}
+ You're editing {{#unless label}}settings{{else}}{{label}}{{/unless}} for one view
+ {{/compare}}
+