Skip to content
5 changes: 5 additions & 0 deletions .changeset/optional-mcp-authorize-return-to.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@truefoundry/trueforge': patch
---

`GET /api/v1/mcp-servers/{name}/authorize` no longer sends a `redirectURL` to the TrueFoundry MCP gateway when `return_to` is omitted, so the gateway shows its own post-authentication page instead of redirecting to the TrueForge home. An empty `return_to` is now rejected with 400.
5 changes: 3 additions & 2 deletions .github/fern/openapi/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -8444,12 +8444,13 @@
}
},
{
"description": "Same-origin path to land in the browser after consent.",
"description": "Same-origin path to land in the browser after consent. When omitted, no post-consent redirect is requested.",
"in": "query",
"name": "return_to",
"required": false,
"schema": {
"description": "Same-origin path to land in the browser after consent.",
"description": "Same-origin path to land in the browser after consent. When omitted, no post-consent redirect is requested.",
"minLength": 1,
"type": "string"
}
}
Expand Down
5 changes: 3 additions & 2 deletions docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -8444,12 +8444,13 @@
}
},
{
"description": "Same-origin path to land in the browser after consent.",
"description": "Same-origin path to land in the browser after consent. When omitted, no post-consent redirect is requested.",
"in": "query",
"name": "return_to",
"required": false,
"schema": {
"description": "Same-origin path to land in the browser after consent.",
"description": "Same-origin path to land in the browser after consent. When omitted, no post-consent redirect is requested.",
"minLength": 1,
"type": "string"
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
* {}
*/
export interface AuthorizeMcpServersRequest {
/** Same-origin path to land in the browser after consent. */
/** Same-origin path to land in the browser after consent. When omitted, no post-consent redirect is requested. */
returnTo?: string;
}
2 changes: 1 addition & 1 deletion packages/trueforge/src/apis/mcpServers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,7 @@ export function createMcpServersRouter<TTransaction>(deps: McpServersRouterDeps<
const requestContext = deps.resolveRequestContext(c);
const userRef = requestContext.subject.id;

if (returnTo && safeReturnTo(returnTo) !== returnTo) {
if (returnTo !== undefined && safeReturnTo(returnTo) !== returnTo) {
return c.json({ error: { message: 'Invalid return_to: must be a same-origin relative path' } }, 400);
}

Expand Down
8 changes: 7 additions & 1 deletion packages/trueforge/src/routes/mcpServerRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,13 @@ export const listMcpServerToolsRoute = createRoute({
});

const McpAuthorizeQuerySchema = z.object({
return_to: z.string().optional().describe('Same-origin path to land in the browser after consent.'),
return_to: z
.string()
.min(1)
.optional()
.describe(
'Same-origin path to land in the browser after consent. When omitted, no post-consent redirect is requested.',
),
});

export const authorizeMcpServerRoute = createRoute({
Expand Down
16 changes: 10 additions & 6 deletions packages/trueforge/src/truefoundry/TrueFoundryMcpServerStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ function withoutAuthorization(headers: Record<string, string> | undefined): Reco
* TODO: add an env var for the public UI path prefix (do not keep deriving mount path from
* `PUBLIC_BASE_URL`) so tenant origin and path are independently configurable.
*/
export function resolveAuthorizeRedirectURL(input: { returnTo?: string; publicBaseUrl: string }): string {
export function resolveAuthorizeRedirectURL(input: { returnTo: string; publicBaseUrl: string }): string {
try {
const origin = new URL(input.publicBaseUrl).origin;
const publicBase = new URL(getPublicUiBasePath(), `${origin}/`);
Expand Down Expand Up @@ -251,15 +251,19 @@ export class TrueFoundryMcpServerStore<TTransaction = never> implements IMcpServ
if (record === undefined) {
throw new McpServerNotFoundError(input.name);
}
const publicBaseUrl = await this.#client.getTenantControlPlaneUrl({ tenantName: input.tenant_id });
const returnTo = input.returnTo;
const redirectURL =
returnTo === undefined
? undefined
: resolveAuthorizeRedirectURL({
returnTo: returnTo,
publicBaseUrl: await this.#client.getTenantControlPlaneUrl({ tenantName: input.tenant_id }),
});
const gatewayAuthorization = await this.#resolveGatewayAuthorization();
return this.#client.getMcpAuthorize({
accessToken: gatewayAuthorization.subjectToken,
mcpServerId: record.id,
redirectURL: resolveAuthorizeRedirectURL({
publicBaseUrl,
...(input.returnTo !== undefined ? { returnTo: input.returnTo } : {}),
}),
...(redirectURL !== undefined ? { redirectURL } : {}),
});
}

Expand Down
7 changes: 7 additions & 0 deletions packages/trueforge/tests/unit/apis/mcpServers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -788,6 +788,13 @@ describe('mcp-servers routers', () => {
}
});

it('GET /{name}/authorize rejects an empty return_to with 400', async () => {
await seedDcrServerWithClient();

const response = await mcpServersRouter.request(`/${putBodyWithDcr.name}/authorize?return_to=`);
expect(response.status).toBe(400);
});

it('GET /{name}/authorize returns 424 when upstream DCR registration fails', async () => {
const asOrigin = 'https://auth-failure.example.com';
const mcpUrl = 'https://mcp-failure.example.com/sse';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,9 +175,21 @@ describe('TrueFoundryMcpServerStore', () => {
});
});

it('omits upstream redirectURL when return_to is not given', async () => {
const { store, client } = createStore();
await store.authorize({ tenant_id: TENANT, name: 'github', userRef: 'user-1' });
expect(client.getTenantControlPlaneUrl).not.toHaveBeenCalled();
expect(client.getMcpAuthorize).toHaveBeenCalledWith({
accessToken: ACCESS_TOKEN,
mcpServerId: 'mcp-id-1',
});
});

it('throws when tenant control-plane URL is invalid', async () => {
const { store } = createStore({ controlPlaneUrl: '' });
await expect(store.authorize({ tenant_id: TENANT, name: 'github', userRef: 'user-1' })).rejects.toMatchObject({
await expect(
store.authorize({ tenant_id: TENANT, name: 'github', userRef: 'user-1', returnTo: '/trueforge/' }),
).rejects.toMatchObject({
message: '[MCP Error] Tenant control-plane URL is required for TrueFoundry MCP OAuth',
statusCode: 500,
});
Expand Down
2 changes: 1 addition & 1 deletion python/trueforge_sdk/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -822,7 +822,7 @@ client.mcp_servers.authorize(
<dl>
<dd>

**return_to:** `typing.Optional[str]` — Same-origin path to land in the browser after consent.
**return_to:** `typing.Optional[str]` — Same-origin path to land in the browser after consent. When omitted, no post-consent redirect is requested.

</dd>
</dl>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ def authorize(
MCP server name.

return_to : typing.Optional[str]
Same-origin path to land in the browser after consent.
Same-origin path to land in the browser after consent. When omitted, no post-consent redirect is requested.

request_options : typing.Optional[RequestOptions]
Request-specific configuration.
Expand Down Expand Up @@ -306,7 +306,7 @@ async def authorize(
MCP server name.

return_to : typing.Optional[str]
Same-origin path to land in the browser after consent.
Same-origin path to land in the browser after consent. When omitted, no post-consent redirect is requested.

request_options : typing.Optional[RequestOptions]
Request-specific configuration.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ def authorize(
MCP server name.

return_to : typing.Optional[str]
Same-origin path to land in the browser after consent.
Same-origin path to land in the browser after consent. When omitted, no post-consent redirect is requested.

request_options : typing.Optional[RequestOptions]
Request-specific configuration.
Expand Down Expand Up @@ -532,7 +532,7 @@ async def authorize(
MCP server name.

return_to : typing.Optional[str]
Same-origin path to land in the browser after consent.
Same-origin path to land in the browser after consent. When omitted, no post-consent redirect is requested.

request_options : typing.Optional[RequestOptions]
Request-specific configuration.
Expand Down
Loading