From f19f3e971753a938020d2a5197e2be2eb6b7c65a Mon Sep 17 00:00:00 2001 From: hkonsti <45428767+hkonsti@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:38:19 +0000 Subject: [PATCH] sign in to mcp servers by client id metadata document where their authorization server takes one An MCP server's authorization server that advertises client_id_metadata_document_supported is now given /oauth/client-metadata.json as the client id, a document the api serves about itself, rather than turned away when it has no registration endpoint. The document is preferred over registering a client, as the MCP spec orders them, when APP_URL is a public https address the authorization server can fetch it from; otherwise a client is registered as before. Discovered servers also ask for offline_access where the authorization server offers it, so the sign-in comes with a refresh token. --- packages/api/src/connections/connect-flow.ts | 15 ++-- .../connections/custom/mcp-discovery.test.ts | 58 ++++++++++++- .../src/connections/custom/mcp-discovery.ts | 51 ++++++++++-- .../api/src/connections/custom/route.test.ts | 83 ++++++++++++++++++- packages/api/src/connections/custom/route.ts | 2 +- .../connections/mcp/client-metadata.test.ts | 54 ++++++++++++ .../src/connections/mcp/client-metadata.ts | 35 ++++++++ .../api/src/connections/mcp/mcp-connect.ts | 46 ++++++++-- packages/api/src/connections/mcp/mcp-oauth.ts | 50 ++++++++--- packages/api/src/oauth/metadata.ts | 3 +- packages/api/src/server/create-api-router.ts | 5 ++ packages/api/src/utils/env-schema.ts | 2 +- packages/api/src/utils/host-check.ts | 13 +++ .../docs/content/connections/custom-mcp.mdx | 21 ++++- 14 files changed, 397 insertions(+), 41 deletions(-) create mode 100644 packages/api/src/connections/mcp/client-metadata.test.ts create mode 100644 packages/api/src/connections/mcp/client-metadata.ts diff --git a/packages/api/src/connections/connect-flow.ts b/packages/api/src/connections/connect-flow.ts index 0ed4a019..4c379cd2 100644 --- a/packages/api/src/connections/connect-flow.ts +++ b/packages/api/src/connections/connect-flow.ts @@ -223,12 +223,17 @@ type SignInArgs = { // The provider's name in errors and logs: "Notion". name: string; // What the start keeps in the cookie for the callback beyond the handshake itself, such as the - // client an MCP server registered for this sign-in. + // client an MCP sign-in is made as. keptSchema: z.ZodType; // The code traded for tokens, and the connection they make. finish: (answer: SignInAnswer) => Promise, ApiError>>; }; +/** Where an integration's sign-in comes back to, as its provider is told: its callback route under `appUrl`. */ +export function signInCallbackUrl(appUrl: string, integrationId: IntegrationId): string { + return `${appUrl}/api/connections/${integrationId}/callback`; +} + /** * The sign-in to one integration: `beginSignIn` for the route that knows the provider, and the * callback route. @@ -255,10 +260,6 @@ export function signInFlow(args: SignInArgs) { type StatePayload = {state: string; verifier: string; userId: string; orgId: string; slug: string; returnTo?: ReturnTo; shared?: boolean; connectionId?: string; kept: E}; - function redirectUri(appUrl: string): string { - return `${appUrl}${basePath}/callback`; - } - function stateCookieOptions() { return {httpOnly: true, sameSite: 'lax' as const, secure: useSecureCookies(), path: basePath}; } @@ -289,7 +290,7 @@ export function signInFlow(args: SignInArgs) { const appUrl = $(requirePresent(env.APP_URL, ApiErr.internalError(new Error('APP_URL is not configured')))); const state = randomBytes(16).toString('hex'); const {verifier, challenge} = makePkce(); - const {url, kept} = $(await begin.start({redirectUri: redirectUri(appUrl), state, challenge, account: begin.account})); + const {url, kept} = $(await begin.start({redirectUri: signInCallbackUrl(appUrl, integrationId), state, challenge, account: begin.account})); const payload: StatePayload = { state, verifier, @@ -324,7 +325,7 @@ export function signInFlow(args: SignInArgs) { const membership = $(requirePresent($(await getMembership(payload.userId, payload.orgId)), ApiErr.notFound('organization', payload.slug))); const viewer: ManagingViewer = {orgId: payload.orgId, userId: payload.userId, role: membership.role}; - const connected = $(await args.finish({redirectUri: redirectUri(appUrl), code, verifier: payload.verifier, kept: payload.kept})); + const connected = $(await args.finish({redirectUri: signInCallbackUrl(appUrl, integrationId), code, verifier: payload.verifier, kept: payload.kept})); const {row, added} = $(await saveConnection(viewer, integrationId, connected, {shared: payload.shared, connectionId: payload.connectionId ?? null})); return {id: row.id, added}; }); diff --git a/packages/api/src/connections/custom/mcp-discovery.test.ts b/packages/api/src/connections/custom/mcp-discovery.test.ts index 3b2a4491..ecab86f3 100644 --- a/packages/api/src/connections/custom/mcp-discovery.test.ts +++ b/packages/api/src/connections/custom/mcp-discovery.test.ts @@ -182,7 +182,58 @@ describe('discoverMcpServer', () => { expect((await discoverMcpServer(new URL(MCP_URL))).unwrapErr().kind).toBe('validation_error'); }); - test('an authorization server that does not let apps register themselves, or is not https, cannot be signed in to', async () => { + test('an authorization server that takes a client ID metadata document is signed in to with it, registering too where it lets apps', async () => { + const {discoverMcpServer} = await import('./mcp-discovery'); + servers.answers.set('https://mcp.example.com/.well-known/oauth-protected-resource', () => Response.json({authorization_servers: ['https://auth.example.com']})); + servers.answers.set('https://auth.example.com/.well-known/oauth-authorization-server', () => + Response.json({authorization_endpoint: 'https://auth.example.com/oauth2/authorize', token_endpoint: 'https://auth.example.com/oauth2/token', client_id_metadata_document_supported: true}), + ); + const server = (await discoverMcpServer(new URL(MCP_URL))).unwrap(); + expect(server).toMatchObject({clientIdMetadataDocument: true, authorizeUrl: 'https://auth.example.com/oauth2/authorize', tokenUrl: 'https://auth.example.com/oauth2/token'}); + expect(server.registerUrl).toBeUndefined(); + + servers.answers.set('https://auth.example.com/.well-known/oauth-authorization-server', () => + Response.json({ + authorization_endpoint: 'https://auth.example.com/oauth2/authorize', + token_endpoint: 'https://auth.example.com/oauth2/token', + registration_endpoint: 'https://auth.example.com/oauth2/register', + client_id_metadata_document_supported: true, + }), + ); + expect((await discoverMcpServer(new URL(MCP_URL))).unwrap()).toMatchObject({clientIdMetadataDocument: true, registerUrl: 'https://auth.example.com/oauth2/register'}); + }); + + test('asks for offline_access beside the scopes the server names where the authorization server offers it, so the sign-in comes with a refresh token', async () => { + const {discoverMcpServer} = await import('./mcp-discovery'); + servers.answers.set('https://mcp.example.com/.well-known/oauth-protected-resource', () => Response.json({authorization_servers: ['https://auth.example.com']})); + servers.answers.set('https://auth.example.com/.well-known/oauth-authorization-server', () => + Response.json({ + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + client_id_metadata_document_supported: true, + scopes_supported: ['openid', 'email', 'offline_access'], + }), + ); + expect((await discoverMcpServer(new URL(MCP_URL))).unwrap().scope).toBe('offline_access'); + + servers.answers.set('https://mcp.example.com/.well-known/oauth-protected-resource', () => + Response.json({authorization_servers: ['https://auth.example.com'], scopes_supported: ['docs:read', 'offline_access']}), + ); + expect((await discoverMcpServer(new URL(MCP_URL))).unwrap().scope).toBe('docs:read offline_access'); + + servers.answers.set('https://mcp.example.com/.well-known/oauth-protected-resource', () => Response.json({authorization_servers: ['https://auth.example.com'], scopes_supported: ['docs:read']})); + servers.answers.set('https://auth.example.com/.well-known/oauth-authorization-server', () => + Response.json({ + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + registration_endpoint: 'https://auth.example.com/register', + scopes_supported: ['docs:read'], + }), + ); + expect((await discoverMcpServer(new URL(MCP_URL))).unwrap().scope).toBe('docs:read'); + }); + + test('an authorization server that neither lets apps register themselves nor takes a client ID metadata document, or is not https, cannot be signed in to', async () => { const {discoverMcpServer} = await import('./mcp-discovery'); servers.answers.set('https://mcp.example.com/.well-known/oauth-protected-resource', () => Response.json({authorization_servers: ['https://auth.example.com']})); servers.answers.set('https://auth.example.com/.well-known/oauth-authorization-server', () => @@ -192,6 +243,11 @@ describe('discoverMcpServer', () => { expect(refused.kind).toBe('validation_error'); expect('message' in refused && refused.message).toContain('register'); + servers.answers.set('https://auth.example.com/.well-known/oauth-authorization-server', () => + Response.json({authorization_endpoint: 'https://auth.example.com/authorize', token_endpoint: 'https://auth.example.com/token', client_id_metadata_document_supported: false}), + ); + expect((await discoverMcpServer(new URL(MCP_URL))).unwrapErr().kind).toBe('validation_error'); + servers.answers.set('https://mcp.example.com/.well-known/oauth-protected-resource', () => Response.json({authorization_servers: ['http://auth.example.com']})); expect((await discoverMcpServer(new URL(MCP_URL))).unwrapErr().kind).toBe('validation_error'); }); diff --git a/packages/api/src/connections/custom/mcp-discovery.ts b/packages/api/src/connections/custom/mcp-discovery.ts index a766735c..e76f6691 100644 --- a/packages/api/src/connections/custom/mcp-discovery.ts +++ b/packages/api/src/connections/custom/mcp-discovery.ts @@ -45,8 +45,43 @@ const authorizationServerSchema = z.object({ authorization_endpoint: z.string(), token_endpoint: z.string(), registration_endpoint: z.string().optional(), + client_id_metadata_document_supported: z.boolean().optional(), + scopes_supported: z.array(z.string()).optional(), }); +type AuthorizationServerMetadata = z.infer; + +// Asked for so a sign-in comes with a refresh token, where the authorization server offers it, as +// the MCP spec allows: some hand one out only for it, and the connection would end with the first +// access token otherwise. +const OFFLINE_ACCESS = 'offline_access'; + +// What a sign-in asks for: the scopes the server's metadata names, and `offline_access` where the +// authorization server offers it. +function signInScopes(scopes: string[], metadata: AuthorizationServerMetadata | null): string[] { + if (scopes.includes(OFFLINE_ACCESS) || !metadata?.scopes_supported?.includes(OFFLINE_ACCESS)) { + return scopes; + } + return [...scopes, OFFLINE_ACCESS]; +} + +// How a client is had for a sign-in: registered where the authorization server lets one be, the +// client ID metadata document where it takes one, the usual registration address where it has no +// metadata; null where it does neither. +function clientRegistration(metadata: AuthorizationServerMetadata | null, base: string): Pick | null { + if (!metadata) { + return {registerUrl: `${base}/register`}; + } + const registration = { + ...(metadata.registration_endpoint ? {registerUrl: metadata.registration_endpoint} : {}), + ...(metadata.client_id_metadata_document_supported ? {clientIdMetadataDocument: true as const} : {}), + }; + if (!registration.registerUrl && !registration.clientIdMetadataDocument) { + return null; + } + return registration; +} + /** What a probe found: the server's tools, reachable as it was asked, or that it asks for a sign-in. */ export type Probe = {kind: 'tools'; tools: McpTool[]} | {kind: 'signIn'}; @@ -118,8 +153,10 @@ async function findAuthorizationServer(url: URL): Promise<{issuer: URL; scopes: /** * The server and its authorization server, from its address, for signing in: its endpoints as the * authorization server's metadata names them, or the usual ones under the issuer where it has - * none, and the scopes the server's own metadata asks for, none otherwise. An authorization server - * that doesn't let apps register themselves can't be signed in to. + * none, how a client is had (`clientRegistration`), and the scopes the server's own metadata asks + * for, none otherwise, with `offline_access` where the authorization server offers it. An + * authorization server that neither lets apps register themselves nor takes a client ID metadata + * document can't be signed in to. */ export function discoverMcpServer(url: URL): Promise> { return Do(async ($) => { @@ -130,9 +167,9 @@ export function discoverMcpServer(url: URL): Promise [...wellKnownUrls(issuer, 'oauth-authorization-server'), ...wellKnownUrls(issuer, 'openid-configuration'), `${base}/.well-known/openid-configuration`], authorizationServerSchema, ); - const registerUrl = metadata ? metadata.registration_endpoint : `${base}/register`; - if (!registerUrl) { - return $(Err(ApiErr.validationError('This server’s sign-in doesn’t let apps register themselves, so Personal Agent Proxy can’t sign in to it'))); + const registration = clientRegistration(metadata, base); + if (!registration) { + return $(Err(ApiErr.validationError('This server’s sign-in neither lets apps register themselves nor takes a client ID metadata document, so Personal Agent Proxy can’t sign in to it'))); } // The browser is sent to the sign-in page, so it is checked as every address the api calls is: // metadata could name anything, a `javascript:` address included. @@ -141,10 +178,10 @@ export function discoverMcpServer(url: URL): Promise $((await checkUrl(signInPage)).mapErr((error) => ApiErr.validationError(`The server’s sign-in page (${authorizeUrl}) can’t be used: ${'message' in error ? error.message : error.kind}`))); return { ...customEndpoint(url.toString()), - registerUrl, + ...registration, authorizeUrl, tokenUrl: metadata?.token_endpoint ?? `${base}/token`, - scope: scopes.join(' '), + scope: signInScopes(scopes, metadata).join(' '), }; }); } diff --git a/packages/api/src/connections/custom/route.test.ts b/packages/api/src/connections/custom/route.test.ts index b609b762..b335cfb5 100644 --- a/packages/api/src/connections/custom/route.test.ts +++ b/packages/api/src/connections/custom/route.test.ts @@ -45,10 +45,13 @@ mock.module('../../observability/log', () => ({ serializeError: mock(() => ({})), })); -mock.module('../../utils/env', () => ({env: {NODE_ENV: 'production', APP_URL: 'https://app.example.com', ALLOW_PRIVATE_HOSTS: false}})); +const env = {NODE_ENV: 'production', APP_URL: 'https://app.example.com', ALLOW_PRIVATE_HOSTS: false}; -// The example server is on the internet. -mock.module('node:dns/promises', () => ({default: {lookup: async () => [{address: '93.184.216.34', family: 4}]}})); +mock.module('../../utils/env', () => ({env})); + +// The example servers, and Personal Agent Proxy at app.example.com, are on the internet; one +// self-hosted at proxy.internal is on a private network. +mock.module('node:dns/promises', () => ({default: {lookup: async (host: string) => [{address: host === 'proxy.internal' ? '10.0.0.4' : '93.184.216.34', family: 4}]}})); // Readable stand-ins for encryption, so a test can see what was stored. mock.module('../../utils/secret-crypto', () => ({ @@ -149,6 +152,7 @@ async function connect(body: unknown) { beforeEach(() => { mock.clearAllMocks(); + env.APP_URL = 'https://app.example.com'; findAccountConnection.mockResolvedValue(Ok(null)); createConnection.mockImplementation(async (data: {account: string; tools: string; defaults: string[]; name?: string}) => Ok({...row, account: data.account, tools: data.tools, name: data.name ?? null, defaults: data.defaults.map((actionId) => ({actionId}))}), @@ -170,6 +174,17 @@ beforeEach(() => { ); }); +// A server whose authorization server takes a client ID metadata document and lets no app register. +const documentServer = { + name: 'mcp.example.com', + url: 'https://mcp.example.com/mcp', + handshake: true, + clientIdMetadataDocument: true, + authorizeUrl: 'https://auth.example.com/oauth2/authorize', + tokenUrl: 'https://auth.example.com/oauth2/token', + scope: 'offline_access', +} as const; + describe('handleConnectCustomMcpRoute', () => { test('connects a server that answers without a sign-in, its tools kept and the ones that only read on by default', async () => { const res = await connect({name: 'Docs', url: 'https://mcp.example.com/mcp'}); @@ -262,6 +277,37 @@ describe('handleConnectCustomMcpRoute', () => { expect(refused.sent).toMatchObject({error: 'validation_error', message: 'No registration'}); }); + test('an authorization server that takes a client ID metadata document is sent its address as the client, rather than one registered', async () => { + probeMcpServer.mockResolvedValue(Ok({kind: 'signIn'})); + discoverMcpServer.mockResolvedValue(Ok({...documentServer, registerUrl: 'https://auth.example.com/register'})); + const res = await connect({name: 'Docs', url: 'https://mcp.example.com/mcp'}); + + const authorizeUrl = new URL((res.sent as {url: string}).url); + expect(authorizeUrl.origin + authorizeUrl.pathname).toBe('https://auth.example.com/oauth2/authorize'); + expect(authorizeUrl.searchParams.get('client_id')).toBe('https://app.example.com/oauth/client-metadata.json'); + expect(authorizeUrl.searchParams.get('redirect_uri')).toBe('https://app.example.com/api/connections/mcp/callback'); + expect(authorizeUrl.searchParams.get('code_challenge_method')).toBe('S256'); + expect(authorizeUrl.searchParams.get('scope')).toBe('offline_access'); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + test('a Personal Agent Proxy an authorization server could not fetch the document from registers a client instead, and can not sign in where that is the only way', async () => { + probeMcpServer.mockResolvedValue(Ok({kind: 'signIn'})); + discoverMcpServer.mockResolvedValue(Ok({...documentServer, registerUrl: 'https://auth.example.com/register'})); + for (const appUrl of ['https://proxy.internal', 'http://localhost:5173']) { + env.APP_URL = appUrl; + const res = await connect({name: 'Docs', url: 'https://mcp.example.com/mcp'}); + expect(new URL((res.sent as {url: string}).url).searchParams.get('client_id')).toBe('client-1'); + expect(String(fetchSpy.mock.calls.at(-1)?.[0])).toBe('https://auth.example.com/register'); + } + + discoverMcpServer.mockResolvedValue(Ok(documentServer)); + const refused = await connect({name: 'Docs', url: 'https://mcp.example.com/mcp'}); + expect(refused.statusCode).toBe(400); + expect(refused.sent).toMatchObject({error: 'validation_error'}); + expect(refused.cookies).toEqual([]); + }); + test('refuses an address that is not https, before asking anything', async () => { const res = await connect({name: 'Docs', url: 'http://mcp.example.com/mcp'}); expect(res.statusCode).toBe(400); @@ -359,6 +405,37 @@ describe('handleCustomMcpCallbackRoute', () => { expect(JSON.parse(created.credential.replace(/^enc:/, ''))).toEqual({auth: 'oauth', url: 'https://mcp.example.com/mcp', server: endpoints, ...tokens}); expect(res.redirectedTo).toBe('https://app.example.com/org-1/connections/mcp-1?added'); }); + test('a sign-in made with the client ID metadata document keeps its address as the client, which refreshing sends', async () => { + probeMcpServer.mockResolvedValue(Ok({kind: 'signIn'})); + discoverMcpServer.mockResolvedValue(Ok(documentServer)); + const begun = await connect({name: 'Docs', url: 'https://mcp.example.com/mcp'}); + const state = new URL((begun.sent as {url: string}).url).searchParams.get('state') ?? ''; + const cookie = `proxy.mcp-state=${encodeURIComponent(begun.cookies[0]?.value ?? '')}`; + + const clientId = 'https://app.example.com/oauth/client-metadata.json'; + const tokens = {clientId, accessToken: 'access-1', refreshToken: 'refresh-1', expiresAt: null}; + exchangeMcpCode.mockResolvedValue(Ok(tokens)); + listMcpTools.mockResolvedValue(Ok([search])); + const {handleCustomMcpCallbackRoute} = await import('./route'); + const res = makeRes(); + await handleCustomMcpCallbackRoute(makeRequest(undefined, {}, {state, code: 'code-1'}, {cookie}), res as never); + + expect(exchangeMcpCode).toHaveBeenCalledWith(documentServer, {clientId, redirectUri: 'https://app.example.com/api/connections/mcp/callback', code: 'code-1', verifier: expect.any(String)}); + const created = createConnection.mock.calls[0]?.[0] as {credential: string}; + const stored = customMcp.customCredentialSchema.parse(JSON.parse(created.credential.replace(/^enc:/, ''))); + expect(stored).toEqual({auth: 'oauth', url: 'https://mcp.example.com/mcp', server: documentServer, ...tokens}); + + fetchSpy.mockImplementationOnce((async (_input: string | URL | Request) => Response.json({access_token: 'access-2', refresh_token: 'refresh-2', expires_in: 3600})) as typeof fetch); + if (stored.auth !== 'oauth') { + throw new Error('Not signed in'); + } + const refreshed = (await mcpOauth.refreshMcpTokens({...customMcp.customEndpoint(stored.url), ...stored.server}, stored)).unwrap(); + expect(refreshed).toMatchObject({clientId, accessToken: 'access-2', refreshToken: 'refresh-2'}); + const [refreshUrl, refreshInit] = fetchSpy.mock.calls.at(-1) ?? []; + expect(String(refreshUrl)).toBe('https://auth.example.com/oauth2/token'); + expect(new URLSearchParams(String(refreshInit?.body)).get('client_id')).toBe(clientId); + expect(new URLSearchParams(String(refreshInit?.body)).get('grant_type')).toBe('refresh_token'); + }); }); describe('handleRefreshCustomMcpToolsRoute', () => { diff --git a/packages/api/src/connections/custom/route.ts b/packages/api/src/connections/custom/route.ts index a5f77dfa..63e98270 100644 --- a/packages/api/src/connections/custom/route.ts +++ b/packages/api/src/connections/custom/route.ts @@ -39,7 +39,7 @@ const {beginSignIn, handleCallback: handleCustomMcpCallbackRoute} = signInFlow({ integrationId: 'mcp', name: 'MCP server', // The server found from the address and the name the person gave the connection, beside the - // client registered for the sign-in. + // client the sign-in is made as. keptSchema: z.object({clientId: z.string(), server: mcpEndpointsSchema, name: z.string()}), // Signed in, the server is asked for its tools with the new token, and the connection keeps its // endpoints beside the tokens, for refreshing them. diff --git a/packages/api/src/connections/mcp/client-metadata.test.ts b/packages/api/src/connections/mcp/client-metadata.test.ts new file mode 100644 index 00000000..0531dccf --- /dev/null +++ b/packages/api/src/connections/mcp/client-metadata.test.ts @@ -0,0 +1,54 @@ +import {describe, expect, mock, test} from 'bun:test'; + +mock.module('../../utils/env', () => ({env: {NODE_ENV: 'production', APP_URL: 'https://app.example.com'}})); + +// What the route answered, and the headers it set. +function makeRes() { + const res = { + headers: new Map(), + sent: undefined as unknown, + statusCode: 200, + setHeader: mock((name: string, value: string) => { + res.headers.set(name.toLowerCase(), value); + }), + status: mock((code: number) => { + res.statusCode = code; + return res; + }), + json: mock((body: unknown) => { + res.sent = body; + return res; + }), + }; + return res; +} + +describe('handleClientMetadataRoute', () => { + test('names its own address as the client, a public one, with every MCP sign-in’s callback', async () => { + const {handleClientMetadataRoute} = await import('./client-metadata'); + const res = makeRes(); + handleClientMetadataRoute({} as never, res as never); + + expect(res.statusCode).toBe(200); + expect(res.headers.get('cache-control')).toBe('public, max-age=3600'); + expect(res.sent).toEqual({ + client_id: 'https://app.example.com/oauth/client-metadata.json', + client_name: 'Personal Agent Proxy', + client_uri: 'https://app.example.com', + logo_uri: 'https://app.example.com/favicon.svg', + redirect_uris: [ + 'https://app.example.com/api/connections/mcp/callback', + 'https://app.example.com/api/connections/granola/callback', + 'https://app.example.com/api/connections/notion/callback', + 'https://app.example.com/api/connections/linear/callback', + 'https://app.example.com/api/connections/cloudflare/callback', + 'https://app.example.com/api/connections/exa/callback', + 'https://app.example.com/api/connections/parallel/callback', + ], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + application_type: 'web', + }); + }); +}); diff --git a/packages/api/src/connections/mcp/client-metadata.ts b/packages/api/src/connections/mcp/client-metadata.ts new file mode 100644 index 00000000..b7297756 --- /dev/null +++ b/packages/api/src/connections/mcp/client-metadata.ts @@ -0,0 +1,35 @@ +import type {Request, Response} from 'express'; + +import {sendMetadata} from '../../oauth/metadata'; +import {builtInMcps} from '../built-in-mcp'; +import {signInCallbackUrl} from '../connect-flow'; +import {clientMetadataUrl} from './mcp-oauth'; + +/** + * Personal Agent Proxy as an MCP client, described for an authorization server that takes a client + * ID metadata document (draft-ietf-oauth-client-id-metadata-document) rather than a client + * registered for each sign-in: its address is the `client_id`, so the document names itself as + * one, and lists where every MCP sign-in comes back to, a custom server's and each built-in + * integration's, since the authorization server holds a sign-in to those exactly. A public client: + * no secret, PKCE, and refresh tokens. + */ + +/** The document, for Personal Agent Proxy at `appUrl`. */ +export function clientMetadata(appUrl: string) { + return { + client_id: clientMetadataUrl(appUrl), + client_name: 'Personal Agent Proxy', + client_uri: appUrl, + logo_uri: `${appUrl}/favicon.svg`, + redirect_uris: ['mcp' as const, ...builtInMcps().map((builtIn) => builtIn.integrationId)].map((integrationId) => signInCallbackUrl(appUrl, integrationId)), + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + application_type: 'web', + }; +} + +/** `GET /oauth/client-metadata.json` */ +export function handleClientMetadataRoute(_req: Request, res: Response): void { + sendMetadata(res, clientMetadata); +} diff --git a/packages/api/src/connections/mcp/mcp-connect.ts b/packages/api/src/connections/mcp/mcp-connect.ts index 0405a1de..5b236214 100644 --- a/packages/api/src/connections/mcp/mcp-connect.ts +++ b/packages/api/src/connections/mcp/mcp-connect.ts @@ -8,27 +8,63 @@ import {type Connection, connectWithKeyBodySchema, type ReturnTo} from '@proxy/c import type {Connector} from '../../records/connector'; import type {AuthenticatedRequest} from '../../server/middleware/require-auth'; +import {env} from '../../utils/env'; +import {isPublicHttpsUrl} from '../../utils/host-check'; import {requireViewer, type OrgViewer} from '../../utils/user-org'; import {respond} from '../connection-response'; import {saveConnection, signInFlow, signInRoutes, type Connected, type SaveOptions, type SignInRequest} from '../connect-flow'; import type {ApiIntegration} from '../integration'; import {requireManaged, type ManagingViewer} from '../managed'; import type {BuiltInMcp, ProviderAccount} from './built-in'; -import {exchangeMcpCode, mcpAuthorizeUrl, registerMcpClient, type McpCredential, type McpServer} from './mcp-oauth'; +import {clientMetadataUrl, exchangeMcpCode, mcpAuthorizeUrl, registerMcpClient, type McpCredential, type McpServer} from './mcp-oauth'; import type {McpKeyCredential} from './mcp-session'; /** * Connecting an integration built on its MCP server: by signing in to it, as Granola, Notion and * Linear are and as a custom server found from its address is (`custom/route.ts`), or with an API - * key where the server takes one, as Exa's and Parallel's do. A sign-in registers a client with the - * server's authorization server and sends the browser to its sign-in page; a key is checked with + * key where the server takes one, as Exa's and Parallel's do. A sign-in is made as Personal Agent + * Proxy's client ID metadata document or a client registered with the server's authorization + * server (`mcpClientId`), and the browser goes to its sign-in page; a key is checked with * the server, then kept as the connection's credential, sent as its bearer token. */ -/** Registers a client for this sign-in and answers the server's sign-in page, the client kept for the callback beside `kept`. */ +// Personal Agent Proxy's client ID metadata document, where an authorization server out on the +// internet can fetch it: under an `https` APP_URL on a public address. A self-hosted one on a +// private network, or in development, registers a client instead. +async function reachableClientMetadataUrl(): Promise { + if (!env.APP_URL || !(await isPublicHttpsUrl(new URL(env.APP_URL)))) { + return null; + } + return clientMetadataUrl(env.APP_URL); +} + +// Why an authorization server that takes only the document can't be signed in to from here. +const DOCUMENT_ONLY = + 'This server’s sign-in only takes apps by a client ID metadata document, which its authorization server fetches from Personal Agent Proxy: that needs APP_URL to be a public https:// address'; + +/** + * The client a sign-in is made as, in the order the MCP spec gives: the client ID metadata + * document, where the authorization server takes one and can fetch it, else a client registered + * for this sign-in, returning back to `redirectUri`. One that takes only the document can't be + * signed in to from a Personal Agent Proxy it can't reach. + */ +function mcpClientId(server: McpServer, redirectUri: string): Promise> { + return Do(async ($) => { + const document = server.clientIdMetadataDocument ? await reachableClientMetadataUrl() : null; + if (document) { + return document; + } + if (server.registerUrl) { + return $(await registerMcpClient(server, server.registerUrl, redirectUri)); + } + return $(Err(ApiErr.validationError(DOCUMENT_ONLY))); + }); +} + +/** Answers the server's sign-in page, as the client `mcpClientId` gives, which is kept for the callback beside `kept`. */ export function startMcpSignIn(server: McpServer, {redirectUri, state, challenge}: SignInRequest, kept: K): Promise> { return Do(async ($) => { - const clientId = $(await registerMcpClient(server, redirectUri)); + const clientId = $(await mcpClientId(server, redirectUri)); return {url: mcpAuthorizeUrl(server, {clientId, redirectUri, state, challenge}), kept: {...kept, clientId}}; }); } diff --git a/packages/api/src/connections/mcp/mcp-oauth.ts b/packages/api/src/connections/mcp/mcp-oauth.ts index 57587be8..91fef9fe 100644 --- a/packages/api/src/connections/mcp/mcp-oauth.ts +++ b/packages/api/src/connections/mcp/mcp-oauth.ts @@ -7,11 +7,13 @@ import {checkedRequest} from '../../utils/checked-request'; /** * Signing in to an MCP server, as the MCP spec asks of every client: OAuth 2.0 with PKCE against - * the server's own authorization server, a client registered on the spot (Dynamic Client - * Registration) with no secret, and refresh tokens for staying connected. Each connection keeps - * the client it was made with, since refreshing needs it. Nothing has to be set up beforehand, so - * a self-hosted Personal Agent Proxy connects the same way. Every request goes through - * `checkedRequest`, since a custom server's endpoints are wherever its metadata said. + * the server's own authorization server, as a public client with no secret, and refresh tokens for + * staying connected. The client is Personal Agent Proxy's client ID metadata document, the address + * of a document it serves about itself (`client-metadata.ts`), where the authorization server takes + * one and can fetch it, else one registered on the spot (Dynamic Client Registration). Each + * connection keeps the client it was made with, since refreshing needs it. Nothing has to be set up + * beforehand, so a self-hosted Personal Agent Proxy connects the same way. Every request goes + * through `checkedRequest`, since a custom server's endpoints are wherever its metadata said. */ /** An MCP server, as far as calling its tools goes. */ @@ -33,7 +35,12 @@ export type McpServer = McpEndpoint & { // The resource its tokens are for, where it isn't `url`: an endpoint whose address carries a // query of the server's own, as Cloudflare's does, has its tokens for the address without it. resource?: string; - registerUrl: string; + // Where a client is registered for a sign-in (Dynamic Client Registration), where the + // authorization server lets one be. + registerUrl?: string; + // The authorization server takes Personal Agent Proxy's client ID metadata document as the client + // (`client_id_metadata_document_supported`), which the spec has clients prefer. + clientIdMetadataDocument?: true; authorizeUrl: string; tokenUrl: string; // What is asked for at sign-in; nothing when the server names no scopes. @@ -44,7 +51,8 @@ export type McpServer = McpEndpoint & { export const mcpEndpointsSchema = z.object({ name: z.string(), url: z.string(), - registerUrl: z.string(), + registerUrl: z.string().optional(), + clientIdMetadataDocument: z.literal(true).optional(), authorizeUrl: z.string(), tokenUrl: z.string(), scope: z.string(), @@ -55,8 +63,26 @@ export type McpEndpoints = z.infer; /** A server's endpoints alone, for keeping: what `mcpEndpointsSchema` reads back. */ export function mcpEndpoints(server: McpServer): McpEndpoints { - const {name, url, registerUrl, authorizeUrl, tokenUrl, scope, handshake} = server; - return {name, url, registerUrl, authorizeUrl, tokenUrl, scope, ...(handshake ? {handshake} : {})}; + const {name, url, registerUrl, clientIdMetadataDocument, authorizeUrl, tokenUrl, scope, handshake} = server; + return { + name, + url, + ...(registerUrl ? {registerUrl} : {}), + ...(clientIdMetadataDocument ? {clientIdMetadataDocument} : {}), + authorizeUrl, + tokenUrl, + scope, + ...(handshake ? {handshake} : {}), + }; +} + +/** + * Personal Agent Proxy's client ID metadata document, under `APP_URL`: the address is the client's + * id, which an authorization server that takes one fetches to learn the client's name and the + * addresses its sign-ins come back to. + */ +export function clientMetadataUrl(appUrl: string): string { + return `${appUrl}/oauth/client-metadata.json`; } const TIMEOUT_MS = 15_000; @@ -90,10 +116,10 @@ export function mcpAuthError(error: FetchError): ApiError { return ApiErr.providerUnreachable(error); } -/** Registers Personal Agent Proxy with the server for one sign-in, returning back to `redirectUri`. */ -export async function registerMcpClient(server: McpServer, redirectUri: string): Promise> { +/** Registers Personal Agent Proxy at `registerUrl`, the server's, for one sign-in, returning back to `redirectUri`. */ +export async function registerMcpClient(server: McpServer, registerUrl: string, redirectUri: string): Promise> { const registration = await checkedRequest( - server.registerUrl, + registerUrl, { method: 'POST', headers: {'content-type': 'application/json'}, diff --git a/packages/api/src/oauth/metadata.ts b/packages/api/src/oauth/metadata.ts index b06cbbdf..0265aff0 100644 --- a/packages/api/src/oauth/metadata.ts +++ b/packages/api/src/oauth/metadata.ts @@ -6,7 +6,8 @@ import {mcpResourceUrl, OAUTH_SCOPE, requireAppUrl} from './oauth-config'; // metadata (RFC 9728) points at the authorization server, whose metadata (RFC 8414) lists its // endpoints. Both are public. -function sendMetadata(res: Response, build: (appUrl: string) => Record): void { +/** A public metadata document built from `APP_URL`, cached for an hour. */ +export function sendMetadata(res: Response, build: (appUrl: string) => Record): void { const appUrl = requireAppUrl(); if (appUrl.isErr()) { res.status(500).json({error: 'server_error', error_description: 'APP_URL is not set'}); diff --git a/packages/api/src/server/create-api-router.ts b/packages/api/src/server/create-api-router.ts index 6d1a6cf4..187a4d62 100644 --- a/packages/api/src/server/create-api-router.ts +++ b/packages/api/src/server/create-api-router.ts @@ -32,6 +32,7 @@ import {handleSignOutRoute} from '../auth/signout'; import {handleRefreshCustomMcpToolsRoute} from '../connections/custom/route'; import type {ApiIntegration} from '../connections/integration'; import {apiIntegrations} from '../connections/integrations'; +import {handleClientMetadataRoute} from '../connections/mcp/client-metadata'; import { handleDeleteConnectionRoute, handleGetConnectionRoute, @@ -230,6 +231,10 @@ export function createApiRouter(): express.Router { router.get('/mcp', handleMcpMethodNotAllowedRoute); router.delete('/mcp', handleMcpMethodNotAllowedRoute); + // Personal Agent Proxy as an MCP client: the client ID metadata document an MCP server's + // authorization server fetches, where it takes one, to learn who is signing in to it. + router.get('/oauth/client-metadata.json', allowAnyOrigin, handleClientMetadataRoute); + // A body that isn't JSON: JSON-RPC's parse error for the MCP server, Express's own answer elsewhere. router.use((error: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => { if (req.path !== '/mcp') { diff --git a/packages/api/src/utils/env-schema.ts b/packages/api/src/utils/env-schema.ts index ec8d3202..b616e474 100644 --- a/packages/api/src/utils/env-schema.ts +++ b/packages/api/src/utils/env-schema.ts @@ -42,7 +42,7 @@ export const envSchema = z.object({ group: 'Core', required: true, description: - 'Where people open Personal Agent Proxy, such as `https://proxy.example.com`. Sign-in links and Google sign-in return here, and it is the only origin the api accepts browser requests from.', + 'Where people open Personal Agent Proxy, such as `https://proxy.example.com`. Sign-in links and Google sign-in return here, and it is the only origin the api accepts browser requests from. When it is an `https` address on the public internet, MCP servers whose sign-in takes a client ID metadata document fetch Personal Agent Proxy’s from `/oauth/client-metadata.json`.', }), AUTH_SECRET: documented(z.string().min(1).optional(), { group: 'Core', diff --git a/packages/api/src/utils/host-check.ts b/packages/api/src/utils/host-check.ts index 890c3f4f..09fc52dc 100644 --- a/packages/api/src/utils/host-check.ts +++ b/packages/api/src/utils/host-check.ts @@ -112,3 +112,16 @@ export function checkUrl(url: URL): Promise> { return url; }); } + +/** + * Whether a server out on the internet can reach the address: `https`, on a host that resolves only + * to public addresses, whatever `ALLOW_PRIVATE_HOSTS` says, since that is about what the api calls + * rather than what calls it. + */ +export async function isPublicHttpsUrl(url: URL): Promise { + if (url.protocol !== 'https:') { + return false; + } + const addresses = await lookup(hostOf(url)); + return addresses.isOk() && addresses.value.length > 0 && !addresses.value.some(isPrivateAddress); +} diff --git a/packages/docs/content/connections/custom-mcp.mdx b/packages/docs/content/connections/custom-mcp.mdx index 4681e8a3..74afbe9b 100644 --- a/packages/docs/content/connections/custom-mcp.mdx +++ b/packages/docs/content/connections/custom-mcp.mdx @@ -75,10 +75,25 @@ tool the server later lists by that name. ## How the sign-in works Personal Agent Proxy signs in to a server the way the MCP specification lays out: the server says -which authorization server its tokens come from, Personal Agent Proxy registers itself there as a +which authorization server its tokens come from, Personal Agent Proxy introduces itself there as a client, and you approve access on the authorization server's own page. Tokens are refreshed as they -run out. A server whose authorization server does not let clients register themselves cannot be -connected this way; use an access token instead, where the server offers one. +run out, and where the authorization server offers `offline_access`, Personal Agent Proxy asks for +it, so the sign-in comes with a refresh token. + +An authorization server can take a client in one of two ways, and Personal Agent Proxy uses the +one the specification prefers: + +- **Client ID metadata document.** Where the authorization server takes one, Personal Agent Proxy + gives the address of a document it serves about itself, `/oauth/client-metadata.json`, + as its client ID, and the authorization server fetches it. That only works when the + authorization server can reach Personal Agent Proxy, so it is used only when `APP_URL` is an + `https` address on the public internet. +- **Registering itself.** Otherwise Personal Agent Proxy registers a client with the authorization + server for the sign-in (Dynamic Client Registration). + +A server whose authorization server takes neither cannot be connected this way, nor one that only +takes the document when Personal Agent Proxy is [self-hosted](/self-hosting) where the internet +can't reach it; use an access token instead, where the server offers one. ## When a server becomes built in