mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(mcp): send application_type in OAuth dynamic client registration
OpenID Connect authorization servers treat a registration without application_type as a web client and reject http loopback redirect URIs. Derive it from redirect_uris when unset (MCP SEP-837): native for loopback hosts and custom schemes, otherwise web. closes #10493
This commit is contained in:
parent
021eae60a7
commit
147b502816
6 changed files with 53 additions and 1 deletions
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
- Fixed syntax highlighting losing colors after the first line of multiline strings and comments in fenced code blocks ([#10143](https://github.com/earendil-works/pi/issues/10143))
|
||||
- Fixed codemode scripts not receiving images from `read`: `tools.read()` now resolves to an image block for image files, which `image()` shows ([#10251](https://github.com/earendil-works/pi/issues/10251))
|
||||
- Fixed MCP OAuth sign-in failing with `invalid_redirect_uri` on servers with OpenID Connect client registration, such as `mcp.modem.dev`: pi now registers as a native client ([#10493](https://github.com/earendil-works/pi/issues/10493))
|
||||
|
||||
## [1.0.3] - 2026-10-05
|
||||
|
||||
|
|
|
|||
|
|
@ -158,6 +158,8 @@ describe("AgentSession MCP OAuth", () => {
|
|||
const fallback = await setup("follow");
|
||||
await fallback.harness.session.prompt("/mcp login issues");
|
||||
expect(fallback.server.registrations.map((metadata) => metadata.client_name)).toEqual(["pi"]);
|
||||
// OpenID Connect servers reject the loopback redirect URI of a `web` client (#10493).
|
||||
expect(fallback.server.registrations.map((metadata) => metadata.application_type)).toEqual(["native"]);
|
||||
});
|
||||
|
||||
it("adds the listening port to a callback URL without one", async () => {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed dynamic client registration failing on OpenID Connect authorization servers with `invalid_redirect_uri` for loopback redirect URIs. `registerClient()` now sends `application_type` (MCP SEP-837), derived from `redirect_uris` when `OAuthClientMetadata.application_type` is not set: `native` for loopback hosts and custom schemes, otherwise `web` ([#10493](https://github.com/earendil-works/pi/issues/10493))
|
||||
|
||||
## [1.0.3] - 2026-10-05
|
||||
|
||||
## [1.0.2] - 2026-10-04
|
||||
|
|
|
|||
|
|
@ -103,6 +103,19 @@ function loopback(hostname: string): boolean {
|
|||
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
|
||||
}
|
||||
|
||||
/**
|
||||
* The OpenID Connect `application_type` for `redirect_uris` (MCP SEP-837). Without one, OpenID Connect servers
|
||||
* assume `web`, which rejects http loopback redirect URIs. Loopback hosts and custom schemes are native (RFC 8252).
|
||||
*/
|
||||
function applicationType(redirectUris: readonly string[]): "native" | "web" {
|
||||
const native = redirectUris.some((uri) => {
|
||||
if (!URL.canParse(uri)) return false;
|
||||
const url = new URL(uri);
|
||||
return (url.protocol !== "http:" && url.protocol !== "https:") || loopback(url.hostname);
|
||||
});
|
||||
return native ? "native" : "web";
|
||||
}
|
||||
|
||||
function secureEndpoint(value: string | URL): URL {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "https:" && !loopback(url.hostname)) throw new OAuthInsecureEndpointError(url.href);
|
||||
|
|
@ -239,7 +252,12 @@ export async function registerClient(
|
|||
{
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json", "content-type": "application/json" },
|
||||
body: JSON.stringify({ ...options.clientMetadata, ...(options.scope ? { scope: options.scope } : {}) }),
|
||||
body: JSON.stringify({
|
||||
...options.clientMetadata,
|
||||
application_type:
|
||||
options.clientMetadata.application_type ?? applicationType(options.clientMetadata.redirect_uris),
|
||||
...(options.scope ? { scope: options.scope } : {}),
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new OAuthRegistrationError(response.status, await response.text());
|
||||
|
|
|
|||
|
|
@ -43,6 +43,11 @@ export interface OAuthClientMetadata {
|
|||
token_endpoint_auth_method?: string;
|
||||
grant_types?: string[];
|
||||
response_types?: string[];
|
||||
/**
|
||||
* OpenID Connect client type, `native` or `web`. Dynamic client registration derives it from
|
||||
* `redirect_uris` when absent (MCP SEP-837).
|
||||
*/
|
||||
application_type?: string;
|
||||
client_name?: string;
|
||||
client_uri?: string;
|
||||
logo_uri?: string;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
OAuthInsecureEndpointError,
|
||||
OAuthIssuerMismatchError,
|
||||
type OAuthTokens,
|
||||
registerClient,
|
||||
} from "../src/oauth/index.ts";
|
||||
import { closeServers, listen, readBody } from "./helpers.ts";
|
||||
|
||||
|
|
@ -407,6 +408,27 @@ describe("MCP OAuth", () => {
|
|||
expect(await second.tokens()).toBeUndefined();
|
||||
});
|
||||
|
||||
// #10493
|
||||
it("registers with an application_type derived from the redirect URIs unless one is set", async () => {
|
||||
const bodies: Record<string, unknown>[] = [];
|
||||
const origin = await listen(async (request, response) => {
|
||||
const metadata = JSON.parse(await readBody(request)) as Record<string, unknown>;
|
||||
bodies.push(metadata);
|
||||
response.writeHead(201, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ ...metadata, client_id: "client" }));
|
||||
});
|
||||
const register = (redirect_uris: string[], application_type?: string) =>
|
||||
registerClient(origin, {
|
||||
clientMetadata: { redirect_uris, ...(application_type ? { application_type } : {}) },
|
||||
});
|
||||
await register(["http://127.0.0.1:1234/callback"]);
|
||||
await register(["http://[::1]/callback"]);
|
||||
await register(["com.example.app:/callback"]);
|
||||
await register(["https://app.example/callback"]);
|
||||
await register(["http://localhost/callback"], "web");
|
||||
expect(bodies.map((body) => body.application_type)).toEqual(["native", "native", "native", "web", "web"]);
|
||||
});
|
||||
|
||||
it("rejects authorization metadata whose issuer does not match discovery", async () => {
|
||||
const origin = await listen(async (request, response, serverOrigin) => {
|
||||
const url = new URL(request.url ?? "/", serverOrigin);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue