Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions docs/decisions/2026-10-17-service-list-connection-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Service lists isolate broken connections while single-service operations remain strict

- **Status:** Accepted
- **Revisions:** v1 2026-10-17 (initial)
- **Deciders:** Vamsi-klu
- **Guard:** `ServiceEntityResourceTest` and `ServiceConnectionResilienceIT`
- **Related:** https://github.com/open-metadata/OpenMetadata/issues/31887,
https://github.com/open-metadata/OpenMetadata/pull/35681

## Context

A stored connection that cannot be decrypted or validated must not hide healthy services in the
same list. The broken service must remain discoverable so its configuration can be repaired.

## Decision

Service list endpoints catch `InvalidServiceConnectionException` for each entry and return that
service with `connection` null. The response keeps its identities, order, pagination and
permissions. The fallback logs only the service category and UUID and does not persist the change.

Single-service GET by ID or name and writes retain their existing strict error behavior, including
HTTP 400 for invalid connections. Other exceptions continue to fail the list.

## Consequences

One broken connection no longer prevents listing healthy services. A null connection in a list
does not distinguish an invalid stored connection from other reasons it may be absent; callers
must not treat it as proof that no connection was configured. Single-service reads retain the
error needed to diagnose the broken configuration, and repair requires an explicit write.
Original file line number Diff line number Diff line change
@@ -1,5 +1,108 @@
package org.openmetadata.it.tests;

// TODO: Implement McpServiceResourceIT extending BaseServiceIT<McpService, CreateMcpService>
// Requires adding McpServiceService to openmetadata-sdk first (only McpServerService exists).
// See LLMServiceResourceIT.java for the pattern to follow.
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.parallel.Execution;
import org.junit.jupiter.api.parallel.ExecutionMode;
import org.openmetadata.it.util.SdkClients;
import org.openmetadata.it.util.TestNamespace;
import org.openmetadata.it.util.TestNamespaceExtension;
import org.openmetadata.schema.api.domains.CreateDomain;
import org.openmetadata.schema.api.services.CreateMcpService;
import org.openmetadata.schema.entity.domains.Domain;
import org.openmetadata.schema.entity.services.McpService;
import org.openmetadata.schema.utils.JsonUtils;
import org.openmetadata.sdk.network.HttpClient;
import org.openmetadata.sdk.network.HttpMethod;
import org.openmetadata.sdk.network.RequestOptions;
import org.openmetadata.service.Entity;
import org.openmetadata.service.resources.services.mcp.McpServiceResource.McpServiceList;
import org.openmetadata.service.secrets.masker.PasswordEntityMasker;

@Execution(ExecutionMode.CONCURRENT)
@ExtendWith(TestNamespaceExtension.class)
public class McpServiceResourceIT {
private static final String COLLECTION = "/v1/services/mcpServices";
private static final String API_KEY = "synthetic-mcp-smoke-api-key";

@Test
void list_preservesIdentityFieldsAndCredentialMasking(TestNamespace ns) {
final Domain domain =
ns.trackRoot(
Entity.DOMAIN,
SdkClients.adminClient()
.domains()
.create(
new CreateDomain()
.withName(ns.prefix("mcp_domain"))
.withDescription("MCP service list isolation")
.withDomainType(CreateDomain.DomainType.AGGREGATE)));
final CreateMcpService request =
JsonUtils.readValue(
"""
{"serviceType":"Mcp","connection":{"config":{"type":"Mcp","servers":[{
"name":"http-server","transport":"SSE","url":"http://localhost:9000/sse",
"apiKey":"synthetic-mcp-smoke-api-key"}]}}}
""",
CreateMcpService.class)
.withName(ns.prefix("mcp_service"))
.withDescription("Healthy MCP service")
.withDomains(List.of(domain.getFullyQualifiedName()));
final HttpClient human = SdkClients.adminClient().getHttpClient();
final McpService created =
human.execute(HttpMethod.POST, COLLECTION, request, McpService.class);
try {
final McpServiceList listed =
human.execute(
HttpMethod.GET,
COLLECTION,
null,
McpServiceList.class,
RequestOptions.builder()
.queryParam("domain", domain.getFullyQualifiedName())
.queryParam("fields", "domains,owners")
.queryParam("includePermissions", "true")
.build());
assertEquals(1, listed.getData().size());
final McpService row = listed.getData().getFirst();
assertEquals(created.getId(), row.getId());
assertEquals(request.getDescription(), row.getDescription());
assertEquals(
List.of(domain.getId()),
row.getDomains().stream().map(reference -> reference.getId()).toList());
assertTrue(listed.getEntityPermissions().containsKey(created.getId().toString()));
assertEquals(
PasswordEntityMasker.PASSWORD_MASK,
JsonUtils.readTree(JsonUtils.pojoToJson(row.getConnection().getConfig()))
.path("servers")
.get(0)
.path("apiKey")
.asText());
final McpService bot =
SdkClients.ingestionBotClient()
.getHttpClient()
.execute(HttpMethod.GET, COLLECTION + "/" + created.getId(), null, McpService.class);
assertEquals(
API_KEY,
JsonUtils.readTree(JsonUtils.pojoToJson(bot.getConnection().getConfig()))
.path("servers")
.get(0)
.path("apiKey")
.asText());
} finally {
human.execute(
HttpMethod.DELETE,
COLLECTION + "/" + created.getId(),
null,
Void.class,
RequestOptions.builder()
.queryParam("hardDelete", "true")
.queryParam("recursive", "true")
.build());
}
}
}
Loading
Loading