





























@@ -2,7 +2,19 @@
22import fs from "node:fs/promises";
33import { withTempHome } from "openclaw/plugin-sdk/test-env";
44import { describe, expect, it } from "vitest";
5-import { clearMcpOAuthCredentials, createMcpOAuthClientProvider } from "./mcp-oauth.js";
5+import { vi } from "vitest";
6+import {
7+clearMcpOAuthCredentials,
8+createMcpOAuthClientProvider,
9+isMcpOAuthRedirectRegistrationError,
10+runMcpOAuthLogin,
11+} from "./mcp-oauth.js";
12+13+const authMock = vi.hoisted(() => vi.fn());
14+15+vi.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
16+auth: authMock,
17+}));
618719describe("MCP OAuth provider", () => {
820it("stores token state under the OpenClaw state directory with restricted permissions", async () => {
@@ -66,6 +78,137 @@ describe("MCP OAuth provider", () => {
6678);
6779});
688081+it("keeps the legacy loopback redirect as the default for upgrade compatibility", () => {
82+const provider = createMcpOAuthClientProvider({
83+serverName: "Calendly",
84+serverUrl: "https://mcp.calendly.com/",
85+});
86+87+expect(provider.clientMetadata.redirect_uris).toEqual(["http://127.0.0.1:8989/oauth/callback"]);
88+expect(provider.redirectUrl).toBe("http://127.0.0.1:8989/oauth/callback");
89+});
90+91+it("detects redirect registration failures for localhost fallback", () => {
92+expect(
93+isMcpOAuthRedirectRegistrationError(
94+new Error("HTTP 400: invalid_client_metadata redirect_uri must be localhost"),
95+),
96+).toBe(true);
97+expect(isMcpOAuthRedirectRegistrationError(new Error("unauthorized"))).toBe(false);
98+});
99+100+it("retries MCP OAuth login with localhost after redirect registration rejection", async () => {
101+authMock.mockReset();
102+authMock
103+.mockRejectedValueOnce(new Error("invalid_client_metadata: redirect_uri rejected"))
104+.mockResolvedValueOnce("AUTHORIZED");
105+106+await expect(
107+runMcpOAuthLogin({
108+serverName: "Calendly",
109+serverUrl: "https://mcp.calendly.com/",
110+}),
111+).resolves.toBe("authorized");
112+113+expect(authMock).toHaveBeenCalledTimes(2);
114+expect(authMock.mock.calls[1]?.[0]?.clientMetadata.redirect_uris).toEqual([
115+"http://localhost:8989/oauth/callback",
116+]);
117+});
118+119+it("does not retry a code exchange redirect mismatch", async () => {
120+authMock.mockReset();
121+authMock.mockRejectedValueOnce(new Error("invalid_grant: redirect_uri mismatch"));
122+123+await expect(
124+runMcpOAuthLogin({
125+serverName: "Calendly",
126+serverUrl: "https://mcp.calendly.com/",
127+authorizationCode: "code-123",
128+}),
129+).rejects.toThrow("redirect_uri mismatch");
130+131+expect(authMock).toHaveBeenCalledOnce();
132+});
133+134+it("does not persist localhost when the fallback attempt fails", async () => {
135+await withTempHome(
136+async (home) => {
137+authMock.mockReset();
138+authMock
139+.mockRejectedValueOnce(new Error("invalid_client_metadata: redirect_uri rejected"))
140+.mockRejectedValueOnce(new Error("localhost redirect also rejected"));
141+142+await expect(
143+runMcpOAuthLogin({
144+serverName: "Calendly",
145+serverUrl: "https://mcp.calendly.com/",
146+}),
147+).rejects.toThrow("localhost redirect also rejected");
148+149+await expect(fs.readdir(`${home}/.openclaw/mcp-oauth`)).rejects.toThrow();
150+},
151+{
152+prefix: "openclaw-mcp-oauth-localhost-failure-",
153+skipSessionCleanup: true,
154+env: {
155+OPENCLAW_CONFIG_PATH: undefined,
156+OPENCLAW_STATE_DIR: undefined,
157+},
158+},
159+);
160+});
161+162+it("persists localhost redirect for a later code exchange login", async () => {
163+await withTempHome(
164+async (home) => {
165+authMock.mockReset();
166+authMock
167+.mockRejectedValueOnce(new Error("invalid_client_metadata: redirect_uri rejected"))
168+.mockImplementationOnce(async (provider) => {
169+await provider.saveCodeVerifier?.("verifier");
170+return "REDIRECT";
171+});
172+173+await expect(
174+runMcpOAuthLogin({
175+serverName: "Calendly",
176+serverUrl: "https://mcp.calendly.com/",
177+onAuthorizationUrl: () => {},
178+}),
179+).resolves.toBe("redirect");
180+181+const tokenDir = `${home}/.openclaw/mcp-oauth`;
182+const entries = await fs.readdir(tokenDir);
183+const store = JSON.parse(await fs.readFile(`${tokenDir}/${entries[0]}`, "utf-8")) as {
184+codeVerifier?: string;
185+redirectUrl?: string;
186+};
187+expect(store.redirectUrl).toBe("http://localhost:8989/oauth/callback");
188+expect(store.codeVerifier).toBe("verifier");
189+190+authMock.mockReset();
191+authMock.mockResolvedValueOnce("AUTHORIZED");
192+await runMcpOAuthLogin({
193+serverName: "Calendly",
194+serverUrl: "https://mcp.calendly.com/",
195+authorizationCode: "code-123",
196+});
197+expect(authMock.mock.calls[0]?.[0]?.clientMetadata.redirect_uris).toEqual([
198+"http://localhost:8989/oauth/callback",
199+]);
200+},
201+{
202+prefix: "openclaw-mcp-oauth-localhost-persist-",
203+skipSessionCleanup: true,
204+env: {
205+OPENCLAW_CONFIG_PATH: undefined,
206+OPENCLAW_STATE_DIR: undefined,
207+},
208+},
209+);
210+});
211+69212it("does not start hidden authorization flows without an authorization callback", async () => {
70213// Normal agent/tool execution must not open browser auth flows implicitly;
71214// operators use the explicit mcp login command instead.
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。