Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions Caddyfile
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@
}
}

# MCP server (FastMCP serves at /mcp by default, so preserve the path)
handle /mcp* {
# MCP server (FastMCP serves at /mcp by default, so preserve the path).
# Use path_regexp to avoid matching /mcp-auth/* (frontend routes).
@mcp_server path /mcp /mcp/*
handle @mcp_server {
reverse_proxy http://mcp:8099 {
flush_interval -1
}
Expand Down
4 changes: 2 additions & 2 deletions deployments/fargate/modules/ecs/locals.tf
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,8 @@ locals {
local.tracecat_db_configs,
{
TRACECAT__DB_ENDPOINT = local.core_db_hostname
OIDC_ISSUER = var.oidc_issuer
OIDC_SCOPES = var.oidc_scopes
TRACECAT__API_URL = local.internal_api_url
TRACECAT__PUBLIC_API_URL = local.public_api_url
TRACECAT_MCP__HOST = "0.0.0.0"
TRACECAT_MCP__PORT = "8099"
TRACECAT_MCP__BASE_URL = "https://${var.domain_name}"
Expand Down
3 changes: 1 addition & 2 deletions deployments/fargate/modules/ecs/secrets.tf
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,6 @@ locals {

mcp_secrets = concat(
local.tracecat_base_secrets,
local.oidc_client_id_secret,
local.oidc_client_secret_secret,
local.user_auth_secret_secret,
)
}
11 changes: 6 additions & 5 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -393,11 +393,10 @@ services:
TRACECAT__EE_MULTI_TENANT: ${TRACECAT__EE_MULTI_TENANT:-false}
# Redis
REDIS_URL: ${REDIS_URL}
# OIDC
OIDC_ISSUER: ${OIDC_ISSUER}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET}
OIDC_SCOPES: ${OIDC_SCOPES}
# Internal OIDC issuer (on the API server)
USER_AUTH_SECRET: ${USER_AUTH_SECRET}
TRACECAT__API_URL: ${TRACECAT__API_URL:-http://api:8000}
TRACECAT__PUBLIC_API_URL: ${TRACECAT__PUBLIC_API_URL:-http://localhost:${PUBLIC_APP_PORT:-80}/api}
# MCP-specific
TRACECAT_MCP__BASE_URL: ${TRACECAT_MCP__BASE_URL:-${PUBLIC_URL:-http://localhost:${PUBLIC_APP_PORT:-80}}}
TRACECAT_MCP__FILE_TRANSFER_URL_EXPIRY_SECONDS: ${TRACECAT_MCP__FILE_TRANSFER_URL_EXPIRY_SECONDS:-300}
Expand All @@ -413,6 +412,8 @@ services:
- ./packages:/app/packages
command: ["python", "-m", "tracecat.mcp"]
depends_on:
api:
condition: service_healthy
migrations:
condition: service_completed_successfully
temporal:
Expand Down
10 changes: 6 additions & 4 deletions docker-compose.local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -412,10 +412,10 @@ services:
TRACECAT__FEATURE_FLAGS: ${TRACECAT__FEATURE_FLAGS}
TRACECAT__EE_MULTI_TENANT: ${TRACECAT__EE_MULTI_TENANT:-false}
REDIS_URL: ${REDIS_URL}
OIDC_ISSUER: ${OIDC_ISSUER}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET}
OIDC_SCOPES: ${OIDC_SCOPES}
# Internal OIDC issuer (on the API server)
USER_AUTH_SECRET: ${USER_AUTH_SECRET}
TRACECAT__API_URL: ${TRACECAT__API_URL:-http://api:8000}
TRACECAT__PUBLIC_API_URL: ${TRACECAT__PUBLIC_API_URL:-http://localhost:${PUBLIC_APP_PORT:-80}/api}
TRACECAT_MCP__BASE_URL: ${TRACECAT_MCP__BASE_URL:-${PUBLIC_URL:-http://localhost:${PUBLIC_APP_PORT:-80}}}
TRACECAT_MCP__FILE_TRANSFER_URL_EXPIRY_SECONDS: ${TRACECAT_MCP__FILE_TRANSFER_URL_EXPIRY_SECONDS:-300}
TRACECAT_MCP__STARTUP_MAX_ATTEMPTS: ${TRACECAT_MCP__STARTUP_MAX_ATTEMPTS:-3}
Expand All @@ -426,6 +426,8 @@ services:
TEMPORAL__API_KEY: ${TEMPORAL__API_KEY}
command: ["python", "-m", "tracecat.mcp"]
depends_on:
api:
condition: service_healthy
migrations:
condition: service_completed_successfully
temporal:
Expand Down
10 changes: 6 additions & 4 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -402,10 +402,10 @@ services:
TRACECAT__FEATURE_FLAGS: ${TRACECAT__FEATURE_FLAGS}
TRACECAT__EE_MULTI_TENANT: ${TRACECAT__EE_MULTI_TENANT:-false}
REDIS_URL: ${REDIS_URL}
OIDC_ISSUER: ${OIDC_ISSUER}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET}
OIDC_SCOPES: ${OIDC_SCOPES}
# Internal OIDC issuer (on the API server)
USER_AUTH_SECRET: ${USER_AUTH_SECRET}
TRACECAT__API_URL: ${TRACECAT__API_URL:-http://api:8000}
TRACECAT__PUBLIC_API_URL: ${TRACECAT__PUBLIC_API_URL:-http://localhost:${PUBLIC_APP_PORT:-80}/api}
TRACECAT_MCP__BASE_URL: ${TRACECAT_MCP__BASE_URL:-${PUBLIC_URL:-http://localhost:${PUBLIC_APP_PORT:-80}}}
TRACECAT_MCP__FILE_TRANSFER_URL_EXPIRY_SECONDS: ${TRACECAT_MCP__FILE_TRANSFER_URL_EXPIRY_SECONDS:-300}
TRACECAT_MCP__STARTUP_MAX_ATTEMPTS: ${TRACECAT_MCP__STARTUP_MAX_ATTEMPTS:-3}
Expand All @@ -416,6 +416,8 @@ services:
TEMPORAL__API_KEY: ${TEMPORAL__API_KEY}
command: ["python", "-m", "tracecat.mcp"]
depends_on:
api:
condition: service_healthy
migrations:
condition: service_completed_successfully
temporal:
Expand Down
50 changes: 50 additions & 0 deletions frontend/src/app/mcp-auth/continue/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"use client"

import { useRouter, useSearchParams } from "next/navigation"
import { Suspense, useEffect } from "react"

import { CenteredSpinner } from "@/components/loading/spinner"
import { useAuth } from "@/hooks/use-auth"

/**
* MCP auth resume page.
*
* After the internal OIDC issuer redirects here because the user has no
* active session, this page either:
* - Redirects to sign-in (preserving the txn param via returnUrl), or
* - Redirects back to the OIDC authorize/resume endpoint if already logged in.
*/
function McpAuthContinueContent() {
const { user, userIsLoading } = useAuth()
const router = useRouter()
const searchParams = useSearchParams()
const txnId = searchParams?.get("txn")

useEffect(() => {
if (userIsLoading) return
if (!txnId) {
router.replace("/")
return
}

if (user) {
// Already logged in — resume the authorization flow.
window.location.href = `/api/mcp-oidc/authorize/resume?txn=${encodeURIComponent(txnId)}`
return
}

// Not logged in — redirect to sign-in with a return URL back here.
const returnUrl = `/mcp-auth/continue?txn=${encodeURIComponent(txnId)}`
router.replace(`/sign-in?returnUrl=${encodeURIComponent(returnUrl)}`)
}, [user, userIsLoading, txnId, router])

return <CenteredSpinner />
}

export default function McpAuthContinuePage() {
return (
<Suspense fallback={<CenteredSpinner />}>
<McpAuthContinueContent />
</Suspense>
)
}
118 changes: 118 additions & 0 deletions frontend/src/app/mcp-auth/select-org/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"use client"

import Cookies from "js-cookie"
import { useRouter, useSearchParams } from "next/navigation"
import { Suspense, useEffect, useState } from "react"
import { adminListOrganizations } from "@/client/services.gen"
import { CenteredSpinner } from "@/components/loading/spinner"
import { Button } from "@/components/ui/button"
import { useAuth } from "@/hooks/use-auth"

/**
* MCP auth organization picker for platform superadmins.
*
* When a superadmin initiates MCP auth without a selected organization,
* the internal OIDC issuer redirects here so they can choose one.
* After selection, the org cookie is set and the authorization flow resumes.
*/
function McpAuthSelectOrgContent() {
const { user, userIsLoading } = useAuth()
const router = useRouter()
const searchParams = useSearchParams()
const txnId = searchParams?.get("txn")

const [orgs, setOrgs] = useState<{ id: string; name: string }[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)

useEffect(() => {
if (userIsLoading) return
if (!user) {
// Not authenticated — go to sign-in first.
const returnUrl = `/mcp-auth/select-org?txn=${encodeURIComponent(txnId ?? "")}`
router.replace(`/sign-in?returnUrl=${encodeURIComponent(returnUrl)}`)
return
}
if (!txnId) {
router.replace("/")
return
}

// Fetch orgs
adminListOrganizations()
.then((data) => {
const orgList = (data ?? []).map((org) => ({
id: org.id,
name: org.name ?? org.id,
}))
setOrgs(orgList)
setLoading(false)
})
.catch((err) => {
setError(
err instanceof Error ? err.message : "Failed to load organizations"
)
setLoading(false)
})
}, [user, userIsLoading, txnId, router])

function handleSelectOrg(orgId: string) {
Cookies.set("tracecat-org-id", orgId, { path: "/", sameSite: "lax" })
// Resume the authorization flow.
window.location.href = `/api/mcp-oidc/authorize/resume?txn=${encodeURIComponent(txnId ?? "")}`
}

if (userIsLoading || loading) {
return <CenteredSpinner />
}

if (error) {
return (
<div className="container flex h-full max-w-[600px] flex-col items-center justify-center space-y-4 p-16">
<h2 className="text-xl font-semibold">Error</h2>
<p className="text-muted-foreground">{error}</p>
<Button variant="outline" onClick={() => router.replace("/")}>
Go home
</Button>
</div>
)
}

return (
<div className="container flex h-full max-w-[600px] flex-col items-center justify-center space-y-6 p-16">
<div className="space-y-2 text-center">
<h2 className="text-2xl font-semibold tracking-tight">
Select organization
</h2>
<p className="text-sm text-muted-foreground">
Choose which organization to use for this MCP session.
</p>
</div>
<div className="flex w-full max-w-[400px] flex-col gap-2">
{orgs.map((org) => (
<Button
key={org.id}
variant="outline"
className="w-full justify-start"
onClick={() => handleSelectOrg(org.id)}
>
{org.name}
</Button>
))}
{orgs.length === 0 && (
<p className="text-center text-sm text-muted-foreground">
No organizations found.
</p>
)}
</div>
</div>
)
}

export default function McpAuthSelectOrgPage() {
return (
<Suspense fallback={<CenteredSpinner />}>
<McpAuthSelectOrgContent />
</Suspense>
)
}
7 changes: 7 additions & 0 deletions frontend/src/lib/auth-return-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ describe("sanitizeReturnUrl", () => {
it("does not over-match unrelated internal routes", () => {
expect(sanitizeReturnUrl("/authentic/path")).toBe("/authentic/path")
})

it.each(["/mcp-auth/continue?txn=abc123", "/mcp-auth/select-org?txn=abc123"])(
"allows MCP auth resume paths for %s",
(value) => {
expect(sanitizeReturnUrl(value)).toBe(value)
}
)
})

describe("decodeAndSanitizeReturnUrl", () => {
Expand Down
Loading
Loading