Skip to content

Commit 97a609a

Browse files
authored
v0.6.86: CORS updates, OAuth MCP, navigation pinning dynamic pages, google slides endpoints, DB access pattern improvements
2 parents e6b3cce + d730015 commit 97a609a

152 files changed

Lines changed: 49218 additions & 2121 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/app/(landing)/blog/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { getNavBlogPosts } from '@/lib/blog/registry'
22
import { SITE_URL } from '@/lib/core/utils/urls'
33
import Footer from '@/app/(landing)/components/footer/footer'
44
import Navbar from '@/app/(landing)/components/navbar/navbar'
5+
import { ScrollToTop } from '@/app/(landing)/components/scroll-to-top'
56

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

3031
return (
3132
<div className='flex min-h-screen flex-col bg-[var(--landing-bg)] font-[430] font-season text-[var(--landing-text)]'>
33+
<ScrollToTop />
3234
<script
3335
type='application/ld+json'
3436
dangerouslySetInnerHTML={{ __html: JSON.stringify(orgJsonLd) }}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
'use client'
2+
3+
import { useEffect } from 'react'
4+
import { usePathname } from 'next/navigation'
5+
6+
/**
7+
* Resets window scroll to the top on App Router pathname changes.
8+
*
9+
* Next.js's default scroll handling only brings the new Page element into view,
10+
* which often resolves to "no scroll" inside shared layouts (see vercel/next.js#64435).
11+
* Skipped when a hash anchor is targeted so the browser's native anchor scroll wins.
12+
*/
13+
export function ScrollToTop() {
14+
const pathname = usePathname()
15+
16+
useEffect(() => {
17+
if (window.location.hash) return
18+
window.scrollTo(0, 0)
19+
}, [pathname])
20+
21+
return null
22+
}

apps/sim/app/(landing)/integrations/(shell)/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { getNavBlogPosts } from '@/lib/blog/registry'
22
import { SITE_URL } from '@/lib/core/utils/urls'
33
import Footer from '@/app/(landing)/components/footer/footer'
44
import Navbar from '@/app/(landing)/components/navbar/navbar'
5+
import { ScrollToTop } from '@/app/(landing)/components/scroll-to-top'
56

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

3031
return (
3132
<div className='dark flex min-h-screen flex-col bg-[var(--landing-bg)] font-[430] font-season text-[var(--landing-text)]'>
33+
<ScrollToTop />
3234
<script
3335
type='application/ld+json'
3436
dangerouslySetInnerHTML={{ __html: JSON.stringify(orgJsonLd) }}

apps/sim/app/(landing)/models/(shell)/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { getNavBlogPosts } from '@/lib/blog/registry'
22
import { SITE_URL } from '@/lib/core/utils/urls'
33
import Footer from '@/app/(landing)/components/footer/footer'
44
import Navbar from '@/app/(landing)/components/navbar/navbar'
5+
import { ScrollToTop } from '@/app/(landing)/components/scroll-to-top'
56

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

2526
return (
2627
<div className='dark flex min-h-screen flex-col bg-[var(--landing-bg)] font-[430] font-season text-[var(--landing-text)]'>
28+
<ScrollToTop />
2729
<script
2830
type='application/ld+json'
2931
dangerouslySetInnerHTML={{ __html: JSON.stringify(orgJsonLd) }}

apps/sim/app/api/knowledge/utils.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,32 @@ vi.mock('@sim/db', async () => {
116116
},
117117
}
118118
},
119+
innerJoin() {
120+
// document × knowledge_base context JOIN — return the first kb and
121+
// doc row merged (covers processDocumentAsync's prefetch).
122+
return {
123+
leftJoin: () => ({
124+
where: () => ({
125+
limit: (n: number) =>
126+
Promise.resolve(
127+
kbRows.length > 0 && docRows.length > 0
128+
? [
129+
{ ...kbRows[0], ...docRows[0], billedAccountUserId: 'billing-user-1' },
130+
].slice(0, n)
131+
: []
132+
),
133+
}),
134+
}),
135+
where: () => ({
136+
limit: (n: number) =>
137+
Promise.resolve(
138+
kbRows.length > 0 && docRows.length > 0
139+
? [{ ...kbRows[0], ...docRows[0] }].slice(0, n)
140+
: []
141+
),
142+
}),
143+
}
144+
},
119145
}
120146
},
121147
}
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { auth as mcpAuth } from '@modelcontextprotocol/sdk/client/auth.js'
2+
import { db } from '@sim/db'
3+
import { mcpServers } from '@sim/db/schema'
4+
import { createLogger } from '@sim/logger'
5+
import { toError } from '@sim/utils/errors'
6+
import { and, eq, isNull } from 'drizzle-orm'
7+
import type { NextRequest } from 'next/server'
8+
import { NextResponse } from 'next/server'
9+
import { mcpOauthCallbackContract } from '@/lib/api/contracts/mcp'
10+
import { parseRequest } from '@/lib/api/server'
11+
import { getSession } from '@/lib/auth'
12+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
13+
import {
14+
assertSafeOauthServerUrl,
15+
clearState,
16+
clearVerifier,
17+
loadOauthRowByState,
18+
loadPreregisteredClient,
19+
type McpOauthCallbackReason,
20+
SimMcpOauthProvider,
21+
} from '@/lib/mcp/oauth'
22+
import { mcpService } from '@/lib/mcp/service'
23+
24+
const logger = createLogger('McpOauthCallbackAPI')
25+
26+
export const dynamic = 'force-dynamic'
27+
28+
function escapeHtml(value: string): string {
29+
return value
30+
.replace(/&/g, '&amp;')
31+
.replace(/</g, '&lt;')
32+
.replace(/>/g, '&gt;')
33+
.replace(/"/g, '&quot;')
34+
.replace(/'/g, '&#39;')
35+
}
36+
37+
function jsonLiteral(value: string | undefined): string {
38+
if (value === undefined) return 'undefined'
39+
return JSON.stringify(value).replace(/</g, '\\u003c').replace(/>/g, '\\u003e')
40+
}
41+
42+
function htmlClose(
43+
message: string,
44+
ok: boolean,
45+
reason: McpOauthCallbackReason,
46+
serverId?: string
47+
): NextResponse {
48+
const safeMessage = escapeHtml(message)
49+
const title = ok ? 'Connected' : 'Connection failed'
50+
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>
51+
try { window.opener && window.opener.postMessage({ type: 'mcp-oauth', ok: ${ok ? 'true' : 'false'}, serverId: ${jsonLiteral(serverId)}, reason: ${jsonLiteral(reason)} }, window.location.origin) } catch (e) {}
52+
setTimeout(function () { window.close() }, 800)
53+
</script></body></html>`
54+
return new NextResponse(body, {
55+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
56+
})
57+
}
58+
59+
export const GET = withRouteHandler(async (request: NextRequest) => {
60+
const parsed = await parseRequest(mcpOauthCallbackContract, request, {})
61+
if (!parsed.success) {
62+
return htmlClose('Malformed authorization callback.', false, 'missing_params')
63+
}
64+
const { state, code, error: errorParam } = parsed.data.query
65+
66+
const initialRow = state ? await loadOauthRowByState(state).catch(() => null) : null
67+
const stateRowServerId = initialRow?.mcpServerId
68+
69+
if (errorParam) {
70+
logger.warn(`MCP OAuth callback received error: ${errorParam}`)
71+
if (initialRow) await clearState(initialRow.id).catch(() => {})
72+
return htmlClose(
73+
`Authorization failed: ${errorParam}`,
74+
false,
75+
'provider_error',
76+
stateRowServerId
77+
)
78+
}
79+
if (!state || !code) {
80+
return htmlClose(
81+
'Missing state or code in callback URL.',
82+
false,
83+
'missing_params',
84+
stateRowServerId
85+
)
86+
}
87+
88+
let serverId: string | undefined
89+
try {
90+
const session = await getSession()
91+
if (!session?.user?.id) {
92+
return htmlClose(
93+
'You must be signed in to complete authorization.',
94+
false,
95+
'unauthenticated',
96+
stateRowServerId
97+
)
98+
}
99+
100+
const row = initialRow
101+
if (!row) {
102+
return htmlClose('Invalid or expired authorization state.', false, 'invalid_state')
103+
}
104+
serverId = row.mcpServerId
105+
106+
if (session.user.id !== row.userId) {
107+
return htmlClose(
108+
'You must be signed in as the same user that initiated the flow.',
109+
false,
110+
'user_mismatch',
111+
serverId
112+
)
113+
}
114+
115+
const [server] = await db
116+
.select({ id: mcpServers.id, url: mcpServers.url, workspaceId: mcpServers.workspaceId })
117+
.from(mcpServers)
118+
.where(and(eq(mcpServers.id, row.mcpServerId), isNull(mcpServers.deletedAt)))
119+
.limit(1)
120+
if (!server || !server.url) {
121+
return htmlClose('Server no longer exists.', false, 'server_gone', serverId)
122+
}
123+
if (server.workspaceId !== row.workspaceId) {
124+
return htmlClose(
125+
'Workspace mismatch on authorization callback.',
126+
false,
127+
'invalid_state',
128+
serverId
129+
)
130+
}
131+
try {
132+
assertSafeOauthServerUrl(server.url)
133+
} catch {
134+
return htmlClose(
135+
'MCP OAuth requires https (or http://localhost for development).',
136+
false,
137+
'insecure_url',
138+
serverId
139+
)
140+
}
141+
142+
// Burn state before token exchange so a replayed callback cannot reuse it.
143+
await clearState(row.id)
144+
145+
const preregistered = await loadPreregisteredClient(server.id)
146+
const provider = new SimMcpOauthProvider({ row, preregistered })
147+
let result: Awaited<ReturnType<typeof mcpAuth>>
148+
try {
149+
result = await mcpAuth(provider, {
150+
serverUrl: server.url,
151+
authorizationCode: code,
152+
})
153+
} catch (e) {
154+
logger.error('Token exchange failed during MCP OAuth callback', e)
155+
return htmlClose(
156+
'Token exchange failed. Please try again.',
157+
false,
158+
'token_exchange_failed',
159+
server.id
160+
)
161+
} finally {
162+
await clearVerifier(row.id)
163+
}
164+
165+
if (result !== 'AUTHORIZED') {
166+
return htmlClose('Authorization did not complete.', false, 'token_exchange_failed', server.id)
167+
}
168+
169+
try {
170+
await mcpService.clearCache(server.workspaceId)
171+
await mcpService.discoverServerTools(session.user.id, server.id, server.workspaceId)
172+
} catch (e) {
173+
logger.warn('Post-auth tools refresh failed', toError(e).message)
174+
}
175+
176+
return htmlClose('Connected. You can close this window.', true, 'authorized', server.id)
177+
} catch (error) {
178+
logger.error('MCP OAuth callback failed', error)
179+
return htmlClose('Authorization failed. Please try again.', false, 'unknown', serverId)
180+
}
181+
})

0 commit comments

Comments
 (0)