Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
f0311a6
feat(table): chunked dispatcher + workflow cascade (#4672)
TheodoreSpeaks May 20, 2026
d9dd7a3
fix(cors): re-enable credentials on chat/form embed CORS policy (#4673)
waleedlatif1 May 20, 2026
46db406
feat(mcp): OAuth 2.1 + PKCE for outbound MCP servers (#4441)
waleedlatif1 May 20, 2026
af8025c
fix(table): dispatcher cold-start, live run counter, smooth typewrite…
TheodoreSpeaks May 20, 2026
c381550
fix(landing-nav): scroll to top on route change in shared shells (#4676)
waleedlatif1 May 20, 2026
a1b2130
improvement(knowledge): batch trigger dispatch, prune redundant DB ro…
waleedlatif1 May 20, 2026
7e67855
improvement(elevenlabs): wire stability and similarity_boost end-to-e…
waleedlatif1 May 20, 2026
b6679a9
feat(google-slides): complete API surface for branded slide generatio…
waleedlatif1 May 20, 2026
4ca7651
improvement(knowledge): eliminate N+1 on tag definitions in bulk uplo…
waleedlatif1 May 20, 2026
9347da5
improvement(branding): white-background sim wordmark for og image (#4…
waleedlatif1 May 20, 2026
4445e31
fix(table): bump run counter on edit/auto-run so Stop shows for queue…
TheodoreSpeaks May 20, 2026
48cf200
fix(helm): allow host[:port][/path] form in global.imageRegistry sche…
waleedlatif1 May 21, 2026
57b9a2f
fix(table): typewriter flash, Run-row completed-skip, dispatch-scope …
TheodoreSpeaks May 21, 2026
e27afaa
fix(mcp): probe-based OAuth detection in test-connection (#4689)
waleedlatif1 May 21, 2026
11ad891
improvement(cleanup): batchTrigger fan-out, chunked queries, batched …
waleedlatif1 May 21, 2026
d730015
perf(mcp): per-server tool cache + surface OAuth start errors (#4691)
waleedlatif1 May 21, 2026
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
2 changes: 2 additions & 0 deletions apps/sim/app/(landing)/blog/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { getNavBlogPosts } from '@/lib/blog/registry'
import { SITE_URL } from '@/lib/core/utils/urls'
import Footer from '@/app/(landing)/components/footer/footer'
import Navbar from '@/app/(landing)/components/navbar/navbar'
import { ScrollToTop } from '@/app/(landing)/components/scroll-to-top'

export default async function StudioLayout({ children }: { children: React.ReactNode }) {
const blogPosts = await getNavBlogPosts()
Expand Down Expand Up @@ -29,6 +30,7 @@ export default async function StudioLayout({ children }: { children: React.React

return (
<div className='flex min-h-screen flex-col bg-[var(--landing-bg)] font-[430] font-season text-[var(--landing-text)]'>
<ScrollToTop />
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(orgJsonLd) }}
Expand Down
22 changes: 22 additions & 0 deletions apps/sim/app/(landing)/components/scroll-to-top.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
'use client'

import { useEffect } from 'react'
import { usePathname } from 'next/navigation'

/**
* Resets window scroll to the top on App Router pathname changes.
*
* Next.js's default scroll handling only brings the new Page element into view,
* which often resolves to "no scroll" inside shared layouts (see vercel/next.js#64435).
* Skipped when a hash anchor is targeted so the browser's native anchor scroll wins.
*/
export function ScrollToTop() {
const pathname = usePathname()

useEffect(() => {
if (window.location.hash) return
window.scrollTo(0, 0)
}, [pathname])

return null
}
2 changes: 2 additions & 0 deletions apps/sim/app/(landing)/integrations/(shell)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { getNavBlogPosts } from '@/lib/blog/registry'
import { SITE_URL } from '@/lib/core/utils/urls'
import Footer from '@/app/(landing)/components/footer/footer'
import Navbar from '@/app/(landing)/components/navbar/navbar'
import { ScrollToTop } from '@/app/(landing)/components/scroll-to-top'

export default async function IntegrationsLayout({ children }: { children: React.ReactNode }) {
const blogPosts = await getNavBlogPosts()
Expand Down Expand Up @@ -29,6 +30,7 @@ export default async function IntegrationsLayout({ children }: { children: React

return (
<div className='dark flex min-h-screen flex-col bg-[var(--landing-bg)] font-[430] font-season text-[var(--landing-text)]'>
<ScrollToTop />
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(orgJsonLd) }}
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/app/(landing)/models/(shell)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { getNavBlogPosts } from '@/lib/blog/registry'
import { SITE_URL } from '@/lib/core/utils/urls'
import Footer from '@/app/(landing)/components/footer/footer'
import Navbar from '@/app/(landing)/components/navbar/navbar'
import { ScrollToTop } from '@/app/(landing)/components/scroll-to-top'

export default async function ModelsLayout({ children }: { children: React.ReactNode }) {
const blogPosts = await getNavBlogPosts()
Expand All @@ -24,6 +25,7 @@ export default async function ModelsLayout({ children }: { children: React.React

return (
<div className='dark flex min-h-screen flex-col bg-[var(--landing-bg)] font-[430] font-season text-[var(--landing-text)]'>
<ScrollToTop />
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(orgJsonLd) }}
Expand Down
26 changes: 26 additions & 0 deletions apps/sim/app/api/knowledge/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,32 @@ vi.mock('@sim/db', async () => {
},
}
},
innerJoin() {
// document × knowledge_base context JOIN — return the first kb and
// doc row merged (covers processDocumentAsync's prefetch).
return {
leftJoin: () => ({
where: () => ({
limit: (n: number) =>
Promise.resolve(
kbRows.length > 0 && docRows.length > 0
? [
{ ...kbRows[0], ...docRows[0], billedAccountUserId: 'billing-user-1' },
].slice(0, n)
: []
),
}),
}),
where: () => ({
limit: (n: number) =>
Promise.resolve(
kbRows.length > 0 && docRows.length > 0
? [{ ...kbRows[0], ...docRows[0] }].slice(0, n)
: []
),
}),
}
},
}
},
}
Expand Down
181 changes: 181 additions & 0 deletions apps/sim/app/api/mcp/oauth/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { auth as mcpAuth } from '@modelcontextprotocol/sdk/client/auth.js'
import { db } from '@sim/db'
import { mcpServers } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { mcpOauthCallbackContract } from '@/lib/api/contracts/mcp'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
assertSafeOauthServerUrl,
clearState,
clearVerifier,
loadOauthRowByState,
loadPreregisteredClient,
type McpOauthCallbackReason,
SimMcpOauthProvider,
} from '@/lib/mcp/oauth'
import { mcpService } from '@/lib/mcp/service'

const logger = createLogger('McpOauthCallbackAPI')

export const dynamic = 'force-dynamic'

function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}

function jsonLiteral(value: string | undefined): string {
if (value === undefined) return 'undefined'
return JSON.stringify(value).replace(/</g, '\\u003c').replace(/>/g, '\\u003e')
}

function htmlClose(
message: string,
ok: boolean,
reason: McpOauthCallbackReason,
serverId?: string
): NextResponse {
const safeMessage = escapeHtml(message)
const title = ok ? 'Connected' : 'Connection failed'
const body = `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title></head><body style="font-family: system-ui; padding: 24px"><p>${safeMessage}</p><script>
try { window.opener && window.opener.postMessage({ type: 'mcp-oauth', ok: ${ok ? 'true' : 'false'}, serverId: ${jsonLiteral(serverId)}, reason: ${jsonLiteral(reason)} }, window.location.origin) } catch (e) {}
setTimeout(function () { window.close() }, 800)
</script></body></html>`
return new NextResponse(body, {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
})
}

export const GET = withRouteHandler(async (request: NextRequest) => {
const parsed = await parseRequest(mcpOauthCallbackContract, request, {})
if (!parsed.success) {
return htmlClose('Malformed authorization callback.', false, 'missing_params')
}
const { state, code, error: errorParam } = parsed.data.query

const initialRow = state ? await loadOauthRowByState(state).catch(() => null) : null
const stateRowServerId = initialRow?.mcpServerId

if (errorParam) {
logger.warn(`MCP OAuth callback received error: ${errorParam}`)
if (initialRow) await clearState(initialRow.id).catch(() => {})
return htmlClose(
`Authorization failed: ${errorParam}`,
false,
'provider_error',
stateRowServerId
)
}
if (!state || !code) {
return htmlClose(
'Missing state or code in callback URL.',
false,
'missing_params',
stateRowServerId
)
}

let serverId: string | undefined
try {
const session = await getSession()
if (!session?.user?.id) {
return htmlClose(
'You must be signed in to complete authorization.',
false,
'unauthenticated',
stateRowServerId
)
}

const row = initialRow
if (!row) {
return htmlClose('Invalid or expired authorization state.', false, 'invalid_state')
}
serverId = row.mcpServerId

if (session.user.id !== row.userId) {
return htmlClose(
'You must be signed in as the same user that initiated the flow.',
false,
'user_mismatch',
serverId
)
}

const [server] = await db
.select({ id: mcpServers.id, url: mcpServers.url, workspaceId: mcpServers.workspaceId })
.from(mcpServers)
.where(and(eq(mcpServers.id, row.mcpServerId), isNull(mcpServers.deletedAt)))
.limit(1)
if (!server || !server.url) {
return htmlClose('Server no longer exists.', false, 'server_gone', serverId)
}
if (server.workspaceId !== row.workspaceId) {
return htmlClose(
'Workspace mismatch on authorization callback.',
false,
'invalid_state',
serverId
)
}
try {
assertSafeOauthServerUrl(server.url)
} catch {
return htmlClose(
'MCP OAuth requires https (or http://localhost for development).',
false,
'insecure_url',
serverId
)
}

// Burn state before token exchange so a replayed callback cannot reuse it.
await clearState(row.id)

const preregistered = await loadPreregisteredClient(server.id)
const provider = new SimMcpOauthProvider({ row, preregistered })
let result: Awaited<ReturnType<typeof mcpAuth>>
try {
result = await mcpAuth(provider, {
serverUrl: server.url,
authorizationCode: code,
})
} catch (e) {
logger.error('Token exchange failed during MCP OAuth callback', e)
return htmlClose(
'Token exchange failed. Please try again.',
false,
'token_exchange_failed',
server.id
)
} finally {
await clearVerifier(row.id)
}

if (result !== 'AUTHORIZED') {
return htmlClose('Authorization did not complete.', false, 'token_exchange_failed', server.id)
}

try {
await mcpService.clearCache(server.workspaceId)
await mcpService.discoverServerTools(session.user.id, server.id, server.workspaceId)
} catch (e) {
logger.warn('Post-auth tools refresh failed', toError(e).message)
}

return htmlClose('Connected. You can close this window.', true, 'authorized', server.id)
} catch (error) {
logger.error('MCP OAuth callback failed', error)
return htmlClose('Authorization failed. Please try again.', false, 'unknown', serverId)
}
})
Loading
Loading