diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index cbc7d5fa2d..dff4681a80 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -1251,6 +1251,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance config.IncludeSubAgentStreamingEvents, config.McpServers, config.McpOAuthTokenStorage, + config.AuthClientIdMetadataUrl, "direct", config.CustomAgents, config.DefaultAgent, @@ -1503,6 +1504,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes config.IncludeSubAgentStreamingEvents, config.McpServers, config.McpOAuthTokenStorage, + config.AuthClientIdMetadataUrl, "direct", config.CustomAgents, config.DefaultAgent, @@ -3033,6 +3035,7 @@ internal record CreateSessionRequest( bool? IncludeSubAgentStreamingEvents, IDictionary? McpServers, McpOAuthTokenStorageMode? McpOAuthTokenStorage, + string? AuthClientIdMetadataUrl, string? EnvValueMode, IList? CustomAgents, DefaultAgentConfig? DefaultAgent, @@ -3162,6 +3165,7 @@ internal record ResumeSessionRequest( bool? IncludeSubAgentStreamingEvents, IDictionary? McpServers, McpOAuthTokenStorageMode? McpOAuthTokenStorage, + string? AuthClientIdMetadataUrl, string? EnvValueMode, IList? CustomAgents, DefaultAgentConfig? DefaultAgent, diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 755b42f296..1cc4919093 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -3327,6 +3327,7 @@ protected SessionConfigBase(SessionConfigBase? other) : new Dictionary(other.McpServers)) : null; McpOAuthTokenStorage = other.McpOAuthTokenStorage; + AuthClientIdMetadataUrl = other.AuthClientIdMetadataUrl; Model = other.Model; ModelCapabilities = other.ModelCapabilities; OnAutoModeSwitchRequest = other.OnAutoModeSwitchRequest; @@ -3721,6 +3722,12 @@ protected SessionConfigBase(SessionConfigBase? other) /// public McpOAuthTokenStorageMode? McpOAuthTokenStorage { get; set; } + /// + /// OAuth Client ID Metadata Document URL identifying the host for MCP authorization. + /// When unset, no host identity is supplied. + /// + public string? AuthClientIdMetadataUrl { get; set; } + /// Custom agent configurations for the session. public IList? CustomAgents { get; set; } diff --git a/dotnet/test/E2E/McpOAuthE2ETests.cs b/dotnet/test/E2E/McpOAuthE2ETests.cs index e947ea7640..17d3162805 100644 --- a/dotnet/test/E2E/McpOAuthE2ETests.cs +++ b/dotnet/test/E2E/McpOAuthE2ETests.cs @@ -6,6 +6,7 @@ using GitHub.Copilot.Test.Harness; using System.Diagnostics; using System.Net.Http; +using System.Net; using System.Text.Json; using System.Threading.Channels; using Xunit; @@ -19,6 +20,30 @@ public class McpOAuthE2ETests(E2ETestFixture fixture, ITestOutputHelper output) private const string RefreshToken = ExpectedToken + "-refresh"; private const string UpscopeToken = ExpectedToken + "-upscope"; private const string ReauthToken = ExpectedToken + "-reauth"; + private const string CimdUrl = "https://github.com/copilot/cli/client-metadata.json"; + + [Fact] + public async Task Should_Use_Cimd_Url_Instead_Of_Dynamic_Registration() + { + await using var oauthServer = await OAuthMcpServer.StartAsync(ExpectedToken, cimdSupported: true); + const string serverName = "oauth-cimd-mcp"; + await using var session = await CreateSessionAsync(new SessionConfig + { + AuthClientIdMetadataUrl = CimdUrl, + McpServers = new Dictionary + { + [serverName] = new McpHttpServerConfig { Url = $"{oauthServer.Url}/mcp", Tools = ["*"] } + } + }); + await WaitForMcpServerStatusAsync(session, serverName, McpServerStatus.NeedsAuth); + var result = await session.Rpc.Mcp.Oauth.LoginAsync(serverName); + Assert.NotNull(result.AuthorizationUrl); + var clientIdParameter = new Uri(result.AuthorizationUrl!).Query.TrimStart('?') + .Split('&').Single(part => part.StartsWith("client_id=", StringComparison.Ordinal)); + var clientId = clientIdParameter.Substring("client_id=".Length); + Assert.Equal(CimdUrl, WebUtility.UrlDecode(clientId)); + Assert.DoesNotContain(await oauthServer.GetRequestsAsync(), request => request.Path == "/register"); + } [Fact] public async Task Should_Satisfy_MCP_OAuth_Using_Host_Provided_Token() @@ -282,7 +307,7 @@ private OAuthMcpServer(Process process, string url) public string Url { get; } - public static async Task StartAsync(string expectedToken) + public static async Task StartAsync(string expectedToken, bool cimdSupported = false) { var repoRoot = FindRepoRoot(); var script = GetRepoRelativePath(repoRoot, "test", "harness", "test-mcp-oauth-server.mjs"); @@ -295,6 +320,7 @@ public static async Task StartAsync(string expectedToken) UseShellExecute = false }; startInfo.Environment["EXPECTED_TOKEN"] = expectedToken; + startInfo.Environment["CIMD_SUPPORTED"] = cimdSupported ? "true" : "false"; var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start OAuth MCP server."); @@ -326,7 +352,8 @@ public async Task> GetRequestsAsync() element.TryGetProperty("authorization", out var authorization) && authorization.ValueKind is JsonValueKind.String ? authorization.GetString() - : null)) + : null, + element.GetProperty("path").GetString()!)) .ToList(); } @@ -370,5 +397,5 @@ private static string QuoteProcessArgument(string argument) => "\"" + argument.Replace("\"", "\\\"") + "\""; } - private sealed record OAuthMcpRequest(string? Authorization); + private sealed record OAuthMcpRequest(string? Authorization, string Path); } diff --git a/go/client.go b/go/client.go index 6cac724f0d..e450c595f2 100644 --- a/go/client.go +++ b/go/client.go @@ -903,6 +903,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses req.AdditionalDirectories = config.AdditionalDirectories req.MCPServers = config.MCPServers req.MCPOAuthTokenStorage = config.MCPOAuthTokenStorage + req.AuthClientIDMetadataURL = config.AuthClientIDMetadataURL req.EnvValueMode = "direct" req.CustomAgents = config.CustomAgents req.DefaultAgent = config.DefaultAgent @@ -1322,6 +1323,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, req.ContinuePendingWork = config.ContinuePendingWork req.MCPServers = config.MCPServers req.MCPOAuthTokenStorage = config.MCPOAuthTokenStorage + req.AuthClientIDMetadataURL = config.AuthClientIDMetadataURL req.EnvValueMode = "direct" req.CustomAgents = config.CustomAgents req.DefaultAgent = config.DefaultAgent diff --git a/go/internal/e2e/mcp_oauth_e2e_test.go b/go/internal/e2e/mcp_oauth_e2e_test.go index 356805371b..62e141b119 100644 --- a/go/internal/e2e/mcp_oauth_e2e_test.go +++ b/go/internal/e2e/mcp_oauth_e2e_test.go @@ -4,6 +4,7 @@ import ( "bufio" "encoding/json" "net/http" + "net/url" "os" "os/exec" "slices" @@ -27,6 +28,41 @@ func TestMCPOAuthE2E(t *testing.T) { client := ctx.NewClient() t.Cleanup(func() { client.ForceStop() }) + t.Run("uses CIMD URL instead of dynamic registration", func(t *testing.T) { + baseURL := startOAuthMCPServer(t, true) + serverName := "oauth-cimd-mcp" + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + AuthClientIDMetadataURL: "https://github.com/copilot/cli/client-metadata.json", + MCPServers: map[string]copilot.MCPServerConfig{ + serverName: copilot.MCPHTTPServerConfig{URL: baseURL + "/mcp", Tools: []string{"*"}}, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + t.Cleanup(func() { session.Disconnect() }) + waitForMCPServerStatus(t, session, serverName, rpc.MCPServerStatusNeedsAuth) + result, err := session.RPC.MCP.Oauth().Login(t.Context(), &rpc.MCPOauthLoginRequest{ServerName: serverName}) + if err != nil { + t.Fatalf("MCP OAuth login failed: %v", err) + } + if result.AuthorizationURL == nil { + t.Fatal("Expected authorization URL") + } + parsed, err := url.Parse(*result.AuthorizationURL) + if err != nil { + t.Fatalf("Invalid authorization URL: %v", err) + } + if parsed.Query().Get("client_id") != "https://github.com/copilot/cli/client-metadata.json" { + t.Fatalf("Expected CIMD client_id, got %q", parsed.Query().Get("client_id")) + } + for _, request := range fetchOAuthMCPRequests(t, baseURL) { + if request.Path == "/register" { + t.Fatal("Runtime should not dynamically register when CIMD is supported") + } + } + }) + t.Run("satisfy MCP OAuth using host-provided token", func(t *testing.T) { baseURL := startOAuthMCPServer(t) serverName := "oauth-protected-mcp" @@ -335,18 +371,23 @@ func TestMCPOAuthE2E(t *testing.T) { type oauthMCPRequest struct { Authorization *string `json:"authorization"` + Path string `json:"path"` } -func startOAuthMCPServer(t *testing.T) string { +func startOAuthMCPServer(t *testing.T, cimdSupported ...bool) string { t.Helper() serverPath := testharness.RepoPath("test", "harness", "test-mcp-oauth-server.mjs") cmd := exec.Command("node", serverPath) cmd.Env = append(os.Environ(), "EXPECTED_TOKEN="+expectedMCPOAuthToken) + if len(cimdSupported) > 0 && cimdSupported[0] { + cmd.Env = append(cmd.Env, "CIMD_SUPPORTED=true") + } stdout, err := cmd.StdoutPipe() if err != nil { t.Fatalf("Failed to pipe OAuth MCP server stdout: %v", err) } + var stderr syncBuffer cmd.Stderr = &stderr if err := cmd.Start(); err != nil { diff --git a/go/types.go b/go/types.go index b320066a55..7bc5bfb9af 100644 --- a/go/types.go +++ b/go/types.go @@ -1493,6 +1493,9 @@ type SessionConfig struct { // MCPOAuthTokenStorage controls how MCP OAuth tokens are stored for this session. // When empty, the runtime default ("in-memory") is used. MCPOAuthTokenStorage string + // AuthClientIDMetadataURL identifies the host for MCP OAuth authorization. + // When empty, no host identity is supplied. + AuthClientIDMetadataURL string // CustomAgents configures custom agents for the session CustomAgents []CustomAgentConfig // DefaultAgent configures the default agent (the built-in agent that handles turns when no custom agent is selected). @@ -2053,6 +2056,9 @@ type ResumeSessionConfig struct { // MCPOAuthTokenStorage controls how MCP OAuth tokens are stored for this session. // When empty, the runtime default ("in-memory") is used. MCPOAuthTokenStorage string + // AuthClientIDMetadataURL identifies the host for MCP OAuth authorization. + // When empty, no host identity is supplied. + AuthClientIDMetadataURL string // CustomAgents configures custom agents for the session CustomAgents []CustomAgentConfig // DefaultAgent configures the default agent (the built-in agent that handles turns when no custom agent is selected). @@ -2645,6 +2651,7 @@ type createSessionRequest struct { EnableGitHubTelemetryForwarding *bool `json:"enableGitHubTelemetryForwarding,omitempty"` MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"` MCPOAuthTokenStorage string `json:"mcpOAuthTokenStorage,omitempty"` + AuthClientIDMetadataURL string `json:"authClientIdMetadataUrl,omitempty"` EnvValueMode string `json:"envValueMode,omitempty"` CustomAgents []CustomAgentConfig `json:"customAgents,omitempty"` DefaultAgent *DefaultAgentConfig `json:"defaultAgent,omitempty"` @@ -2756,6 +2763,7 @@ type resumeSessionRequest struct { EnableGitHubTelemetryForwarding *bool `json:"enableGitHubTelemetryForwarding,omitempty"` MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"` MCPOAuthTokenStorage string `json:"mcpOAuthTokenStorage,omitempty"` + AuthClientIDMetadataURL string `json:"authClientIdMetadataUrl,omitempty"` EnvValueMode string `json:"envValueMode,omitempty"` CustomAgents []CustomAgentConfig `json:"customAgents,omitempty"` DefaultAgent *DefaultAgentConfig `json:"defaultAgent,omitempty"` diff --git a/java/sdk/src/main/java/com/github/copilot/SessionRequestBuilder.java b/java/sdk/src/main/java/com/github/copilot/SessionRequestBuilder.java index d54889cc4b..b336e70472 100644 --- a/java/sdk/src/main/java/com/github/copilot/SessionRequestBuilder.java +++ b/java/sdk/src/main/java/com/github/copilot/SessionRequestBuilder.java @@ -149,6 +149,7 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config, String sess config.getIncludeSubAgentStreamingEvents().ifPresent(request::setIncludeSubAgentStreamingEvents); request.setMcpServers(config.getMcpServers()); request.setMcpOAuthTokenStorage(config.getMcpOAuthTokenStorage()); + request.setAuthClientIdMetadataUrl(config.getAuthClientIdMetadataUrl()); request.setCustomAgents(config.getCustomAgents()); request.setCustomAgentsLocalOnly( resolveCustomAgentsLocalOnly(config.getCustomAgentsLocalOnly().orElse(null), mode)); @@ -304,6 +305,7 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo config.getIncludeSubAgentStreamingEvents().ifPresent(request::setIncludeSubAgentStreamingEvents); request.setMcpServers(config.getMcpServers()); request.setMcpOAuthTokenStorage(config.getMcpOAuthTokenStorage()); + request.setAuthClientIdMetadataUrl(config.getAuthClientIdMetadataUrl()); request.setCustomAgents(config.getCustomAgents()); request.setCustomAgentsLocalOnly( resolveCustomAgentsLocalOnly(config.getCustomAgentsLocalOnly().orElse(null), mode)); diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java b/java/sdk/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java index f6b5001e7a..b7fc219258 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java @@ -119,6 +119,9 @@ public final class CreateSessionRequest { @JsonProperty("mcpOAuthTokenStorage") private String mcpOAuthTokenStorage; + @JsonProperty("authClientIdMetadataUrl") + private String authClientIdMetadataUrl; + @JsonProperty("envValueMode") private String envValueMode; @@ -604,6 +607,16 @@ public void setMcpOAuthTokenStorage(String mcpOAuthTokenStorage) { this.mcpOAuthTokenStorage = mcpOAuthTokenStorage; } + /** Gets the host OAuth client metadata URL. @return the metadata URL */ + public String getAuthClientIdMetadataUrl() { + return authClientIdMetadataUrl; + } + + /** Sets the host OAuth client metadata URL. @param url the metadata URL */ + public void setAuthClientIdMetadataUrl(String url) { + this.authClientIdMetadataUrl = url; + } + /** Gets MCP environment variable value mode. @return the mode */ public String getEnvValueMode() { return envValueMode; diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java b/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java index ac4f71e07b..ec31562323 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java @@ -86,6 +86,7 @@ public class ResumeSessionConfig { private Boolean includeSubAgentStreamingEvents; private Map mcpServers; private String mcpOAuthTokenStorage; + private String authClientIdMetadataUrl; private List customAgents; private DefaultAgentConfig defaultAgent; private String agent; @@ -1428,6 +1429,29 @@ public ResumeSessionConfig setMcpOAuthTokenStorage(String mcpOAuthTokenStorage) return this; } + /** + * Gets the OAuth Client ID Metadata Document URL identifying the host. + * + * @return the metadata URL, or {@code null} if not set + */ + public String getAuthClientIdMetadataUrl() { + return authClientIdMetadataUrl; + } + + /** + * Sets the OAuth Client ID Metadata Document URL identifying the host for MCP + * authorization. Re-supply the same host identity used when the session was + * created. + * + * @param authClientIdMetadataUrl + * the metadata URL + * @return this config for method chaining + */ + public ResumeSessionConfig setAuthClientIdMetadataUrl(String authClientIdMetadataUrl) { + this.authClientIdMetadataUrl = authClientIdMetadataUrl; + return this; + } + /** * Gets the custom agent configurations. * @@ -2119,6 +2143,8 @@ public ResumeSessionConfig clone() { copy.streaming = this.streaming; copy.includeSubAgentStreamingEvents = this.includeSubAgentStreamingEvents; copy.mcpServers = this.mcpServers != null ? new java.util.HashMap<>(this.mcpServers) : null; + copy.mcpOAuthTokenStorage = this.mcpOAuthTokenStorage; + copy.authClientIdMetadataUrl = this.authClientIdMetadataUrl; copy.customAgents = this.customAgents != null ? new ArrayList<>(this.customAgents) : null; copy.defaultAgent = this.defaultAgent; copy.agent = this.agent; diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java b/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java index 776d58137b..42d0ee536d 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java @@ -162,6 +162,9 @@ public final class ResumeSessionRequest { @JsonProperty("mcpOAuthTokenStorage") private String mcpOAuthTokenStorage; + @JsonProperty("authClientIdMetadataUrl") + private String authClientIdMetadataUrl; + @JsonProperty("envValueMode") private String envValueMode; @@ -830,6 +833,16 @@ public void setMcpOAuthTokenStorage(String mcpOAuthTokenStorage) { this.mcpOAuthTokenStorage = mcpOAuthTokenStorage; } + /** Gets the host OAuth client metadata URL. @return the metadata URL */ + public String getAuthClientIdMetadataUrl() { + return authClientIdMetadataUrl; + } + + /** Sets the host OAuth client metadata URL. @param url the metadata URL */ + public void setAuthClientIdMetadataUrl(String url) { + this.authClientIdMetadataUrl = url; + } + /** Gets MCP environment variable value mode. @return the mode */ public String getEnvValueMode() { return envValueMode; diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/SessionConfig.java b/java/sdk/src/main/java/com/github/copilot/rpc/SessionConfig.java index cbcacd9771..d77642c103 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/SessionConfig.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/SessionConfig.java @@ -75,6 +75,7 @@ public class SessionConfig { private Boolean includeSubAgentStreamingEvents; private Map mcpServers; private String mcpOAuthTokenStorage; + private String authClientIdMetadataUrl; private List customAgents; private DefaultAgentConfig defaultAgent; private String agent; @@ -1081,6 +1082,28 @@ public SessionConfig setMcpOAuthTokenStorage(String mcpOAuthTokenStorage) { return this; } + /** + * Gets the OAuth Client ID Metadata Document URL identifying the host. + * + * @return the metadata URL, or {@code null} if not set + */ + public String getAuthClientIdMetadataUrl() { + return authClientIdMetadataUrl; + } + + /** + * Sets the OAuth Client ID Metadata Document URL identifying the host for MCP + * authorization. When unset, no host identity is supplied. + * + * @param authClientIdMetadataUrl + * the metadata URL + * @return this config instance for method chaining + */ + public SessionConfig setAuthClientIdMetadataUrl(String authClientIdMetadataUrl) { + this.authClientIdMetadataUrl = authClientIdMetadataUrl; + return this; + } + /** * Gets the custom agent configurations. * @@ -2249,6 +2272,8 @@ public SessionConfig clone() { copy.streaming = this.streaming; copy.includeSubAgentStreamingEvents = this.includeSubAgentStreamingEvents; copy.mcpServers = this.mcpServers != null ? new java.util.HashMap<>(this.mcpServers) : null; + copy.mcpOAuthTokenStorage = this.mcpOAuthTokenStorage; + copy.authClientIdMetadataUrl = this.authClientIdMetadataUrl; copy.customAgents = this.customAgents != null ? new ArrayList<>(this.customAgents) : null; copy.defaultAgent = this.defaultAgent; copy.agent = this.agent; diff --git a/java/sdk/src/test/java/com/github/copilot/McpOAuthE2ETest.java b/java/sdk/src/test/java/com/github/copilot/McpOAuthE2ETest.java index 9985f24f9e..623033a4aa 100644 --- a/java/sdk/src/test/java/com/github/copilot/McpOAuthE2ETest.java +++ b/java/sdk/src/test/java/com/github/copilot/McpOAuthE2ETest.java @@ -11,11 +11,13 @@ import java.io.IOException; import java.io.InputStreamReader; import java.net.URI; +import java.net.URLDecoder; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.List; import java.util.Map; @@ -37,6 +39,7 @@ import com.github.copilot.generated.rpc.McpServerStatus; import com.github.copilot.generated.rpc.SessionMcpListToolsParams; import com.github.copilot.generated.rpc.SessionMcpOauthHandlePendingRequestParams; +import com.github.copilot.generated.rpc.SessionMcpOauthLoginParams; import com.github.copilot.rpc.McpAuthInvocation; import com.github.copilot.rpc.McpAuthRequest; import com.github.copilot.rpc.McpAuthResult; @@ -50,6 +53,7 @@ public class McpOAuthE2ETest { private static final String REFRESH_TOKEN = EXPECTED_TOKEN + "-refresh"; private static final String UPSCOPE_TOKEN = EXPECTED_TOKEN + "-upscope"; private static final String REAUTH_TOKEN = EXPECTED_TOKEN + "-reauth"; + private static final String CIMD_URL = "https://github.com/copilot/cli/client-metadata.json"; private static final ObjectMapper MAPPER = new ObjectMapper(); private static E2ETestContext ctx; @@ -66,6 +70,28 @@ static void teardown() throws Exception { } } + @Test + void testUsesCimdUrlInsteadOfDynamicRegistration() throws Exception { + try (var oauthServer = OAuthMcpServer.start(ctx.getRepoRoot(), false, true); + var client = ctx.createClient(); + var session = client.createSession(new SessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setAuthClientIdMetadataUrl(CIMD_URL) + .setMcpServers(Map.of("oauth-cimd-mcp", + new McpHttpServerConfig().setUrl(oauthServer.url() + "/mcp").setTools(List.of("*"))))) + .get()) { + waitForMcpServerStatus(session, "oauth-cimd-mcp", McpServerStatus.NEEDS_AUTH, new AtomicReference<>()); + var result = session.getRpc().mcp.oauth.login(new SessionMcpOauthLoginParams(session.getSessionId(), + "oauth-cimd-mcp", null, null, null, null, null, null, null)).get(30, TimeUnit.SECONDS); + assertNotNull(result.authorizationUrl()); + var clientId = List.of(URI.create(result.authorizationUrl()).getQuery().split("&")).stream() + .filter(part -> part.startsWith("client_id=")).findFirst() + .map(part -> URLDecoder.decode(part.substring("client_id=".length()), StandardCharsets.UTF_8)) + .orElseThrow(); + assertEquals(CIMD_URL, clientId); + assertTrue(oauthServer.requests().stream().noneMatch(request -> "/register".equals(request.path()))); + } + } + @Test void testShouldSatisfyMcpOauthUsingHostProvidedToken() throws Exception { try (var oauthServer = OAuthMcpServer.start(ctx.getRepoRoot())) { @@ -303,7 +329,7 @@ private static McpAuthRequest waitForAuthRequest(AtomicReference } @JsonIgnoreProperties(ignoreUnknown = true) - private record OAuthMcpRequest(String authorization) { + private record OAuthMcpRequest(String authorization, String path) { } private record OAuthMcpServer(Process process, String url) implements AutoCloseable { @@ -312,12 +338,20 @@ static OAuthMcpServer start(Path repoRoot) throws Exception { } static OAuthMcpServer start(Path repoRoot, boolean deferInitialChallenge) throws Exception { + return start(repoRoot, deferInitialChallenge, false); + } + + static OAuthMcpServer start(Path repoRoot, boolean deferInitialChallenge, boolean cimdSupported) + throws Exception { var script = repoRoot.resolve("test").resolve("harness").resolve("test-mcp-oauth-server.mjs"); var processBuilder = new ProcessBuilder(resolveExecutable("node"), script.toString()); processBuilder.environment().put("EXPECTED_TOKEN", EXPECTED_TOKEN); if (deferInitialChallenge) { processBuilder.environment().put("DEFER_INITIAL_CHALLENGE", "true"); } + if (cimdSupported) { + processBuilder.environment().put("CIMD_SUPPORTED", "true"); + } var process = processBuilder.start(); var stderr = new StringBuilder(); Thread stderrThread = new Thread(() -> { diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index eb92cf0bed..bca8346f64 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1689,6 +1689,7 @@ export class CopilotClient { : {}), mcpServers: toWireMcpServers(config.mcpServers), mcpOAuthTokenStorage: config.mcpOAuthTokenStorage, + authClientIdMetadataUrl: config.authClientIdMetadataUrl, envValueMode: "direct", customAgents: toWireCustomAgents(config.customAgents), customAgentsLocalOnly: config.customAgentsLocalOnly, @@ -1970,6 +1971,7 @@ export class CopilotClient { : {}), mcpServers: toWireMcpServers(config.mcpServers), mcpOAuthTokenStorage: config.mcpOAuthTokenStorage, + authClientIdMetadataUrl: config.authClientIdMetadataUrl, envValueMode: "direct", customAgents: toWireCustomAgents(config.customAgents), customAgentsLocalOnly: config.customAgentsLocalOnly, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index ff135c734f..efff9b47df 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -2740,6 +2740,12 @@ export interface SessionConfigBase { */ mcpOAuthTokenStorage?: "persistent" | "in-memory"; + /** + * OAuth Client ID Metadata Document URL identifying the host for MCP authorization. + * When unset, no host identity is supplied. + */ + authClientIdMetadataUrl?: string; + /** * MCP server configurations for the session. * Keys are server names, values are server configurations. diff --git a/nodejs/test/e2e/mcp_oauth.e2e.test.ts b/nodejs/test/e2e/mcp_oauth.e2e.test.ts index c9f1cfb1e7..cd7a6b88c7 100644 --- a/nodejs/test/e2e/mcp_oauth.e2e.test.ts +++ b/nodejs/test/e2e/mcp_oauth.e2e.test.ts @@ -19,6 +19,7 @@ const EXPECTED_TOKEN = "sdk-host-token"; const REFRESH_TOKEN = `${EXPECTED_TOKEN}-refresh`; const UPSCOPE_TOKEN = `${EXPECTED_TOKEN}-upscope`; const REAUTH_TOKEN = `${EXPECTED_TOKEN}-reauth`; +const CIMD_URL = "https://github.com/copilot/cli/client-metadata.json"; describe("MCP OAuth host auth", async () => { const { copilotClient: client } = await createSdkTestContext({ @@ -30,6 +31,38 @@ describe("MCP OAuth host auth", async () => { }, }); + it( + "should use the host CIMD URL instead of dynamic registration", + { timeout: 120_000 }, + async () => { + const oauthServer = await startOAuthMcpServer({ cimdSupported: true }); + const serverName = "oauth-cimd-mcp"; + const session = await client.createSession({ + onPermissionRequest: approveAll, + authClientIdMetadataUrl: CIMD_URL, + mcpServers: { + [serverName]: { + type: "http", + url: `${oauthServer.url}/mcp`, + tools: ["*"], + } as MCPServerConfig, + }, + }); + onTestFinished(() => disconnectSession(session)); + + await waitForMcpServerStatus(session, serverName, "needs-auth"); + const result = await session.rpc.mcp.oauth.login({ + serverName, + clientName: "SDK E2E", + }); + + expect(result.authorizationUrl).toBeDefined(); + expect(new URL(result.authorizationUrl!).searchParams.get("client_id")).toBe(CIMD_URL); + const requests = await oauthServer.requests(); + expect(requests.filter((request) => request.path === "/register")).toHaveLength(0); + } + ); + it("should satisfy MCP OAuth using host-provided token", { timeout: 120_000 }, async () => { const oauthServer = await startOAuthMcpServer(); const serverName = "oauth-protected-mcp"; @@ -314,12 +347,16 @@ async function callWhoami( expect(result.content).toEqual([{ type: "text", text: "oauth-test-user" }]); } -async function startOAuthMcpServer(): Promise<{ +async function startOAuthMcpServer(options: { cimdSupported?: boolean } = {}): Promise<{ url: string; - requests: () => Promise>; + requests: () => Promise>; }> { const child = spawn(process.execPath, [TEST_MCP_OAUTH_SERVER], { - env: { ...process.env, EXPECTED_TOKEN }, + env: { + ...process.env, + EXPECTED_TOKEN, + CIMD_SUPPORTED: options.cimdSupported ? "true" : "false", + }, stdio: ["ignore", "pipe", "pipe"], }); onTestFinished(() => stopChildProcess(child)); diff --git a/python/copilot/client.py b/python/copilot/client.py index dd531a2be9..089e15c5f0 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -2286,6 +2286,7 @@ async def create_session( include_sub_agent_streaming_events: bool | None = None, mcp_servers: dict[str, MCPServerConfig] | None = None, mcp_oauth_token_storage: Literal["persistent", "in-memory"] | None = None, + auth_client_id_metadata_url: str | None = None, embedding_cache_storage: Literal["persistent", "in-memory"] | None = None, custom_agents: list[CustomAgentConfig] | None = None, default_agent: DefaultAgentConfig | dict[str, Any] | None = None, @@ -2420,6 +2421,9 @@ async def create_session( ``"persistent"`` uses the OS keychain (shared across sessions). ``"in-memory"`` stores tokens in memory (discarded on session end). Defaults to ``"in-memory"`` for safe multitenant behavior. + auth_client_id_metadata_url: OAuth Client ID Metadata Document URL + identifying the host for MCP authorization. When unset, no host + identity is supplied. embedding_cache_storage: Controls how embedding caches are stored. `"persistent"` uses disk-based storage (shared across sessions). `"in-memory"` stores embeddings in memory (discarded on session end). @@ -2727,6 +2731,8 @@ async def create_session( mcp_oauth_token_storage = _mcp_oauth_token_storage_default(mode, mcp_oauth_token_storage) if mcp_oauth_token_storage is not None: payload["mcpOAuthTokenStorage"] = mcp_oauth_token_storage + if auth_client_id_metadata_url is not None: + payload["authClientIdMetadataUrl"] = auth_client_id_metadata_url embedding_cache_storage = _embedding_cache_storage_default(mode, embedding_cache_storage) if embedding_cache_storage is not None: payload["embeddingCacheStorage"] = embedding_cache_storage @@ -3065,6 +3071,7 @@ async def resume_session( include_sub_agent_streaming_events: bool | None = None, mcp_servers: dict[str, MCPServerConfig] | None = None, mcp_oauth_token_storage: Literal["persistent", "in-memory"] | None = None, + auth_client_id_metadata_url: str | None = None, embedding_cache_storage: Literal["persistent", "in-memory"] | None = None, custom_agents: list[CustomAgentConfig] | None = None, default_agent: DefaultAgentConfig | dict[str, Any] | None = None, @@ -3202,6 +3209,9 @@ async def resume_session( ``"persistent"`` uses the OS keychain (shared across sessions). ``"in-memory"`` stores tokens in memory (discarded on session end). Defaults to ``"in-memory"`` for safe multitenant behavior. + auth_client_id_metadata_url: OAuth Client ID Metadata Document URL + identifying the host for MCP authorization. Re-supply the same + host identity used when the session was created. embedding_cache_storage: Controls how embedding caches are stored. `"persistent"` uses disk-based storage (shared across sessions). `"in-memory"` stores embeddings in memory (discarded on session end). @@ -3504,6 +3514,8 @@ async def resume_session( mcp_oauth_token_storage = _mcp_oauth_token_storage_default(mode, mcp_oauth_token_storage) if mcp_oauth_token_storage is not None: payload["mcpOAuthTokenStorage"] = mcp_oauth_token_storage + if auth_client_id_metadata_url is not None: + payload["authClientIdMetadataUrl"] = auth_client_id_metadata_url embedding_cache_storage = _embedding_cache_storage_default(mode, embedding_cache_storage) if embedding_cache_storage is not None: payload["embeddingCacheStorage"] = embedding_cache_storage diff --git a/python/e2e/test_mcp_oauth_e2e.py b/python/e2e/test_mcp_oauth_e2e.py index 3502ccdf4a..20832fabcd 100644 --- a/python/e2e/test_mcp_oauth_e2e.py +++ b/python/e2e/test_mcp_oauth_e2e.py @@ -3,6 +3,7 @@ import os from pathlib import Path from typing import Any +from urllib.parse import parse_qs, urlparse import httpx import pytest @@ -12,9 +13,10 @@ MCPAppsCallToolRequest, MCPListToolsRequest, MCPOauthHandlePendingRequest, + MCPOauthLoginRequest, MCPOauthPendingRequestResponse, ) -from copilot.session import MCPServerConfig, PermissionHandler +from copilot.session import MCPHTTPServerConfig, MCPServerConfig, PermissionHandler from copilot.session_events import McpServerStatus from .testharness import E2ETestContext, wait_for_condition @@ -26,17 +28,24 @@ REFRESH_TOKEN = f"{EXPECTED_TOKEN}-refresh" UPSCOPE_TOKEN = f"{EXPECTED_TOKEN}-upscope" REAUTH_TOKEN = f"{EXPECTED_TOKEN}-reauth" +CIMD_URL = "https://github.com/copilot/cli/client-metadata.json" pytestmark = pytest.mark.asyncio(loop_scope="module") -async def _start_oauth_mcp_server() -> tuple[str, asyncio.subprocess.Process]: +async def _start_oauth_mcp_server( + cimd_supported: bool = False, +) -> tuple[str, asyncio.subprocess.Process]: process = await asyncio.create_subprocess_exec( "node", TEST_MCP_OAUTH_SERVER, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, - env={**os.environ, "EXPECTED_TOKEN": EXPECTED_TOKEN}, + env={ + **os.environ, + "EXPECTED_TOKEN": EXPECTED_TOKEN, + "CIMD_SUPPORTED": "true" if cimd_supported else "false", + }, ) assert process.stdout is not None @@ -100,6 +109,32 @@ async def matches() -> bool: class TestMcpOAuth: + async def test_uses_cimd_url_instead_of_dynamic_registration(self, ctx: E2ETestContext): + url, process = await _start_oauth_mcp_server(cimd_supported=True) + server_name = "oauth-cimd-mcp" + try: + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + auth_client_id_metadata_url=CIMD_URL, + mcp_servers={ + server_name: MCPHTTPServerConfig( + type="http", + url=f"{url}/mcp", + tools=["*"], + ) + }, + ) + await _wait_for_mcp_server_status(session, server_name, McpServerStatus.NEEDS_AUTH) + result = await session.rpc.mcp.oauth.login( + MCPOauthLoginRequest(server_name=server_name) + ) + assert result.authorization_url is not None + assert parse_qs(urlparse(result.authorization_url).query)["client_id"] == [CIMD_URL] + assert not any(request.get("path") == "/register" for request in await _requests(url)) + await session.disconnect() + finally: + await _stop_process(process) + async def test_should_satisfy_mcp_oauth_using_host_provided_token(self, ctx: E2ETestContext): url, process = await _start_oauth_mcp_server() server_name = "oauth-protected-mcp" diff --git a/rust/src/types.rs b/rust/src/types.rs index c6d25dbbc9..332a48d18c 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -2011,6 +2011,13 @@ pub struct SessionConfig { /// applied automatically at session creation/resume time. `None` means no /// explicit value is set and the runtime default takes effect. pub mcp_oauth_token_storage: Option, + /// URL identifying this host's OAuth client metadata document. + /// + /// Authorization servers that support client ID metadata documents can use + /// this URL as the MCP OAuth client ID. When unset, the SDK does not supply + /// a first-party host identity and the runtime uses its generic, + /// session-isolated OAuth client behavior. + pub auth_client_id_metadata_url: Option, /// Enables runtime discovery of supported configuration. Explicitly supplied /// configuration takes precedence over discovered values. pub enable_config_discovery: Option, @@ -2319,6 +2326,10 @@ impl std::fmt::Debug for SessionConfig { .field("included_builtin_skills", &self.included_builtin_skills) .field("mcp_servers", &self.mcp_servers) .field("mcp_oauth_token_storage", &self.mcp_oauth_token_storage) + .field( + "auth_client_id_metadata_url", + &self.auth_client_id_metadata_url, + ) .field("embedding_cache_storage", &self.embedding_cache_storage) .field("enable_config_discovery", &self.enable_config_discovery) .field("skip_embedding_retrieval", &self.skip_embedding_retrieval) @@ -2459,6 +2470,7 @@ impl Default for SessionConfig { included_builtin_skills: None, mcp_servers: None, mcp_oauth_token_storage: None, + auth_client_id_metadata_url: None, enable_config_discovery: None, skip_embedding_retrieval: None, organization_custom_instructions: None, @@ -2628,6 +2640,7 @@ impl SessionConfig { tool_filter_precedence: "excluded", mcp_servers: self.mcp_servers, mcp_oauth_token_storage: self.mcp_oauth_token_storage, + auth_client_id_metadata_url: self.auth_client_id_metadata_url, embedding_cache_storage: self.embedding_cache_storage, env_value_mode: "direct", enable_config_discovery: self.enable_config_discovery, @@ -2969,6 +2982,12 @@ impl SessionConfig { self } + /// Set the URL identifying this host's OAuth client metadata document. + pub fn with_auth_client_id_metadata_url(mut self, url: impl Into) -> Self { + self.auth_client_id_metadata_url = Some(url.into()); + self + } + /// Set embedding cache storage mode. pub fn with_embedding_cache_storage( mut self, @@ -3445,6 +3464,12 @@ pub struct ResumeSessionConfig { /// Controls how MCP OAuth tokens are stored for this session. /// See [`SessionConfig::mcp_oauth_token_storage`] for details. pub mcp_oauth_token_storage: Option, + /// Re-supply the host OAuth client metadata document URL on resume. + /// + /// Set this to the same host identity used when the session was created. + /// When unset, the SDK does not supply a first-party host identity. + /// See [`SessionConfig::auth_client_id_metadata_url`] for details. + pub auth_client_id_metadata_url: Option, /// Enables runtime discovery of supported configuration. Explicitly supplied /// configuration takes precedence over discovered values. pub enable_config_discovery: Option, @@ -3672,6 +3697,10 @@ impl std::fmt::Debug for ResumeSessionConfig { .field("included_builtin_skills", &self.included_builtin_skills) .field("mcp_servers", &self.mcp_servers) .field("mcp_oauth_token_storage", &self.mcp_oauth_token_storage) + .field( + "auth_client_id_metadata_url", + &self.auth_client_id_metadata_url, + ) .field("embedding_cache_storage", &self.embedding_cache_storage) .field("enable_config_discovery", &self.enable_config_discovery) .field("skip_embedding_retrieval", &self.skip_embedding_retrieval) @@ -3854,6 +3883,7 @@ impl ResumeSessionConfig { tool_filter_precedence: "excluded", mcp_servers: self.mcp_servers, mcp_oauth_token_storage: self.mcp_oauth_token_storage, + auth_client_id_metadata_url: self.auth_client_id_metadata_url, embedding_cache_storage: self.embedding_cache_storage, env_value_mode: "direct", enable_config_discovery: self.enable_config_discovery, @@ -3963,6 +3993,7 @@ impl ResumeSessionConfig { included_builtin_skills: None, mcp_servers: None, mcp_oauth_token_storage: None, + auth_client_id_metadata_url: None, enable_config_discovery: None, skip_embedding_retrieval: None, organization_custom_instructions: None, @@ -4276,6 +4307,12 @@ impl ResumeSessionConfig { self } + /// Set the host OAuth client metadata document URL on resume. + pub fn with_auth_client_id_metadata_url(mut self, url: impl Into) -> Self { + self.auth_client_id_metadata_url = Some(url.into()); + self + } + /// Set embedding cache storage mode on resume. pub fn with_embedding_cache_storage( mut self, @@ -7036,6 +7073,37 @@ mod tests { assert!(empty_json.get("largeOutput").is_none()); } + #[test] + fn auth_client_id_metadata_url_reaches_create_and_resume_wire_payloads() { + let url = "https://example.com/oauth/client-metadata.json"; + + let (create_wire, _) = SessionConfig::default() + .with_auth_client_id_metadata_url(url) + .into_wire(None) + .expect("default create has no duplicate handlers"); + let create_json = serde_json::to_value(&create_wire).unwrap(); + assert_eq!(create_json["authClientIdMetadataUrl"], url); + + let (resume_wire, _) = ResumeSessionConfig::new(SessionId::from("sess-1")) + .with_auth_client_id_metadata_url(url) + .into_wire() + .expect("default resume has no duplicate handlers"); + let resume_json = serde_json::to_value(&resume_wire).unwrap(); + assert_eq!(resume_json["authClientIdMetadataUrl"], url); + + let (empty_create_wire, _) = SessionConfig::default() + .into_wire(None) + .expect("default create has no duplicate handlers"); + let empty_create_json = serde_json::to_value(&empty_create_wire).unwrap(); + assert!(empty_create_json.get("authClientIdMetadataUrl").is_none()); + + let (empty_resume_wire, _) = ResumeSessionConfig::new(SessionId::from("sess-2")) + .into_wire() + .expect("default resume has no duplicate handlers"); + let empty_resume_json = serde_json::to_value(&empty_resume_wire).unwrap(); + assert!(empty_resume_json.get("authClientIdMetadataUrl").is_none()); + } + #[test] fn session_config_clones_disabled_mcp_servers() { let create = SessionConfig::default().with_disabled_mcp_servers(["local-files"]); diff --git a/rust/src/wire.rs b/rust/src/wire.rs index 75e17f4e9c..325dfdaaf1 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -94,6 +94,8 @@ pub(crate) struct SessionCreateWire { #[serde(skip_serializing_if = "Option::is_none")] pub mcp_oauth_token_storage: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub auth_client_id_metadata_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub embedding_cache_storage: Option, pub env_value_mode: &'static str, #[serde(skip_serializing_if = "Option::is_none")] @@ -253,6 +255,8 @@ pub(crate) struct SessionResumeWire { #[serde(skip_serializing_if = "Option::is_none")] pub mcp_oauth_token_storage: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub auth_client_id_metadata_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub embedding_cache_storage: Option, pub env_value_mode: &'static str, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/rust/tests/e2e/mcp_oauth.rs b/rust/tests/e2e/mcp_oauth.rs index 0930721b88..c5e4d7117a 100644 --- a/rust/tests/e2e/mcp_oauth.rs +++ b/rust/tests/e2e/mcp_oauth.rs @@ -5,11 +5,12 @@ use std::sync::Arc; use async_trait::async_trait; use github_copilot_sdk::handler::{McpAuthHandler, McpAuthRequest, McpAuthResult}; -use github_copilot_sdk::rpc::{McpAppsCallToolRequest, McpListToolsRequest}; +use github_copilot_sdk::rpc::{McpAppsCallToolRequest, McpListToolsRequest, McpOauthLoginRequest}; use github_copilot_sdk::session::Session; use github_copilot_sdk::session_events::{McpOauthRequestReason, McpServerStatus}; use github_copilot_sdk::{IndexMap, McpHttpServerConfig, McpServerConfig, RequestId, SessionId}; use parking_lot::Mutex; +use reqwest::Url; use serde::Deserialize; use serde_json::Value; use tokio::io::{AsyncBufReadExt, BufReader}; @@ -22,6 +23,61 @@ const EXPECTED_TOKEN: &str = "sdk-host-token"; const REFRESH_TOKEN: &str = "sdk-host-token-refresh"; const UPSCOPE_TOKEN: &str = "sdk-host-token-upscope"; const REAUTH_TOKEN: &str = "sdk-host-token-reauth"; +const CIMD_URL: &str = "https://github.com/copilot/cli/client-metadata.json"; + +#[tokio::test] +async fn should_use_cimd_url_instead_of_dynamic_registration() { + with_e2e_context_no_snapshot(|ctx| { + Box::pin(async move { + let oauth_server = OAuthMcpServer::start_with_cimd( + ctx.repo_root() + .join("test/harness/test-mcp-oauth-server.mjs"), + ) + .await; + let server_name = "oauth-cimd-mcp"; + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_auth_client_id_metadata_url(CIMD_URL) + .with_mcp_servers(IndexMap::from([( + server_name.to_string(), + McpServerConfig::Http(McpHttpServerConfig { + tools: Some(vec!["*".to_string()]), + timeout: None, + url: format!("{}/mcp", oauth_server.url), + headers: HashMap::new(), + }), + )])), + ) + .await + .expect("create session"); + wait_for_mcp_server_status(&session, server_name, McpServerStatus::NeedsAuth).await; + let result = session + .rpc() + .mcp() + .oauth() + .login(McpOauthLoginRequest { + server_name: server_name.to_string(), + ..Default::default() + }) + .await + .expect("MCP OAuth login"); + let authorization_url = result.authorization_url.expect("authorization URL"); + assert_eq!( + Url::parse(&authorization_url) + .expect("valid authorization URL") + .query_pairs() + .find(|(key, _)| key == "client_id") + .map(|(_, value)| value.into_owned()), + Some(CIMD_URL.to_string()) + ); + let requests = oauth_server.requests().await; + assert!(requests.iter().all(|request| request.path != "/register")); + }) + }) + .await; +} #[tokio::test] async fn should_satisfy_mcp_oauth_using_host_provided_token() { @@ -504,6 +560,7 @@ impl McpAuthHandler for BlockingAuthHandler { #[derive(Deserialize)] struct OAuthMcpRequest { authorization: Option, + path: String, } struct OAuthMcpServer { @@ -513,9 +570,18 @@ struct OAuthMcpServer { impl OAuthMcpServer { async fn start(script: PathBuf) -> Self { + Self::start_with_mode(script, false).await + } + + async fn start_with_cimd(script: PathBuf) -> Self { + Self::start_with_mode(script, true).await + } + + async fn start_with_mode(script: PathBuf, cimd: bool) -> Self { let mut child = Command::new("node") .arg(script) .env("EXPECTED_TOKEN", EXPECTED_TOKEN) + .env("CIMD_SUPPORTED", if cimd { "true" } else { "false" }) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true) @@ -536,7 +602,8 @@ impl OAuthMcpServer { } async fn requests(&self) -> Vec { - let text = reqwest::get(format!("{}/__requests", self.url)) + // This test-only server binds to 127.0.0.1 and never sends request logs off-host. + let text = reqwest::get(format!("{}/__requests", self.url)) // codeql[rust/cleartext-transmission] .await .expect("fetch OAuth MCP requests") .error_for_status() diff --git a/test/harness/test-mcp-oauth-server.mjs b/test/harness/test-mcp-oauth-server.mjs index c268cdb35f..d5872e9c00 100644 --- a/test/harness/test-mcp-oauth-server.mjs +++ b/test/harness/test-mcp-oauth-server.mjs @@ -26,6 +26,7 @@ export async function startOAuthMcpServer({ deferInitialChallenge = false, host = "127.0.0.1", port = 0, + cimdSupported = false, } = {}) { let releaseInitialChallenge = () => {}; const initialChallenge = deferInitialChallenge @@ -54,13 +55,17 @@ export async function startOAuthMcpServer({ `http://${req.headers.host ?? `${host}:${port}`}`, ); const baseUrl = url.origin; + const body = await readBody(req); if (req.method === "GET" && url.pathname === "/__requests") { respondJson(res, 200, requests); return; } - if (req.method === "POST" && url.pathname === "/__release-initial-challenge") { + if ( + req.method === "POST" && + url.pathname === "/__release-initial-challenge" + ) { releaseInitialChallenge(); res.writeHead(204); res.end(); @@ -87,6 +92,23 @@ export async function startOAuthMcpServer({ token_endpoint: `${baseUrl}/token`, response_types_supported: ["code"], grant_types_supported: ["authorization_code"], + ...(cimdSupported + ? { client_id_metadata_document_supported: true } + : {}), + }); + return; + } + + if (req.method === "POST" && url.pathname === "/register") { + requests.push({ + method: req.method, + path: url.pathname, + authorization: req.headers.authorization ?? null, + body, + }); + respondJson(res, 201, { + client_id: "registered-client", + client_id_issued_at: Math.floor(Date.now() / 1000), }); return; } @@ -96,7 +118,6 @@ export async function startOAuthMcpServer({ return; } - const body = await readBody(req); requests.push({ method: req.method, path: url.pathname, @@ -332,6 +353,7 @@ if ( ) { const server = await startOAuthMcpServer({ expectedToken: process.env.EXPECTED_TOKEN ?? DEFAULT_EXPECTED_TOKEN, + cimdSupported: process.env.CIMD_SUPPORTED === "true", deferInitialChallenge: process.env.DEFER_INITIAL_CHALLENGE === "true", }); console.log(`Listening: ${server.url}`);