Skip to content

Commit 69e58ad

Browse files
authored
feat(search-mcp): connect clients with Sim OAuth (#7613)
* feat(search-mcp): connect clients with Sim OAuth * fix(search-mcp): preserve existing API OAuth grants
1 parent e09a8b8 commit 69e58ad

52 files changed

Lines changed: 27405 additions & 507 deletions

Some content is hidden

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

apps/docs/content/docs/search/index.mdx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,11 @@ Organization admins manage source configuration and sync status through **Manage
8484

8585
**Search** in the organization sidebar finds documents directly. The assistant on **Home** can search and read the same sources to answer questions with citations. Conversations are private to their author, including when another organization member is an admin.
8686

87-
To search from an MCP-compatible app, open **Settings → Search MCP**. Generate a personal Sim API key there, or use an existing personal key with the displayed connection details. MCP applies your current organization membership and document access.
87+
To search from Claude, Codex, Claude Code, or Cursor, open **Settings → Search MCP**, choose your app, and copy its URL, command, or configuration. Connect in that app, sign in to Sim, and approve read-only Search access. No API key is needed. In Claude Team or Enterprise, an owner adds the custom connector before members connect.
88+
89+
For another client, choose **Other** and use the server URL with Streamable HTTP and OAuth. The client must support remote MCP authentication. When adding configuration to an existing file, keep your other MCP servers.
90+
91+
Each person signs in with their own Sim account. MCP applies their current organization membership and document access; connecting an app does not add sources or grant new document permissions. To disconnect an app, open **Settings → General → Authorized apps** and revoke it.
8892

8993
## Existing workspace Search
9094

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { type NextRequest, NextResponse } from 'next/server'
2+
import { knowledgeMcpParamsSchema } from '@/lib/api/contracts/knowledge/mcp'
3+
import { isAuthDisabled } from '@/lib/core/config/env-flags'
4+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
5+
import { searchMcpResourceMetadata } from '@/lib/knowledge/mcp/oauth-metadata'
6+
import { getSearchMcpUrl } from '@/lib/knowledge/mcp/urls'
7+
8+
export const GET = withRouteHandler(
9+
async (_request: NextRequest, context: { params: Promise<{ workspaceId: string }> }) => {
10+
if (isAuthDisabled) return new NextResponse(null, { status: 404 })
11+
const parsed = knowledgeMcpParamsSchema.safeParse(await context.params)
12+
if (!parsed.success) return new NextResponse(null, { status: 404 })
13+
return searchMcpResourceMetadata(getSearchMcpUrl('workspace', parsed.data.workspaceId))
14+
}
15+
)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { type NextRequest, NextResponse } from 'next/server'
2+
import { organizationKnowledgeMcpContract } from '@/lib/api/contracts/knowledge/mcp'
3+
import { isAuthDisabled } from '@/lib/core/config/env-flags'
4+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
5+
import { searchMcpResourceMetadata } from '@/lib/knowledge/mcp/oauth-metadata'
6+
import { getSearchMcpUrl } from '@/lib/knowledge/mcp/urls'
7+
8+
export const GET = withRouteHandler(
9+
async (_request: NextRequest, context: { params: Promise<{ organizationId: string }> }) => {
10+
if (isAuthDisabled) return new NextResponse(null, { status: 404 })
11+
const parsed = organizationKnowledgeMcpContract.params.safeParse(await context.params)
12+
if (!parsed.success) return new NextResponse(null, { status: 404 })
13+
return searchMcpResourceMetadata(getSearchMcpUrl('organization', parsed.data.organizationId))
14+
}
15+
)

apps/sim/app/api/auth/oauth2/authorize/route.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,54 @@ describe('OAuth2 authorize route', () => {
133133
mocks.createQuickBooksState.mockReturnValue('signed-state')
134134
})
135135

136+
it('forwards a resource-bound Search authorization to the existing provider', async () => {
137+
const req = request({
138+
client_id: 'mcp-client',
139+
response_type: 'code',
140+
redirect_uri: 'https://client.example/callback',
141+
scope: 'search:read offline_access',
142+
resource: `${BASE_URL}/api/mcp/search/organizations/org-1`,
143+
})
144+
expect((await GET(req)).status).toBe(302)
145+
expect(mocks.betterAuthGET).toHaveBeenCalledWith(req)
146+
expect(mocks.createConnection).not.toHaveBeenCalled()
147+
})
148+
149+
it.each([
150+
{ scope: 'search:read' },
151+
{ scope: 'api:read', resource: `${BASE_URL}/api/mcp/search/organizations/org-1` },
152+
{ scope: 'search:read unknown', resource: `${BASE_URL}/api/mcp/search/organizations/org-1` },
153+
{ scope: 'search:read', resource: 'https://evil.example/api/mcp/search/org-1' },
154+
])('refuses ambiguous or overly broad Search grants: %o', async (params) => {
155+
const response = await GET(
156+
request({
157+
client_id: 'mcp-client',
158+
response_type: 'code',
159+
redirect_uri: 'https://client.example/callback',
160+
...params,
161+
})
162+
)
163+
expect(response.status).toBe(400)
164+
expect(mocks.betterAuthGET).not.toHaveBeenCalled()
165+
})
166+
167+
it('narrows issuer-wide scope requests before the provider signs Search consent', async () => {
168+
const req = request({
169+
client_id: 'mcp-client',
170+
response_type: 'code',
171+
redirect_uri: 'https://client.example/callback',
172+
scope: 'offline_access api:read api:write search:read',
173+
resource: `${BASE_URL}/api/mcp/search/organizations/org-1`,
174+
})
175+
expect((await GET(req)).status).toBe(302)
176+
const forwarded: Request = mocks.betterAuthGET.mock.calls[0][0]
177+
expect(new URL(forwarded.url).searchParams.get('scope')).toBe('search:read offline_access')
178+
expect(new URL(forwarded.url).searchParams.get('resource')).toBe(
179+
req.nextUrl.searchParams.get('resource')
180+
)
181+
expect(req.nextUrl.searchParams.get('scope')).toContain('api:write')
182+
})
183+
136184
it('forwards a provider request without entering the connector flow', async () => {
137185
const providerRequest = request({
138186
client_id: 'client-1',

apps/sim/app/api/auth/oauth2/authorize/route.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { parseRequest } from '@/lib/api/server'
66
import { auth, getSession } from '@/lib/auth/auth'
77
import { oauthAuthorizationErrorResponse } from '@/lib/auth/oauth-authorization-error'
88
import { validateOAuthPkceAuthorizationRequest } from '@/lib/auth/oauth-protocol-request'
9+
import { narrowSearchOAuthScopes, OAUTH_SEARCH_READ_SCOPE } from '@/lib/auth/oauth-provider'
10+
import { InvalidOAuthResourceError, parseOAuthSearchResource } from '@/lib/auth/oauth-resource'
911
import { ForbiddenOperationError } from '@/lib/core/application/forbidden'
1012
import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server'
1113
import { isAuthDisabled } from '@/lib/core/config/env-flags'
@@ -103,11 +105,24 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
103105
'The redirect_uri parameter is required.'
104106
)
105107
}
106-
if (params.has('resource')) {
108+
const scopes = (params.get('scope') ?? '').split(' ').filter(Boolean)
109+
let resource: string | null
110+
try {
111+
resource = parseOAuthSearchResource(params.get('resource'))
112+
} catch (error) {
113+
if (!(error instanceof InvalidOAuthResourceError)) throw error
107114
return oauthAuthorizationErrorResponse(
108115
request,
109116
'invalid_request',
110-
'The resource parameter is not supported.'
117+
'The resource must be a Sim Search server URL.'
118+
)
119+
}
120+
const searchScope = resource ? narrowSearchOAuthScopes(params.get('scope') ?? '') : null
121+
if ((resource && !searchScope) || (!resource && scopes.includes(OAUTH_SEARCH_READ_SCOPE))) {
122+
return oauthAuthorizationErrorResponse(
123+
request,
124+
'invalid_request',
125+
'Sim Search requires its server URL and the search:read scope.'
111126
)
112127
}
113128
if (params.has('request_uri')) {
@@ -136,7 +151,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
136151
if (pkceError) {
137152
return oauthAuthorizationErrorResponse(request, 'invalid_request', pkceError)
138153
}
139-
const response = await betterAuthGET(request)
154+
let providerRequest: Request = request
155+
if (searchScope && params.get('scope') !== searchScope) {
156+
const url = new URL(request.url)
157+
url.searchParams.set('scope', searchScope)
158+
providerRequest = new Request(url, { headers: request.headers })
159+
}
160+
const response = await betterAuthGET(providerRequest)
140161
if (response.status === 403) {
141162
const body: unknown = await response
142163
.clone()
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/** @vitest-environment node */
2+
import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing'
3+
import { NextRequest } from 'next/server'
4+
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const mocks = vi.hoisted(() => ({ register: vi.fn(), rateLimit: vi.fn() }))
7+
vi.mock('better-auth/next-js', () => ({ toNextJsHandler: () => ({ POST: mocks.register }) }))
8+
vi.mock('@/lib/core/rate-limiter', () => ({ enforceIpRateLimit: mocks.rateLimit }))
9+
vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.test' }))
10+
11+
import { POST } from '@/app/api/auth/oauth2/register/route'
12+
13+
const client = {
14+
client_name: 'Test MCP client',
15+
redirect_uris: ['http://127.0.0.1:43123/callback'],
16+
}
17+
function request(body: object = client, headers: Record<string, string> = {}) {
18+
return new NextRequest('https://sim.test/api/auth/oauth2/register', {
19+
method: 'POST',
20+
headers: { 'Content-Type': 'application/json', ...headers },
21+
body: JSON.stringify(body),
22+
})
23+
}
24+
25+
afterAll(resetEnvFlagsMock)
26+
beforeEach(() => {
27+
vi.clearAllMocks()
28+
setEnvFlags({ isAuthDisabled: false })
29+
mocks.rateLimit.mockResolvedValue(null)
30+
mocks.register.mockImplementation(async (req: Request) =>
31+
Response.json(
32+
{
33+
...(await req.clone().json()),
34+
client_id: 'client-1',
35+
client_id_issued_at: 1788000000,
36+
},
37+
{ status: 201 }
38+
)
39+
)
40+
})
41+
42+
describe('MCP public client registration', () => {
43+
it('registers a bounded public Search client without ambient credentials or privileged metadata', async () => {
44+
const response = await POST(
45+
request(
46+
{ ...client, skip_consent: true, require_pkce: false, metadata: { elevated: true } },
47+
{
48+
Cookie: 'session=private',
49+
Authorization: 'Bearer private',
50+
'x-forwarded-for': '203.0.113.10',
51+
}
52+
)
53+
)
54+
expect(response.status).toBe(201)
55+
expect(await response.json()).toMatchObject({
56+
...client,
57+
client_id: 'client-1',
58+
token_endpoint_auth_method: 'none',
59+
scope: 'search:read offline_access',
60+
grant_types: ['authorization_code', 'refresh_token'],
61+
response_types: ['code'],
62+
})
63+
const forwarded: Request = mocks.register.mock.calls[0][0]
64+
expect(forwarded.headers.has('cookie')).toBe(false)
65+
expect(forwarded.headers.has('authorization')).toBe(false)
66+
expect(forwarded.headers.get('x-forwarded-for')).toBe('203.0.113.10')
67+
expect(response.headers.get('cache-control')).toBe('no-store')
68+
})
69+
70+
it('returns only registered Search scopes when clients request all issuer scopes', async () => {
71+
const response = await POST(
72+
request({ ...client, scope: 'offline_access api:read api:write search:read' })
73+
)
74+
expect(response.status).toBe(201)
75+
expect(await response.json()).toMatchObject({ scope: 'search:read offline_access' })
76+
const forwarded: Request = mocks.register.mock.calls[0][0]
77+
expect(await forwarded.json()).toMatchObject({
78+
scope: 'search:read offline_access',
79+
require_pkce: true,
80+
})
81+
})
82+
83+
it('registers Cursor browser and native callbacks together with PKCE required', async () => {
84+
const redirectUris = [
85+
'cursor://anysphere.cursor-mcp/oauth/callback',
86+
'https://www.cursor.com/agents/mcp/oauth/callback',
87+
'http://localhost:8787/callback',
88+
]
89+
const response = await POST(request({ client_name: 'Cursor', redirect_uris: redirectUris }))
90+
expect(response.status).toBe(201)
91+
expect(await response.json()).toMatchObject({ redirect_uris: redirectUris })
92+
const forwarded: Request = mocks.register.mock.calls[0][0]
93+
expect(await forwarded.json()).toMatchObject({
94+
redirect_uris: redirectUris,
95+
require_pkce: true,
96+
token_endpoint_auth_method: 'none',
97+
})
98+
})
99+
100+
it.each([
101+
{ ...client, scope: 'api:write' },
102+
{ ...client, token_endpoint_auth_method: 'client_secret_post' },
103+
{ ...client, grant_types: ['client_credentials'] },
104+
{ ...client, redirect_uris: ['http://evil.example/callback'] },
105+
{ ...client, redirect_uris: ['https://*.example/callback'] },
106+
{ ...client, redirect_uris: ['https://example.com/callback#fragment'] },
107+
{ ...client, redirect_uris: ['https://user:password@example.com/callback'] },
108+
{ ...client, redirect_uris: ['cursor://anysphere.cursor-mcp/other'] },
109+
{ ...client, redirect_uris: ['cursor://anysphere.cursor-mcp/oauth/callback?target=other'] },
110+
{ ...client, redirect_uris: ['cursor://other/oauth/callback'] },
111+
{ ...client, redirect_uris: ['javascript:alert(1)'] },
112+
{ ...client, redirect_uris: ['file:///oauth/callback'] },
113+
{ ...client, redirect_uris: ['data:text/html,callback'] },
114+
{ ...client, redirect_uris: ['unknown-app://oauth/callback'] },
115+
{ ...client, redirect_uris: Array(11).fill('https://example.com/callback') },
116+
{ ...client, client_name: 'a'.repeat(129) },
117+
])('rejects unsupported or unsafe client metadata: %o', async (body) => {
118+
expect((await POST(request(body))).status).toBe(400)
119+
expect(mocks.register).not.toHaveBeenCalled()
120+
})
121+
122+
it('admits before reading metadata or creating a client', async () => {
123+
mocks.rateLimit.mockResolvedValue(Response.json({ error: 'Rate limited' }, { status: 429 }))
124+
expect((await POST(request())).status).toBe(429)
125+
expect(mocks.register).not.toHaveBeenCalled()
126+
})
127+
128+
it('does not enable OAuth in auth-disabled deployments', async () => {
129+
setEnvFlags({ isAuthDisabled: true })
130+
expect((await POST(request())).status).toBe(404)
131+
expect(mocks.register).not.toHaveBeenCalled()
132+
})
133+
})
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { toNextJsHandler } from 'better-auth/next-js'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { registerSearchOAuthClientContract } from '@/lib/api/contracts/oauth-provider'
4+
import { parseRequest } from '@/lib/api/server'
5+
import { auth } from '@/lib/auth'
6+
import { isAuthDisabled } from '@/lib/core/config/env-flags'
7+
import { enforceIpRateLimit } from '@/lib/core/rate-limiter'
8+
import { getBaseUrl } from '@/lib/core/utils/urls'
9+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
10+
11+
const { POST: register } = toNextJsHandler(auth.handler)
12+
const HEADERS = { 'Cache-Control': 'no-store', Pragma: 'no-cache' } as const
13+
14+
function invalidMetadata(description: string, status = 400) {
15+
return NextResponse.json(
16+
{ error: 'invalid_client_metadata', error_description: description },
17+
{ status, headers: HEADERS }
18+
)
19+
}
20+
21+
/** RFC 7591 public registration delegates persistence to Sim's OAuth provider. */
22+
export const POST = withRouteHandler(async (request: NextRequest) => {
23+
if (isAuthDisabled) {
24+
return NextResponse.json(
25+
{ error: 'OAuth provider is not enabled' },
26+
{ status: 404, headers: HEADERS }
27+
)
28+
}
29+
const limited = await enforceIpRateLimit('oauth-provider-register', request, {
30+
maxTokens: 20,
31+
refillRate: 20,
32+
refillIntervalMs: 60_000,
33+
})
34+
if (limited) return limited
35+
if (request.headers.get('content-type')?.split(';', 1)[0].trim() !== 'application/json') {
36+
return invalidMetadata('Client metadata must be sent as application/json.', 415)
37+
}
38+
const parsed = await parseRequest(
39+
registerSearchOAuthClientContract,
40+
request,
41+
{},
42+
{
43+
maxBodyBytes: 32 * 1024,
44+
validationErrorResponse: (error) =>
45+
invalidMetadata(error.issues[0]?.message ?? 'Invalid client metadata.'),
46+
invalidJsonResponse: () => invalidMetadata('Client metadata must be valid JSON.'),
47+
payloadTooLargeResponse: () => invalidMetadata('Client metadata is too large.', 413),
48+
}
49+
)
50+
if (!parsed.success) return parsed.response
51+
52+
const headers = new Headers({ 'Content-Type': 'application/json' })
53+
for (const name of ['x-forwarded-for', 'x-real-ip']) {
54+
const value = request.headers.get(name)
55+
if (value) headers.set(name, value)
56+
}
57+
/** Public clients never inherit an ambient browser session or client-management privileges. */
58+
const response = await register(
59+
new Request(`${getBaseUrl()}/api/auth/oauth2/register`, {
60+
method: 'POST',
61+
headers,
62+
body: JSON.stringify({ ...parsed.data.body, require_pkce: true }),
63+
})
64+
)
65+
if (!response.ok) return response
66+
const body = registerSearchOAuthClientContract.response.schema.parse(await response.json())
67+
return NextResponse.json(body, { status: 201, headers: HEADERS })
68+
})

0 commit comments

Comments
 (0)