Skip to content
Merged
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: 5 additions & 1 deletion apps/docs/content/docs/search/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,11 @@ Organization admins manage source configuration and sync status through **Manage

**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.

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.
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.

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.

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.

## Existing workspace Search

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { type NextRequest, NextResponse } from 'next/server'
import { knowledgeMcpParamsSchema } from '@/lib/api/contracts/knowledge/mcp'
import { isAuthDisabled } from '@/lib/core/config/env-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { searchMcpResourceMetadata } from '@/lib/knowledge/mcp/oauth-metadata'
import { getSearchMcpUrl } from '@/lib/knowledge/mcp/urls'

export const GET = withRouteHandler(
async (_request: NextRequest, context: { params: Promise<{ workspaceId: string }> }) => {
if (isAuthDisabled) return new NextResponse(null, { status: 404 })
const parsed = knowledgeMcpParamsSchema.safeParse(await context.params)
if (!parsed.success) return new NextResponse(null, { status: 404 })
return searchMcpResourceMetadata(getSearchMcpUrl('workspace', parsed.data.workspaceId))
}
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { type NextRequest, NextResponse } from 'next/server'
import { organizationKnowledgeMcpContract } from '@/lib/api/contracts/knowledge/mcp'
import { isAuthDisabled } from '@/lib/core/config/env-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { searchMcpResourceMetadata } from '@/lib/knowledge/mcp/oauth-metadata'
import { getSearchMcpUrl } from '@/lib/knowledge/mcp/urls'

export const GET = withRouteHandler(
async (_request: NextRequest, context: { params: Promise<{ organizationId: string }> }) => {
if (isAuthDisabled) return new NextResponse(null, { status: 404 })
const parsed = organizationKnowledgeMcpContract.params.safeParse(await context.params)
if (!parsed.success) return new NextResponse(null, { status: 404 })
return searchMcpResourceMetadata(getSearchMcpUrl('organization', parsed.data.organizationId))
}
)
48 changes: 48 additions & 0 deletions apps/sim/app/api/auth/oauth2/authorize/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,54 @@ describe('OAuth2 authorize route', () => {
mocks.createQuickBooksState.mockReturnValue('signed-state')
})

it('forwards a resource-bound Search authorization to the existing provider', async () => {
const req = request({
client_id: 'mcp-client',
response_type: 'code',
redirect_uri: 'https://client.example/callback',
scope: 'search:read offline_access',
resource: `${BASE_URL}/api/mcp/search/organizations/org-1`,
})
expect((await GET(req)).status).toBe(302)
expect(mocks.betterAuthGET).toHaveBeenCalledWith(req)
expect(mocks.createConnection).not.toHaveBeenCalled()
})

it.each([
{ scope: 'search:read' },
{ scope: 'api:read', resource: `${BASE_URL}/api/mcp/search/organizations/org-1` },
{ scope: 'search:read unknown', resource: `${BASE_URL}/api/mcp/search/organizations/org-1` },
{ scope: 'search:read', resource: 'https://evil.example/api/mcp/search/org-1' },
])('refuses ambiguous or overly broad Search grants: %o', async (params) => {
const response = await GET(
request({
client_id: 'mcp-client',
response_type: 'code',
redirect_uri: 'https://client.example/callback',
...params,
})
)
expect(response.status).toBe(400)
expect(mocks.betterAuthGET).not.toHaveBeenCalled()
})

it('narrows issuer-wide scope requests before the provider signs Search consent', async () => {
const req = request({
client_id: 'mcp-client',
response_type: 'code',
redirect_uri: 'https://client.example/callback',
scope: 'offline_access api:read api:write search:read',
resource: `${BASE_URL}/api/mcp/search/organizations/org-1`,
})
expect((await GET(req)).status).toBe(302)
const forwarded: Request = mocks.betterAuthGET.mock.calls[0][0]
expect(new URL(forwarded.url).searchParams.get('scope')).toBe('search:read offline_access')
expect(new URL(forwarded.url).searchParams.get('resource')).toBe(
req.nextUrl.searchParams.get('resource')
)
expect(req.nextUrl.searchParams.get('scope')).toContain('api:write')
})

it('forwards a provider request without entering the connector flow', async () => {
const providerRequest = request({
client_id: 'client-1',
Expand Down
27 changes: 24 additions & 3 deletions apps/sim/app/api/auth/oauth2/authorize/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { parseRequest } from '@/lib/api/server'
import { auth, getSession } from '@/lib/auth/auth'
import { oauthAuthorizationErrorResponse } from '@/lib/auth/oauth-authorization-error'
import { validateOAuthPkceAuthorizationRequest } from '@/lib/auth/oauth-protocol-request'
import { narrowSearchOAuthScopes, OAUTH_SEARCH_READ_SCOPE } from '@/lib/auth/oauth-provider'
import { InvalidOAuthResourceError, parseOAuthSearchResource } from '@/lib/auth/oauth-resource'
import { ForbiddenOperationError } from '@/lib/core/application/forbidden'
import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server'
import { isAuthDisabled } from '@/lib/core/config/env-flags'
Expand Down Expand Up @@ -103,11 +105,24 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
'The redirect_uri parameter is required.'
)
}
if (params.has('resource')) {
const scopes = (params.get('scope') ?? '').split(' ').filter(Boolean)
let resource: string | null
try {
resource = parseOAuthSearchResource(params.get('resource'))
} catch (error) {
if (!(error instanceof InvalidOAuthResourceError)) throw error
return oauthAuthorizationErrorResponse(
request,
'invalid_request',
'The resource parameter is not supported.'
'The resource must be a Sim Search server URL.'
)
}
const searchScope = resource ? narrowSearchOAuthScopes(params.get('scope') ?? '') : null
if ((resource && !searchScope) || (!resource && scopes.includes(OAUTH_SEARCH_READ_SCOPE))) {
return oauthAuthorizationErrorResponse(
request,
'invalid_request',
'Sim Search requires its server URL and the search:read scope.'
)
}
if (params.has('request_uri')) {
Expand Down Expand Up @@ -136,7 +151,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
if (pkceError) {
return oauthAuthorizationErrorResponse(request, 'invalid_request', pkceError)
}
const response = await betterAuthGET(request)
let providerRequest: Request = request
if (searchScope && params.get('scope') !== searchScope) {
const url = new URL(request.url)
url.searchParams.set('scope', searchScope)
providerRequest = new Request(url, { headers: request.headers })
}
const response = await betterAuthGET(providerRequest)
if (response.status === 403) {
const body: unknown = await response
.clone()
Expand Down
133 changes: 133 additions & 0 deletions apps/sim/app/api/auth/oauth2/register/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/** @vitest-environment node */
import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({ register: vi.fn(), rateLimit: vi.fn() }))
vi.mock('better-auth/next-js', () => ({ toNextJsHandler: () => ({ POST: mocks.register }) }))
vi.mock('@/lib/core/rate-limiter', () => ({ enforceIpRateLimit: mocks.rateLimit }))
vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.test' }))

import { POST } from '@/app/api/auth/oauth2/register/route'

const client = {
client_name: 'Test MCP client',
redirect_uris: ['http://127.0.0.1:43123/callback'],
}
function request(body: object = client, headers: Record<string, string> = {}) {
return new NextRequest('https://sim.test/api/auth/oauth2/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify(body),
})
}

afterAll(resetEnvFlagsMock)
beforeEach(() => {
vi.clearAllMocks()
setEnvFlags({ isAuthDisabled: false })
mocks.rateLimit.mockResolvedValue(null)
mocks.register.mockImplementation(async (req: Request) =>
Response.json(
{
...(await req.clone().json()),
client_id: 'client-1',
client_id_issued_at: 1788000000,
},
{ status: 201 }
)
)
})

describe('MCP public client registration', () => {
it('registers a bounded public Search client without ambient credentials or privileged metadata', async () => {
const response = await POST(
request(
{ ...client, skip_consent: true, require_pkce: false, metadata: { elevated: true } },
{
Cookie: 'session=private',
Authorization: 'Bearer private',
'x-forwarded-for': '203.0.113.10',
}
)
)
expect(response.status).toBe(201)
expect(await response.json()).toMatchObject({
...client,
client_id: 'client-1',
token_endpoint_auth_method: 'none',
scope: 'search:read offline_access',
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
})
const forwarded: Request = mocks.register.mock.calls[0][0]
expect(forwarded.headers.has('cookie')).toBe(false)
expect(forwarded.headers.has('authorization')).toBe(false)
expect(forwarded.headers.get('x-forwarded-for')).toBe('203.0.113.10')
expect(response.headers.get('cache-control')).toBe('no-store')
})

it('returns only registered Search scopes when clients request all issuer scopes', async () => {
const response = await POST(
request({ ...client, scope: 'offline_access api:read api:write search:read' })
)
expect(response.status).toBe(201)
expect(await response.json()).toMatchObject({ scope: 'search:read offline_access' })
const forwarded: Request = mocks.register.mock.calls[0][0]
expect(await forwarded.json()).toMatchObject({
scope: 'search:read offline_access',
require_pkce: true,
})
})

it('registers Cursor browser and native callbacks together with PKCE required', async () => {
const redirectUris = [
'cursor://anysphere.cursor-mcp/oauth/callback',
'https://www.cursor.com/agents/mcp/oauth/callback',
'http://localhost:8787/callback',
]
const response = await POST(request({ client_name: 'Cursor', redirect_uris: redirectUris }))
expect(response.status).toBe(201)
expect(await response.json()).toMatchObject({ redirect_uris: redirectUris })
const forwarded: Request = mocks.register.mock.calls[0][0]
expect(await forwarded.json()).toMatchObject({
redirect_uris: redirectUris,
require_pkce: true,
token_endpoint_auth_method: 'none',
})
})

it.each([
{ ...client, scope: 'api:write' },
{ ...client, token_endpoint_auth_method: 'client_secret_post' },
{ ...client, grant_types: ['client_credentials'] },
{ ...client, redirect_uris: ['http://evil.example/callback'] },
{ ...client, redirect_uris: ['https://*.example/callback'] },
{ ...client, redirect_uris: ['https://example.com/callback#fragment'] },
{ ...client, redirect_uris: ['https://user:password@example.com/callback'] },
{ ...client, redirect_uris: ['cursor://anysphere.cursor-mcp/other'] },
{ ...client, redirect_uris: ['cursor://anysphere.cursor-mcp/oauth/callback?target=other'] },
{ ...client, redirect_uris: ['cursor://other/oauth/callback'] },
{ ...client, redirect_uris: ['javascript:alert(1)'] },
{ ...client, redirect_uris: ['file:///oauth/callback'] },
{ ...client, redirect_uris: ['data:text/html,callback'] },
{ ...client, redirect_uris: ['unknown-app://oauth/callback'] },
{ ...client, redirect_uris: Array(11).fill('https://example.com/callback') },
{ ...client, client_name: 'a'.repeat(129) },
])('rejects unsupported or unsafe client metadata: %o', async (body) => {
expect((await POST(request(body))).status).toBe(400)
expect(mocks.register).not.toHaveBeenCalled()
})

it('admits before reading metadata or creating a client', async () => {
mocks.rateLimit.mockResolvedValue(Response.json({ error: 'Rate limited' }, { status: 429 }))
expect((await POST(request())).status).toBe(429)
expect(mocks.register).not.toHaveBeenCalled()
})

it('does not enable OAuth in auth-disabled deployments', async () => {
setEnvFlags({ isAuthDisabled: true })
expect((await POST(request())).status).toBe(404)
expect(mocks.register).not.toHaveBeenCalled()
})
})
68 changes: 68 additions & 0 deletions apps/sim/app/api/auth/oauth2/register/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { toNextJsHandler } from 'better-auth/next-js'
import { type NextRequest, NextResponse } from 'next/server'
import { registerSearchOAuthClientContract } from '@/lib/api/contracts/oauth-provider'
import { parseRequest } from '@/lib/api/server'
import { auth } from '@/lib/auth'
import { isAuthDisabled } from '@/lib/core/config/env-flags'
import { enforceIpRateLimit } from '@/lib/core/rate-limiter'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'

const { POST: register } = toNextJsHandler(auth.handler)
const HEADERS = { 'Cache-Control': 'no-store', Pragma: 'no-cache' } as const

function invalidMetadata(description: string, status = 400) {
return NextResponse.json(
{ error: 'invalid_client_metadata', error_description: description },
{ status, headers: HEADERS }
)
}

/** RFC 7591 public registration delegates persistence to Sim's OAuth provider. */
export const POST = withRouteHandler(async (request: NextRequest) => {
if (isAuthDisabled) {
return NextResponse.json(
{ error: 'OAuth provider is not enabled' },
{ status: 404, headers: HEADERS }
)
}
const limited = await enforceIpRateLimit('oauth-provider-register', request, {
maxTokens: 20,
refillRate: 20,
refillIntervalMs: 60_000,
})
if (limited) return limited
if (request.headers.get('content-type')?.split(';', 1)[0].trim() !== 'application/json') {
return invalidMetadata('Client metadata must be sent as application/json.', 415)
}
const parsed = await parseRequest(
registerSearchOAuthClientContract,
request,
{},
{
maxBodyBytes: 32 * 1024,
validationErrorResponse: (error) =>
invalidMetadata(error.issues[0]?.message ?? 'Invalid client metadata.'),
invalidJsonResponse: () => invalidMetadata('Client metadata must be valid JSON.'),
payloadTooLargeResponse: () => invalidMetadata('Client metadata is too large.', 413),
}
)
if (!parsed.success) return parsed.response

const headers = new Headers({ 'Content-Type': 'application/json' })
for (const name of ['x-forwarded-for', 'x-real-ip']) {
const value = request.headers.get(name)
if (value) headers.set(name, value)
}
/** Public clients never inherit an ambient browser session or client-management privileges. */
const response = await register(
new Request(`${getBaseUrl()}/api/auth/oauth2/register`, {
method: 'POST',
headers,
body: JSON.stringify({ ...parsed.data.body, require_pkce: true }),
})
)
if (!response.ok) return response
const body = registerSearchOAuthClientContract.response.schema.parse(await response.json())
return NextResponse.json(body, { status: 201, headers: HEADERS })
})
Loading
Loading