Skip to content

Commit 9d2a892

Browse files
committed
chore(db): resolve capacity migration conflict with staging
2 parents 421a4d0 + 74e77c6 commit 9d2a892

91 files changed

Lines changed: 29246 additions & 843 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/docs/content/docs/integrations/parallel_ai.mdx

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,11 @@ Search the web using Parallel AI. Provides comprehensive search results with int
4343

4444
| Parameter | Type | Required | Description |
4545
| --------- | ---- | -------- | ----------- |
46-
| `objective` | string | Yes | The search objective or question to answer |
47-
| `search_queries` | string | No | Comma-separated list of search queries to execute |
48-
| `mode` | string | No | Search mode: one-shot, agentic, or fast \(default: one-shot\) |
49-
| `max_results` | number | No | Maximum number of results to return \(default: 10\) |
50-
| `max_chars_per_result` | number | No | Maximum characters per result excerpt \(minimum: 1000\) |
46+
| `search_queries` | string | Yes | Comma-separated list of concise keyword search queries \(3-6 words each\). At least one is required |
47+
| `objective` | string | No | Natural-language description of the search intent used to rank and excerpt results |
48+
| `mode` | string | No | Search mode: turbo, fast, basic, or advanced \(default: advanced\) |
49+
| `max_results` | number | No | Maximum number of results to return \(default: 10, max: 20\) |
50+
| `max_chars_per_result` | number | No | Maximum characters of excerpts per result |
5151
| `include_domains` | string | No | Comma-separated list of domains to restrict search results to |
5252
| `exclude_domains` | string | No | Comma-separated list of domains to exclude from search results |
5353
| `apiKey` | string | Yes | Parallel AI API Key |
@@ -71,10 +71,9 @@ Extract targeted information from specific URLs using Parallel AI. Processes pro
7171

7272
| Parameter | Type | Required | Description |
7373
| --------- | ---- | -------- | ----------- |
74-
| `urls` | string | Yes | Comma-separated list of URLs to extract information from |
75-
| `objective` | string | No | What information to extract from the provided URLs |
76-
| `excerpts` | boolean | No | Include relevant excerpts from the content \(default: true\) |
77-
| `full_content` | boolean | No | Include full page content as markdown \(default: false\) |
74+
| `urls` | string | Yes | Comma-separated list of URLs to extract information from \(up to 20\) |
75+
| `objective` | string | No | What information to extract from the provided URLs \(up to 5,000 characters\) |
76+
| `full_content` | boolean | No | Include full page content as markdown in addition to excerpts \(default: false\) |
7877
| `apiKey` | string | Yes | Parallel AI API Key |
7978

8079
#### Output
@@ -87,7 +86,12 @@ Extract targeted information from specific URLs using Parallel AI. Processes pro
8786
|`title` | string | The title of the page |
8887
|`publish_date` | string | Publication date \(YYYY-MM-DD\) |
8988
|`excerpts` | array | Relevant text excerpts in markdown |
90-
|`full_content` | string | Full page content as markdown |
89+
|`full_content` | string | Full page content as markdown \(only when requested\) |
90+
| `errors` | array | URLs that could not be extracted, with the reason |
91+
|`url` | string | The URL that failed |
92+
|`error_type` | string | Category of the failure |
93+
|`http_status_code` | number | HTTP status returned by the page, if any |
94+
|`content` | string | Error detail |
9195

9296
### Parallel AI Deep Research
9397

@@ -98,7 +102,8 @@ Conduct comprehensive deep research across the web using Parallel AI. Synthesize
98102
| Parameter | Type | Required | Description |
99103
| --------- | ---- | -------- | ----------- |
100104
| `input` | string | Yes | Research query or question \(up to 15,000 characters\) |
101-
| `processor` | string | No | Processing tier: pro, ultra, pro-fast, ultra-fast \(default: pro\) |
105+
| `output_format` | string | No | Output format: text for a markdown report with inline citations, auto for a structured JSON object that needs the pro tier or higher \(default: text\) |
106+
| `processor` | string | No | Processing tier: core, core2x, pro, ultra, ultra2x, ultra4x, ultra8x, or a -fast variant \(default: pro\) |
102107
| `include_domains` | string | No | Comma-separated list of domains to restrict research to \(source policy\) |
103108
| `exclude_domains` | string | No | Comma-separated list of domains to exclude from research \(source policy\) |
104109
| `apiKey` | string | Yes | Parallel AI API Key |
@@ -110,14 +115,14 @@ Conduct comprehensive deep research across the web using Parallel AI. Synthesize
110115
| `status` | string | Task status \(completed, failed, running\) |
111116
| `run_id` | string | Unique ID for this research task |
112117
| `message` | string | Status message |
113-
| `content` | object | Research results \(structured based on output_schema\) |
118+
| `content` | json | Research findings: a markdown report string for the text output format, or a structured object with query-specific keys for the auto format |
114119
| `basis` | array | Citations and sources with reasoning and confidence levels |
115120
|`field` | string | Output field dot-notation path |
116121
|`reasoning` | string | Explanation for the result |
117122
|`citations` | array | Array of sources |
118123
|`url` | string | Source URL |
119124
|`title` | string | Source title |
120125
|`excerpts` | array | Relevant excerpts from the source |
121-
|`confidence` | string | Confidence level \(high, medium\) |
126+
|`confidence` | string | Confidence level \(low, medium, high\) |
122127

123128

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+
})

0 commit comments

Comments
 (0)