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 + + + +

Parameters

+ +|Name|In|Type|Required|Description| +|---|---|---|---|---| +|viewId|path|string|true|The view's handle (unique per org). Not the internal database uuid.| + +> Example responses + +> 200 Response + +> 404 Response + +```json +{ + "status": "error", + "code": "ORG_NOT_FOUND", + "message": "Organization not found." +} +``` + +> 500 Response + +```json +{ + "status": "error", + "code": "INTERNAL_SERVER_ERROR", + "message": "An unexpected error occurred." +} +``` + +

Responses

+ +|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}} - - - - + + + + +
+
+ {{> theming}} +
+
+
@@ -229,6 +259,10 @@
3Documentation
+ +
@@ -399,6 +433,33 @@
+ + +
@@ -411,7 +472,104 @@
- + +
+
+
+

MCP Servers

+

Add, edit, and remove the MCP servers exposed in this developer portal.

+
+
+ +
+
+ + + +
+ + + + + + + + + + + + {{#orgMCPs}} + + + + + + + + {{/orgMCPs}} + {{#unless orgMCPs.length}} + + {{/unless}} + +
MCP ServerVersionStatus
+ + +
+ {{firstTwoLetters apiName}} +
+
{{apiName}}
+
/{{apiHandle}}
+
+
+
{{apiVersion}} + {{#if (eq apiStatus "PUBLISHED")}} + + Published + + {{else if (eq apiStatus "DEPRECATED")}} + + Deprecated + + {{else}} + + Draft + + {{/if}} + + +
+ + {{#if (eq apiStatus "PUBLISHED")}} + + {{else}} + + {{/if}} +
+ +
+
No MCP servers found. Click Add MCP Server to get started.
+
+
@@ -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.

+
+ +
+ +
+ + + + + + + + + + + {{#views}} + + + + + + + {{/views}} + {{#unless views.length}} + + {{/unless}} + +
HandleNameLabels
{{id}} + + +
+ {{#each labels}}{{this}}{{/each}} +
+
+
+ + {{#unless (eq id "default")}} + + {{/unless}} +
+
No views yet. Add one to get started.
+
+
+ + +
+
+
+

Labels

+

Labels group APIs; views make labelled APIs visible to consumers.

+
+ +
+ +
+ + + + + + + + + + {{#orgLabels}} + + + + + + {{/orgLabels}} + {{#unless orgLabels.length}} + + {{/unless}} + +
Display nameName
+ + {{id}} +
+ + +
+
No labels yet. Add one to get started.
+
+
@@ -734,6 +1049,74 @@ + + + + + + + + +