Add manual cleanup block for unpublish#477
Conversation
The platform now returns the Public Clients Entra app registration(s) it cannot delete in the customer tenant (AppIdsToCleanup) from the unpublish response. Change UnpublishServerAsync to return the parsed UnpublishMcpServerResponse instead of bool, and have the develop-mcp unpublish subcommand delete each returned app via Graph (resolving the object id from the app id), mirroring the publish rollback pattern. Degrades gracefully with manual-cleanup guidance when Graph is unavailable or the tenant can't be detected. - New models UnpublishMcpServerResponse + McpServerAppEntry. - New GraphApiService.GetAppObjectIdByAppIdAsync (appId -> application object id). - Regression tests updated for the new return type; added null-response and no-graph-service cleanup coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Follow the platform's self-describing unpublish contract: read the Entra apps the platform cannot delete from response.ManualCleanupRequired.Apps instead of the removed AppIdsToCleanup list. Adds the mirroring McpServerManualCleanup model and updates the cleanup regression test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Scanned FilesNone |
There was a problem hiding this comment.
Pull request overview
This PR updates the develop mcp unpublish flow to consume the platform’s new ManualCleanupRequired response block and (best-effort) delete Entra app registrations that the platform cannot remove on its own.
Changes:
- Change
UnpublishServerAsyncto return a parsedUnpublishMcpServerResponse?(instead ofbool) so the command layer can act on returned cleanup artifacts. - Add response/cleanup models (
UnpublishMcpServerResponse,McpServerManualCleanup,McpServerAppEntry) mirroring the platform contract. - Add Graph support to resolve an app object id from an app (client) id and use it to delete returned apps after a successful unpublish.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandRegressionTests.cs | Updates mocks for new unpublish return type; adds regression tests for null response and no-Graph cleanup behavior. |
| src/Microsoft.Agents.A365.DevTools.Cli/Services/IAgent365ToolingService.cs | Updates unpublish service contract to return UnpublishMcpServerResponse?. |
| src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs | Adds appId→objectId lookup helper for later deletion. |
| src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs | Parses unpublish response body (when present) and returns cleanup info to the command layer. |
| src/Microsoft.Agents.A365.DevTools.Cli/Models/UnpublishMcpServerResponse.cs | New unpublish response model (Status/Message/ManualCleanupRequired + IsSuccess). |
| src/Microsoft.Agents.A365.DevTools.Cli/Models/McpServerManualCleanup.cs | New model for the ManualCleanupRequired block. |
| src/Microsoft.Agents.A365.DevTools.Cli/Models/McpServerAppEntry.cs | New model for Entra app registrations returned for cleanup. |
| src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs | Wires unpublish to consume cleanup block and invoke best-effort Entra app deletion. |
Comments suppressed due to low confidence (1)
src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs:528
- The unpublish handler logs an error and returns on failure, but it never sets a non-zero exit code. As a result,
a365 develop mcp unpublishcan fail (null/!IsSuccess response) while still exiting 0, which breaks scripting/CI usage and conflicts with the repo’s command exit-code rule for failure branches.
var response = await toolingService.UnpublishServerAsync(envId, serverName);
if (response is null || !response.IsSuccess)
{
logger.LogError("Failed to unpublish MCP server {ServerName} from environment {EnvId}", serverName, envId);
return;
}
| if (string.IsNullOrWhiteSpace(app.AppId)) | ||
| { | ||
| continue; | ||
| } |
| return unpublishResponse ?? new UnpublishMcpServerResponse | ||
| { | ||
| Status = "Success", | ||
| Message = $"Successfully unpublished {serverName}" | ||
| }; |
| [Fact] | ||
| public async Task UnpublishCommand_WhenServiceReturnsNull_DoesNotThrow() | ||
| { | ||
| // A null response means the unpublish failed; the command must log and return cleanly. | ||
| var testEnvId = "test-environment-789"; | ||
| var testServerName = "msdyn_TestServer"; | ||
|
|
||
| _mockToolingService.UnpublishServerAsync(testEnvId, testServerName) | ||
| .Returns((UnpublishMcpServerResponse?)null); | ||
|
|
||
| var result = await _command.InvokeAsync(new[] | ||
| { | ||
| "unpublish", | ||
| "-e", testEnvId, | ||
| "-s", testServerName | ||
| }); | ||
|
|
||
| result.Should().Be(0); | ||
| await _mockToolingService.Received(1).UnpublishServerAsync(testEnvId, testServerName); | ||
| } |
rbrighenti
left a comment
There was a problem hiding this comment.
Copilot has a few relevant comments that I think should be addressed. Please take a look at them
| foreach (var app in appsToCleanup) | ||
| { | ||
| logger.LogWarning( | ||
| "Graph API is unavailable; cannot delete Entra app '{AppName}' (appId {AppId}) for server '{ServerName}'. Delete it manually in the Azure portal.", |
There was a problem hiding this comment.
Remove "Graph API is unavailable"
| foreach (var app in appsToCleanup) | ||
| { | ||
| logger.LogWarning( | ||
| "Graph API is unavailable; cannot delete Entra app '{AppName}' (appId {AppId}) for server '{ServerName}'. Delete it manually in the Azure portal.", |
There was a problem hiding this comment.
Also, may be log the message just once and then a list of apps?
| foreach (var app in appsToCleanup) | ||
| { | ||
| logger.LogWarning( | ||
| "Could not detect the tenant; cannot delete Entra app '{AppName}' (appId {AppId}) for server '{ServerName}'. Delete it manually in the Azure portal.", |
There was a problem hiding this comment.
Same comment. May be check these two conditions first and then show the app list? User do not need know the internal details.
| /// Looks up an application by its appId (clientId) and returns the object ID. | ||
| /// Retries up to 6 times with a 10-second delay to handle replication lag for newly created apps. | ||
| /// </summary> | ||
| public virtual async Task<string?> GetAppObjectIdByClientIdAsync( |
| _logger.LogDebug("Successfully unpublished MCP server"); | ||
| return true; | ||
|
|
||
| // Allow for an empty/null body: the operation still succeeded, there is just nothing to clean up. |
There was a problem hiding this comment.
This is not true, right? Is there a success case where the body would be empty?
Summary
When you unpublish a custom MCP server, the platform removes the tenant publication (MOS title + MCC record) but cannot delete the Entra app registration it created for the server (the *-PublicClients app), because the platform's identity has no rights to delete app registrations in the customer tenant. Previously that app was left dangling after unpublish, with nothing telling the user it existed.
The platform now returns those apps on the unpublish response under a self-describing ManualCleanupRequired block. This PR teaches the CLI to consume that block and delete the apps on the user's behalf — mirroring the rollback the publish flow already does when a publish fails.
What changed
UnpublishServerAsync now returns the parsed response (Task<UnpublishMcpServerResponse?> instead of Task), so the command layer can act on the returned apps. Returns null on failure.
New models mirroring the platform contract:
Testing
CLI + test projects build clean (warnings-as-errors).
Full suite: 1923 passed / 0 failed / 12 skipped.
Added coverage: null-response path (unpublish failed → no throw) and the no-Graph-service cleanup path (degrades gracefully).