diff --git a/apps/docs/app/[[...slug]]/page.tsx b/apps/docs/app/[[...slug]]/page.tsx index 4d94308ae0e..71853679de9 100644 --- a/apps/docs/app/[[...slug]]/page.tsx +++ b/apps/docs/app/[[...slug]]/page.tsx @@ -125,9 +125,10 @@ export default async function Page(props: { params: Promise<{ slug?: string[] }> // width so the lesson hero/video gets the room (chapters live in-page instead). const isAcademy = slug?.[0] === 'academy' const isCli = slug?.[0] === 'cli' + const isMcp = slug?.[0] === 'mcp' const rawNeighbours = findNeighbour(source.pageTree, page.url) - // Academy, API Reference, and CLI are self-contained sections; keep prev/next + // Academy, API Reference, CLI, and MCP are self-contained sections; keep prev/next // inside the section instead of spilling into the main documentation tree. // Match both the section's pages (`//...`) and its index (`/`). const sectionSlug = isApiReference @@ -136,7 +137,9 @@ export default async function Page(props: { params: Promise<{ slug?: string[] }> ? 'academy' : isCli ? 'cli' - : null + : isMcp + ? 'mcp' + : null const inSection = (url?: string) => url != null && (url.includes(`/${sectionSlug}/`) || url.endsWith(`/${sectionSlug}`)) const neighbours = sectionSlug diff --git a/apps/docs/components/docs-layout/docs-sidebar.tsx b/apps/docs/components/docs-layout/docs-sidebar.tsx index f2d68258adb..24e33677746 100644 --- a/apps/docs/components/docs-layout/docs-sidebar.tsx +++ b/apps/docs/components/docs-layout/docs-sidebar.tsx @@ -103,6 +103,7 @@ export function DocsSidebar() { ['Docs', '/introduction'], ['API Reference', '/api-reference/getting-started'], ['CLI', '/cli'], + ['MCP', '/mcp'], ['Academy', '/academy'], ].map(([label, href]) => ( setOpen(false)}> diff --git a/apps/docs/components/navbar/navbar.tsx b/apps/docs/components/navbar/navbar.tsx index f8b0997cc01..4610c6d392f 100644 --- a/apps/docs/components/navbar/navbar.tsx +++ b/apps/docs/components/navbar/navbar.tsx @@ -9,25 +9,20 @@ import { ThemeToggle } from '@/components/ui/theme-toggle' import { cn } from '@/lib/utils' /** - * Sections that own a tab, in reading order: the main docs, then the two + * Sections that own a tab, in reading order: the main docs, then the three * reference surfaces, then Academy. `Documentation` matches by exclusion, so * every section listed here is one it must not claim. */ -const SECTION_TABS = ['api-reference', 'academy', 'cli'] as const +const SECTION_TABS = ['api-reference', 'academy', 'cli', 'mcp'] as const /** - * Whether a pathname is inside a section, matched by whole path segment. + * Whether a pathname is inside a section, matched on its first path segment. * - * A substring test is wrong: `/integrations/clickup` and - * `/integrations/clickhouse` both contain `/cli`, which lit the CLI tab and - * unlit Documentation on two existing integration pages. + * A substring or suffix test is wrong: `/integrations/clickup` contains `/cli`, + * and `/agents/mcp` ends with `/mcp`, and both belong to Documentation. */ function isInSection(pathname: string, section: string): boolean { - return ( - pathname === `/${section}` || - pathname.endsWith(`/${section}`) || - pathname.includes(`/${section}/`) - ) + return pathname === `/${section}` || pathname.startsWith(`/${section}/`) } const NAV_TABS = [ @@ -49,6 +44,12 @@ const NAV_TABS = [ match: (p: string) => isInSection(p, 'cli'), external: false, }, + { + label: 'MCP', + href: '/mcp', + match: (p: string) => isInSection(p, 'mcp'), + external: false, + }, { label: 'Academy', href: '/academy', diff --git a/apps/docs/content/docs/mcp/authentication.mdx b/apps/docs/content/docs/mcp/authentication.mdx new file mode 100644 index 00000000000..de694a2066c --- /dev/null +++ b/apps/docs/content/docs/mcp/authentication.mdx @@ -0,0 +1,70 @@ +--- +title: Authentication +description: Sign in with OAuth, or connect with an API key +--- + +import { Callout } from 'fumadocs-ui/components/callout' + +## OAuth + +Most apps sign in with OAuth. The first time you connect, your app opens Sim in +the browser, you sign in, and you approve its access. The app then holds a +token that renews itself; you do not copy any secret. + +The approval screen names the app and what it can do: + +| Access | Scope | Allows | +| --- | --- | --- | +| Read-only | `api:read` | Reading workspaces, workflows, runs, tables, files, knowledge bases, and logs | +| Full | `api:write` | Everything above, plus creating, changing, running, deploying, and deleting | + +Most apps request full access. To connect an app for reads only, configure it +to request the `api:read` scope; changes then fail with an insufficient-scope +error. + +Tokens are issued for the Sim MCP server itself. An app cannot take one to +another service and use it there. + +### Revoke access + +Open **Settings → General → Authorized apps** in Sim, find the app, and revoke +it. The app's next request fails, and you can reconnect at any time. Revoking +does not undo changes the app already made. + +## API keys + +Apps that cannot sign in through a browser, such as CI jobs and headless +agents, can send a Sim [API key](/api-reference/authentication) in the +`X-API-Key` header, or as `Authorization: Bearer `. + +```bash +claude mcp add --transport http sim https://mcp.sim.ai/mcp \ + --header "X-API-Key: $SIM_API_KEY" +``` + +```json title="~/.cursor/mcp.json" +{ + "mcpServers": { + "sim": { + "url": "https://mcp.sim.ai/mcp", + "headers": { "X-API-Key": "${env:SIM_API_KEY}" } + } + } +} +``` + +A personal key acts as you in every workspace you can access. A workspace key +reaches only its own workspace, and a few account-level operations refuse it; +`search_operations` marks them `personalCredentialOnly`. + + + An API key does not expire until you revoke it. Prefer OAuth for any app that + can open a browser, and store keys in your app's secret or environment + settings rather than in a shared config file. + + +## Organization policy + +The server follows your organization's access policy. If an administrator turns +off **OAuth apps** or **personal API keys** for your permission group, requests +with that credential are refused in the affected workspaces. diff --git a/apps/docs/content/docs/mcp/index.mdx b/apps/docs/content/docs/mcp/index.mdx new file mode 100644 index 00000000000..8f2a705f0e8 --- /dev/null +++ b/apps/docs/content/docs/mcp/index.mdx @@ -0,0 +1,109 @@ +--- +title: Sim MCP +description: Build, run, and manage everything in your Sim workspace from Claude, Codex, Cursor, and other MCP apps +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' + +The Sim MCP server gives an AI app the whole Sim API through the +[Model Context Protocol](https://modelcontextprotocol.io). Your agent can list +workspaces, run and deploy workflows, query and edit tables, manage files and +knowledge bases, read run logs, and more. It covers the same operations as the +[API](/api-reference/getting-started) and the [CLI](/cli). + +| Deployment | Server URL | +| --- | --- | +| Sim Cloud | `https://mcp.sim.ai/mcp` | +| Self-hosted | `https:///api/mcp`, or your [`SIM_MCP_URL`](/platform/self-hosting/environment-variables) | + +The server uses the Streamable HTTP transport. Sign in with OAuth, the default +in every app below, or send an [API key](/mcp/authentication#api-keys). + +## Connect an app + + + + ```bash + claude mcp add --transport http sim https://mcp.sim.ai/mcp + ``` + + Open `/mcp` in Claude Code, select **sim**, and sign in to Sim in the + browser. + + + Add `https://mcp.sim.ai/mcp` as a custom connector under **Settings → + Connectors**, then connect it and sign in to Sim. For Team or Enterprise, + an owner first adds it under **Organization settings → Connectors**. See + [Claude's custom connector instructions](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp). + + + ```bash + codex mcp add sim --url https://mcp.sim.ai/mcp + ``` + + Complete the browser sign-in. To sign in again later, run + `codex mcp login sim`. + + + Add `sim` to `mcpServers` in `~/.cursor/mcp.json`, then enable it in + Cursor and sign in to Sim: + + ```json + { + "mcpServers": { + "sim": { "url": "https://mcp.sim.ai/mcp" } + } + } + ``` + + + Add `sim` to `.vscode/mcp.json`, then start it and sign in to Sim: + + ```json + { + "servers": { + "sim": { "type": "http", "url": "https://mcp.sim.ai/mcp" } + } + } + ``` + + + +Any other app that supports remote MCP servers with OAuth works the same way: +give it the server URL and choose **Streamable HTTP** if asked. + + + Claude's hosted connectors call your server from Claude's infrastructure, so a + self-hosted Sim must be reachable from the internet. A `localhost` URL works + only with apps that run on your machine, such as Claude Code, Codex, Cursor, + and VS Code. + + +## Try it + +Ask your app: + +- "List my Sim workspaces and the tables in each." +- "Run the `lead-scoring` workflow with this input and show me the result." +- "Find failed runs from the last day and explain what went wrong." +- "Create a table of our open support tickets and add these rows." + +The agent finds the right operation, reads its inputs, and calls it. See +[Tools](/mcp/tools) for how that works. + +## What your agent can do + +The server acts as you. It sees the workspaces you can see, with your role in +each, and every call is authorized, rate limited, and logged exactly like the +same request to the API. Reads leave your resources unchanged, and apps can ask +you to confirm each change. See [Authentication](/mcp/authentication) to limit +an app to reads. + +## Other Sim MCP surfaces + +This server is for operating Sim. Two other MCP features do different jobs: + +- [Search MCP](/search/mcp) searches your organization's indexed sources. +- [MCP deployment](/workflows/deployment/mcp) exposes your own workflows as + tools, and [MCP tools](/agents/mcp) connect external servers to Sim agents. diff --git a/apps/docs/content/docs/mcp/meta.json b/apps/docs/content/docs/mcp/meta.json new file mode 100644 index 00000000000..f111bfd9ece --- /dev/null +++ b/apps/docs/content/docs/mcp/meta.json @@ -0,0 +1,5 @@ +{ + "title": "MCP", + "root": true, + "pages": ["---Sim MCP---", "index", "authentication", "tools"] +} diff --git a/apps/docs/content/docs/mcp/tools.mdx b/apps/docs/content/docs/mcp/tools.mdx new file mode 100644 index 00000000000..6bf6e0fee84 --- /dev/null +++ b/apps/docs/content/docs/mcp/tools.mdx @@ -0,0 +1,55 @@ +--- +title: Tools +description: How an agent finds, reads, and calls Sim operations through four tools +--- + +The Sim API has more than 200 operations. Instead of one tool per operation, +which would crowd your app's tool list and your agent's context, the server +exposes four tools. The agent searches for an operation, reads its inputs, and +calls it. + +| Tool | Does | +| --- | --- | +| `search_operations` | Finds operations by keyword (`"table rows"`) or by area (`tables`, `workflows`, `knowledge`, …). Returns each operation's name, method, path, summary, and the tool that runs it. | +| `describe_operation` | Returns an operation's description and the JSON Schema of its path parameters, query, body, and headers. | +| `call_read_operation` | Runs an operation that only needs read access, such as `listWorkspaces`, `queryRows`, or `getWorkflowRun`. | +| `call_write_operation` | Runs an operation that needs write access: one that creates, changes, runs, or deletes something, or reaches out to another service, such as `createTable`, `executeWorkflow`, or `listMcpServerTools`. | + +Reads and writes are separate tools so your app can approve reads once and still +ask you before each change. + +## Calling an operation + +Operation names match the [CLI](/cli/reference) and the +[API reference](/api-reference/getting-started). A call names the operation and +fills the parts of the request it needs: + +```json +{ + "operation": "listTableRows", + "params": { "tableId": "tbl_8f2c" }, + "query": { "workspaceId": "ws_91ab", "limit": 50 } +} +``` + +| Field | Holds | +| --- | --- | +| `params` | Path parameters, such as `tableId` or `workflowId` | +| `query` | Query-string parameters; most operations need `workspaceId` | +| `body` | The JSON request body (write operations only) | +| `headers` | Headers the operation declares, such as `upload-token` | + +The result is the same JSON the API returns, usually `{ "data": … }`. List +operations page with `limit` and `cursor`. A failed call returns the API's error, +such as `{ "error": { "code": "NOT_FOUND", "message": "…" } }`, so the agent can +correct its request. + +## Limits + +- **Same rules as the API.** Permissions, rate limits, and request validation + are the API's own; nothing is looser through MCP. +- **1 MiB per result.** Page through larger lists with `limit` and `cursor`. +- **No streaming.** Run a workflow without `stream: true` to wait for its + result, or with `async: true` and poll `getWorkflowRun`. +- **No file bytes.** Downloads, knowledge base exports, and multipart document + uploads are not available over MCP; use the [CLI](/cli/files) or the API. diff --git a/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx b/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx index d870ef0e354..23c80273d81 100644 --- a/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx +++ b/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx @@ -34,6 +34,7 @@ import { Callout } from 'fumadocs-ui/components/callout' | `TRUSTED_ORIGINS` | Comma-separated additional origins to trust for auth (apex + `www`, alias domains) | | `AUTH_TRUSTED_PROXIES` | Comma-separated reverse-proxy IPs/CIDRs so the client IP cannot be forged through `X-Forwarded-For` | | `INTERNAL_API_BASE_URL` | Internal URL for server-side self-calls, e.g. `http://sim-app.simstudio.svc.cluster.local:3000`. Optional — falls back to the public base URL. Deliberately ignored inside the Trigger.dev worker runtime, where a cluster-internal address resolves to the worker itself | +| `SIM_MCP_URL` | Public URL of the [Sim MCP server](/mcp) when you serve it on its own host, e.g. `https://mcp.example.com/mcp`. Point that host at the app; Sim serves only the MCP endpoint and its OAuth metadata there, and stops serving `/api/mcp` on the app host so clients use one URL. Optional — defaults to `/api/mcp` | | `DATABASE_REPLICA_URL` | Read-replica connection string for log listing, audit logs, and dashboard aggregations. Falls back to the primary when unset | ## AI Providers diff --git a/apps/docs/lib/integration-navigation.test.ts b/apps/docs/lib/integration-navigation.test.ts index bbe381c7e6a..7b1d3b96e67 100644 --- a/apps/docs/lib/integration-navigation.test.ts +++ b/apps/docs/lib/integration-navigation.test.ts @@ -58,8 +58,8 @@ describe('docs section navigation', () => { } }) - it('keeps root-tab overview pages in the CLI and Academy navigation', () => { - for (const root of ['cli', 'academy']) { + it('keeps root-tab overview pages in the CLI, MCP, and Academy navigation', () => { + for (const root of ['cli', 'mcp', 'academy']) { const folder = folders(source.pageTree.fallback?.children ?? []).find( (node) => node.$ref === `${root}/meta.json` ) diff --git a/apps/sim/.env.example b/apps/sim/.env.example index f5d05f0ac23..255c1e9c9b3 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -20,6 +20,7 @@ BETTER_AUTH_URL=http://localhost:3000 NEXT_PUBLIC_APP_URL=http://localhost:3000 # NEXT_PUBLIC_STATUS_NOTICE_PREVIEW=true # Force the sidebar service-status notice into its critical preview state for testing # INTERNAL_API_BASE_URL=http://sim-app.default.svc.cluster.local:3000 # Optional: internal URL for server-side /api self-calls; defaults to NEXT_PUBLIC_APP_URL +# SIM_MCP_URL=https://mcp.example.com/mcp # Optional: dedicated host for the Sim MCP server; defaults to NEXT_PUBLIC_APP_URL/api/mcp # TRUSTED_ORIGINS=https://www.example.com,https://app.example.com # Optional: comma-separated additional public origins to trust for auth (apex+www, alias domains). Merged into Better Auth trustedOrigins. # AUTH_TRUSTED_PROXIES=10.0.0.0/24,192.0.2.10 # Optional: reverse-proxy IPs/CIDRs in front of the app. Better Auth walks x-forwarded-for right to left, skips these hops, and uses the first untrusted address as the client IP (prevents forwarded-header spoofing). Use your proxies' actual addresses, not broad private ranges that also cover clients. diff --git a/apps/sim/app/.well-known/oauth-protected-resource/api/mcp/route.test.ts b/apps/sim/app/.well-known/oauth-protected-resource/api/mcp/route.test.ts new file mode 100644 index 00000000000..d7ac5c11576 --- /dev/null +++ b/apps/sim/app/.well-known/oauth-protected-resource/api/mcp/route.test.ts @@ -0,0 +1,31 @@ +/** @vitest-environment node */ +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { NextRequest } from 'next/server' +import { afterAll, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.test' })) + +import { GET } from '@/app/.well-known/oauth-protected-resource/api/mcp/route' + +afterAll(resetEnvFlagsMock) + +describe('Sim MCP protected-resource metadata', () => { + it('names the Sim MCP server as a Sim API resource', async () => { + setEnvFlags({ isAuthDisabled: false }) + const response = await GET(new NextRequest('https://sim.test/'), undefined) + expect(await response.json()).toEqual({ + resource: 'https://sim.test/api/mcp', + resource_name: 'Sim', + authorization_servers: ['https://sim.test/api/auth'], + scopes_supported: ['api:read', 'api:write'], + bearer_methods_supported: ['header'], + }) + expect(response.headers.get('access-control-allow-origin')).toBe('*') + }) + + it('does not advertise disabled OAuth', async () => { + setEnvFlags({ isAuthDisabled: true }) + const response = await GET(new NextRequest('https://sim.test/'), undefined) + expect(response.status).toBe(404) + }) +}) diff --git a/apps/sim/app/.well-known/oauth-protected-resource/api/mcp/route.ts b/apps/sim/app/.well-known/oauth-protected-resource/api/mcp/route.ts new file mode 100644 index 00000000000..a6238951e2b --- /dev/null +++ b/apps/sim/app/.well-known/oauth-protected-resource/api/mcp/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from 'next/server' +import { simMcpResourceMetadata } from '@/lib/api/mcp/oauth-metadata' +import { isAuthDisabled } from '@/lib/core/config/env-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +/** RFC 9728 metadata for the Sim MCP server; `proxy.ts` also serves it on the dedicated MCP host. */ +export const GET = withRouteHandler(async () => { + if (isAuthDisabled) return new NextResponse(null, { status: 404 }) + return simMcpResourceMetadata() +}) diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts index 4666fcb4415..8fcc368000e 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts @@ -27,8 +27,12 @@ const mocks = vi.hoisted(() => ({ decryptQuickBooksClientConfig: vi.fn(), createQuickBooksState: vi.fn(), getCanonicalScopes: vi.fn(), + isPubliclyRegistered: vi.fn(), })) +vi.mock('@/lib/auth/oauth-client-registration', () => ({ + isPubliclyRegisteredOAuthClient: mocks.isPubliclyRegistered, +})) vi.mock('better-auth/next-js', () => ({ toNextJsHandler: () => ({ GET: mocks.betterAuthGET }), })) @@ -96,6 +100,7 @@ describe('OAuth2 authorize route', () => { resetDbChainMock() setEnvFlags({ isAuthDisabled: false }) mocks.getBaseUrl.mockReturnValue(BASE_URL) + mocks.isPubliclyRegistered.mockResolvedValue(false) mocks.getSession.mockResolvedValue({ user: { id: 'user-1' }, session: { id: 'session-1' }, @@ -182,6 +187,79 @@ describe('OAuth2 authorize route', () => { expect(req.nextUrl.searchParams.get('scope')).toContain('api:write') }) + it('binds a publicly registered client Sim API grant to the Sim MCP server', async () => { + mocks.isPubliclyRegistered.mockResolvedValue(true) + const unbound = await GET( + request({ + client_id: 'mcp-client', + response_type: 'code', + redirect_uri: 'https://client.example/callback', + scope: 'api:write offline_access', + }) + ) + expect(unbound.status).toBe(400) + expect(mocks.isPubliclyRegistered).toHaveBeenCalledWith('mcp-client') + expect(mocks.betterAuthGET).not.toHaveBeenCalled() + + const bound = await GET( + request({ + client_id: 'mcp-client', + response_type: 'code', + redirect_uri: 'https://client.example/callback', + scope: 'api:write offline_access', + resource: `${BASE_URL}/api/mcp`, + }) + ) + expect(bound.status).toBe(302) + }) + + it('lets an operator-created client request the Sim API without a resource', async () => { + const response = await GET( + request({ + client_id: 'sim-cli', + response_type: 'code', + redirect_uri: 'https://client.example/callback', + scope: 'api:write offline_access', + }) + ) + expect(response.status).toBe(302) + expect(mocks.isPubliclyRegistered).toHaveBeenCalledWith('sim-cli') + }) + + it('narrows issuer-wide scope requests to the Sim API for the Sim MCP server', 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`, + }) + 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( + 'api:read api:write offline_access' + ) + expect(new URL(forwarded.url).searchParams.get('resource')).toBe(`${BASE_URL}/api/mcp`) + }) + + it.each([ + { scope: 'search:read offline_access', resource: `${BASE_URL}/api/mcp` }, + { scope: 'offline_access', resource: `${BASE_URL}/api/mcp` }, + { scope: 'api:read unknown', resource: `${BASE_URL}/api/mcp` }, + { scope: 'api:read', resource: `${BASE_URL}/api/mcp/` }, + ])('refuses Sim MCP grants without Sim API scope: %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('forwards a provider request without entering the connector flow', async () => { const providerRequest = request({ client_id: 'client-1', diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.ts b/apps/sim/app/api/auth/oauth2/authorize/route.ts index 713f6c99632..7402d1c03f9 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.ts @@ -5,9 +5,19 @@ import { authorizeOAuth2Contract } from '@/lib/api/contracts/oauth-connections' import { parseRequest } from '@/lib/api/server' import { auth, getSession } from '@/lib/auth/auth' import { oauthAuthorizationErrorResponse } from '@/lib/auth/oauth-authorization-error' +import { isPubliclyRegisteredOAuthClient } from '@/lib/auth/oauth-client-registration' 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 { + narrowResourceOAuthScopes, + OAUTH_API_READ_SCOPE, + OAUTH_API_WRITE_SCOPE, + OAUTH_SEARCH_READ_SCOPE, +} from '@/lib/auth/oauth-provider' +import { + InvalidOAuthResourceError, + type OAuthResource, + parseOAuthResource, +} 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' @@ -105,26 +115,36 @@ export const GET = withRouteHandler(async (request: NextRequest) => { 'The redirect_uri parameter is required.' ) } - const scopes = (params.get('scope') ?? '').split(' ').filter(Boolean) - let resource: string | null + const rawScope = params.get('scope') ?? '' + const scopes = rawScope.split(' ').filter(Boolean) + const invalidRequest = (description: string) => + oauthAuthorizationErrorResponse(request, 'invalid_request', description) + const searchScopeRequired = 'Sim Search requires its server URL and the search:read scope.' + let resource: OAuthResource | null try { - resource = parseOAuthSearchResource(params.get('resource')) + resource = parseOAuthResource(params.get('resource')) } catch (error) { if (!(error instanceof InvalidOAuthResourceError)) throw error - return oauthAuthorizationErrorResponse( - request, - 'invalid_request', - 'The resource must be a Sim Search server URL.' - ) + return invalidRequest(error.message) } - 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 (!resource && scopes.includes(OAUTH_SEARCH_READ_SCOPE)) { + return invalidRequest(searchScopeRequired) + } + const narrowedScope = resource ? narrowResourceOAuthScopes(rawScope, resource.kind) : null + if (resource && !narrowedScope) { + return invalidRequest( + resource.kind === 'search' + ? searchScopeRequired + : 'The Sim MCP server requires the api:read or api:write scope.' ) } + if ( + !resource && + scopes.some((scope) => scope === OAUTH_API_READ_SCOPE || scope === OAUTH_API_WRITE_SCOPE) && + (await isPubliclyRegisteredOAuthClient(params.get('client_id') ?? '')) + ) { + return invalidRequest('This app must request Sim API access for the Sim MCP server URL.') + } if (params.has('request_uri')) { return oauthAuthorizationErrorResponse( request, @@ -152,9 +172,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return oauthAuthorizationErrorResponse(request, 'invalid_request', pkceError) } let providerRequest: Request = request - if (searchScope && params.get('scope') !== searchScope) { + if (narrowedScope && rawScope !== narrowedScope) { const url = new URL(request.url) - url.searchParams.set('scope', searchScope) + url.searchParams.set('scope', narrowedScope) providerRequest = new Request(url, { headers: request.headers }) } const response = await betterAuthGET(providerRequest) diff --git a/apps/sim/app/api/auth/oauth2/register/route.test.ts b/apps/sim/app/api/auth/oauth2/register/route.test.ts index f01e0d916c8..8f943ce6516 100644 --- a/apps/sim/app/api/auth/oauth2/register/route.test.ts +++ b/apps/sim/app/api/auth/oauth2/register/route.test.ts @@ -3,7 +3,10 @@ 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() })) +const mocks = vi.hoisted(() => ({ register: vi.fn(), rateLimit: vi.fn(), markPublic: vi.fn() })) +vi.mock('@/lib/auth/oauth-client-registration', () => ({ + markPubliclyRegisteredOAuthClient: mocks.markPublic, +})) 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' })) @@ -27,6 +30,7 @@ beforeEach(() => { vi.clearAllMocks() setEnvFlags({ isAuthDisabled: false }) mocks.rateLimit.mockResolvedValue(null) + mocks.markPublic.mockResolvedValue(undefined) mocks.register.mockImplementation(async (req: Request) => Response.json( { @@ -41,7 +45,7 @@ beforeEach(() => { }) describe('MCP public client registration', () => { - it('registers a bounded public Search client without ambient credentials or privileged metadata', async () => { + it('registers a bounded public MCP client without ambient credentials or privileged metadata', async () => { const response = await POST( request( { ...client, skip_consent: true, require_pkce: false, metadata: { elevated: true } }, @@ -57,28 +61,31 @@ describe('MCP public client registration', () => { ...client, client_id: 'client-1', token_endpoint_auth_method: 'none', - scope: 'search:read offline_access', + scope: 'api:read api:write offline_access search:read', grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], }) const forwarded: Request = mocks.register.mock.calls[0][0] + expect(mocks.markPublic).toHaveBeenCalledWith('client-1') 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' }) - ) + it.each([ + [ + 'offline_access api:read api:write search:read', + 'api:read api:write offline_access search:read', + ], + ['api:read offline_access', 'api:read offline_access'], + ['search:read offline_access', 'search:read offline_access'], + ])('registers the registrable scope families a client requests: %s', async (scope, granted) => { + const response = await POST(request({ ...client, scope })) expect(response.status).toBe(201) - expect(await response.json()).toMatchObject({ scope: 'search:read offline_access' }) + expect(await response.json()).toMatchObject({ scope: granted }) const forwarded: Request = mocks.register.mock.calls[0][0] - expect(await forwarded.json()).toMatchObject({ - scope: 'search:read offline_access', - require_pkce: true, - }) + expect(await forwarded.json()).toMatchObject({ scope: granted, require_pkce: true }) }) it('registers Cursor browser and native callbacks together with PKCE required', async () => { @@ -131,7 +138,8 @@ describe('MCP public client registration', () => { ) it.each([ - { ...client, scope: 'api:write' }, + { ...client, scope: 'offline_access' }, + { ...client, scope: 'openid api:read' }, { ...client, token_endpoint_auth_method: 'private_key_jwt' }, { ...client, token_endpoint_auth_method: 'unsupported' }, { ...client, grant_types: ['client_credentials'] }, @@ -153,6 +161,13 @@ describe('MCP public client registration', () => { expect(mocks.register).not.toHaveBeenCalled() }) + it('discloses no client ID when the client cannot be marked as publicly registered', async () => { + mocks.markPublic.mockRejectedValue(new Error('write failed')) + const response = await POST(request()) + expect(response.status).toBe(500) + expect(await response.text()).not.toContain('client-1') + }) + 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) diff --git a/apps/sim/app/api/auth/oauth2/register/route.ts b/apps/sim/app/api/auth/oauth2/register/route.ts index 4591aa16956..0d9b285cc8c 100644 --- a/apps/sim/app/api/auth/oauth2/register/route.ts +++ b/apps/sim/app/api/auth/oauth2/register/route.ts @@ -1,8 +1,9 @@ import { toNextJsHandler } from 'better-auth/next-js' import { type NextRequest, NextResponse } from 'next/server' -import { registerSearchOAuthClientContract } from '@/lib/api/contracts/oauth-provider' +import { registerOAuthClientContract } from '@/lib/api/contracts/oauth-provider' import { parseRequest } from '@/lib/api/server' import { auth } from '@/lib/auth' +import { markPubliclyRegisteredOAuthClient } from '@/lib/auth/oauth-client-registration' import { isAuthDisabled } from '@/lib/core/config/env-flags' import { enforceIpRateLimit } from '@/lib/core/rate-limiter' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -36,7 +37,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return invalidMetadata('Client metadata must be sent as application/json.', 415) } const parsed = await parseRequest( - registerSearchOAuthClientContract, + registerOAuthClientContract, request, {}, { @@ -63,6 +64,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) ) if (!response.ok) return response - const body = registerSearchOAuthClientContract.response.schema.parse(await response.json()) + const body = registerOAuthClientContract.response.schema.parse(await response.json()) + await markPubliclyRegisteredOAuthClient(body.client_id) return NextResponse.json(body, { status: 201, headers: HEADERS }) }) diff --git a/apps/sim/app/api/auth/oauth2/token/route.ts b/apps/sim/app/api/auth/oauth2/token/route.ts index f5eb7ee3062..a0e09ae3fb4 100644 --- a/apps/sim/app/api/auth/oauth2/token/route.ts +++ b/apps/sim/app/api/auth/oauth2/token/route.ts @@ -17,7 +17,7 @@ import { import { withOAuthProviderIssuanceCompensation } from '@/lib/auth/oauth-provider-adapter-guard' import { InvalidOAuthResourceError, - parseOAuthSearchResource, + parseOAuthResource, withOAuthResourceIssuance, } from '@/lib/auth/oauth-resource' import { @@ -66,7 +66,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (grantType !== 'authorization_code' && grantType !== 'refresh_token') { return unsupportedGrantResponse(grantType) } - const resource = parseOAuthSearchResource(parsed.value.form.get('resource')) + const resource = parseOAuthResource(parsed.value.form.get('resource'))?.url ?? null if (grantType === 'authorization_code') { const codeVerifier = parsed.value.form.get('code_verifier') if (codeVerifier !== null && !isValidOAuthCodeVerifier(codeVerifier)) { diff --git a/apps/sim/app/api/mcp/route.ts b/apps/sim/app/api/mcp/route.ts new file mode 100644 index 00000000000..83d628f2c69 --- /dev/null +++ b/apps/sim/app/api/mcp/route.ts @@ -0,0 +1,9 @@ +import { createSimMcpHandlers } from '@/lib/api/mcp/route-handler' + +export const dynamic = 'force-dynamic' + +const handlers = createSimMcpHandlers() + +export const POST = handlers.POST +export const GET = handlers.GET +export const DELETE = handlers.DELETE diff --git a/apps/sim/lib/api-key/crypto.test.ts b/apps/sim/lib/api-key/crypto.test.ts index 334a90ac9c7..26dbbba187d 100644 --- a/apps/sim/lib/api-key/crypto.test.ts +++ b/apps/sim/lib/api-key/crypto.test.ts @@ -10,7 +10,10 @@ */ import { randomBytes } from 'crypto' import { resetEnvMock, setEnv } from '@sim/testing' -import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGenerateSecureToken } = vi.hoisted(() => ({ mockGenerateSecureToken: vi.fn() })) +vi.mock('@sim/security/tokens', () => ({ generateSecureToken: mockGenerateSecureToken })) beforeAll(() => { setEnv({ API_ENCRYPTION_KEY: undefined }) @@ -21,6 +24,7 @@ afterAll(resetEnvMock) import { decryptApiKey, encryptApiKey, + generateApiKey, hashApiKey, isEncryptedApiKeyFormat, isLegacyApiKeyFormat, @@ -86,3 +90,11 @@ describe('api-key format helpers', () => { expect(isEncryptedApiKeyFormat('sim_abc')).toBe(false) }) }) + +describe('generateApiKey', () => { + it('never issues a legacy key that reads as an OAuth access token', () => { + mockGenerateSecureToken.mockReturnValueOnce('oat_collision').mockReturnValueOnce('plain_token') + expect(generateApiKey()).toBe('sim_plain_token') + expect(mockGenerateSecureToken).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/sim/lib/api-key/crypto.ts b/apps/sim/lib/api-key/crypto.ts index b8e89ecb030..2a9233313f4 100644 --- a/apps/sim/lib/api-key/crypto.ts +++ b/apps/sim/lib/api-key/crypto.ts @@ -3,6 +3,7 @@ import { decrypt, encrypt } from '@sim/security/encryption' import { sha256Hex } from '@sim/security/hash' import { generateSecureToken } from '@sim/security/tokens' import { toError } from '@sim/utils/errors' +import { OAUTH_ACCESS_TOKEN_PREFIX } from '@/lib/auth/oauth-provider' import { env } from '@/lib/core/config/env' const logger = createLogger('ApiKeyCrypto') @@ -60,10 +61,17 @@ export async function decryptApiKey(encryptedValue: string): Promise<{ decrypted /** * Generates a standardized API key with the 'sim_' prefix (legacy format) + * + * Never one starting with the OAuth access-token prefix: base64url can spell + * `sim_oat_`, and a bearer credential's prefix is what tells an OAuth token + * from an API key. * @returns A new API key string */ export function generateApiKey(): string { - return `sim_${generateSecureToken(24)}` + for (;;) { + const key = `sim_${generateSecureToken(24)}` + if (!key.startsWith(OAUTH_ACCESS_TOKEN_PREFIX)) return key + } } /** diff --git a/apps/sim/lib/api/application/operations.ts b/apps/sim/lib/api/application/operations.ts index 265033e2b92..8c5c1245476 100644 --- a/apps/sim/lib/api/application/operations.ts +++ b/apps/sim/lib/api/application/operations.ts @@ -18,3 +18,30 @@ export const v2MetaOperations = { principalKinds: ['personal_api_key', 'oauth_access_token', 'workspace_api_key'], }), } as const + +/** + * Connecting to the Sim MCP server. Admission only: every tool call is then a v2 + * operation of its own, authorized and rate-limited by its route exactly as the + * same request over HTTP would be. + */ +export const v2McpOperations = { + // permission-group-exempt: connecting reveals only the static catalog of v2 operations; each tool call is its own v2 operation and enforces that operation's capability + connect: defineOperation({ + id: 'mcp.api.connect', + oauthScope: 'api:read', + capability: 'none', + principalKinds: ['personal_api_key', 'oauth_access_token', 'workspace_api_key'], + }), + /** + * The scope a tool call needs when it runs a raw v2 route, which declares no + * operation of its own (chat, workflow execution, resume); each changes + * something. Declared-operation routes are checked against their own scope. + */ + // permission-group-exempt: a scope gate only; the dispatched v2 route enforces its own capability + rawRoute: defineOperation({ + id: 'mcp.api.raw-route', + oauthScope: 'api:write', + capability: 'none', + principalKinds: ['personal_api_key', 'oauth_access_token', 'workspace_api_key'], + }), +} as const diff --git a/apps/sim/lib/api/contracts/oauth-provider.ts b/apps/sim/lib/api/contracts/oauth-provider.ts index c1384bc24c4..a8b8769fbe6 100644 --- a/apps/sim/lib/api/contracts/oauth-provider.ts +++ b/apps/sim/lib/api/contracts/oauth-provider.ts @@ -1,6 +1,9 @@ import { z } from 'zod' import { defineRouteContract } from '@/lib/api/contracts/types' -import { narrowSearchOAuthScopes, OAUTH_SEARCH_SCOPES } from '@/lib/auth/oauth-provider' +import { + narrowRegistrationOAuthScopes, + OAUTH_PUBLIC_REGISTRATION_SCOPES, +} from '@/lib/auth/oauth-provider' /** Reviewed native callbacks; never accept arbitrary executable or custom URI schemes. */ const NATIVE_MCP_CALLBACKS = new Set(['cursor://anysphere.cursor-mcp/oauth/callback']) @@ -28,7 +31,7 @@ const redirectUriSchema = z } }, 'Redirect URIs must use HTTPS, loopback HTTP, or a supported native app callback, without wildcards or fragments') -export const registerSearchOAuthClientBodySchema = z.object({ +export const registerOAuthClientBodySchema = z.object({ client_name: z.string().trim().min(1).max(128).default('MCP client'), redirect_uris: z.array(redirectUriSchema).min(1).max(10), /** Better Auth negotiates unauthenticated registration to public clients without secrets. */ @@ -48,19 +51,19 @@ export const registerSearchOAuthClientBodySchema = z.object({ scope: z .string() .max(128) - .default(OAUTH_SEARCH_SCOPES.join(' ')) + .default(OAUTH_PUBLIC_REGISTRATION_SCOPES.join(' ')) .transform((scope, context) => { - const granted = narrowSearchOAuthScopes(scope) + const granted = narrowRegistrationOAuthScopes(scope) if (granted !== null) return granted context.addIssue({ code: 'custom', - message: 'Only Sim Search access can be registered automatically', + message: 'Only Sim MCP access can be registered automatically', }) return z.NEVER }), }) -export const registerSearchOAuthClientResponseSchema = z.object({ +export const registerOAuthClientResponseSchema = z.object({ client_id: z.string().min(1).max(255), client_name: z.string().min(1).max(128), redirect_uris: z.array(redirectUriSchema).min(1).max(10), @@ -71,15 +74,16 @@ export const registerSearchOAuthClientResponseSchema = z.object({ client_id_issued_at: z.number().int().nonnegative(), }) -/** Public RFC 7591 registration is limited to read-only Search clients. */ -export const registerSearchOAuthClientContract = defineRouteContract({ +/** + * Public RFC 7591 registration for MCP clients. A registered client holds no + * access by itself: every grant is narrowed to its MCP resource and consented to. + */ +export const registerOAuthClientContract = defineRouteContract({ method: 'POST', path: '/api/auth/oauth2/register', - body: registerSearchOAuthClientBodySchema, - response: { mode: 'json', schema: registerSearchOAuthClientResponseSchema }, + body: registerOAuthClientBodySchema, + response: { mode: 'json', schema: registerOAuthClientResponseSchema }, }) -export type RegisterSearchOAuthClientBody = z.input -export type RegisterSearchOAuthClientResponse = z.output< - typeof registerSearchOAuthClientResponseSchema -> +export type RegisterOAuthClientBody = z.input +export type RegisterOAuthClientResponse = z.output diff --git a/apps/sim/lib/api/contracts/sim-mcp.ts b/apps/sim/lib/api/contracts/sim-mcp.ts new file mode 100644 index 00000000000..a83e367fecf --- /dev/null +++ b/apps/sim/lib/api/contracts/sim-mcp.ts @@ -0,0 +1,10 @@ +import { mcpJsonRpcMessageSchema } from '@/lib/api/contracts/mcp' +import { defineRouteContract } from '@/lib/api/contracts/types' + +/** The Sim MCP server: one stateless Streamable HTTP endpoint carrying JSON-RPC. */ +export const simMcpContract = defineRouteContract({ + method: 'POST', + path: '/api/mcp', + body: mcpJsonRpcMessageSchema, + response: { mode: 'json', schema: mcpJsonRpcMessageSchema }, +}) diff --git a/apps/sim/lib/api/mcp/catalog.test.ts b/apps/sim/lib/api/mcp/catalog.test.ts new file mode 100644 index 00000000000..934bf20222b --- /dev/null +++ b/apps/sim/lib/api/mcp/catalog.test.ts @@ -0,0 +1,109 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + callerHeaderNames, + describeOperation, + getMcpOperation, + OPERATION_DOMAINS, + resolveOperation, + searchOperations, +} from '@/lib/api/mcp/catalog' +import { V2_MCP_OPERATIONS, type V2McpOperationName } from '@/lib/api/mcp/generated/v2-operations' +import { v2RouteOperation } from '@/lib/api/server/routes/v2-json-route' + +const ALL_OPERATION_NAMES = Object.keys(V2_MCP_OPERATIONS) as V2McpOperationName[] + +describe('Sim MCP catalog', () => { + /** + * Loads every route the catalog names, so a handler that is missing, or a + * declared operation the tool split cannot read, fails here rather than on a + * caller's first call. + */ + it('gives every operation exactly one tool, from the scope its route declares', async () => { + for (const name of ALL_OPERATION_NAMES) { + const route = await getMcpOperation(name).handler() + expect(typeof route, name).toBe('function') + const scope = v2RouteOperation(route)?.oauthScope + const tool = scope === 'api:read' || scope === 'search:read' ? 'read' : 'write' + const other = tool === 'read' ? 'write' : 'read' + expect(await resolveOperation(name, tool), name).toEqual({ operation: name }) + expect(await resolveOperation(name, other), name).toEqual({ error: expect.any(String) }) + } + }, 120_000) + + it('classifies by declared scope, not HTTP method', async () => { + expect(await resolveOperation('listMcpServerTools', 'read')).toEqual({ + error: 'listMcpServerTools needs write access; run it with call_write_operation.', + }) + expect(await resolveOperation('queryRows', 'read')).toEqual({ operation: 'queryRows' }) + expect(await resolveOperation('executeWorkflow', 'write')).toEqual({ + operation: 'executeWorkflow', + }) + }) + + it('suggests the closest operations for an unknown name', async () => { + expect(await resolveOperation('createTableRow', 'write')).toEqual({ + error: expect.stringContaining('createTableRows'), + }) + expect(await resolveOperation('toString', 'any')).toEqual({ + error: expect.stringContaining('Unknown operation "toString"'), + }) + }) + + it('leaves out operations a JSON tool call cannot carry', () => { + const names: readonly string[] = ALL_OPERATION_NAMES + expect(names).not.toContain('downloadFile') + expect(names).not.toContain('uploadKnowledgeDocument') + expect(names).toContain('executeWorkflow') + }) + + it('describes every operation as JSON Schema', async () => { + for (const name of ALL_OPERATION_NAMES) { + const description = await describeOperation(name) + expect(description.operation).toBe(name) + const { contract } = V2_MCP_OPERATIONS[name] + for (const slot of ['params', 'query', 'body'] as const) { + if (!contract[slot]) continue + expect( + Object.keys(description.input[slot] ?? {}).length, + `${name} ${slot}` + ).toBeGreaterThan(0) + expect(description.input[slot]).not.toHaveProperty('$schema') + } + } + }, 120_000) + + it('describes path parameters, query, body, and the tool to use', async () => { + const { input, tool, domain, description } = await describeOperation('createTable') + expect(description).toEqual(expect.any(String)) + expect(tool).toBe('call_write_operation') + expect(domain).toBe('tables') + expect(input.body).toMatchObject({ type: 'object' }) + expect(input.body?.properties).toHaveProperty('workspaceId') + expect((await describeOperation('getTable')).input.params?.properties).toHaveProperty('tableId') + }) + + it('exposes only the contract headers a caller may set', async () => { + expect(callerHeaderNames('completeFileUpload')).toEqual(['upload-token']) + expect(callerHeaderNames('listTables')).toEqual([]) + expect((await describeOperation('listTables')).input.headers).toBeUndefined() + }) + + it('ranks name matches first and names each result’s tool', async () => { + const { operations } = await searchOperations({ query: 'table rows', limit: 50 }) + expect(operations.length).toBeGreaterThan(0) + expect(operations[0].operation.toLowerCase()).toContain('rows') + expect(operations[0].operation.toLowerCase()).toContain('table') + expect(operations.every((entry) => entry.tool.startsWith('call_'))).toBe(true) + }) + + it('filters by domain and bounds the page', async () => { + expect(OPERATION_DOMAINS).toContain('workflows') + const { total, operations } = await searchOperations({ domain: 'workflows', limit: 3 }) + expect(operations).toHaveLength(3) + expect(total).toBeGreaterThan(3) + expect(operations.every((entry) => entry.domain === 'workflows')).toBe(true) + }) +}) diff --git a/apps/sim/lib/api/mcp/catalog.ts b/apps/sim/lib/api/mcp/catalog.ts new file mode 100644 index 00000000000..283fcbae93f --- /dev/null +++ b/apps/sim/lib/api/mcp/catalog.ts @@ -0,0 +1,269 @@ +import { omit, toRecord } from '@sim/utils/object' +import { z } from 'zod' +import type { ApiSchema, HttpMethod } from '@/lib/api/contracts/types' +import { V2_MCP_OPERATIONS, type V2McpOperationName } from '@/lib/api/mcp/generated/v2-operations' +import type { V2McpOperation } from '@/lib/api/mcp/types' +import { v2RouteOperation } from '@/lib/api/server/routes/v2-json-route' +import { OAUTH_API_READ_SCOPE, oauthScopeSatisfies } from '@/lib/auth/oauth-provider' + +/** Headers the dispatcher owns; a contract declaring one still never takes it from a tool call. */ +const MANAGED_HEADERS: ReadonlySet = new Set([ + 'x-api-key', + 'authorization', + 'accept', + 'content-type', + 'user-agent', +]) + +/** The tool that runs each kind of operation. */ +export const TOOL_NAMES = { read: 'call_read_operation', write: 'call_write_operation' } as const +type ToolKind = keyof typeof TOOL_NAMES + +const REQUEST_SLOTS = ['params', 'query', 'body', 'headers'] as const +type RequestSlot = (typeof REQUEST_SLOTS)[number] +type JsonSchema = Record + +/** One catalog row: enough to choose an operation, not to call it. */ +interface McpOperationEntry { + operation: V2McpOperationName + method: HttpMethod + path: string + domain: string + summary: string + /** The tool that runs it: the read tool only for operations that need nothing beyond read access. */ + /** Refuses workspace API keys; call it with a personal key or an OAuth connection. */ + personalCredentialOnly?: true +} + +interface McpOperationSummary extends McpOperationEntry { + /** The tool that runs it: the read tool only for operations that need nothing beyond read access. */ + tool: (typeof TOOL_NAMES)[ToolKind] +} + +/** Everything needed to call one operation: its documentation plus the JSON Schema of each request slot. */ +interface McpOperationDescription extends McpOperationSummary { + description?: string + input: Partial> +} + +const OPERATION_NAMES = Object.keys(V2_MCP_OPERATIONS) as V2McpOperationName[] + +export function getMcpOperation(name: V2McpOperationName): V2McpOperation { + return V2_MCP_OPERATIONS[name] +} + +/** Memo of each operation's tool; a failed load is dropped so the next call retries it. */ +const toolKinds = new Map>() + +/** + * Which tool runs an operation, from the OAuth scope its route declares rather + * than its HTTP method: a GET can need `api:write` and reach out to another + * system, and a POST can be a pure query. Raw routes declare no operation and + * all change something, so they run through the write tool. + */ +function operationToolKind(name: V2McpOperationName): Promise { + const cached = toolKinds.get(name) + if (cached) return cached + const kind = getMcpOperation(name) + .handler() + .then((route): ToolKind => { + const scope = v2RouteOperation(route)?.oauthScope + return scope && oauthScopeSatisfies([OAUTH_API_READ_SCOPE], scope) ? 'read' : 'write' + }) + .catch((error: unknown) => { + toolKinds.delete(name) + throw error + }) + toolKinds.set(name, kind) + return kind +} + +async function withTool(entry: McpOperationEntry): Promise { + return { ...entry, tool: TOOL_NAMES[await operationToolKind(entry.operation)] } +} + +function isOperationName(name: string): name is V2McpOperationName { + return Object.hasOwn(V2_MCP_OPERATIONS, name) +} + +/** `/api/v2/tables/[tableId]/rows` → `tables`. */ +function domainOf(path: string): string { + return path.split('/')[3] ?? 'v2' +} + +function summarize(name: V2McpOperationName): McpOperationEntry { + const { contract, summary, workspaceKeyUnsupported } = getMcpOperation(name) + return { + operation: name, + method: contract.method, + path: contract.path, + domain: domainOf(contract.path), + summary: summary ?? `${contract.method} ${contract.path}`, + ...(workspaceKeyUnsupported ? { personalCredentialOnly: true } : {}), + } +} + +/** Each summary with its lower-cased search fields, built once for the fixed catalog. */ +const SEARCH_ENTRIES = OPERATION_NAMES.map((name) => { + const entry = summarize(name) + return { + entry, + name: name.toLowerCase(), + summary: entry.summary.toLowerCase(), + path: entry.path.toLowerCase(), + description: getMcpOperation(name).description?.toLowerCase() ?? '', + } +}) + +/** Every domain the catalog covers, e.g. `tables`, `workflows`, `knowledge`. */ +const DOMAINS = [...new Set(SEARCH_ENTRIES.map(({ entry }) => entry.domain))].sort() +const [FIRST_DOMAIN, ...OTHER_DOMAINS] = DOMAINS +if (!FIRST_DOMAIN) throw new Error('The Sim MCP catalog has no operations') +export const OPERATION_DOMAINS: [string, ...string[]] = [FIRST_DOMAIN, ...OTHER_DOMAINS] + +/** + * Ranks operations by keyword and domain. Every term must appear in the + * operation's name, summary, path, or description; hits in the name rank first. + */ +function rankOperations( + query: string | undefined, + domain: string | undefined +): McpOperationEntry[] { + const terms = (query ?? '').toLowerCase().split(/\s+/).filter(Boolean) + const ranked: Array<{ entry: McpOperationEntry; score: number }> = [] + for (const { entry, name, summary, path, description } of SEARCH_ENTRIES) { + if (domain && entry.domain !== domain) continue + let score = 0 + for (const term of terms) { + const termScore = + (name.includes(term) ? 4 : 0) + + (summary.includes(term) ? 3 : 0) + + (path.includes(term) ? 2 : 0) + + (description.includes(term) ? 1 : 0) + if (termScore === 0) { + score = 0 + break + } + score += termScore + } + if (score > 0 || terms.length === 0) ranked.push({ entry, score }) + } + ranked.sort((a, b) => b.score - a.score || a.entry.operation.localeCompare(b.entry.operation)) + return ranked.map(({ entry }) => entry) +} + +export async function searchOperations(options: { + query?: string + domain?: string + limit: number +}): Promise<{ total: number; operations: McpOperationSummary[] }> { + const ranked = rankOperations(options.query, options.domain) + return { + total: ranked.length, + operations: await Promise.all(ranked.slice(0, options.limit).map(withTool)), + } +} + +function toJsonSchema(schema: ApiSchema): JsonSchema { + const { $schema: _, ...json } = z.toJSONSchema(schema, { io: 'input', unrepresentable: 'any' }) + return json +} + +/** + * The contract's header schema without the headers the dispatcher owns, or + * `null` when nothing is left for a caller to set. + */ +function callerHeaderSchema(schema: ApiSchema): JsonSchema | null { + const json = toJsonSchema(schema) + const properties = toRecord(json.properties) + const managed = Object.keys(properties).filter((header) => + MANAGED_HEADERS.has(header.toLowerCase()) + ) + const allowed = omit(properties, managed) + if (Object.keys(allowed).length === 0) return null + const required = Array.isArray(json.required) + ? json.required.filter((header) => !managed.includes(header)) + : undefined + return { ...json, properties: allowed, ...(required ? { required } : {}) } +} + +/** Memo over a fixed catalog: at most one entry per operation, never evicted. */ +const inputs = new Map() + +/** The JSON Schema of each request slot an operation takes. */ +function describeInput(name: V2McpOperationName): McpOperationDescription['input'] { + const cached = inputs.get(name) + if (cached) return cached + const { contract } = getMcpOperation(name) + const input: McpOperationDescription['input'] = {} + for (const slot of REQUEST_SLOTS) { + const schema = contract[slot] + if (!schema) continue + if (slot === 'headers') { + const headers = callerHeaderSchema(schema) + if (headers) input.headers = headers + continue + } + input[slot] = toJsonSchema(schema) + } + inputs.set(name, input) + return input +} + +/** Contract headers a tool call may set. */ +export function callerHeaderNames(name: V2McpOperationName): string[] { + return Object.keys(toRecord(describeInput(name).headers?.properties)) +} + +export async function describeOperation( + name: V2McpOperationName +): Promise { + const { description } = getMcpOperation(name) + return { + ...(await withTool(summarize(name))), + ...(description ? { description } : {}), + input: describeInput(name), + } +} + +/** Catalog names close to an unknown one, found by searching its camelCase words. */ +function suggestOperations(name: string): string[] { + const words = name + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean) + for (let count = words.length; count > 0; count--) { + const ranked = rankOperations(words.slice(0, count).join(' '), undefined) + if (ranked.length > 0) return ranked.slice(0, 5).map((entry) => entry.operation) + } + return [] +} + +/** + * Resolves the operation a tool call names, or explains what to call instead: + * the closest names for an unknown one, or the other tool for the wrong kind. + * `read` and `write` are the read and write tools; `any` is describe_operation. + */ +export async function resolveOperation( + name: string, + tool: ToolKind | 'any' +): Promise<{ operation: V2McpOperationName } | { error: string }> { + if (!isOperationName(name)) { + const suggestions = suggestOperations(name) + return { + error: `Unknown operation "${name}".${ + suggestions.length > 0 ? ` Closest matches: ${suggestions.join(', ')}.` : '' + } Use search_operations to find operations.`, + } + } + if (tool === 'any') return { operation: name } + const kind = await operationToolKind(name) + if (kind === tool) return { operation: name } + return { + error: + kind === 'write' + ? `${name} needs write access; run it with ${TOOL_NAMES.write}.` + : `${name} only reads; run it with ${TOOL_NAMES.read}.`, + } +} diff --git a/apps/sim/lib/api/mcp/dispatch.test.ts b/apps/sim/lib/api/mcp/dispatch.test.ts new file mode 100644 index 00000000000..e84924a743e --- /dev/null +++ b/apps/sim/lib/api/mcp/dispatch.test.ts @@ -0,0 +1,215 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' + +const mocks = vi.hoisted(() => ({ route: vi.fn(), audiences: [] as unknown[] })) + +vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.test' })) +vi.mock('@/lib/api/mcp/catalog', () => { + const contracts = { + getTableRow: { + method: 'GET', + path: '/api/v2/tables/[tableId]/rows/[rowId]', + response: { mode: 'json' }, + }, + completeFileUpload: { + method: 'POST', + path: '/api/v2/files/uploads/[uploadId]/complete', + body: z.object({ workspaceId: z.string() }), + headers: z.object({ 'upload-token': z.string() }), + response: { mode: 'json' }, + }, + } + return { + getMcpOperation: (name: keyof typeof contracts) => ({ + contract: contracts[name], + handler: async () => mocks.route, + }), + callerHeaderNames: (name: string) => (name === 'completeFileUpload' ? ['upload-token'] : []), + } +}) + +import { dispatchMcpOperation } from '@/lib/api/mcp/dispatch' +import { getOAuthAccessTokenAudience } from '@/lib/auth/oauth-access-token' + +const audience = { resource: 'https://mcp.sim.test/mcp', allowUnboundApiTokens: true } +const context = { + inbound: new NextRequest('https://mcp.sim.test/mcp', { + method: 'POST', + headers: { 'x-forwarded-for': '203.0.113.7', cookie: 'session=private' }, + }), + credential: { apiKey: null, bearer: 'sim_oat_token' }, + audience, + signal: new AbortController().signal, +} + +function jsonResponse(body: unknown, status = 200) { + return Response.json(body, { status }) +} + +function dispatched(): NextRequest { + return mocks.route.mock.calls[0][0] +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.audiences.length = 0 + mocks.route.mockImplementation(async () => { + mocks.audiences.push(getOAuthAccessTokenAudience()) + return jsonResponse({ data: { ok: true } }) + }) +}) + +describe('dispatchMcpOperation', () => { + it('builds the HTTP request the route would receive', async () => { + const result = await dispatchMcpOperation( + { + operation: 'getTableRow', + params: { tableId: 'tbl 1', rowId: 'row-1' }, + query: { workspaceId: 'ws-1', includeDeleted: false, limit: 5 }, + }, + context + ) + expect(result).toEqual({ content: [{ type: 'text', text: '{"data":{"ok":true}}' }] }) + const request = dispatched() + expect(request.method).toBe('GET') + expect(request.url).toBe( + 'https://sim.test/api/v2/tables/tbl%201/rows/row-1?workspaceId=ws-1&includeDeleted=false&limit=5' + ) + expect(await mocks.route.mock.calls[0][1].params).toEqual({ tableId: 'tbl 1', rowId: 'row-1' }) + }) + + it('carries the verified credential and inherited context, never ambient cookies', async () => { + await dispatchMcpOperation( + { operation: 'getTableRow', params: { tableId: 't', rowId: 'r' } }, + context + ) + const headers = dispatched().headers + expect(headers.get('authorization')).toBe('Bearer sim_oat_token') + expect(headers.get('x-api-key')).toBeNull() + expect(headers.get('x-forwarded-for')).toBe('203.0.113.7') + expect(headers.get('x-sim-client-info')).toBe('mcp') + expect(headers.get('cookie')).toBeNull() + }) + + it('sends an API key as x-api-key', async () => { + await dispatchMcpOperation( + { operation: 'getTableRow', params: { tableId: 't', rowId: 'r' } }, + { ...context, credential: { apiKey: 'sk-sim-key', bearer: null } } + ) + expect(dispatched().headers.get('x-api-key')).toBe('sk-sim-key') + expect(dispatched().headers.get('authorization')).toBeNull() + }) + + it('runs the route under the MCP token audience only', async () => { + await dispatchMcpOperation( + { operation: 'getTableRow', params: { tableId: 't', rowId: 'r' } }, + context + ) + expect(mocks.audiences).toEqual([audience]) + expect(getOAuthAccessTokenAudience()).toEqual({}) + }) + + it('sends a JSON body and the headers the contract declares', async () => { + await dispatchMcpOperation( + { + operation: 'completeFileUpload', + params: { uploadId: 'up-1' }, + body: { workspaceId: 'ws-1' }, + headers: { 'upload-token': 'signed' }, + }, + context + ) + const request = dispatched() + expect(request.method).toBe('POST') + expect(request.headers.get('content-type')).toBe('application/json') + expect(request.headers.get('upload-token')).toBe('signed') + expect(await request.json()).toEqual({ workspaceId: 'ws-1' }) + }) + + it.each([ + [{ tableId: 't' }, 'Missing path parameter rowId.'], + [{ tableId: 't', rowId: 'r', extra: 'x' }, 'Unknown path parameter extra'], + ])('rejects wrong path parameters %j', async (params, message) => { + const result = await dispatchMcpOperation({ operation: 'getTableRow', params }, context) + expect(result.isError).toBe(true) + expect(result.content[0]).toMatchObject({ text: expect.stringContaining(message) }) + expect(mocks.route).not.toHaveBeenCalled() + }) + + it.each([ + [{ operation: 'getTableRow', params: { tableId: '..', rowId: 'r' } }, 'cannot be "." or ".."'], + [ + { operation: 'getTableRow', params: { tableId: 't', rowId: 'r' }, body: { x: 1 } }, + 'takes no request body', + ], + [ + { + operation: 'completeFileUpload', + params: { uploadId: 'up-1' }, + body: { workspaceId: 'ws-1', stream: true }, + }, + 'Streaming is not supported', + ], + ] as const)('refuses a request the route would mishandle: %j', async (call, message) => { + const result = await dispatchMcpOperation(call, context) + expect(result.isError).toBe(true) + expect(result.content[0]).toMatchObject({ text: expect.stringContaining(message) }) + expect(mocks.route).not.toHaveBeenCalled() + }) + + it.each(['authorization', 'x-api-key', 'x-forwarded-for'])( + 'refuses to let a tool call set %s', + async (header) => { + const result = await dispatchMcpOperation( + { + operation: 'completeFileUpload', + params: { uploadId: 'up-1' }, + headers: { [header]: 'forged' }, + }, + context + ) + expect(result.isError).toBe(true) + expect(mocks.route).not.toHaveBeenCalled() + } + ) + + it('returns a route error as a tool error', async () => { + mocks.route.mockResolvedValue( + jsonResponse({ error: { code: 'NOT_FOUND', message: 'Row not found' } }, 404) + ) + const result = await dispatchMcpOperation( + { operation: 'getTableRow', params: { tableId: 't', rowId: 'r' } }, + context + ) + expect(result).toEqual({ + isError: true, + content: [{ type: 'text', text: '{"error":{"code":"NOT_FOUND","message":"Row not found"}}' }], + }) + }) + + it('refuses a streaming response', async () => { + mocks.route.mockResolvedValue( + new Response('data: {}\n\n', { headers: { 'content-type': 'text/event-stream' } }) + ) + const result = await dispatchMcpOperation( + { operation: 'getTableRow', params: { tableId: 't', rowId: 'r' } }, + context + ) + expect(result.isError).toBe(true) + expect(result.content[0]).toMatchObject({ text: expect.stringContaining('text/event-stream') }) + }) + + it('refuses a result too large to return', async () => { + mocks.route.mockResolvedValue(jsonResponse({ data: 'x'.repeat(1024 * 1024) })) + const result = await dispatchMcpOperation( + { operation: 'getTableRow', params: { tableId: 't', rowId: 'r' } }, + context + ) + expect(result.isError).toBe(true) + expect(result.content[0]).toMatchObject({ text: expect.stringContaining('too large') }) + }) +}) diff --git a/apps/sim/lib/api/mcp/dispatch.ts b/apps/sim/lib/api/mcp/dispatch.ts new file mode 100644 index 00000000000..06c71f1b7f8 --- /dev/null +++ b/apps/sim/lib/api/mcp/dispatch.ts @@ -0,0 +1,189 @@ +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js' +import { createLogger } from '@sim/logger' +import { CLIENT_INFO_HEADER } from '@sim/utils/client-info' +import { isPlainRecord } from '@sim/utils/object' +import { NextRequest } from 'next/server' +import { callerHeaderNames, getMcpOperation } from '@/lib/api/mcp/catalog' +import type { V2McpOperationName } from '@/lib/api/mcp/generated/v2-operations' +import { API_KEY_HEADER } from '@/lib/api/server/credential-headers' +import type { V2CredentialHeaders } from '@/lib/api/server/routes/v2-credential-headers' +import { + type OAuthAccessTokenOptions, + withOAuthAccessTokenAudience, +} from '@/lib/auth/oauth-access-token' +import { + consumeOrCancelBody, + isPayloadSizeLimitError, + readResponseTextWithLimit, +} from '@/lib/core/utils/stream-limits' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { toolError } from '@/lib/mcp/tool-result' + +const logger = createLogger('SimMcpDispatch') + +/** Bounds one tool result; list operations page well below this. */ +const MAX_RESULT_BYTES = 1024 * 1024 + +/** + * Headers the dispatched request inherits from the MCP request: the client + * address the v2 pre-authentication limit is keyed on, and trace context. + */ +const INHERITED_HEADERS = ['x-forwarded-for', 'traceparent', 'user-agent'] as const + +type V2RouteHandler = ( + request: NextRequest, + context: { params: Promise> } +) => Promise + +/** One tool call, as the read and write tools accept it. */ +export interface McpOperationCall { + operation: V2McpOperationName + params?: Record + query?: Record + body?: unknown + headers?: Record +} + +/** What the MCP request established: who is calling, and the audience their token was verified for. */ +export interface McpDispatchContext { + /** The MCP HTTP request the tool call arrived on. */ + inbound: NextRequest + credential: V2CredentialHeaders + audience: OAuthAccessTokenOptions + signal: AbortSignal +} + +function isRouteHandler(value: unknown): value is V2RouteHandler { + return typeof value === 'function' +} + +/** `/api/v2/tables/[tableId]` + `{ tableId }` → `/api/v2/tables/t_1`, or an error naming what is wrong. */ +function resolvePath( + template: string, + params: Record +): { path: string } | { error: string } { + const expected = [...template.matchAll(/\[([^\]]+)\]/g)].map((match) => match[1]) + const unknown = Object.keys(params).filter((name) => !expected.includes(name)) + if (unknown.length > 0) { + return { + error: `Unknown path parameter ${unknown.join(', ')}. This operation takes: ${expected.join(', ') || 'none'}.`, + } + } + const missing = expected.filter((name) => !params[name]) + if (missing.length > 0) return { error: `Missing path parameter ${missing.join(', ')}.` } + const dotSegment = expected.find((name) => params[name] === '.' || params[name] === '..') + if (dotSegment) return { error: `Path parameter ${dotSegment} cannot be "." or "..".` } + return { + path: template.replace(/\[([^\]]+)\]/g, (_, name: string) => encodeURIComponent(params[name])), + } +} + +/** The dispatched request's headers: inherited context, the verified credential, and caller-settable contract headers. */ +function buildHeaders( + call: McpOperationCall, + context: McpDispatchContext, + hasBody: boolean +): Headers | { error: string } { + const headers = new Headers({ accept: 'application/json', [CLIENT_INFO_HEADER]: 'mcp' }) + if (hasBody) headers.set('content-type', 'application/json') + for (const name of INHERITED_HEADERS) { + const value = context.inbound.headers.get(name) + if (value) headers.set(name, value) + } + if (context.credential.apiKey) headers.set(API_KEY_HEADER, context.credential.apiKey) + if (context.credential.bearer) headers.set('authorization', `Bearer ${context.credential.bearer}`) + + const allowed = callerHeaderNames(call.operation) + for (const [name, value] of Object.entries(call.headers ?? {})) { + if (!allowed.includes(name.toLowerCase())) { + return { + error: `Header ${name} cannot be set on ${call.operation}. Settable headers: ${allowed.join(', ') || 'none'}.`, + } + } + headers.set(name, value) + } + return headers +} + +/** Returns the route's JSON answer as tool content; a v2 error envelope becomes a tool error. */ +async function toToolResult( + operation: V2McpOperationName, + response: Response +): Promise { + const contentType = response.headers.get('content-type') ?? '' + if (!/[/+]json\b/i.test(contentType)) { + await consumeOrCancelBody(response) + return toolError( + `${operation} answered with ${contentType || 'an empty body'} (HTTP ${response.status}). Only JSON responses can be returned over MCP.` + ) + } + let text: string + try { + text = await readResponseTextWithLimit(response, { + maxBytes: MAX_RESULT_BYTES, + label: `${operation} result`, + }) + } catch (error) { + if (!isPayloadSizeLimitError(error)) throw error + return toolError('Result is too large. Request a smaller page with limit or cursor.') + } + return response.ok ? { content: [{ type: 'text', text }] } : toolError(text) +} + +/** + * Serves a tool call through the v2 route that owns the operation. + * + * The call becomes the HTTP request the route would receive — same path, query, + * body, and credential — and the route handles it end to end: authentication, + * OAuth scope, rate limit, validation, the application use case, and the error + * envelope. MCP adds no authorization of its own, so no operation can be looser + * here than over HTTP. + */ +export async function dispatchMcpOperation( + call: McpOperationCall, + context: McpDispatchContext +): Promise { + const startedAt = performance.now() + const { contract, handler } = getMcpOperation(call.operation) + + const resolved = resolvePath(contract.path, call.params ?? {}) + if ('error' in resolved) return toolError(resolved.error) + + const url = new URL(resolved.path, getBaseUrl()) + for (const [name, value] of Object.entries(call.query ?? {})) { + url.searchParams.set(name, String(value)) + } + + const hasBody = contract.method !== 'GET' && contract.body !== undefined + if (!hasBody && call.body !== undefined) { + return toolError(`${call.operation} takes no request body. Use params and query instead.`) + } + if (isPlainRecord(call.body) && call.body.stream === true) { + return toolError( + 'Streaming is not supported over MCP. Omit stream to wait for the result, or set async: true and poll getWorkflowRun.' + ) + } + const headers = buildHeaders(call, context, hasBody) + if ('error' in headers) return toolError(headers.error) + + const route = await handler() + if (!isRouteHandler(route)) { + throw new Error(`${call.operation} has no ${contract.method} handler at ${contract.path}`) + } + + const request = new NextRequest(url, { + method: contract.method, + headers, + body: hasBody ? JSON.stringify(call.body ?? {}) : undefined, + signal: context.signal, + }) + const response = await withOAuthAccessTokenAudience(context.audience, () => + route(request, { params: Promise.resolve(call.params ?? {}) }) + ) + logger.info('Sim MCP operation dispatched', { + operation: call.operation, + status: response.status, + durationMs: Math.round(performance.now() - startedAt), + }) + return toToolResult(call.operation, response) +} diff --git a/apps/sim/lib/api/mcp/generated/v2-operations.ts b/apps/sim/lib/api/mcp/generated/v2-operations.ts new file mode 100644 index 00000000000..37a8e4cd58a --- /dev/null +++ b/apps/sim/lib/api/mcp/generated/v2-operations.ts @@ -0,0 +1,2258 @@ +/** + * GENERATED FILE — DO NOT EDIT. + * + * Emitted from the Zod route contracts in `apps/sim/lib/api/contracts/v2/**` + * by `scripts/generate-v2-mcp-operations.ts`. Regenerate with + * `bun run generate:mcp-operations`; CI fails when this file is stale. + */ + +import { v2GetAuditLogContract, v2ListAuditLogsContract } from '@/lib/api/contracts/v2/audit-logs' +import { + v2GetBillingStatusContract, + v2ListBillingLogsContract, +} from '@/lib/api/contracts/v2/billing' +import { + v2ExecuteToolContract, + v2GetBlockContract, + v2GetToolContract, + v2ListBlocksContract, + v2ListConnectorTypesContract, + v2ListToolsContract, +} from '@/lib/api/contracts/v2/catalog' +import { v2ChatContract } from '@/lib/api/contracts/v2/chat' +import { + v2DeleteWorkflowChatDeploymentContract, + v2GetWorkflowChatDeploymentContract, + v2ListChatDeploymentsContract, + v2ReplaceWorkflowChatDeploymentContract, +} from '@/lib/api/contracts/v2/chat-deployments' +import { + v2CreateCredentialConnectionContract, + v2CreateServiceAccountCredentialContract, + v2DeleteCredentialContract, + v2ListCredentialProvidersContract, + v2ListCredentialsContract, + v2UpdateCredentialContract, +} from '@/lib/api/contracts/v2/credentials' +import { + v2CreateCustomToolContract, + v2DeleteCustomToolContract, + v2GetCustomToolContract, + v2ListCustomToolsContract, + v2UpdateCustomToolContract, +} from '@/lib/api/contracts/v2/custom-tools' +import { + v2AbortFileUploadContract, + v2BulkDeleteFilesContract, + v2CompleteFileUploadContract, + v2CreateFileContract, + v2CreateFileFolderContract, + v2CreateFileUploadContract, + v2CreateFileUploadPartUrlsContract, + v2DeleteFileContract, + v2DeleteFileFolderContract, + v2EditFileContentContract, + v2GetFileContract, + v2GetFileShareContract, + v2GetFileUploadContract, + v2ListFileFoldersContract, + v2ListFilesContract, + v2MoveFileItemsContract, + v2ReadFileTextContract, + v2RelocateFileFolderContract, + v2RenameFileContract, + v2RestoreFileContract, + v2RestoreFileFolderContract, + v2SearchFileContentContract, + v2UnzipFileContract, + v2UpdateFileContentContract, + v2UpsertFileShareContract, +} from '@/lib/api/contracts/v2/files' +import { + v2AbortKnowledgeDocumentUploadContract, + v2AddWorkspaceFilesToKnowledgeBaseContract, + v2BulkUpdateKnowledgeDocumentsContract, + v2CompleteKnowledgeDocumentUploadContract, + v2CreateKnowledgeBaseContract, + v2CreateKnowledgeConnectorContract, + v2CreateKnowledgeDocumentUploadContract, + v2CreateKnowledgeDocumentUploadPartUrlsContract, + v2CreateKnowledgeFolderContract, + v2DeleteKnowledgeBaseContract, + v2DeleteKnowledgeConnectorContract, + v2DeleteKnowledgeDocumentContract, + v2DeleteKnowledgeFolderContract, + v2GetKnowledgeBaseContract, + v2GetKnowledgeConnectorContract, + v2GetKnowledgeDocumentContract, + v2ListKnowledgeBasesContract, + v2ListKnowledgeConnectorDocumentsContract, + v2ListKnowledgeConnectorsContract, + v2ListKnowledgeDocumentsContract, + v2ListKnowledgeFoldersContract, + v2ListKnowledgeTagsContract, + v2RelocateKnowledgeFolderContract, + v2RestoreKnowledgeBaseContract, + v2SearchKnowledgeContract, + v2SyncKnowledgeConnectorContract, + v2UpdateKnowledgeBaseContract, + v2UpdateKnowledgeConnectorContract, + v2UpdateKnowledgeConnectorDocumentsContract, + v2UpdateKnowledgeDocumentContract, +} from '@/lib/api/contracts/v2/knowledge' +import { + v2BulkUpdateKnowledgeChunksContract, + v2CreateKnowledgeChunkContract, + v2DeleteKnowledgeChunkContract, + v2GetKnowledgeChunkContract, + v2ListKnowledgeChunksContract, + v2UpdateKnowledgeChunkContract, +} from '@/lib/api/contracts/v2/knowledge-chunks' +import { + v2BulkSaveKnowledgeTagDefinitionsContract, + v2CreateKnowledgeTagContract, + v2DeleteKnowledgeTagContract, + v2DeleteKnowledgeTagDefinitionsContract, + v2GetNextKnowledgeTagSlotContract, + v2ListKnowledgeTagUsageContract, + v2UpdateKnowledgeTagContract, +} from '@/lib/api/contracts/v2/knowledge-tags' +import { v2GetLogContract, v2ListLogsContract } from '@/lib/api/contracts/v2/logs' +import { v2GetLogStatsContract } from '@/lib/api/contracts/v2/logs-stats' +import { + v2CreateMcpServerContract, + v2DeleteMcpServerContract, + v2GetMcpServerContract, + v2ListMcpServersContract, + v2ListMcpServerToolsContract, + v2UpdateMcpServerContract, +} from '@/lib/api/contracts/v2/mcp-servers' +import { v2GetMetaContract } from '@/lib/api/contracts/v2/meta' +import { + v2CreateSandboxContract, + v2DeleteSandboxContract, + v2GetSandboxContract, + v2ListSandboxesContract, + v2UpdateSandboxContract, +} from '@/lib/api/contracts/v2/sandboxes' +import { + v2DeleteSecretContract, + v2ListSecretsContract, + v2SetSecretContract, +} from '@/lib/api/contracts/v2/secrets' +import { v2GetSelectorContract, v2ListSelectorContract } from '@/lib/api/contracts/v2/selectors' +import { + v2CreateSkillContract, + v2DeleteSkillContract, + v2GetSkillContract, + v2GrantSkillEditorContract, + v2ListSkillEditorsContract, + v2ListSkillsContract, + v2RevokeSkillEditorContract, + v2UpdateSkillContract, +} from '@/lib/api/contracts/v2/skills' +import { + v2AddTableColumnContract, + v2AddWorkflowGroupContract, + v2BulkDeleteTablesContract, + v2BulkUpdateTableRowsContract, + v2CancelTableDispatchContract, + v2CancelTableExportContract, + v2CancelTableImportContract, + v2CancelTableRunsContract, + v2CompleteTableImportContract, + v2CreateTableContract, + v2CreateTableDispatchContract, + v2CreateTableExportContract, + v2CreateTableFolderContract, + v2CreateTableImportContract, + v2CreateTableImportPartUrlsContract, + v2CreateTableRowsContract, + v2CreateTableViewContract, + v2DeleteTableColumnContract, + v2DeleteTableContract, + v2DeleteTableFolderContract, + v2DeleteTableRowContract, + v2DeleteTableRowsContract, + v2DeleteTableViewContract, + v2DeleteWorkflowGroupContract, + v2GetRowEnrichmentContract, + v2GetTableContract, + v2GetTableDispatchContract, + v2GetTableExportContract, + v2GetTableImportContract, + v2GetTableRowContract, + v2GetTableViewContract, + v2ListTableDispatchesContract, + v2ListTableFoldersContract, + v2ListTableRowsContract, + v2ListTablesContract, + v2ListTableViewsContract, + v2ListWorkflowGroupsContract, + v2MoveTablesContract, + v2QueryRowsContract, + v2QueryRowsCountContract, + v2RelocateTableFolderContract, + v2RestoreTableContract, + v2RestoreTableFolderContract, + v2RunRowEnrichmentContract, + v2SearchTableRowsContract, + v2TableExportDownloadContract, + v2UpdateRowsByFilterContract, + v2UpdateTableColumnContract, + v2UpdateTableContract, + v2UpdateTableRowContract, + v2UpdateTableViewContract, + v2UpdateWorkflowGroupContract, + v2UpsertTableRowContract, +} from '@/lib/api/contracts/v2/tables' +import { + v2CreateWorkflowMcpServerContract, + v2DeleteWorkflowMcpServerContract, + v2DeployWorkflowMcpToolContract, + v2GetWorkflowMcpServerContract, + v2ListWorkflowMcpServersContract, + v2ListWorkflowMcpToolsContract, + v2UndeployWorkflowMcpToolContract, + v2UpdateWorkflowMcpServerContract, +} from '@/lib/api/contracts/v2/workflow-mcp-servers' +import { + v2ActivateWorkflowVersionContract, + v2ApplyWorkflowOperationsContract, + v2ApplyWorkflowVariablesContract, + v2CancelWorkflowRunContract, + v2CreateWorkflowContract, + v2CreateWorkflowFolderContract, + v2DeleteWorkflowContract, + v2DeleteWorkflowFolderContract, + v2DeployWorkflowContract, + v2DuplicateWorkflowContract, + v2ExecuteWorkflowContract, + v2ExportWorkflowContract, + v2GetWorkflowContract, + v2GetWorkflowDeploymentContract, + v2GetWorkflowRunContract, + v2GetWorkflowStateContract, + v2GetWorkflowVersionContract, + v2ImportWorkflowContract, + v2ListWorkflowFoldersContract, + v2ListWorkflowRunsContract, + v2ListWorkflowsContract, + v2ListWorkflowVersionsContract, + v2MoveWorkflowsContract, + v2PreviewWorkflowImportContract, + v2RelocateWorkflowFolderContract, + v2ReplaceWorkflowStateContract, + v2RestoreWorkflowContract, + v2ResumeWorkflowContract, + v2RevertWorkflowVersionContract, + v2RollbackWorkflowContract, + v2UndeployWorkflowContract, + v2UpdateWorkflowContract, + v2UpdateWorkflowPublicApiContract, + v2UpdateWorkflowVersionContract, +} from '@/lib/api/contracts/v2/workflows' +import { + v2ForkWorkspaceContract, + v2GetWorkspaceForkAvailabilityContract, + v2GetWorkspaceForkLineageContract, + v2GetWorkspaceForkMappingsContract, + v2ListWorkspaceForkChildrenContract, + v2ListWorkspaceForkResourcesContract, + v2PreviewWorkspaceForkContract, + v2PreviewWorkspacePullContract, + v2PreviewWorkspacePushContract, + v2PullWorkspaceContract, + v2PushWorkspaceContract, + v2RollbackWorkspaceForkContract, + v2UnlinkWorkspaceForkContract, + v2UpdateWorkspaceForkExclusionsContract, + v2UpdateWorkspaceForkMappingsContract, +} from '@/lib/api/contracts/v2/workspace-fork' +import { + v2GetWorkspaceOperationContract, + v2ListWorkspaceOperationsContract, +} from '@/lib/api/contracts/v2/workspace-operations' +import { + v2GetWorkspaceContract, + v2ListWorkspaceMembersContract, + v2ListWorkspacesContract, +} from '@/lib/api/contracts/v2/workspaces' +import type { V2McpOperation } from '@/lib/api/mcp/types' + +export const V2_MCP_OPERATIONS = { + abortFileUpload: { + contract: v2AbortFileUploadContract, + summary: 'Abort File Upload', + description: + 'Abort an incomplete upload session and discard its uploaded data. Completed uploads cannot be aborted.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/files/uploads/[uploadId]/route').then((route) => route.DELETE), + }, + abortKnowledgeDocumentUpload: { + contract: v2AbortKnowledgeDocumentUploadContract, + summary: 'Abort Document Upload', + description: + 'Abort an incomplete upload session and discard its uploaded data. Completed uploads cannot be aborted.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/route').then( + (route) => route.DELETE + ), + }, + activateWorkflowVersion: { + contract: v2ActivateWorkflowVersionContract, + summary: 'Activate Workflow Version', + description: + 'Asynchronously activate a specific deployment version, including when the workflow is not currently deployed. The draft remains unchanged. Read Get Workflow Deployment for `isDeployed` and `latestDeploymentAttempt`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workflows/[workflowId]/versions/[version]/activate/route').then( + (route) => route.POST + ), + }, + addTableColumn: { + contract: v2AddTableColumnContract, + summary: 'Add Column', + description: + 'Add a typed column and return the complete resulting table schema.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/columns/route').then((route) => route.POST), + }, + addWorkflowGroup: { + contract: v2AddWorkflowGroupContract, + summary: 'Add Workflow Group', + description: + 'Bind a workflow or enrichment to the table and create the columns populated by its outputs.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/tables/[tableId]/groups/route').then((route) => route.POST), + }, + addWorkspaceFilesToKnowledgeBase: { + contract: v2AddWorkspaceFilesToKnowledgeBaseContract, + summary: 'Index Workspace Files', + description: + 'Queue stored workspace files for indexing without re-uploading bytes. Unreadable, unsupported, or over-100 MB files appear in `failed`; valid files are queued. Partial success returns `200`. Use Get Document to poll processing after receiving document IDs. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/documents/from-workspace-files/route').then( + (route) => route.POST + ), + }, + applyWorkflowOperations: { + contract: v2ApplyWorkflowOperationsContract, + summary: 'Apply Workflow Operations', + description: + 'Edit the draft graph and block enablement in one write. Inspect `skipped` for failures; do not retry `deferred` edges. With `atomic=true`, skipped operations or dropped inputs return `409` (`OPERATIONS_NOT_APPLIED`) without saving. `mintedBlockIds` maps labels to generated IDs. Lint is advisory; `dryRun=true` validates without saving, auditing, or notifying. The live deployment is unchanged. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workflows/[workflowId]/operations/route').then((route) => route.POST), + }, + applyWorkflowVariables: { + contract: v2ApplyWorkflowVariablesContract, + summary: 'Update Workflow Variables', + description: + 'Add, edit, or delete variables by name, applying operations in order. Values are coerced to their declared type when possible; otherwise they are stored as supplied. A batch with no changes returns `200` with `changed: false`. Read current variables with Get Workflow.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/workflows/[workflowId]/variables/route').then((route) => route.PATCH), + }, + bulkDeleteFiles: { + contract: v2BulkDeleteFilesContract, + summary: 'Delete Files', + description: + 'Archive up to 1,000 workspace files while retaining their stored bytes. Use Restore File to recover each file.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/files/bulk-delete/route').then((route) => route.POST), + }, + bulkDeleteTables: { + contract: v2BulkDeleteTablesContract, + summary: 'Bulk Delete Tables and Folders', + description: + 'Archive up to 100 selected tables and folders, including folder contents. Items succeed or fail independently, with `skipped`, `notFound`, and `failed` outcomes. `deletedItems` includes all descendants. Use Restore Table or Restore Folder to recover archived items.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/tables/bulk-delete/route').then((route) => route.POST), + }, + bulkSaveKnowledgeTagDefinitions: { + contract: v2BulkSaveKnowledgeTagDefinitionsContract, + summary: 'Bulk Save Tag Definitions', + description: + 'Create or update tag definitions, preserving unspecified slots. Updates require `originalDisplayName`; other entries create tags. Slot and name conflicts appear in per-definition `errors` with HTTP `200`, leaving conflicting values unchanged. Use Update Document to set tag values. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/tags/route').then((route) => route.PUT), + }, + bulkUpdateKnowledgeChunks: { + contract: v2BulkUpdateKnowledgeChunksContract, + summary: 'Bulk Update Chunks', + description: + 'Enable, disable, or delete multiple chunks in one best-effort request. Unknown chunk IDs appear in `errors` without failing the request; `processed` counts matched chunks, not changes. Connector-synced chunks are read-only and return `403` with `error.details.code: "CONNECTOR_MANAGED_RESOURCE_READ_ONLY"`; change the source and re-sync, or exclude the document. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/route').then( + (route) => route.PATCH + ), + }, + bulkUpdateKnowledgeDocuments: { + contract: v2BulkUpdateKnowledgeDocumentsContract, + summary: 'Bulk Enable or Disable Documents', + description: + 'Enable or disable selected documents, or use `selectAll` for the entire knowledge base. Use Delete Document to remove documents individually. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/documents/route').then( + (route) => route.PATCH + ), + }, + bulkUpdateTableRows: { + contract: v2BulkUpdateTableRowsContract, + summary: 'Bulk Update Rows', + description: + 'Apply separate partial patches to up to 1,000 rows, preserving omitted columns. A row outside the table rejects the entire request with `400` and lists missing IDs. Use Update Rows by Filter to apply one patch to every matching row.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/rows/bulk-update/route').then((route) => route.POST), + }, + cancelTableDispatch: { + contract: v2CancelTableDispatchContract, + summary: 'Cancel Run Dispatch', + description: + 'Stop a dispatch from scheduling more cells. Already queued or running cells continue; use Cancel Column Runs to stop them. Completed or canceled dispatches return unchanged.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/dispatches/[dispatchId]/route').then( + (route) => route.DELETE + ), + }, + cancelTableExport: { + contract: v2CancelTableExportContract, + summary: 'Cancel Table Export', + description: 'Cancel an export that is still in progress.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/exports/[exportId]/route').then( + (route) => route.DELETE + ), + }, + cancelTableImport: { + contract: v2CancelTableImportContract, + summary: 'Cancel Table Import', + description: + 'Cancel an upload or processing import. Committed row batches remain. Non-cancelable states, including `expired`, return `409`; unknown or purged imports return `404`.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/imports/[importId]/route').then((route) => route.DELETE), + }, + cancelTableRuns: { + contract: v2CancelTableRunsContract, + summary: 'Cancel Column Runs', + description: + 'Stop in-flight and pending workflow or enrichment cell runs across the table or one selected row.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/cancel-runs/route').then((route) => route.POST), + }, + cancelWorkflowRun: { + contract: v2CancelWorkflowRunContract, + summary: 'Cancel Workflow Run', + description: + 'Request cancellation of a running, queued, or paused workflow run. Terminal runs return successfully without changes. A table workflow-group run returns `409` if its cell can no longer accept cancellation.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/workflows/[workflowId]/runs/[runId]/cancel/route').then( + (route) => route.POST + ), + }, + chat: { + contract: v2ChatContract, + handler: () => import('@/app/api/v2/chat/route').then((route) => route.POST), + }, + completeFileUpload: { + contract: v2CompleteFileUploadContract, + summary: 'Complete File Upload', + description: + 'Finalize an upload and register its workspace file. Repeating a completed upload returns the existing file.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/files/uploads/[uploadId]/complete/route').then((route) => route.POST), + }, + completeKnowledgeDocumentUpload: { + contract: v2CompleteKnowledgeDocumentUploadContract, + summary: 'Complete Document Upload', + description: + 'Verify a direct upload or assemble multipart parts, create the knowledge document, and queue asynchronous processing.\n\nOAuth scope: `api:write`.', + handler: () => + import( + '@/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/complete/route' + ).then((route) => route.POST), + }, + completeTableImport: { + contract: v2CompleteTableImportContract, + summary: 'Complete Table Import Upload', + description: + 'Verify or assemble uploaded CSV bytes and start processing under the same import ID. Requires an import awaiting upload completion; other states return `409`. Unknown or purged imports return `404`.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/imports/[importId]/complete/route').then((route) => route.POST), + }, + createCredentialConnection: { + contract: v2CreateCredentialConnectionContract, + summary: 'Create Credential Connection', + description: + 'Create a short-lived browser URL for connecting an OAuth provider or reconnecting an existing OAuth credential. Open the URL, sign in as the authenticated user, complete provider authorization, then refresh the credentials list. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/credentials/connections/route').then((route) => route.POST), + }, + createCustomTool: { + contract: v2CreateCustomToolContract, + summary: 'Create Custom Tool', + description: + 'Create a code-backed custom tool in a workspace. Its title must be unique because tools resolve by title at call time.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/custom-tools/route').then((route) => route.POST), + }, + createFile: { + contract: v2CreateFileContract, + summary: 'Create File', + description: + 'Create a workspace file from inline UTF-8 or base64 content. Use an upload session for streamed or larger files.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/files/route').then((route) => route.POST), + }, + createFileFolder: { + contract: v2CreateFileFolderContract, + summary: 'Create Folder', + description: 'Create a folder at the supplied workspace path.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/files/folders/route').then((route) => route.POST), + }, + createFileUpload: { + contract: v2CreateFileUploadContract, + summary: 'Create File Upload', + description: + 'Create a resumable upload session and receive either a signed PUT URL or multipart instructions.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/files/uploads/route').then((route) => route.POST), + }, + createFileUploadPartUrls: { + contract: v2CreateFileUploadPartUrlsContract, + summary: 'Create File Upload Part URLs', + description: + 'Create signed URLs for a bounded set of multipart upload part numbers.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/files/uploads/[uploadId]/parts/route').then((route) => route.POST), + }, + createKnowledgeBase: { + contract: v2CreateKnowledgeBaseContract, + summary: 'Create Knowledge Base', + description: + 'Create a knowledge base in a workspace with optional folder placement and chunking configuration. An unknown `folderPath` returns `404`. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/knowledge/route').then((route) => route.POST), + }, + createKnowledgeChunk: { + contract: v2CreateKnowledgeChunkContract, + summary: 'Create Chunk', + description: + 'Append a chunk, embedding it before the response so it is immediately searchable. It inherits the document\'s tags and next `chunkIndex`. Connector-synced chunks are read-only and return `403` with `error.details.code: "CONNECTOR_MANAGED_RESOURCE_READ_ONLY"`; change the source and re-sync, or exclude the document. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/route').then( + (route) => route.POST + ), + }, + createKnowledgeConnector: { + contract: v2CreateKnowledgeConnectorContract, + summary: 'Create Knowledge Connector', + description: + 'Validate and connect an external source, then queue its initial synchronization. The `apiKey` field is never returned. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/connectors/route').then( + (route) => route.POST + ), + }, + createKnowledgeDocumentUpload: { + contract: v2CreateKnowledgeDocumentUploadContract, + summary: 'Create Document Upload', + description: + 'Create a resumable upload session and receive direct PUT or multipart transfer instructions.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/route').then( + (route) => route.POST + ), + }, + createKnowledgeDocumentUploadPartUrls: { + contract: v2CreateKnowledgeDocumentUploadPartUrlsContract, + summary: 'Create Document Upload Part URLs', + description: + 'Create short-lived signed PUT URLs for up to 100 multipart part numbers.\n\nOAuth scope: `api:write`.', + handler: () => + import( + '@/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/parts/route' + ).then((route) => route.POST), + }, + createKnowledgeFolder: { + contract: v2CreateKnowledgeFolderContract, + summary: 'Create Folder', + description: + 'Create a folder in the knowledge-base folder tree. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/knowledge/folders/route').then((route) => route.POST), + }, + createKnowledgeTag: { + contract: v2CreateKnowledgeTagContract, + summary: 'Create Tag', + description: + 'Create a tag definition. Write document values by `tagSlot` and filter by `displayName`. Omitting `tagSlot` selects a free slot; exhaustion returns `400`. An occupied slot or duplicate name returns `409`. Use Bulk Save Tag Definitions for multiple definitions. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/tags/route').then((route) => route.POST), + }, + createMcpServer: { + contract: v2CreateMcpServerContract, + summary: 'Create MCP Server', + description: + 'Register an external MCP server without connecting to it. A duplicate URL returns `409`; use Update MCP Server to change the existing registration. The server remains disconnected until List MCP Server Tools succeeds.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/mcp-servers/route').then((route) => route.POST), + }, + createSandbox: { + contract: v2CreateSandboxContract, + summary: 'Create Sandbox', + description: + 'Create a uniquely named dependency environment. If a build is needed, track readiness with `buildStatus`; null means no build is required. Invalid dependencies return `400` with field details. Requires workspace admin access on Max or Enterprise. Creates and updates share a rate limit; respect `Retry-After`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/sandboxes/route').then((route) => route.POST), + }, + createServiceAccountCredential: { + contract: v2CreateServiceAccountCredentialContract, + summary: 'Create Service-Account Credential', + description: + 'Verify and store a service-account credential using the fields from List Credential Providers, encoded as a JSON object string in `credentials`. Secrets are never returned. A matching source returns the existing credential with `200`; creation returns `201`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/credentials/route').then((route) => route.POST), + }, + createSkill: { + contract: v2CreateSkillContract, + summary: 'Create Skill', + description: + 'Create one skill in a workspace. Its kebab-case name must be unique and cannot be reserved by a built-in skill. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/skills/route').then((route) => route.POST), + }, + createTable: { + contract: v2CreateTableContract, + summary: 'Create Table', + description: + 'Create a table with a typed column schema and optional folder placement.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/tables/route').then((route) => route.POST), + }, + createTableDispatch: { + contract: v2CreateTableDispatchContract, + summary: 'Create Run Dispatch', + description: + 'Start workflow or enrichment groups across all rows or selected rows. Poll Get Run Dispatch until `complete` or `canceled`. A null `dispatchId` means no dispatch is available to poll; check row outcomes with `includeRunState`. Use Cancel Run Dispatch to stop further scheduling.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/dispatches/route').then((route) => route.POST), + }, + createTableExport: { + contract: v2CreateTableExportContract, + summary: 'Create Table Export', + description: + 'Create a CSV or JSON export. Exports of small tables finish during the request; larger exports run asynchronously.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/exports/route').then((route) => route.POST), + }, + createTableFolder: { + contract: v2CreateTableFolderContract, + summary: 'Create Folder', + description: + 'Create one table-folder leaf whose parent path already exists.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/tables/folders/route').then((route) => route.POST), + }, + createTableImport: { + contract: v2CreateTableImportContract, + summary: 'Create Table Import', + description: + 'Create a CSV import. Upload sources receive signed transfer instructions; workspace-file sources start processing directly.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/tables/imports/route').then((route) => route.POST), + }, + createTableImportPartUrls: { + contract: v2CreateTableImportPartUrlsContract, + summary: 'Create Table Import Part URLs', + description: + 'Create signed URLs for multipart upload parts. Requires the `uploading` state; other states return `409`. Unknown or purged imports return `404`.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/imports/[importId]/parts/route').then((route) => route.POST), + }, + createTableRows: { + contract: v2CreateTableRowsContract, + summary: 'Create Rows', + description: + 'Insert one row with a data object or insert a bounded batch with a rows array. Cell keys are column names.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/tables/[tableId]/rows/route').then((route) => route.POST), + }, + createTableView: { + contract: v2CreateTableViewContract, + summary: 'Create View', + description: + 'Save a filter, sort, and column layout as a named presentation of a table.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/tables/[tableId]/views/route').then((route) => route.POST), + }, + createWorkflow: { + contract: v2CreateWorkflowContract, + summary: 'Create Workflow', + description: + 'Create a workflow at the workspace root or in a workflow folder. The response includes seeded blocks and their IDs for attaching edges. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/workflows/route').then((route) => route.POST), + }, + createWorkflowFolder: { + contract: v2CreateWorkflowFolderContract, + summary: 'Create Workflow Folder', + description: + 'Create a workflow folder in a workspace. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/workflows/folders/route').then((route) => route.POST), + }, + createWorkflowMcpServer: { + contract: v2CreateWorkflowMcpServerContract, + summary: 'Create Workflow MCP Server', + description: + 'Create an MCP server that exposes deployed workflows as tools. Every supplied workflow must already be deployed. With `isPublic: true`, anyone with the server URL can execute its workflows without a Sim API key. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/workflow-mcp-servers/route').then((route) => route.POST), + }, + deleteCredential: { + contract: v2DeleteCredentialContract, + summary: 'Disconnect Credential', + description: + 'Disconnect an OAuth or service-account credential and clear its stored workflow, deployment, paused-run, knowledge-connector, and webhook references. Credential admin access is required. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/credentials/[credentialId]/route').then((route) => route.DELETE), + }, + deleteCustomTool: { + contract: v2DeleteCustomToolContract, + summary: 'Delete Custom Tool', + description: + 'Delete a custom tool. Agent blocks retain their configuration but can no longer call the deleted tool.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/custom-tools/[customToolId]/route').then((route) => route.DELETE), + }, + deleteFile: { + contract: v2DeleteFileContract, + summary: 'Delete File', + description: + 'Archive a workspace file, retaining its stored bytes and removing API read access. List Files with `scope=archived` finds it; Restore File recovers it. Archiving an already archived file returns `404`.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/files/[fileId]/route').then((route) => route.DELETE), + }, + deleteFileFolder: { + contract: v2DeleteFileFolderContract, + summary: 'Delete Folder', + description: + 'Archive an empty folder, or set `recursive=true` to archive its files and subfolders. Use Restore Folder to recover the archived contents.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/files/folders/route').then((route) => route.DELETE), + }, + deleteKnowledgeBase: { + contract: v2DeleteKnowledgeBaseContract, + summary: 'Delete Knowledge Base', + description: + 'Archive a knowledge base, its documents, and its connectors, pausing synchronization. Use List Knowledge Bases with `scope=archived` to find it and Restore Knowledge Base to recover it.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/route').then((route) => route.DELETE), + }, + deleteKnowledgeChunk: { + contract: v2DeleteKnowledgeChunkContract, + summary: 'Delete Chunk', + description: + 'Permanently remove one chunk and subtract it from document counts. Remaining `chunkIndex` values stay stable and may become non-contiguous. Connector-synced chunks are read-only and return `403` with `error.details.code: "CONNECTOR_MANAGED_RESOURCE_READ_ONLY"`; change the source and re-sync, or exclude the document. Documents that have not finished processing return `409` with their current status. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import( + '@/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/[chunkId]/route' + ).then((route) => route.DELETE), + }, + deleteKnowledgeConnector: { + contract: v2DeleteKnowledgeConnectorContract, + summary: 'Delete Knowledge Connector', + description: + 'Delete a connector and optionally its synchronized documents. Documents are retained by default. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/connectors/[connectorId]/route').then( + (route) => route.DELETE + ), + }, + deleteKnowledgeDocument: { + contract: v2DeleteKnowledgeDocumentContract, + summary: 'Delete Document', + description: + 'Remove a document from listings and search. Uploaded documents and their chunks are deleted. Connector documents are excluded while retaining their stored data; later synchronization does not re-add them.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/route').then( + (route) => route.DELETE + ), + }, + deleteKnowledgeFolder: { + contract: v2DeleteKnowledgeFolderContract, + summary: 'Delete Folder', + description: + 'Archive an empty folder, or set `recursive=true` to archive its subfolders and knowledge bases. Use Restore Knowledge Base to recover knowledge bases.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/knowledge/folders/route').then((route) => route.DELETE), + }, + deleteKnowledgeTag: { + contract: v2DeleteKnowledgeTagContract, + summary: 'Delete Tag', + description: + 'Permanently delete a tag definition and its values from every document and chunk in the knowledge base. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/tags/[tagId]/route').then( + (route) => route.DELETE + ), + }, + deleteKnowledgeTagDefinitions: { + contract: v2DeleteKnowledgeTagDefinitionsContract, + summary: 'Delete Tag Definitions', + description: + 'Delete unused tag definitions by default. With `unused=false`, permanently delete all definitions and their values from documents and chunks. Use Delete Tag to remove one definition. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/tags/route').then((route) => route.DELETE), + }, + deleteMcpServer: { + contract: v2DeleteMcpServerContract, + summary: 'Delete MCP Server', + description: + "Remove an MCP server and revoke its OAuth tokens. Workflows retain blocks that referenced the server's tools, but those tools can no longer be called.\n\nOAuth scope: `api:write`.", + handler: () => + import('@/app/api/v2/mcp-servers/[mcpServerId]/route').then((route) => route.DELETE), + }, + deleteSandbox: { + contract: v2DeleteSandboxContract, + summary: 'Delete Sandbox', + description: + 'Delete a sandbox. Function blocks using it fail until reconfigured. Requires workspace admin access on Max or Enterprise. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/sandboxes/[sandboxId]/route').then((route) => route.DELETE), + }, + deleteSecret: { + contract: v2DeleteSecretContract, + summary: 'Delete Secret', + description: + 'Delete a workspace or caller-owned personal secret without reading or returning its stored value. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/secrets/[name]/route').then((route) => route.DELETE), + }, + deleteSkill: { + contract: v2DeleteSkillContract, + summary: 'Delete Skill', + description: + 'Delete a workspace skill. Built-in skills are read-only and cannot be deleted. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/skills/[skillId]/route').then((route) => route.DELETE), + }, + deleteTable: { + contract: v2DeleteTableContract, + summary: 'Delete Table', + description: + 'Archive a table while retaining its rows. Use List Tables with `scope=archived` to find it and Restore Table to recover it.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/tables/[tableId]/route').then((route) => route.DELETE), + }, + deleteTableColumn: { + contract: v2DeleteTableColumnContract, + summary: 'Delete Column', + description: + 'Delete a column by name while preserving at least one table column.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/columns/route').then((route) => route.DELETE), + }, + deleteTableFolder: { + contract: v2DeleteTableFolderContract, + summary: 'Delete Folder', + description: + 'Archive an empty folder, or set `recursive=true` to archive its tables and subfolders. Use Restore Folder to recover the archived contents.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/tables/folders/route').then((route) => route.DELETE), + }, + deleteTableRow: { + contract: v2DeleteTableRowContract, + summary: 'Delete Row', + description: 'Delete one row by identifier.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/rows/[rowId]/route').then((route) => route.DELETE), + }, + deleteTableRows: { + contract: v2DeleteTableRowsContract, + summary: 'Delete Rows', + description: + 'Delete rows by a non-empty predicate or an explicit bounded list of row identifiers.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/tables/[tableId]/rows/route').then((route) => route.DELETE), + }, + deleteTableView: { + contract: v2DeleteTableViewContract, + summary: 'Delete View', + description: + 'Delete a saved presentation without changing any table rows.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/views/[viewId]/route').then((route) => route.DELETE), + }, + deleteWorkflow: { + contract: v2DeleteWorkflowContract, + summary: 'Delete Workflow', + description: + 'Archive a workflow and stop its schedules, webhooks, MCP tools, and chats. Use List Workflows with `scope=archived` to find it and Restore Workflow to recover it and its archived resources. Both `deleted` and `archived` acknowledge archival.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/workflows/[workflowId]/route').then((route) => route.DELETE), + }, + deleteWorkflowChatDeployment: { + contract: v2DeleteWorkflowChatDeploymentContract, + summary: 'Delete Workflow Chat Deployment', + description: + "Remove a workflow's hosted chat and release its URL identifier. The workflow API deployment remains active; use Undeploy Workflow to stop it. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workflows/[workflowId]/deployments/chat/route').then( + (route) => route.DELETE + ), + }, + deleteWorkflowFolder: { + contract: v2DeleteWorkflowFolderContract, + summary: 'Delete Workflow Folder', + description: + 'Archive an empty workflow folder, or set `recursive=true` to archive its subfolders and workflows. Use Restore Workflow to recover workflows.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/workflows/folders/route').then((route) => route.DELETE), + }, + deleteWorkflowGroup: { + contract: v2DeleteWorkflowGroupContract, + summary: 'Delete Workflow Group', + description: + 'Delete a workflow group and every table column populated by that group.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/groups/route').then((route) => route.DELETE), + }, + deleteWorkflowMcpServer: { + contract: v2DeleteWorkflowMcpServerContract, + summary: 'Delete Workflow MCP Server', + description: + 'Delete a workflow MCP server and stop serving its tools. The underlying workflows remain deployed and executable through the workflow API. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workflow-mcp-servers/[serverId]/route').then((route) => route.DELETE), + }, + deployWorkflow: { + contract: v2DeployWorkflowContract, + summary: 'Deploy Workflow', + description: + 'Create and asynchronously activate a deployment version. Every call creates a new version; retrying after a timeout can create a duplicate. Read Get Workflow Deployment to check activation. A conflicting webhook path returns `409`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workflows/[workflowId]/deploy/route').then((route) => route.POST), + }, + deployWorkflowMcpTool: { + contract: v2DeployWorkflowMcpToolContract, + summary: 'Publish Workflow As MCP Tool', + description: + 'Publish a deployed workflow as an MCP tool using its deployed input schema. Each server has at most one tool per workflow; repeating the call replaces that tool and returns `200` with `updated: true`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workflow-mcp-servers/[serverId]/tools/route').then( + (route) => route.POST + ), + }, + duplicateWorkflow: { + contract: v2DuplicateWorkflowContract, + summary: 'Duplicate Workflow', + description: + "Copy a workflow's graph and variables into the same workspace. Omit `name` to reuse the source name; name collisions in the destination folder are resolved automatically. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.", + handler: () => + import('@/app/api/v2/workflows/[workflowId]/duplicate/route').then((route) => route.POST), + }, + editFileContent: { + contract: v2EditFileContentContract, + summary: 'Edit File Content', + description: + 'Edit part of a UTF-8 file; use Replace File Content to replace it entirely. Search-and-replace requires one exact match unless `replaceAll` is true. Anchored modes match trimmed complete lines; their input descriptions specify boundary handling. Non-UTF-8 files return `400`. Concurrent writes return `409`; re-read before retrying.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/files/[fileId]/content/route').then((route) => route.PATCH), + }, + executeTool: { + contract: v2ExecuteToolContract, + summary: 'Run Tool', + description: + 'Run a built-in tool using published parameter IDs. Sim resolves `credentialId`, hosted keys, and whole-value `{{VAR_NAME}}` references for `user-only` parameters; other values pass through verbatim. Third-party refusal returns `200` with `status: "failed"`; the error envelope covers API failures. Hidden or missing tools return `404`; disallowed integrations return `403` with `error.details.code: INTEGRATION_NOT_ALLOWED`. Hosted-key use is billed to the workspace. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/tools/[toolId]/execute/route').then((route) => route.POST), + }, + executeWorkflow: { + contract: v2ExecuteWorkflowContract, + summary: 'Execute Workflow', + description: + 'Execute a deployment or use `run.source: "manual"` for the draft. Manual runs require personal or OAuth write access and reject async. Public deployments allow anonymous sync or streaming. Request `application/x-ndjson` for heartbeats and the final result. Timeouts return `200` with failed status and `TIMEOUT`. Supply `X-Run-Id` to prevent duplicate execution; reuse returns `409`, never a replay. Input descriptions specify compatible modes; invalid combinations return `400`.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/workflows/[workflowId]/execute/route').then((route) => route.POST), + }, + exportWorkflow: { + contract: v2ExportWorkflowContract, + summary: 'Export Workflow', + description: + 'Export a portable, secret-sanitized workflow; Set includeReferences=true to include non-secret source reference identities for mapped import; default exports keep their existing sanitized shape. Exporting records an audit event. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/workflows/[workflowId]/export/route').then((route) => route.GET), + }, + forkWorkspace: { + contract: v2ForkWorkspaceContract, + summary: 'Fork Workspace', + description: + 'Create a child workspace with undeployed workflow drafts. Requires the reviewed preview fingerprint and a stable request ID. Identical retries return the same operation; reuse with different inputs returns 409. Poll Get Workspace Operation until selected resource copies complete. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/fork/route').then((route) => route.POST), + }, + getAuditLog: { + contract: v2GetAuditLogContract, + summary: 'Get Audit Log', + description: + 'Get one organization audit-log entry. Requires an Enterprise subscription and organization admin or owner access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/audit-logs/[auditLogId]/route').then((route) => route.GET), + }, + getBillingStatus: { + contract: v2GetBillingStatusContract, + summary: 'Get Billing Status', + description: + "Get the current plan, billing standing, credit allowance, and storage quota. Pooled `credits` and `storage` are visible only to callers who can manage the payer's billing; workspace API keys receive null for both. Use List Billing Logs for credit history.\n\nOAuth scope: `api:read`.", + handler: () => import('@/app/api/v2/billing/status/route').then((route) => route.GET), + }, + getBlock: { + contract: v2GetBlockContract, + summary: 'Get Block', + description: + "Get a block's fields, conditions, operations, tool schemas, and triggers. Unversioned types resolve to the newest visible version; the returned `id` identifies that version. Hidden or missing blocks return `404`.\n\nOAuth scope: `api:read`.", + handler: () => import('@/app/api/v2/blocks/[blockId]/route').then((route) => route.GET), + }, + getCustomTool: { + contract: v2GetCustomToolContract, + summary: 'Get Custom Tool', + description: + 'Get one custom tool by identifier, scoped to its workspace.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/custom-tools/[customToolId]/route').then((route) => route.GET), + }, + getFile: { + contract: v2GetFileContract, + summary: 'Get File Metadata', + description: + 'Get file metadata and its public-share configuration. The `share` field is null when the file has never been shared.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/files/[fileId]/metadata/route').then((route) => route.GET), + }, + getFileShare: { + contract: v2GetFileShareContract, + summary: 'Get File Share', + description: + "Get a file's public-share configuration. An unshared file returns `data: null`; a disabled share returns its configuration with `isActive: false`.\n\nOAuth scope: `api:read`.", + handler: () => import('@/app/api/v2/files/[fileId]/share/route').then((route) => route.GET), + }, + getFileUpload: { + contract: v2GetFileUploadContract, + summary: 'Get File Upload', + description: + "Get an upload session's state to determine whether an interrupted transfer can resume. Requires the signed upload token and current workspace access.\n\nOAuth scope: `api:read`.", + handler: () => import('@/app/api/v2/files/uploads/[uploadId]/route').then((route) => route.GET), + }, + getKnowledgeBase: { + contract: v2GetKnowledgeBaseContract, + summary: 'Get Knowledge Base', + description: + "Get a knowledge base's metadata and document counts. Inaccessible knowledge bases return `404`. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.", + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/route').then((route) => route.GET), + }, + getKnowledgeChunk: { + contract: v2GetKnowledgeChunkContract, + summary: 'Get Chunk', + description: + 'Get one chunk of a document, including the exact text that was embedded. Documents that have not finished processing return `409` with their current status. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import( + '@/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/[chunkId]/route' + ).then((route) => route.GET), + }, + getKnowledgeConnector: { + contract: v2GetKnowledgeConnectorContract, + summary: 'Get Knowledge Connector', + description: + 'Get one connector and its ten most recent synchronization attempts. Stored API keys are never returned. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/connectors/[connectorId]/route').then( + (route) => route.GET + ), + }, + getKnowledgeDocument: { + contract: v2GetKnowledgeDocumentContract, + summary: 'Get Document', + description: + 'Get document metadata, processing status, and source connector details.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/route').then( + (route) => route.GET + ), + }, + getLog: { + contract: v2GetLogContract, + summary: 'Get Log', + description: + "Get a run's workflow graph, trace spans, final output, and cost. Trace spans expire separately, so an empty `traceSpans` array does not prove none were recorded. Expired runs are permanently deleted. Retention is 30 days from run start on Free, unlimited on Pro and Team, and configured per organization on Enterprise with workspace overrides. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.", + handler: () => import('@/app/api/v2/logs/[runId]/route').then((route) => route.GET), + }, + getLogStats: { + contract: v2GetLogStatsContract, + summary: 'Get Log Statistics', + description: + 'Get run counts, success and error counts, and latency by workspace or workflow. Default bounds span recorded runs, or the last 24 hours when empty. Buckets may extend past the end. Folder filters include descendants; `workflowsTruncated` affects series, not totals. Expired runs are permanently deleted. Retention is 30 days from run start on Free, unlimited on Pro and Team, and configured per organization on Enterprise with workspace overrides. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/logs/stats/route').then((route) => route.GET), + }, + getMcpServer: { + contract: v2GetMcpServerContract, + summary: 'Get MCP Server', + description: + 'Get one MCP server by identifier. Request-header values and OAuth client secrets are never returned.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/mcp-servers/[mcpServerId]/route').then((route) => route.GET), + }, + getMeta: { + contract: v2GetMetaContract, + summary: 'Get API Capabilities', + description: + 'Get whether v2 is available, what kind of API credential is calling, and when it expires. Requires a valid API key or OAuth access token.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/meta/route').then((route) => route.GET), + }, + getNextKnowledgeTagSlot: { + contract: v2GetNextKnowledgeTagSlotContract, + summary: 'Get Next Tag Slot', + description: + 'Get the next available slot and remaining capacity for a field type. This does not reserve a slot. Create Tag selects a free slot when `tagSlot` is omitted. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/tags/next-slot/route').then( + (route) => route.GET + ), + }, + getRowEnrichment: { + contract: v2GetRowEnrichmentContract, + summary: 'Get Enrichment Run Detail', + description: + "Get an enrichment cell's provider attempts, statuses, hosted-key costs, durations, and matching provider. Null means no run detail was recorded; `404` means the table, row, or group does not exist.\n\nOAuth scope: `api:read`.", + handler: () => + import('@/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route').then( + (route) => route.GET + ), + }, + getSandbox: { + contract: v2GetSandboxContract, + summary: 'Get Sandbox', + description: + 'Get one sandbox by identifier, scoped to its workspace, including its current build state and any build failure.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/sandboxes/[sandboxId]/route').then((route) => route.GET), + }, + getSelector: { + contract: v2GetSelectorContract, + summary: 'Get Selector Option', + description: + 'Resolve a workspace configuration option by its provider identifier and declared dependencies. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/selectors/get/route').then((route) => route.POST), + }, + getSkill: { + contract: v2GetSkillContract, + summary: 'Get Skill', + description: + 'Get one workspace or built-in skill, including its full content. Built-in skills are marked read-only.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/skills/[skillId]/route').then((route) => route.GET), + }, + getTable: { + contract: v2GetTableContract, + summary: 'Get Table', + description: + 'Get a table with its metadata, column schema, locks, and current job. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/tables/[tableId]/route').then((route) => route.GET), + }, + getTableDispatch: { + contract: v2GetTableDispatchContract, + summary: 'Get Run Dispatch', + description: + "Get a dispatch's current state. Poll until `complete` or `canceled`; use row reads with `includeRunState` for per-cell outcomes.\n\nOAuth scope: `api:read`.", + handler: () => + import('@/app/api/v2/tables/[tableId]/dispatches/[dispatchId]/route').then( + (route) => route.GET + ), + }, + getTableExport: { + contract: v2GetTableExportContract, + summary: 'Get Table Export', + description: "Get a table export's progress and status.\n\nOAuth scope: `api:read`.", + handler: () => + import('@/app/api/v2/tables/[tableId]/exports/[exportId]/route').then((route) => route.GET), + }, + getTableImport: { + contract: v2GetTableImportContract, + summary: 'Get Table Import', + description: + "Get an import's progress and status. During `uploading`, the signed upload token is required; omitting it returns `404`.\n\nOAuth scope: `api:read`.", + handler: () => + import('@/app/api/v2/tables/imports/[importId]/route').then((route) => route.GET), + }, + getTableRow: { + contract: v2GetTableRowContract, + summary: 'Get Row', + description: + "Get one row by identifier. Set `includeRunState=true` to attach the row's per-workflow-group run outcomes.\n\nOAuth scope: `api:read`.", + handler: () => + import('@/app/api/v2/tables/[tableId]/rows/[rowId]/route').then((route) => route.GET), + }, + getTableView: { + contract: v2GetTableViewContract, + summary: 'Get View', + description: 'Get one saved table view by identifier.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/views/[viewId]/route').then((route) => route.GET), + }, + getTool: { + contract: v2GetToolContract, + summary: 'Get Tool', + description: + "Get a built-in tool's parameters and outputs. Registered IDs resolve exactly; other names resolve to the newest family version. The returned `id` identifies the resolved tool. Hidden or missing tools return `404`.\n\nOAuth scope: `api:read`.", + handler: () => import('@/app/api/v2/tools/[toolId]/route').then((route) => route.GET), + }, + getWorkflow: { + contract: v2GetWorkflowContract, + summary: 'Get Workflow', + description: + 'Get a workflow with its variables and deployed API-trigger inputs. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/workflows/[workflowId]/route').then((route) => route.GET), + }, + getWorkflowChatDeployment: { + contract: v2GetWorkflowChatDeploymentContract, + summary: 'Get Workflow Chat Deployment', + description: + "Get a workflow's hosted chat and visitor access settings. Requires workspace admin access; a missing chat returns `404`. Passwords are never returned; `hasPassword` indicates whether one is set. Hosted chat and workflow API deployment are managed separately. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workflows/[workflowId]/deployments/chat/route').then( + (route) => route.GET + ), + }, + getWorkflowDeployment: { + contract: v2GetWorkflowDeploymentContract, + summary: 'Get Workflow Deployment', + description: + 'Get the live version, latest deployment attempt, readiness, draft changes (`needsRedeployment`), and public API access. With `isPublicApi: true`, anyone with the execution URL can run the workflow and consume billed usage without an API key. Hosted chat is managed separately.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/workflows/[workflowId]/deployment/route').then((route) => route.GET), + }, + getWorkflowMcpServer: { + contract: v2GetWorkflowMcpServerContract, + summary: 'Get Workflow MCP Server', + description: + "Get a published workflow MCP server's metadata and client endpoint. Use List Workflow MCP Tools for its tool inventory. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workflow-mcp-servers/[serverId]/route').then((route) => route.GET), + }, + getWorkflowRun: { + contract: v2GetWorkflowRunContract, + summary: 'Get Workflow Run', + description: + 'Get current run state with optional final and block outputs. With `includeOutput`, `files` includes download paths; `includeFileBase64` inlines file bytes and returns `413` with the download path when one file or the total exceeds 16 MiB. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/workflows/[workflowId]/runs/[runId]/route').then((route) => route.GET), + }, + getWorkflowState: { + contract: v2GetWorkflowStateContract, + summary: 'Get Workflow State', + description: + 'Get the editable draft graph, including blocks, edges, loop and parallel containers, and variables. Use this state with Replace Workflow State to preserve workspace bindings; Export Workflow removes those bindings for portability. This read records no audit event, and `HEAD` mirrors `GET`.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/workflows/[workflowId]/state/route').then((route) => route.GET), + }, + getWorkflowVersion: { + contract: v2GetWorkflowVersionContract, + summary: 'Get Workflow Version', + description: + 'Get an immutable deployment version and its pinned workflow graph snapshot.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/workflows/[workflowId]/versions/[version]/route').then( + (route) => route.GET + ), + }, + getWorkspace: { + contract: v2GetWorkspaceContract, + summary: 'Get Workspace', + description: 'Get metadata for an accessible workspace.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/workspaces/[workspaceId]/route').then((route) => route.GET), + }, + getWorkspaceForkAvailability: { + contract: v2GetWorkspaceForkAvailabilityContract, + summary: 'Get Workspace Fork Availability', + description: + 'Inspect workspace fork information and copyable resources. Lineage does not grant access to the other workspace; fork creation requires source admin and sync requires admin on both sides. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/fork/availability/route').then( + (route) => route.GET + ), + }, + getWorkspaceForkLineage: { + contract: v2GetWorkspaceForkLineageContract, + summary: 'Get Workspace Fork Lineage', + description: + 'Inspect workspace fork information and copyable resources. Lineage does not grant access to the other workspace; fork creation requires source admin and sync requires admin on both sides. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/fork/lineage/route').then((route) => route.GET), + }, + getWorkspaceForkMappings: { + contract: v2GetWorkspaceForkMappingsContract, + summary: 'Get Workspace Fork Mappings', + description: + 'Read persisted mappings in the requested source-to-target direction. Candidate discovery uses the destination resource and selector listing operations. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/fork/mappings/route').then( + (route) => route.GET + ), + }, + getWorkspaceOperation: { + contract: v2GetWorkspaceOperationContract, + summary: 'Get Workspace Operation', + description: + 'Read a committed operation, copy progress, exact deployment readiness, and structured issues. A failed follow-up does not mean the business transaction was rolled back.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/operations/[operationId]/route').then( + (route) => route.GET + ), + }, + grantSkillEditor: { + contract: v2GrantSkillEditorContract, + summary: 'Grant Skill Editor', + description: + 'Grant skill editor access to a workspace member by email. Requires an existing editor or workspace admin; admins already have access and cannot receive explicit grants. Existing grants return `200`; new grants return `201`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/skills/[skillId]/editors/route').then((route) => route.POST), + }, + importWorkflow: { + contract: v2ImportWorkflowContract, + summary: 'Import Workflow', + description: + 'Create an undeployed workflow from a portable export object, bare state, or JSON string. Mapping options require a preview fingerprint and stable request ID; unresolved required configuration creates nothing. Mapped imports return source-to-imported block IDs and an operation receipt. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/workflows/import/route').then((route) => route.POST), + }, + listAuditLogs: { + contract: v2ListAuditLogsContract, + summary: 'List Audit Logs', + description: + 'List an organization audit trail with filters and opaque cursor pagination. Requires an Enterprise subscription and organization admin or owner access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/audit-logs/route').then((route) => route.GET), + }, + listBillingLogs: { + contract: v2ListBillingLogsContract, + summary: 'List Billing Logs', + description: + 'List credit usage with source filtering and cursor pagination. The default `period` is `30d`; pagination covers only the selected time window. An inverted custom window returns `400`.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/billing/logs/route').then((route) => route.GET), + }, + listBlocks: { + contract: v2ListBlocksContract, + summary: 'List Blocks', + description: + 'List built-in and workspace-deployed blocks visible to the caller. Integration allowlists and preview visibility restrict results. Use `capability=trigger` for workflow starters and Get Block or Get Tool to resolve operation and tool IDs.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/blocks/route').then((route) => route.GET), + }, + listChatDeployments: { + contract: v2ListChatDeploymentsContract, + summary: 'List Chat Deployments', + description: + "List hosted chats and their public URLs with cursor pagination. Filter by `workflowId` for one workflow's chat. The list requires workspace read access; Get Workflow Chat Deployment requires admin access and includes visitor access settings and customizations. Passwords are never returned.\n\nOAuth scope: `api:read`.", + handler: () => import('@/app/api/v2/chat-deployments/route').then((route) => route.GET), + }, + listConnectorTypes: { + contract: v2ListConnectorTypesContract, + summary: 'List Connector Types', + description: + 'List connector types and accepted source configuration. A field with `multi: true` stores `string[]`. `canonicalParamId` links picker and manual fields that write the same key; send exactly one, keyed by `canonicalParamId` rather than its own `id`. Returns the complete set in one page; `nextCursor` is always null.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/connector-types/route').then((route) => route.GET), + }, + listCredentialProviders: { + contract: v2ListCredentialProvidersContract, + summary: 'List Credential Providers', + description: + 'List OAuth and service-account connection methods and their availability. OAuth options provide provider IDs for browser connections; service-account methods declare required fields and write-only secrets. Supports provider-name search. Returns the complete set in one page; `nextCursor` is always null.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/credentials/providers/route').then((route) => route.GET), + }, + listCredentials: { + contract: v2ListCredentialsContract, + summary: 'List Credentials', + description: + 'List OAuth and service-account connections visible to the caller. Secret material is never returned.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/credentials/route').then((route) => route.GET), + }, + listCustomTools: { + contract: v2ListCustomToolsContract, + summary: 'List Custom Tools', + description: + 'List code-backed custom tools in a workspace with cursor pagination.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/custom-tools/route').then((route) => route.GET), + }, + listFileFolders: { + contract: v2ListFileFoldersContract, + summary: 'List Folders', + description: + 'List workspace file folders with parent-path filtering and sorting. Use `scope=archived` to find paths accepted by Restore Folder. Returns the complete set in one page; `nextCursor` is always null.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/files/folders/route').then((route) => route.GET), + }, + listFiles: { + contract: v2ListFilesContract, + summary: 'List Files', + description: + 'List active workspace files with folder filtering, search, sorting, and cursor pagination. Use `scope=archived` to find files available for restoration. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/files/route').then((route) => route.GET), + }, + listKnowledgeBases: { + contract: v2ListKnowledgeBasesContract, + summary: 'List Knowledge Bases', + description: + 'List active knowledge bases in a workspace with folder filtering, search, sorting, and cursor pagination. Use `scope=archived` to find knowledge bases available for restoration. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/knowledge/route').then((route) => route.GET), + }, + listKnowledgeChunks: { + contract: v2ListKnowledgeChunksContract, + summary: 'List Chunks', + description: + 'List document chunks with content search, enabled filtering, sorting, and cursor pagination. Tags use slots; use List Tags to resolve display names. Documents that have not finished processing return `409` with their current status. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/route').then( + (route) => route.GET + ), + }, + listKnowledgeConnectorDocuments: { + contract: v2ListKnowledgeConnectorDocumentsContract, + summary: 'List Knowledge Connector Documents', + description: + 'List documents produced by one connector with opaque cursor pagination. Excluded documents are omitted unless explicitly requested. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import( + '@/app/api/v2/knowledge/[knowledgeBaseId]/connectors/[connectorId]/documents/route' + ).then((route) => route.GET), + }, + listKnowledgeConnectors: { + contract: v2ListKnowledgeConnectorsContract, + summary: 'List Knowledge Connectors', + description: + 'List external sources connected to a knowledge base with cursor pagination. Stored API keys are never returned. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/connectors/route').then( + (route) => route.GET + ), + }, + listKnowledgeDocuments: { + contract: v2ListKnowledgeDocumentsContract, + summary: 'List Documents', + description: + 'List documents with filename search, state and tag filters, sorting, and cursor pagination. Tag values use display names; use List Tags to resolve the slots required for writes.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/documents/route').then((route) => route.GET), + }, + listKnowledgeFolders: { + contract: v2ListKnowledgeFoldersContract, + summary: 'List Folders', + description: + 'List folders in the knowledge-base folder tree with filtering and sorting. Returns the complete set in one page; `nextCursor` is always null. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/knowledge/folders/route').then((route) => route.GET), + }, + listKnowledgeTags: { + contract: v2ListKnowledgeTagsContract, + summary: 'List Tags', + description: + "List the knowledge base's tag definitions with display names, write slots, and field types. Filters and document reads use display names; document writes use slots. Returns the complete set in one page; `nextCursor` is always null.\n\nOAuth scope: `api:read`.", + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/tags/route').then((route) => route.GET), + }, + listKnowledgeTagUsage: { + contract: v2ListKnowledgeTagUsageContract, + summary: 'List Tag Usage', + description: + 'Count the documents and chunks with a value for each defined tag. Returns the complete set in one page; `nextCursor` is always null. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/tags/usage/route').then( + (route) => route.GET + ), + }, + listLogs: { + contract: v2ListLogsContract, + summary: 'List Logs', + description: + 'List logs with filters, selectable detail, sorting, and cursor pagination. `includeJobRuns=true` includes chat and Sim-agent jobs only with `sortBy=startedAt`, because other orderings are unsupported. `files` contains only run-produced files; use the files API for input attachments. Expired runs are permanently deleted. Retention is 30 days from run start on Free, unlimited on Pro and Team, and configured per organization on Enterprise with workspace overrides. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/logs/route').then((route) => route.GET), + }, + listMcpServers: { + contract: v2ListMcpServersContract, + summary: 'List MCP Servers', + description: + 'List MCP servers registered in a workspace, excluding request-header values and OAuth secrets. Connection metadata remains at registration defaults until List MCP Server Tools performs discovery.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/mcp-servers/route').then((route) => route.GET), + }, + listMcpServerTools: { + contract: v2ListMcpServerToolsContract, + summary: 'List MCP Server Tools', + description: + 'Discover up to 1,000 tools within 5 MB, connect to the server, and update connection metadata. Results are unpaginated. Invalid OAuth returns `409` with `MCP_SERVER_REAUTHORIZATION_REQUIRED`; reauthorize through the browser. Unavailable servers return `503`. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/mcp-servers/[mcpServerId]/tools/route').then((route) => route.GET), + }, + listSandboxes: { + contract: v2ListSandboxesContract, + summary: 'List Sandboxes', + description: + 'List reusable dependency environments for Function blocks, including language packages, managed CLIs, and system packages. Sandboxes remain visible after a plan downgrade.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/sandboxes/route').then((route) => route.GET), + }, + listSecrets: { + contract: v2ListSecretsContract, + summary: 'List Secrets', + description: + 'List workspace and caller-owned personal secrets with cursor pagination. Only workspace secrets marked `unredacted` include values; all other entries contain metadata only. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/secrets/route').then((route) => route.GET), + }, + listSelector: { + contract: v2ListSelectorContract, + summary: 'List Selector Options', + description: + 'List workspace-scoped configuration choices using the selector key and dependencies from an import or sync preview. Missing OAuth connections require human authorization before provider choices can be discovered. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/selectors/list/route').then((route) => route.POST), + }, + listSkillEditors: { + contract: v2ListSkillEditorsContract, + summary: 'List Skill Editors', + description: + 'List skill editors and workspace administrators with cursor pagination.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/skills/[skillId]/editors/route').then((route) => route.GET), + }, + listSkills: { + contract: v2ListSkillsContract, + summary: 'List Skills', + description: + 'List workspace and built-in skills with cursor pagination. Built-in skills are read-only. The list omits skill bodies; use Get Skill to read content.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/skills/route').then((route) => route.GET), + }, + listTableDispatches: { + contract: v2ListTableDispatchesContract, + summary: 'List Active Run Dispatches', + description: + 'List in-flight run dispatches for a table in one page; `nextCursor` is always null. Use Get Run Dispatch to read a settled dispatch.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/dispatches/route').then((route) => route.GET), + }, + listTableFolders: { + contract: v2ListTableFoldersContract, + summary: 'List Folders', + description: + 'List table folders, optionally limiting results to direct children of a parent path. Returns the complete set in one page; `nextCursor` is always null.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/tables/folders/route').then((route) => route.GET), + }, + listTableRows: { + contract: v2ListTableRowsContract, + summary: 'List Rows', + description: + 'List rows in default order with cursor pagination. Pages default to a 5 MB limit and may contain fewer rows than requested; continue until `nextCursor` is null. Use Query Rows for filtering and sorting. `includeRunState=true` adds per-group run outcomes and reduces the row limit.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/tables/[tableId]/rows/route').then((route) => route.GET), + }, + listTables: { + contract: v2ListTablesContract, + summary: 'List Tables', + description: + 'List active tables with folder filtering, search, sorting, and cursor pagination. Use `scope=archived` to find tables available for restoration. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/tables/route').then((route) => route.GET), + }, + listTableViews: { + contract: v2ListTableViewsContract, + summary: 'List Views', + description: + 'List saved table views, omitting references to removed columns. Returns the complete set in one page; `nextCursor` is always null.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/tables/[tableId]/views/route').then((route) => route.GET), + }, + listTools: { + contract: v2ListToolsContract, + summary: 'List Tools', + description: + "List built-in tools exposed by blocks visible to the caller. Use List MCP Server Tools for an external server's tools and List Custom Tools for workspace code-backed tools.\n\nOAuth scope: `api:read`.", + handler: () => import('@/app/api/v2/tools/route').then((route) => route.GET), + }, + listWorkflowFolders: { + contract: v2ListWorkflowFoldersContract, + summary: 'List Workflow Folders', + description: + 'List workflow folders in a workspace. Returns the complete set in one page; `nextCursor` is always null. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/workflows/folders/route').then((route) => route.GET), + }, + listWorkflowGroups: { + contract: v2ListWorkflowGroupsContract, + summary: 'List Workflow Groups', + description: + 'List the workflow and enrichment groups that can be dispatched for a table. Returns the complete set in one page; `nextCursor` is always null.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/tables/[tableId]/groups/route').then((route) => route.GET), + }, + listWorkflowMcpServers: { + contract: v2ListWorkflowMcpServersContract, + summary: 'List Workflow MCP Servers', + description: + "List MCP servers that expose deployed workflows to external clients. Use List MCP Servers for external servers Sim calls. Tool names share a 2,000-name page limit; inspect `toolNamesTruncated` and use List Workflow MCP Tools for a server's inventory. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/workflow-mcp-servers/route').then((route) => route.GET), + }, + listWorkflowMcpTools: { + contract: v2ListWorkflowMcpToolsContract, + summary: 'List Workflow MCP Tools', + description: + "List a server's published tools by name, including workflow IDs used to unpublish them. Returns up to 2,000 tools with `nextCursor: null`; `truncated` indicates an incomplete inventory that cannot be paginated. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workflow-mcp-servers/[serverId]/tools/route').then((route) => route.GET), + }, + listWorkflowRuns: { + contract: v2ListWorkflowRunsContract, + summary: 'List Workflow Runs', + description: + 'List recorded runs of a workflow with filtering and opaque cursor pagination. Expired runs are permanently deleted. Retention is 30 days from run start on Free, unlimited on Pro and Team, and configured per organization on Enterprise with workspace overrides.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/workflows/[workflowId]/runs/route').then((route) => route.GET), + }, + listWorkflows: { + contract: v2ListWorkflowsContract, + summary: 'List Workflows', + description: + 'List active workflows in a workspace. Use `scope=archived` to find workflows available for restoration. Supports folder and deployment filters, search, sorting, and cursor pagination. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/workflows/route').then((route) => route.GET), + }, + listWorkflowVersions: { + contract: v2ListWorkflowVersionsContract, + summary: 'List Workflow Versions', + description: + 'List immutable deployment versions of a workflow, newest first.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/workflows/[workflowId]/versions/route').then((route) => route.GET), + }, + listWorkspaceForkChildren: { + contract: v2ListWorkspaceForkChildrenContract, + summary: 'List Workspace Fork Children', + description: + 'Inspect workspace fork information and copyable resources. Lineage does not grant access to the other workspace; fork creation requires source admin and sync requires admin on both sides. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/fork/children/route').then( + (route) => route.GET + ), + }, + listWorkspaceForkResources: { + contract: v2ListWorkspaceForkResourcesContract, + summary: 'List Workspace Fork Resources', + description: + 'Inspect workspace fork information and copyable resources. Lineage does not grant access to the other workspace; fork creation requires source admin and sync requires admin on both sides. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/fork/resources/route').then( + (route) => route.GET + ), + }, + listWorkspaceMembers: { + contract: v2ListWorkspaceMembersContract, + summary: 'List Workspace Members', + description: + 'List workspace members by email, including explicit grants and inherited organization admin access.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/members/route').then((route) => route.GET), + }, + listWorkspaceOperations: { + contract: v2ListWorkspaceOperationsContract, + summary: 'List Workspace Operations', + description: + 'Page committed operations newest first. Filter by the original request ID to reconcile an uncertain mutation response.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/operations/route').then((route) => route.GET), + }, + listWorkspaces: { + contract: v2ListWorkspacesContract, + summary: 'List Workspaces', + description: + 'List active workspaces available to the calling credential with opaque cursor pagination. A personal API key or OAuth token sees accessible workspaces that permit user-held API credentials; a workspace API key sees only its bound workspace.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/workspaces/route').then((route) => route.GET), + }, + moveFileItems: { + contract: v2MoveFileItemsContract, + summary: 'Move Files', + description: + 'Move up to 1,000 files to a folder path or the workspace root.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/files/move/route').then((route) => route.POST), + }, + moveTables: { + contract: v2MoveTablesContract, + summary: 'Move Tables and Folders', + description: + 'Move up to 100 tables and folders to one destination. Items succeed or fail independently: covered tables are `skipped`, missing items are `notFound`, and lock or cycle failures include reasons in `failed`. An invalid destination rejects the request before any move.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/tables/move/route').then((route) => route.POST), + }, + moveWorkflows: { + contract: v2MoveWorkflowsContract, + summary: 'Move Workflows', + description: + 'Move up to 100 workflows into one folder. Moves succeed or fail independently; missing, archived, or locked workflows appear in `failed`. Duplicate IDs are ignored. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/workflows/move/route').then((route) => route.POST), + }, + previewWorkflowImport: { + contract: v2PreviewWorkflowImportContract, + summary: 'Preview Workflow Import', + description: + 'Validate destination mappings and dependent choices without creating a workflow. Returns unresolved fields, discovery instructions, and a fingerprint required by mapped import. No source workspace is queried from imported provenance.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/workflows/import/preview/route').then((route) => route.POST), + }, + previewWorkspaceFork: { + contract: v2PreviewWorkspaceForkContract, + summary: 'Preview Workspace Fork', + description: + 'Preview the deployed workflows and explicitly selected resources that a new workspace fork would copy. The result is read-only and supplies the fingerprint required by Fork Workspace. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/fork/preview/route').then( + (route) => route.POST + ), + }, + previewWorkspacePull: { + contract: v2PreviewWorkspacePullContract, + summary: 'Preview Workspace Pull', + description: + 'Preview deployed source workflows replacing mapped targets along a direct fork edge. Push sends the current workspace to the other; pull brings the other into the current workspace. Proposed mappings are not saved. Dependent choices use source workflow, block, and field identities. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/fork/pull/preview/route').then( + (route) => route.POST + ), + }, + previewWorkspacePush: { + contract: v2PreviewWorkspacePushContract, + summary: 'Preview Workspace Push', + description: + 'Preview deployed source workflows replacing mapped targets along a direct fork edge. Push sends the current workspace to the other; pull brings the other into the current workspace. Proposed mappings are not saved. Dependent choices use source workflow, block, and field identities. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/fork/push/preview/route').then( + (route) => route.POST + ), + }, + pullWorkspace: { + contract: v2PullWorkspaceContract, + summary: 'Pull Workspace', + description: + 'Apply a reviewed push or pull with inline mappings in one transaction. Requires confirmation, the preview fingerprint, and a stable request ID. Unresolved or changed plans return 409 without applying. The receipt distinguishes committed changes from copy and deployment readiness; poll Get Workspace Operation before treating the target as ready. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/fork/pull/route').then((route) => route.POST), + }, + pushWorkspace: { + contract: v2PushWorkspaceContract, + summary: 'Push Workspace', + description: + 'Apply a reviewed push or pull with inline mappings in one transaction. Requires confirmation, the preview fingerprint, and a stable request ID. Unresolved or changed plans return 409 without applying. The receipt distinguishes committed changes from copy and deployment readiness; poll Get Workspace Operation before treating the target as ready. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/fork/push/route').then((route) => route.POST), + }, + queryRows: { + contract: v2QueryRowsContract, + summary: 'Query Rows', + description: + 'Query rows with typed predicates, sorting, and cursor pagination. Omit the predicate to match all rows. Pages default to a 5 MB limit; continue until `nextCursor` is null. Oversized predicates return `413`. `includeRunState` adds per-group outcomes and reduces the row limit. Counts are read separately and can differ from paged results if rows change.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/tables/[tableId]/query/route').then((route) => route.POST), + }, + queryRowsCount: { + contract: v2QueryRowsCountContract, + summary: 'Count Rows', + description: + 'Count rows matching a typed predicate, or omit the predicate to count all rows. The count is read separately from row pages and can change between requests. Oversized predicates return `413`.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/query/count/route').then((route) => route.POST), + }, + readFileText: { + contract: v2ReadFileTextContract, + summary: 'Read File Text', + description: + 'Extract text without changing the file. Use Unzip File to unpack archives or Download File for original bytes. Unsupported types return `400`, compiling documents return `409`, and oversized files return `413`. `degraded: true` indicates incomplete or synthesized text, such as the legacy `.pptx` fallback; `truncated: true` indicates a parser limit.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/files/[fileId]/text/route').then((route) => route.GET), + }, + relocateFileFolder: { + contract: v2RelocateFileFolderContract, + summary: 'Rename or Move Folder', + description: + 'Rename or move a folder and atomically update all descendant paths.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/files/folders/route').then((route) => route.PATCH), + }, + relocateKnowledgeFolder: { + contract: v2RelocateKnowledgeFolderContract, + summary: 'Rename or Move Folder', + description: + 'Rename or move a folder and atomically rewrite descendant paths. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/knowledge/folders/route').then((route) => route.PATCH), + }, + relocateTableFolder: { + contract: v2RelocateTableFolderContract, + summary: 'Rename or Move Folder', + description: + 'Rename or move a table folder and update all descendant paths.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/tables/folders/route').then((route) => route.PATCH), + }, + relocateWorkflowFolder: { + contract: v2RelocateWorkflowFolderContract, + summary: 'Rename or Move Workflow Folder', + description: + 'Rename or move a workflow folder and update all descendant paths. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/workflows/folders/route').then((route) => route.PATCH), + }, + renameFile: { + contract: v2RenameFileContract, + summary: 'Rename File', + description: + 'Rename a workspace file without changing its containing folder.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/files/[fileId]/route').then((route) => route.PATCH), + }, + replaceWorkflowChatDeployment: { + contract: v2ReplaceWorkflowChatDeploymentContract, + summary: 'Create or Replace Workflow Chat Deployment', + description: + "Create or replace a workflow's hosted chat and deploy its draft. Omitted fields reset to defaults except per-field customizations. Password authentication requires `password`; email or SSO requires non-empty `allowedEmails`. Public authentication allows anyone with the chat URL to use it. A duplicate identifier or pending deployment returns `409`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workflows/[workflowId]/deployments/chat/route').then( + (route) => route.PUT + ), + }, + replaceWorkflowState: { + contract: v2ReplaceWorkflowStateContract, + summary: 'Replace Workflow State', + description: + 'Replace the draft graph atomically; concurrent writes are last-write-wins. Recompute containers from blocks and preserve omitted variables. Foreign IDs return `409`; lint is advisory. The live deployment is unchanged. `dryRun=true` validates without saving, auditing, or notifying; `needsRedeployment` describes the pre-write state. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workflows/[workflowId]/state/route').then((route) => route.PUT), + }, + restoreFile: { + contract: v2RestoreFileContract, + summary: 'Restore File', + description: + 'Restore an archived file to the workspace root. Name collisions add a `_restored` suffix; use the returned `name` and `folderPath`. An active file returns unchanged. An archived workspace returns `400`; an unresolved name collision returns `409`.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/files/[fileId]/restore/route').then((route) => route.POST), + }, + restoreFileFolder: { + contract: v2RestoreFileFolderContract, + summary: 'Restore Folder', + description: + 'Restore a folder and the files and subfolders archived with it. Use the path from List Folders with `scope=archived`. A path that is not archived returns `404`.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/files/folders/restore/route').then((route) => route.POST), + }, + restoreKnowledgeBase: { + contract: v2RestoreKnowledgeBaseContract, + summary: 'Restore Knowledge Base', + description: + 'Restore a knowledge base and the documents and connectors archived with it. Active knowledge bases return unchanged without a new audit event. An archived workspace returns `409`; an archived containing folder moves the restored knowledge base to the workspace root. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/restore/route').then((route) => route.POST), + }, + restoreTable: { + contract: v2RestoreTableContract, + summary: 'Restore Table', + description: + 'Restore a table and its archived rows, views, and workflow groups. Active tables return unchanged without a new audit event. Name conflicts may change the returned `name`. Find archived tables with List Tables and `scope=archived`.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/restore/route').then((route) => route.POST), + }, + restoreTableFolder: { + contract: v2RestoreTableFolderContract, + summary: 'Restore Folder', + description: + 'Restore an archived table folder, its descendants, and tables using its former path. An archived parent moves it to the root; name conflicts may change the returned `path`. Non-archived paths return `404`. Save the path from Delete Folder, because List Folders does not include archived table folders.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/tables/folders/restore/route').then((route) => route.POST), + }, + restoreWorkflow: { + contract: v2RestoreWorkflowContract, + summary: 'Restore Workflow', + description: + 'Restore an archived workflow and the schedules, webhooks, MCP tools, and chats archived with it. An active workflow returns `409`. If its folder is archived, the workflow returns to the workspace root. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/workflows/[workflowId]/restore/route').then((route) => route.POST), + }, + resumeWorkflow: { + contract: v2ResumeWorkflowContract, + summary: 'Resume Workflow Run', + description: + 'Resume one human-in-the-loop pause. The resumed attempt receives a new run ID and returns either a synchronous result or a queue receipt.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/workflows/[workflowId]/runs/[runId]/resume/route').then( + (route) => route.POST + ), + }, + revertWorkflowVersion: { + contract: v2RevertWorkflowVersionContract, + summary: 'Revert Workflow To Version', + description: + 'Replace the editable draft with a deployment version, discarding current draft edits. Use `active` for the live version. The live deployment remains unchanged; Activate Workflow Version or Rollback Workflow changes it. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workflows/[workflowId]/versions/[version]/revert/route').then( + (route) => route.POST + ), + }, + revokeSkillEditor: { + contract: v2RevokeSkillEditorContract, + summary: 'Revoke Skill Editor', + description: + 'Revoke an explicit editor grant by email. The caller must already be a skill editor or workspace administrator. Workspace administrators have derived access that cannot be revoked. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/skills/[skillId]/editors/route').then((route) => route.DELETE), + }, + rollbackWorkflow: { + contract: v2RollbackWorkflowContract, + summary: 'Rollback Workflow', + description: + 'Asynchronously activate a previous deployment version, defaulting to the preceding active version. Requires a deployed workflow and leaves the draft unchanged. Use Activate Workflow Version to select a version when the workflow is undeployed. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workflows/[workflowId]/rollback/route').then((route) => route.POST), + }, + rollbackWorkspaceFork: { + contract: v2RollbackWorkspaceForkContract, + summary: 'Rollback Workspace Fork', + description: + 'Restore the latest sync into this workspace using its prior deployed versions. Requires target admin. It does not restore arbitrary drafts or remove every copied resource. Pending activations are reported. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/fork/rollback/route').then( + (route) => route.POST + ), + }, + runRowEnrichment: { + contract: v2RunRowEnrichmentContract, + summary: 'Run Enrichment For One Row', + description: + 'Start one workflow or enrichment group for a table row. Poll Get Run Dispatch using the returned `dispatchId`. A null `dispatchId` means no dispatch is available to poll; check row outcomes with `includeRunState`.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route').then( + (route) => route.POST + ), + }, + searchFileContent: { + contract: v2SearchFileContentContract, + summary: 'Search File Content', + description: + 'Search indexed text in active workspace files and return matching lines with file IDs and line numbers. `folderPaths` limits both results and reported coverage. Missing matches are inconclusive if `complete` is false or `indexStatus.skippedFiles` or `indexStatus.partialFiles` is nonzero. `truncated` means additional matches exist beyond `maxResults`.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/files/search/route').then((route) => route.GET), + }, + searchKnowledge: { + contract: v2SearchKnowledgeContract, + summary: 'Search Knowledge', + description: + 'Search one or more knowledge bases with semantic vector retrieval, optional hybrid full-text retrieval, and structured tag filters. Every result names the `knowledgeBaseId` it came from. A request body over 2 MiB is a `413`. Reranking returns `409` when the stored results cannot pass secret-provenance enforcement.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/knowledge/search/route').then((route) => route.POST), + }, + searchTableRows: { + contract: v2SearchTableRowsContract, + summary: 'Search Rows', + description: + 'Search cell text for a case-insensitive substring within an optional filtered and sorted view. Returns cell coordinates, not row data; `ordinal` matches the view used by Query Rows. Results are unpaginated and capped at 1000. If `truncated` is true, narrow the search or predicate.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/rows/search/route').then((route) => route.POST), + }, + setSecret: { + contract: v2SetSecretContract, + summary: 'Set Secret', + description: + 'Create or replace a workspace or personal secret without returning its value. For existing workspace secrets, omit `value` to update metadata only; this returns `404` if absent. Personal secrets always require `value`. List Secrets can reveal workspace values marked `unredacted`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/secrets/[name]/route').then((route) => route.PUT), + }, + syncKnowledgeConnector: { + contract: v2SyncKnowledgeConnectorContract, + summary: 'Sync Knowledge Connector', + description: + 'Queue a connector synchronization. Rehydration forces existing documents to be fetched and indexed again. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/connectors/[connectorId]/sync/route').then( + (route) => route.POST + ), + }, + tableExportDownload: { + contract: v2TableExportDownloadContract, + summary: 'Download Table Export', + description: + 'Get a short-lived signed download URL for a completed export. Other states return `409`; an unavailable export file returns `404`.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/exports/[exportId]/download/route').then( + (route) => route.GET + ), + }, + undeployWorkflow: { + contract: v2UndeployWorkflowContract, + summary: 'Undeploy Workflow', + description: + 'Deactivate the currently serving workflow version. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workflows/[workflowId]/deploy/route').then((route) => route.DELETE), + }, + undeployWorkflowMcpTool: { + contract: v2UndeployWorkflowMcpToolContract, + summary: 'Unpublish Workflow MCP Tool', + description: + "Unpublish an MCP tool by its workflow ID. The workflow's API deployment remains active. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workflow-mcp-servers/[serverId]/tools/[workflowId]/route').then( + (route) => route.DELETE + ), + }, + unlinkWorkspaceFork: { + contract: v2UnlinkWorkspaceForkContract, + summary: 'Unlink Workspace Fork', + description: + 'Remove the direct fork relationship and its mappings. Requires admin on the acting workspace. Existing workflow and resource content remains available. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/fork/unlink/route').then((route) => route.POST), + }, + unzipFile: { + contract: v2UnzipFileContract, + summary: 'Unzip File', + description: + 'Extract a ZIP archive into a new sibling folder and return counts and the destination path. Use List Files to inspect its contents. Large archives can take minutes; concurrent extraction of the same archive returns `409`. Size or processing-time limits return `413`.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/files/[fileId]/unzip/route').then((route) => route.POST), + }, + updateCredential: { + contract: v2UpdateCredentialContract, + summary: 'Update Credential', + description: + 'Rename a service-account credential or rotate its secret fields, preserving omitted values and the credential ID. Requires credential admin access. Provider rejection preserves the old secret and returns `400` with `providerErrorCode`; outages return `503`. Fields for a different credential type return `400`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/credentials/[credentialId]/route').then((route) => route.PATCH), + }, + updateCustomTool: { + contract: v2UpdateCustomToolContract, + summary: 'Update Custom Tool', + description: + 'Update a custom tool. Omitted fields remain unchanged; titles must remain unique within the workspace.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/custom-tools/[customToolId]/route').then((route) => route.PATCH), + }, + updateFileContent: { + contract: v2UpdateFileContentContract, + summary: 'Replace File Content', + description: + 'Replace the complete contents of an existing file from UTF-8 or base64 input.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/files/[fileId]/content/route').then((route) => route.PUT), + }, + updateKnowledgeBase: { + contract: v2UpdateKnowledgeBaseContract, + summary: 'Update Knowledge Base', + description: + "Update a knowledge base's name, description, chunking configuration, or folder placement. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.", + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/route').then((route) => route.PATCH), + }, + updateKnowledgeChunk: { + contract: v2UpdateKnowledgeChunkContract, + summary: 'Update Chunk', + description: + 'Correct chunk text or disable it from search. Changing `content` re-embeds immediately and recalculates document token and character counts; disabling retains the index. Connector-synced chunks are read-only and return `403` with `error.details.code: "CONNECTOR_MANAGED_RESOURCE_READ_ONLY"`; change the source and re-sync, or exclude the document. Documents that have not finished processing return `409` with their current status. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import( + '@/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/[chunkId]/route' + ).then((route) => route.PATCH), + }, + updateKnowledgeConnector: { + contract: v2UpdateKnowledgeConnectorContract, + summary: 'Update Knowledge Connector', + description: + 'Update connector source configuration, schedule, or active state. Replacing source configuration on a runnable connector queues an immediate synchronization; paused connectors retain the change without synchronizing until resumed. Source configuration cannot be replaced while synchronization is already in progress. Authentication material cannot be changed through this operation. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/connectors/[connectorId]/route').then( + (route) => route.PATCH + ), + }, + updateKnowledgeConnectorDocuments: { + contract: v2UpdateKnowledgeConnectorDocumentsContract, + summary: 'Update Knowledge Connector Documents', + description: + 'Exclude connector documents from knowledge search or restore previously excluded documents. Only documents produced by the selected connector can change. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import( + '@/app/api/v2/knowledge/[knowledgeBaseId]/connectors/[connectorId]/documents/route' + ).then((route) => route.PATCH), + }, + updateKnowledgeDocument: { + contract: v2UpdateKnowledgeDocumentContract, + summary: 'Update Document', + description: + 'Rename a document, change search availability, update tag slots, or requeue processing. Omitted fields remain unchanged; indexing state is read-only. Use List Tags to resolve names to slots and Get Document for source connector details, which this response omits. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/route').then( + (route) => route.PATCH + ), + }, + updateKnowledgeTag: { + contract: v2UpdateKnowledgeTagContract, + summary: 'Update Tag', + description: + "Rename a tag or change its slot-compatible `fieldType`. Renaming changes read and filter names without moving the slot or its values. Slots are fixed for a tag's lifetime; an incompatible type returns `400` and requires creating a new tag. A duplicate display name returns `409`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/tags/[tagId]/route').then( + (route) => route.PATCH + ), + }, + updateMcpServer: { + contract: v2UpdateMcpServerContract, + summary: 'Update MCP Server', + description: + "Update an MCP server's supplied fields. Omitted fields remain unchanged unless the field specifies otherwise. Authentication changes revoke the stored OAuth grant and reset connection metadata. Use List MCP Server Tools to reconnect.\n\nOAuth scope: `api:write`.", + handler: () => + import('@/app/api/v2/mcp-servers/[mcpServerId]/route').then((route) => route.PATCH), + }, + updateRowsByFilter: { + contract: v2UpdateRowsByFilterContract, + summary: 'Update Rows by Filter', + description: + 'Apply the same partial data patch to every row matching a non-empty predicate.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/tables/[tableId]/rows/route').then((route) => route.PATCH), + }, + updateSandbox: { + contract: v2UpdateSandboxContract, + summary: 'Update Sandbox', + description: + 'Update a sandbox, preserving omitted fields and replacing supplied lists. Dependency changes may start a build; resending a failed specification retries its build. `buildStatus: null` means no build is required. Requires workspace admin access on Max or Enterprise. Creates and updates share a rate limit; respect `Retry-After`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/sandboxes/[sandboxId]/route').then((route) => route.PATCH), + }, + updateSkill: { + contract: v2UpdateSkillContract, + summary: 'Update Skill', + description: + 'Update a workspace skill. Omitted fields remain unchanged. Built-in skills are read-only. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/skills/[skillId]/route').then((route) => route.PATCH), + }, + updateTable: { + contract: v2UpdateTableContract, + summary: 'Update Table', + description: + 'Rename a table, edit its description, or move it to a folder. Fields are saved independently: a failed request may leave partial changes. `error.details.applied` lists saved fields; retry only the remaining fields. If absent, nothing changed. Lock flags are read-only. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/tables/[tableId]/route').then((route) => route.PATCH), + }, + updateTableColumn: { + contract: v2UpdateTableColumnContract, + summary: 'Update Column', + description: + 'Update a column by name and return the complete resulting table schema.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/columns/route').then((route) => route.PATCH), + }, + updateTableRow: { + contract: v2UpdateTableRowContract, + summary: 'Update Row', + description: + 'Merge a partial data patch into one row by identifier.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/rows/[rowId]/route').then((route) => route.PATCH), + }, + updateTableView: { + contract: v2UpdateTableViewContract, + summary: 'Update View', + description: + 'Rename a view, replace or shallow-merge its configuration, or promote it to the table default.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/views/[viewId]/route').then((route) => route.PATCH), + }, + updateWorkflow: { + contract: v2UpdateWorkflowContract, + summary: 'Update Workflow', + description: + "Update a workflow's name, description, or folder path. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.", + handler: () => import('@/app/api/v2/workflows/[workflowId]/route').then((route) => route.PATCH), + }, + updateWorkflowGroup: { + contract: v2UpdateWorkflowGroupContract, + summary: 'Update Workflow Group', + description: + 'Restructure a workflow group, its producer, outputs, or execution behavior. Repointing the group at a different workflow concurrently invalidates the resolved output types and returns `409` — retry the update.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/groups/route').then((route) => route.PATCH), + }, + updateWorkflowMcpServer: { + contract: v2UpdateWorkflowMcpServerContract, + summary: 'Update Workflow MCP Server', + description: + "Update a workflow MCP server's name, description, or public access. Omitted fields remain unchanged; `description: null` clears the description. Publish or unpublish tools separately. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workflow-mcp-servers/[serverId]/route').then((route) => route.PATCH), + }, + updateWorkflowPublicApi: { + contract: v2UpdateWorkflowPublicApiContract, + summary: 'Update Workflow Public API Access', + description: + 'Enable or disable unauthenticated execution of the deployed workflow. Enabling allows anyone with the execution URL to consume billed usage. Organization sharing restrictions return `403` with `PUBLIC_SHARING_NOT_ALLOWED`. Hosted chat is managed separately. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workflows/[workflowId]/deployment/route').then((route) => route.PATCH), + }, + updateWorkflowVersion: { + contract: v2UpdateWorkflowVersionContract, + summary: 'Update Workflow Version', + description: + "Update a deployment version's name or release note. Omitted fields remain unchanged; `description: null` clears the note. The graph and live version remain unchanged. Use Activate Workflow Version to make this version live.\n\nOAuth scope: `api:write`.", + handler: () => + import('@/app/api/v2/workflows/[workflowId]/versions/[version]/route').then( + (route) => route.PATCH + ), + }, + updateWorkspaceForkExclusions: { + contract: v2UpdateWorkspaceForkExclusionsContract, + summary: 'Update Workspace Fork Exclusions', + description: + 'Include or exclude selected workflows from fork sync. Excluded workflows are skipped as sources and targets. Missing, archived, and unchanged workflow IDs are skipped. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/fork/exclusions/route').then( + (route) => route.PUT + ), + }, + updateWorkspaceForkMappings: { + contract: v2UpdateWorkspaceForkMappingsContract, + summary: 'Update Workspace Fork Mappings', + description: + 'Update edge mappings after validating destination resource membership and credential provider compatibility. Push addresses current-to-other mappings; pull addresses other-to-current mappings. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/fork/mappings/route').then( + (route) => route.PUT + ), + }, + upsertFileShare: { + contract: v2UpsertFileShareContract, + summary: 'Enable or Disable File Share', + description: + "Create or update a file's public share. `isActive` is required; other fields describe their behavior when access modes change. Enabling a protected mode on a previously unshared file requires its credential in the same request. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/files/[fileId]/share/route').then((route) => route.PATCH), + }, + upsertTableRow: { + contract: v2UpsertTableRowContract, + summary: 'Upsert Row', + description: + 'Insert a row or replace the row matching a selected unique column. On replacement, omitted columns are cleared; send the complete row. Use Update Row for a partial patch.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/rows/upsert/route').then((route) => route.POST), + }, +} as const satisfies Record + +export type V2McpOperationName = keyof typeof V2_MCP_OPERATIONS diff --git a/apps/sim/lib/api/mcp/host-routing.test.ts b/apps/sim/lib/api/mcp/host-routing.test.ts new file mode 100644 index 00000000000..2ddf32a14e9 --- /dev/null +++ b/apps/sim/lib/api/mcp/host-routing.test.ts @@ -0,0 +1,81 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ mcpUrl: undefined as string | undefined })) + +vi.mock('@/lib/core/config/env', () => ({ + getEnv: (name: string) => (name === 'SIM_MCP_URL' ? mocks.mcpUrl : undefined), +})) +vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.ai' })) + +import { resolveSimMcpHostPath } from '@/lib/api/mcp/host-routing' +import { getSimMcpUrl } from '@/lib/api/mcp/urls' + +describe('Sim MCP host routing', () => { + beforeEach(() => { + mocks.mcpUrl = undefined + }) + + it('serves the MCP server from the app origin by default', () => { + expect(getSimMcpUrl()).toBe('https://sim.ai/api/mcp') + expect(resolveSimMcpHostPath('sim.ai', '/api/mcp')).toBe('/api/mcp') + expect(resolveSimMcpHostPath('sim.ai', '/workspace')).toBeNull() + expect(resolveSimMcpHostPath('mcp.sim.ai', '/mcp')).toBeNull() + }) + + describe('on a dedicated host', () => { + beforeEach(() => { + mocks.mcpUrl = 'https://mcp.sim.ai/mcp/' + }) + + it('uses the configured URL as the canonical resource', () => { + expect(getSimMcpUrl()).toBe('https://mcp.sim.ai/mcp') + }) + + it.each([ + ['/mcp', '/api/mcp'], + [ + '/.well-known/oauth-protected-resource/mcp', + '/.well-known/oauth-protected-resource/api/mcp', + ], + ['/.well-known/oauth-authorization-server', '/.well-known/oauth-authorization-server'], + ])('maps %s to %s', (pathname, target) => { + expect(resolveSimMcpHostPath('mcp.sim.ai', pathname)).toBe(target) + expect(resolveSimMcpHostPath('MCP.SIM.AI', pathname)).toBe(target) + }) + + it.each(['/', '/login', '/workspace/ws-1', '/api/mcp', '/api/v2/workspaces', '/mcp/'])( + 'exposes nothing else: %s', + (pathname) => { + expect(resolveSimMcpHostPath('mcp.sim.ai', pathname)).toBe('not_found') + } + ) + + it.each(['mcp.sim.ai:443', 'mcp.sim.ai.', 'MCP.SIM.AI.:443'])( + 'recognizes the host spelled %s', + (host) => { + expect(resolveSimMcpHostPath(host, '/login')).toBe('not_found') + expect(resolveSimMcpHostPath(host, '/mcp')).toBe('/api/mcp') + } + ) + + it('tells the MCP host from an app on the same hostname but another port', () => { + mocks.mcpUrl = 'http://localhost:3001/mcp' + expect(resolveSimMcpHostPath('localhost:3000', '/workspace')).toBeNull() + expect(resolveSimMcpHostPath('localhost:3001', '/mcp')).toBe('/api/mcp') + expect(resolveSimMcpHostPath('localhost:3001', '/workspace')).toBe('not_found') + }) + + it('serves the app host as before, without a second MCP URL', () => { + expect(resolveSimMcpHostPath('sim.ai', '/mcp')).toBeNull() + expect(resolveSimMcpHostPath('sim.ai', '/workspace')).toBeNull() + expect(resolveSimMcpHostPath('sim.ai', '/api/mcp')).toBe('not_found') + expect(resolveSimMcpHostPath('sim.ai', '/.well-known/oauth-protected-resource/api/mcp')).toBe( + 'not_found' + ) + expect(resolveSimMcpHostPath('sim.ai', '/api/mcp/search/organizations/org-1')).toBeNull() + }) + }) +}) diff --git a/apps/sim/lib/api/mcp/host-routing.ts b/apps/sim/lib/api/mcp/host-routing.ts new file mode 100644 index 00000000000..0a0311900c4 --- /dev/null +++ b/apps/sim/lib/api/mcp/host-routing.ts @@ -0,0 +1,49 @@ +import { getSimMcpUrl, SIM_MCP_ROUTE_PATH } from '@/lib/api/mcp/urls' +import { getBaseUrl } from '@/lib/core/utils/urls' + +const PROTECTED_RESOURCE_METADATA = '/.well-known/oauth-protected-resource' +const AUTHORIZATION_SERVER_METADATA = '/.well-known/oauth-authorization-server' + +/** + * A `Host` header as a URL authority under `protocol`: lower-cased, without the + * trailing root dot, and without the scheme's default port, so it compares + * equal to `URL.host`. `null` when the header is not a valid authority. + */ +function authorityOf(host: string, protocol: string): string | null { + const normalized = host.replace(/\.(?=:\d+$|$)/, '') + return URL.canParse(`${protocol}//${normalized}`) + ? new URL(`${protocol}//${normalized}`).host + : null +} + +/** + * Routes requests for the Sim MCP server's canonical URL. + * + * On the MCP URL's host, the MCP path and its RFC 9728 metadata map onto the + * app routes that serve them. When that host is dedicated (`mcp.sim.ai`), it + * also serves the authorization-server metadata older clients look for at the + * resource origin, and every other path the proxy sees is `not_found`; the app + * host in turn answers `not_found` for the internal MCP paths, so the server has + * exactly one URL and every client binds its tokens to it. `null` leaves the + * request to the rest of the proxy. + * + * Reads `Host` rather than `X-Forwarded-Host`, which a client could set to + * reach the rest of the app through the dedicated host. + */ +export function resolveSimMcpHostPath( + host: string | null, + pathname: string +): string | 'not_found' | null { + const mcp = new URL(getSimMcpUrl()) + const dedicated = mcp.origin !== new URL(getBaseUrl()).origin + const internalMetadataPath = `${PROTECTED_RESOURCE_METADATA}${SIM_MCP_ROUTE_PATH}` + if (!host || authorityOf(host, mcp.protocol) !== mcp.host) { + return dedicated && (pathname === SIM_MCP_ROUTE_PATH || pathname === internalMetadataPath) + ? 'not_found' + : null + } + if (pathname === mcp.pathname) return SIM_MCP_ROUTE_PATH + if (pathname === `${PROTECTED_RESOURCE_METADATA}${mcp.pathname}`) return internalMetadataPath + if (!dedicated) return null + return pathname === AUTHORIZATION_SERVER_METADATA ? pathname : 'not_found' +} diff --git a/apps/sim/lib/api/mcp/oauth-metadata.ts b/apps/sim/lib/api/mcp/oauth-metadata.ts new file mode 100644 index 00000000000..b821cc531c6 --- /dev/null +++ b/apps/sim/lib/api/mcp/oauth-metadata.ts @@ -0,0 +1,25 @@ +import { getSimMcpUrl } from '@/lib/api/mcp/urls' +import { + type OAuthProtectedResource, + protectedResourceMetadataResponse, + withOAuthResourceChallenge, +} from '@/lib/auth/oauth-protected-resource' +import { OAUTH_API_READ_SCOPE, OAUTH_API_WRITE_SCOPE } from '@/lib/auth/oauth-provider' + +/** + * `offline_access` is the authorization server's to grant, not the resource's + * to advertise (MCP authorization, scope selection), so it is left out here. + */ +const SIM_MCP_SCOPES = [OAUTH_API_READ_SCOPE, OAUTH_API_WRITE_SCOPE] as const + +function simMcpResource(): OAuthProtectedResource { + return { resource: getSimMcpUrl(), name: 'Sim', scopes: SIM_MCP_SCOPES } +} + +export function simMcpResourceMetadata() { + return protectedResourceMetadataResponse(simMcpResource()) +} + +export function withSimMcpAuthChallenge(response: T): T { + return withOAuthResourceChallenge(response, simMcpResource()) +} diff --git a/apps/sim/lib/api/mcp/route-handler.test.ts b/apps/sim/lib/api/mcp/route-handler.test.ts new file mode 100644 index 00000000000..271ea4af8ec --- /dev/null +++ b/apps/sim/lib/api/mcp/route-handler.test.ts @@ -0,0 +1,229 @@ +/** + * @vitest-environment node + */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) + +import { createSimMcpHandlers } from '@/lib/api/mcp/route-handler' +import { getBaseUrl } from '@/lib/core/utils/urls' + +const BASE = getBaseUrl() + +const handlers = createSimMcpHandlers() +const auth = { + principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' }, + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, + keyExpiresAt: null, +} +const audience = { resource: `${BASE}/api/mcp`, allowUnboundApiTokens: true } + +function rpc( + message: Record, + headers: Record = { authorization: 'Bearer sk-sim-personal' } +) { + return new NextRequest(`${BASE}/api/mcp`, { + method: 'POST', + headers: { + accept: 'application/json, text/event-stream', + 'content-type': 'application/json', + 'x-forwarded-for': '203.0.113.7', + ...headers, + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, ...message }), + }) +} + +async function callTool( + name: string, + args: Record, + headers?: Record +) { + const response = await handlers.POST( + rpc({ method: 'tools/call', params: { name, arguments: args } }, headers), + undefined + ) + expect(response.status).toBe(200) + const body = await response.json() + return body.result as { isError?: boolean; content: Array<{ type: string; text: string }> } +} + +beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) +}) + +describe('Sim MCP admission', () => { + it.each(['GET', 'POST', 'DELETE'] as const)( + 'points an unauthenticated %s at the protected-resource metadata', + async (method) => { + v2RouteMocks.authenticate.mockRejectedValue(new MockV2ApiKeyUnauthenticatedError()) + const response = await handlers[method](rpc({ method: 'tools/list' }, {}), undefined) + expect(response.status).toBe(401) + expect(response.headers.get('WWW-Authenticate')).toBe( + `Bearer resource_metadata="${BASE}/.well-known/oauth-protected-resource/api/mcp", scope="api:read api:write"` + ) + } + ) + + it('verifies OAuth tokens against the Sim MCP audience', async () => { + await handlers.POST( + rpc({ method: 'tools/list' }, { authorization: 'Bearer sim_oat_abc' }), + undefined + ) + expect(v2RouteMocks.authenticate).toHaveBeenCalledWith( + { apiKey: null, bearer: 'sim_oat_abc' }, + audience + ) + }) + + it('refuses browser requests from other origins', async () => { + const response = await handlers.POST( + rpc( + { method: 'tools/list' }, + { authorization: 'Bearer sk-sim-personal', origin: 'https://attacker.example' } + ), + undefined + ) + if (response.status !== 403) console.log('BODY', await response.clone().text()) + expect(response.status).toBe(403) + }) + + it('answers GET with 405 once authenticated', async () => { + const response = await handlers.GET(rpc({ method: 'tools/list' }), undefined) + expect(response.status).toBe(405) + expect(response.headers.get('Allow')).toBe('POST') + }) +}) + +describe('Sim MCP tools', () => { + it('lists four tools with reads and writes annotated apart', async () => { + const response = await handlers.POST(rpc({ method: 'tools/list' }), undefined) + const { result } = await response.json() + const tools = Object.fromEntries( + result.tools.map((tool: { name: string; annotations: Record }) => [ + tool.name, + tool.annotations, + ]) + ) + expect(Object.keys(tools).sort()).toEqual([ + 'call_read_operation', + 'call_write_operation', + 'describe_operation', + 'search_operations', + ]) + expect(tools.call_read_operation.readOnlyHint).toBe(true) + expect(tools.call_write_operation.destructiveHint).toBe(true) + }) + + it('finds operations by keyword', async () => { + const result = await callTool('search_operations', { query: 'workspaces', limit: 5 }) + const { operations } = JSON.parse(result.content[0].text) + expect(operations.map((entry: { operation: string }) => entry.operation)).toContain( + 'listWorkspaces' + ) + }) + + it('serves a read through the v2 route with the MCP credential and audience', async () => { + const result = await callTool('call_read_operation', { operation: 'getMeta' }) + expect(result.isError).toBeFalsy() + expect(JSON.parse(result.content[0].text)).toEqual({ + data: { v2Enabled: true, keyType: 'personal', expiresAt: null }, + }) + expect(v2RouteMocks.authenticate).toHaveBeenCalledTimes(2) + expect(v2RouteMocks.authenticate).toHaveBeenLastCalledWith( + { apiKey: 'sk-sim-personal', bearer: null, malformedOAuthBearer: false }, + audience + ) + }) + + it('returns the v2 error envelope as a tool error', async () => { + v2RouteMocks.authenticate + .mockResolvedValueOnce(auth) + .mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError('Invalid API key')) + const result = await callTool('call_read_operation', { operation: 'getMeta' }) + expect(result.isError).toBe(true) + expect(JSON.parse(result.content[0].text)).toMatchObject({ + error: { code: 'UNAUTHORIZED', message: 'Invalid API key' }, + }) + }) + + it('asks an OAuth token without api:write to step up before the write tool runs', async () => { + v2RouteMocks.authenticate.mockResolvedValue({ + ...auth, + principal: { + kind: 'oauth_access_token' as const, + userId: 'user-1', + clientId: 'client-1', + tokenId: 'token-1', + scopes: ['api:read'], + expiresAt: new Date(Date.now() + 60_000), + }, + keyType: 'oauth_access_token' as const, + }) + const response = await handlers.POST( + rpc( + { + method: 'tools/call', + params: { name: 'call_write_operation', arguments: { operation: 'createTable' } }, + }, + { authorization: 'Bearer sim_oat_read_only' } + ), + undefined + ) + expect(response.status).toBe(403) + expect(response.headers.get('WWW-Authenticate')).toBe( + `Bearer error="insufficient_scope", resource_metadata="${BASE}/.well-known/oauth-protected-resource/api/mcp", scope="api:write"` + ) + expect(v2RouteMocks.authenticate).toHaveBeenCalledTimes(1) + }) + + it('checks the scope the dispatched operation declares, not its HTTP method', async () => { + v2RouteMocks.authenticate.mockResolvedValue({ + ...auth, + principal: { + kind: 'oauth_access_token' as const, + userId: 'user-1', + clientId: 'client-1', + tokenId: 'token-1', + scopes: ['api:read'], + expiresAt: new Date(Date.now() + 60_000), + }, + keyType: 'oauth_access_token' as const, + }) + const response = await handlers.POST( + rpc( + { + method: 'tools/call', + params: { + name: 'call_write_operation', + arguments: { operation: 'queryRows', params: { tableId: 'tbl_1' } }, + }, + }, + { authorization: 'Bearer sim_oat_read_only' } + ), + undefined + ) + expect(response.status).toBe(200) + }) + + it('refuses a write operation on the read tool', async () => { + const result = await callTool('call_read_operation', { operation: 'createTable' }) + expect(result.isError).toBe(true) + expect(v2RouteMocks.authenticate).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/sim/lib/api/mcp/route-handler.ts b/apps/sim/lib/api/mcp/route-handler.ts new file mode 100644 index 00000000000..9f986ec701e --- /dev/null +++ b/apps/sim/lib/api/mcp/route-handler.ts @@ -0,0 +1,114 @@ +import { isPlainRecord } from '@sim/utils/object' +import type { NextRequest } from 'next/server' +import { v2McpOperations } from '@/lib/api/application/operations' +import { simMcpContract } from '@/lib/api/contracts/sim-mcp' +import { getMcpOperation, resolveOperation, TOOL_NAMES } from '@/lib/api/mcp/catalog' +import { withSimMcpAuthChallenge } from '@/lib/api/mcp/oauth-metadata' +import { createSimMcpServer } from '@/lib/api/mcp/server' +import { getSimMcpUrl } from '@/lib/api/mcp/urls' +import { parseRequest } from '@/lib/api/server' +import { + mcpCredentialAuth, + mcpMethodNotAllowed, + readMcpCredentialHeaders, + serveStatelessMcp, +} from '@/lib/api/server/routes/mcp-server-route' +import { + admitV2Request, + v2RateLimits, + v2RouteOperation, +} from '@/lib/api/server/routes/v2-json-route' +import type { OAuthAccessTokenOptions } from '@/lib/auth/oauth-access-token' +import { type ApplicationOperation, requireOAuthOperationScope } from '@/lib/core/application' +import { isSameOrigin } from '@/lib/core/utils/validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' + +/** Matches the largest body an ordinary v2 JSON route accepts; each route still applies its own limit. */ +const MAX_MCP_BODY_BYTES = 10 * 1024 * 1024 + +/** + * OAuth tokens must be bound to this server, or be an existing unbound Sim API + * grant such as the CLI's — the same API either way. + */ +function simMcpAudience(): OAuthAccessTokenOptions { + return { resource: getSimMcpUrl(), allowUnboundApiTokens: true } +} + +function admit(request: NextRequest) { + return admitV2Request( + request, + v2McpOperations.connect, + mcpCredentialAuth(simMcpAudience()), + v2RateLimits.publicApi + ) +} + +/** + * The v2 operation a JSON-RPC message will run, when it calls the read or write + * tool with a known operation. Its OAuth scope is checked before the SDK runs, + * so a token without it gets the protocol's `insufficient_scope` step-up + * challenge rather than a tool error. Raw routes declare no operation and all + * change something, so they need `api:write`. + */ +async function toolCallOperation( + message: Record +): Promise { + if (message.method !== 'tools/call' || !isPlainRecord(message.params)) return null + const { name, arguments: args } = message.params + if (name !== TOOL_NAMES.read && name !== TOOL_NAMES.write) return null + if (!isPlainRecord(args) || typeof args.operation !== 'string') return null + const resolved = await resolveOperation( + args.operation, + name === TOOL_NAMES.read ? 'read' : 'write' + ) + if ('error' in resolved) return null + const route = await getMcpOperation(resolved.operation).handler() + return v2RouteOperation(route) ?? v2McpOperations.rawRoute +} + +/** Browsers may only reach the server from Sim's own origins (DNS-rebinding protection). */ +function isAllowedOrigin(origin: string | null): boolean { + return !origin || isSameOrigin(origin) || isSameOrigin(origin, getSimMcpUrl()) +} + +export function createSimMcpHandlers() { + /** JSON-RPC is a protocol boundary; every tool call is dispatched to its own v2 route. */ + const handler = withRouteHandler(async (request: NextRequest) => { + const admission = await admit(request) + if (!admission.success) return withSimMcpAuthChallenge(admission.response) + if (!isAllowedOrigin(request.headers.get('origin'))) { + return v2Error('FORBIDDEN', 'Origin is not allowed') + } + try { + const parsed = await parseRequest( + simMcpContract, + request, + {}, + { maxBodyBytes: MAX_MCP_BODY_BYTES } + ) + if (!parsed.success) return parsed.response + const operation = await toolCallOperation(parsed.data.body) + if (operation) requireOAuthOperationScope(admission.auth.principal, operation) + const server = createSimMcpServer({ + inbound: request, + credential: readMcpCredentialHeaders(request.headers), + audience: simMcpAudience(), + }) + return await serveStatelessMcp(server, request, parsed.data.body) + } catch (error) { + const response = v2CaughtOrchestrationError(error) + if (response) return withSimMcpAuthChallenge(response) + throw error + } + }) + + /** Stateless clients use POST only; authenticate unsupported methods before returning 405. */ + const unsupportedMethod = withRouteHandler(async (request: NextRequest) => { + const admission = await admit(request) + if (!admission.success) return withSimMcpAuthChallenge(admission.response) + return mcpMethodNotAllowed() + }) + + return { POST: handler, GET: unsupportedMethod, DELETE: unsupportedMethod } +} diff --git a/apps/sim/lib/api/mcp/server.ts b/apps/sim/lib/api/mcp/server.ts new file mode 100644 index 00000000000..805aebd9e7a --- /dev/null +++ b/apps/sim/lib/api/mcp/server.ts @@ -0,0 +1,169 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js' +import { createLogger } from '@sim/logger' +import { z } from 'zod' +import { + describeOperation, + OPERATION_DOMAINS, + resolveOperation, + searchOperations, + TOOL_NAMES, +} from '@/lib/api/mcp/catalog' +import { + dispatchMcpOperation, + type McpDispatchContext, + type McpOperationCall, +} from '@/lib/api/mcp/dispatch' +import { jsonToolResult, toolError } from '@/lib/mcp/tool-result' + +const logger = createLogger('SimMcpServer') + +const INSTRUCTIONS = `Sim is the AI workspace where teams build, deploy, and manage AI agents. This server exposes the full Sim API: workspaces, workflows and their runs, tables, knowledge bases, files, logs, credentials, deployments, and more. + +1. Find an operation with search_operations (keywords, optionally a domain). +2. Read its input schemas with describe_operation. +3. Run it with the tool search_operations names: call_read_operation for operations that only read, call_write_operation for everything else. + +Most operations take a workspaceId; listWorkspaces returns the workspaces you can use. Put path parameters in params, query-string values in query, and the JSON request body in body. Responses use the Sim API envelope ({ "data": ... }); list operations page with limit and cursor. Streaming options are not supported over MCP.` + +const operationName = z + .string() + .trim() + .min(1) + .max(128) + .describe('Operation name from search_operations, e.g. "listTables".') + +const operationArgs = { + params: z + .record(z.string(), z.string()) + .optional() + .describe('Path parameters by name, e.g. { "tableId": "..." }.'), + query: z + .record(z.string(), z.union([z.string(), z.number(), z.boolean()])) + .optional() + .describe('Query-string parameters by name.'), + headers: z + .record(z.string(), z.string()) + .optional() + .describe('Request headers the operation declares, such as upload-token.'), +} + +const searchInput = z + .object({ + query: z + .string() + .trim() + .max(200) + .optional() + .describe( + 'Keywords matched against operation names, summaries, and paths, e.g. "table rows".' + ), + domain: z.enum(OPERATION_DOMAINS).optional().describe('Only operations in this API area.'), + limit: z.number().int().min(1).max(100).default(25), + }) + .strict() + +const describeInput = z.object({ operation: operationName }).strict() + +const readInput = z.object({ operation: operationName, ...operationArgs }).strict() + +const writeInput = z + .object({ + operation: operationName, + ...operationArgs, + body: z.unknown().optional().describe('JSON request body, as describe_operation specifies.'), + }) + .strict() + +/** + * The Sim MCP server for one HTTP request. A request owns its server, so no + * credential outlives the request that presented it. + * + * Four tools cover the whole v2 API instead of one tool per operation: a + * catalog of 200-odd tools would overflow most clients' tool limits and spend + * the model's context on schemas it never uses. Reads and writes are separate + * tools so a client can approve reads once and still confirm every change. + */ +export function createSimMcpServer(context: Omit): McpServer { + const server = new McpServer({ name: 'Sim', version: '1.0.0' }, { instructions: INSTRUCTIONS }) + + async function call( + tool: 'read' | 'write', + { operation, ...input }: Omit & { operation: string }, + toolSignal: AbortSignal + ): Promise { + const resolved = await resolveOperation(operation, tool) + if ('error' in resolved) return toolError(resolved.error) + const signal = AbortSignal.any([context.inbound.signal, toolSignal]) + try { + return await dispatchMcpOperation( + { ...input, operation: resolved.operation }, + { ...context, signal } + ) + } catch (error) { + if (signal.aborted) return toolError('The operation was cancelled.') + logger.error('Sim MCP operation failed', { operation, error }) + return toolError('Unable to complete this operation. Please try again.') + } + } + + server.registerTool( + 'search_operations', + { + title: 'Search operations', + description: + 'Find Sim API operations by keyword or domain. Returns each operation’s name, HTTP method, path, summary, and the tool that runs it. Call without a query to list a domain.', + inputSchema: searchInput, + annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }, + }, + async (input) => jsonToolResult(await searchOperations(input)) + ) + + server.registerTool( + 'describe_operation', + { + title: 'Describe operation', + description: + 'Get the JSON Schema of an operation’s path parameters, query, body, and headers. Read it before calling an operation for the first time.', + inputSchema: describeInput, + annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }, + }, + async ({ operation }) => { + const resolved = await resolveOperation(operation, 'any') + return 'error' in resolved + ? toolError(resolved.error) + : jsonToolResult(await describeOperation(resolved.operation)) + } + ) + + server.registerTool( + TOOL_NAMES.read, + { + title: 'Read from Sim', + description: + 'Run a Sim API operation that only reads, such as listWorkspaces, listTables, queryRows, or getWorkflowRun. search_operations says which tool runs each operation.', + inputSchema: readInput, + annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }, + }, + async (input, extra) => call('read', input, extra.signal) + ) + + server.registerTool( + TOOL_NAMES.write, + { + title: 'Change Sim', + description: + 'Run a Sim API operation that creates, changes, runs, or deletes something, such as createTable, executeWorkflow, or deleteFile.', + inputSchema: writeInput, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, + }, + async (input, extra) => call('write', input, extra.signal) + ) + + return server +} diff --git a/apps/sim/lib/api/mcp/types.ts b/apps/sim/lib/api/mcp/types.ts new file mode 100644 index 00000000000..623c1e1a6d1 --- /dev/null +++ b/apps/sim/lib/api/mcp/types.ts @@ -0,0 +1,14 @@ +import type { AnyApiRouteContract } from '@/lib/api/contracts/types' + +/** One v2 operation as the Sim MCP server exposes it; entries are generated from the contracts. */ +export interface V2McpOperation { + readonly contract: AnyApiRouteContract + /** The OpenAPI summary, when the operation has one. */ + readonly summary?: string + /** The OpenAPI description: behavior, constraints, and caveats beyond the summary. */ + readonly description?: string + /** The operation refuses workspace API keys; a personal credential is required. */ + readonly workspaceKeyUnsupported?: true + /** Loads the route handler that serves this operation over HTTP. */ + readonly handler: () => Promise +} diff --git a/apps/sim/lib/api/mcp/urls.ts b/apps/sim/lib/api/mcp/urls.ts new file mode 100644 index 00000000000..190d7196585 --- /dev/null +++ b/apps/sim/lib/api/mcp/urls.ts @@ -0,0 +1,18 @@ +import { getEnv } from '@/lib/core/config/env' +import { getBaseUrl } from '@/lib/core/utils/urls' + +/** Where the Sim MCP route lives in this app, whichever host serves it publicly. */ +export const SIM_MCP_ROUTE_PATH = '/api/mcp' + +/** + * The Sim MCP server's canonical URL. Client setup, protected-resource + * discovery, and OAuth token audience all use this one string. + * + * `SIM_MCP_URL` names a dedicated host (hosted Sim serves `https://mcp.sim.ai/mcp`), + * which `proxy.ts` maps onto {@link SIM_MCP_ROUTE_PATH}. Without it the server + * is served from the app's own origin. + */ +export function getSimMcpUrl(): string { + const configured = getEnv('SIM_MCP_URL')?.trim().replace(/\/+$/, '') + return configured || `${getBaseUrl()}${SIM_MCP_ROUTE_PATH}` +} diff --git a/apps/sim/lib/api/server/routes/mcp-server-route.ts b/apps/sim/lib/api/server/routes/mcp-server-route.ts new file mode 100644 index 00000000000..b4aadc4fe5c --- /dev/null +++ b/apps/sim/lib/api/server/routes/mcp-server-route.ts @@ -0,0 +1,65 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js' +import type { NextRequest } from 'next/server' +import { + authenticateV2ApiKey, + V2ApiKeyUnauthenticatedError, +} from '@/lib/api/server/routes/v2-api-key-auth' +import type { V2CredentialHeaders } from '@/lib/api/server/routes/v2-credential-headers' +import { type OAuthAccessTokenOptions, parseBearerToken } from '@/lib/auth/oauth-access-token' +import { OAUTH_ACCESS_TOKEN_PREFIX } from '@/lib/auth/oauth-provider' + +const NO_STORE = 'private, no-store' + +/** + * The credential an MCP request presents. MCP clients send whatever they hold + * as `Authorization: Bearer`, so a bearer that is not one of Sim's OAuth access + * tokens is an API key. `x-api-key` is accepted too; two different credentials + * are refused rather than one being silently chosen. + */ +export function readMcpCredentialHeaders(headers: Headers): V2CredentialHeaders { + const apiKey = headers.get('x-api-key') + const bearer = parseBearerToken(headers) + if ((headers.has('authorization') && !bearer) || (apiKey && bearer && apiKey !== bearer)) { + throw new V2ApiKeyUnauthenticatedError('Provide one valid API key') + } + const oauthBearer = bearer?.startsWith(OAUTH_ACCESS_TOKEN_PREFIX) ? bearer : null + return { apiKey: apiKey ?? (oauthBearer ? null : bearer), bearer: oauthBearer } +} + +/** Authenticates an MCP request, verifying OAuth tokens against the server's own audience. */ +export function mcpCredentialAuth(audience: OAuthAccessTokenOptions) { + return { + authenticate(request: NextRequest) { + return authenticateV2ApiKey(readMcpCredentialHeaders(request.headers), audience) + }, + } +} + +/** + * Serves one JSON-RPC message statelessly: the server exists for this request + * only, so no credential outlives the request that presented it. + */ +export async function serveStatelessMcp( + server: McpServer, + request: Request, + parsedBody: unknown +): Promise { + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }) + try { + await server.connect(transport) + const response = await transport.handleRequest(request, { parsedBody }) + response.headers.set('Cache-Control', NO_STORE) + return response + } finally { + await server.close() + } +} + +/** Stateless servers take POST only; callers authenticate before answering 405. */ +export function mcpMethodNotAllowed(): Response { + return new Response(null, { status: 405, headers: { Allow: 'POST', 'Cache-Control': NO_STORE } }) +} diff --git a/apps/sim/lib/api/server/routes/v2-json-route.ts b/apps/sim/lib/api/server/routes/v2-json-route.ts index 74c8d2ee22e..b6ff4bca615 100644 --- a/apps/sim/lib/api/server/routes/v2-json-route.ts +++ b/apps/sim/lib/api/server/routes/v2-json-route.ts @@ -28,6 +28,7 @@ import { type ParseRequestOptions, parseRequest, } from '@/lib/api/server/validation' +import { getOAuthAccessTokenAudience } from '@/lib/auth/oauth-access-token' import { type ApplicationOperation, InsufficientScopeError, @@ -67,7 +68,10 @@ export class V2RouteInfrastructureError extends Error { */ export const v2ApiKeyAuth = { authenticate(request: NextRequest) { - return authenticateV2ApiKey(readV2CredentialHeaders(request.headers)) + return authenticateV2ApiKey( + readV2CredentialHeaders(request.headers), + getOAuthAccessTokenAudience() + ) }, } as const @@ -446,6 +450,18 @@ interface V2JsonRouteOptions): number } +/** + * The operation each v2 JSON route handler serves, so another transport for + * the same route (the Sim MCP server) can read its policy, such as the OAuth + * scope, without restating it. Keys are the module-level handlers. + */ +const routeOperations = new WeakMap() + +/** The operation a loaded v2 route handler serves, or `null` for a raw route. */ +export function v2RouteOperation(handler: unknown): ApplicationOperation | null { + return typeof handler === 'function' ? (routeOperations.get(handler) ?? null) : null +} + export function defineV2JsonRoute< C extends JsonApiRouteContract, O extends ApplicationOperation, @@ -561,5 +577,7 @@ export function defineV2JsonRoute< } ) - return async (request, context) => wrapped(request, context) + const route: JsonNextRouteHandler = async (request, context) => wrapped(request, context) + routeOperations.set(route, options.operation) + return route } diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 7d724bed1ec..5e7bc09be32 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -50,10 +50,10 @@ import { OAUTH_ACCESS_TOKEN_PREFIX, OAUTH_ACCESS_TOKEN_TTL_SECONDS, OAUTH_CODE_TTL_SECONDS, + OAUTH_PUBLIC_REGISTRATION_SCOPES, OAUTH_REFRESH_TOKEN_PREFIX, OAUTH_REFRESH_TOKEN_TTL_SECONDS, OAUTH_SCOPES, - OAUTH_SEARCH_SCOPES, SIM_CLI_CLIENT_ID, } from '@/lib/auth/oauth-provider' import { bindOAuthIssuedResource, oauthResourcePlugin } from '@/lib/auth/oauth-resource' @@ -1288,7 +1288,10 @@ export const auth = betterAuth({ * earlier rotation. This is an OAuth API-authorization surface, not an * OpenID Connect identity provider; `disableJwtPlugin` keeps JWT/JWKS and * ID-token semantics out of the advertised protocol. Public registration - * is limited to read-only Search clients; other clients are operator-created. + * serves MCP clients: a registered client may request the Sim API and + * Search families, every grant is consented to, and a grant bound to an MCP + * resource is narrowed to the family that resource allows (see + * `oauth-resource.ts`). First-party clients are operator-created. */ ...(!isAuthDisabled ? [ @@ -1306,8 +1309,8 @@ export const auth = betterAuth({ allowPublicClientPrelogin: true, allowDynamicClientRegistration: true, allowUnauthenticatedClientRegistration: true, - clientRegistrationAllowedScopes: [...OAUTH_SEARCH_SCOPES], - clientRegistrationDefaultScopes: [...OAUTH_SEARCH_SCOPES], + clientRegistrationAllowedScopes: [...OAUTH_PUBLIC_REGISTRATION_SCOPES], + clientRegistrationDefaultScopes: [...OAUTH_PUBLIC_REGISTRATION_SCOPES], customTokenResponseFields: bindOAuthIssuedResource, /** * Client-management endpoints remain operator-only. Public registration diff --git a/apps/sim/lib/auth/oauth-access-token.ts b/apps/sim/lib/auth/oauth-access-token.ts index 4204d4fb80e..f458f9a969a 100644 --- a/apps/sim/lib/auth/oauth-access-token.ts +++ b/apps/sim/lib/auth/oauth-access-token.ts @@ -1,3 +1,4 @@ +import { AsyncLocalStorage } from 'node:async_hooks' import type { OAuthAccessTokenPrincipal } from '@sim/auth/principal' import { db } from '@sim/db' import { oauthAccessToken, oauthClient, user } from '@sim/db/schema' @@ -76,6 +77,30 @@ export interface OAuthAccessTokenOptions { allowUnboundApiTokens?: boolean } +const audience = new AsyncLocalStorage() + +/** + * Runs `work` with v2 bearer tokens verified against `options` rather than the + * unbound API audience. + * + * The Sim MCP server dispatches each tool call to its v2 route handler + * in-process, and those handlers authenticate the request themselves. This is + * how they accept a token bound to the MCP server — the same audience the MCP + * endpoint already verified — without the REST API accepting MCP tokens from + * anyone else. Only server code can set it; no request input reaches it. + */ +export function withOAuthAccessTokenAudience( + options: OAuthAccessTokenOptions, + work: () => Promise +): Promise { + return audience.run(options, work) +} + +/** The audience set by {@link withOAuthAccessTokenAudience}, or the unbound API audience. */ +export function getOAuthAccessTokenAudience(): OAuthAccessTokenOptions { + return audience.getStore() ?? {} +} + /** * Resolves an opaque OAuth access token to the principal it stands for. * diff --git a/apps/sim/lib/auth/oauth-client-registration.ts b/apps/sim/lib/auth/oauth-client-registration.ts new file mode 100644 index 00000000000..db9b928c4e7 --- /dev/null +++ b/apps/sim/lib/auth/oauth-client-registration.ts @@ -0,0 +1,53 @@ +import { db } from '@sim/db' +import { oauthClient } from '@sim/db/schema' +import { isPlainRecord } from '@sim/utils/object' +import { eq } from 'drizzle-orm' + +/** + * Server-owned marker the public registration endpoint stamps on every client + * it creates. Clients cannot set metadata through registration and hold no + * management privileges, so only this server writes it. + */ +const PUBLIC_REGISTRATION_METADATA = { registration: 'public' } as const + +/** + * Stamps a just-registered client. Registration discloses the client ID only + * after this succeeds, so an unstamped client is never usable: if the write + * fails, the registration fails and nobody holds the ID. + */ +export async function markPubliclyRegisteredOAuthClient(clientId: string): Promise { + const updated = await db + .update(oauthClient) + .set({ metadata: PUBLIC_REGISTRATION_METADATA }) + .where(eq(oauthClient.clientId, clientId)) + .returning({ clientId: oauthClient.clientId }) + if (updated.length !== 1) { + throw new Error(`Registered OAuth client ${clientId} was not found to mark`) + } +} + +function readMetadata(value: unknown): unknown { + if (typeof value !== 'string') return value + try { + return JSON.parse(value) + } catch { + return null + } +} + +/** + * Whether a client was created through public registration rather than by an + * operator. Such a client may reach the Sim API only through the Sim MCP + * server, so its API grants must name that server as their resource. + */ +export async function isPubliclyRegisteredOAuthClient(clientId: string): Promise { + const [client] = await db + .select({ metadata: oauthClient.metadata }) + .from(oauthClient) + .where(eq(oauthClient.clientId, clientId)) + .limit(1) + const metadata = readMetadata(client?.metadata) + return ( + isPlainRecord(metadata) && metadata.registration === PUBLIC_REGISTRATION_METADATA.registration + ) +} diff --git a/apps/sim/lib/auth/oauth-protected-resource.ts b/apps/sim/lib/auth/oauth-protected-resource.ts new file mode 100644 index 00000000000..4774097f504 --- /dev/null +++ b/apps/sim/lib/auth/oauth-protected-resource.ts @@ -0,0 +1,67 @@ +import { NextResponse } from 'next/server' +import { getBaseUrl } from '@/lib/core/utils/urls' + +/** An MCP endpoint protected by Sim's OAuth authorization server. */ +export interface OAuthProtectedResource { + /** The canonical resource URL tokens are bound to. */ + resource: string + /** Human-readable name clients show while connecting. */ + name: string + /** Scopes a token for this resource may carry. */ + scopes: readonly string[] +} + +/** RFC 9728 metadata location for a resource: the well-known prefix inserted before its path. */ +function getProtectedResourceMetadataUrl(resource: string): string { + const url = new URL(resource) + return `${url.origin}/.well-known/oauth-protected-resource${url.pathname}` +} + +/** Public protocol metadata describes the endpoint without looking up protected data. */ +export function protectedResourceMetadataResponse({ + resource, + name, + scopes, +}: OAuthProtectedResource) { + return NextResponse.json( + { + resource, + resource_name: name, + authorization_servers: [`${getBaseUrl()}/api/auth`], + scopes_supported: scopes, + bearer_methods_supported: ['header'], + }, + { + headers: { + 'Cache-Control': 'public, max-age=300', + 'Access-Control-Allow-Origin': '*', + }, + } + ) +} + +/** + * Points a refused request at the resource's metadata (RFC 9728 §5.1), keeping + * the route's RFC 6750 error code (`invalid_token` tells a client to refresh). A + * `401` asks for every scope the resource grants; an `insufficient_scope` `403` + * keeps the scope the request needed, so the client can step up to exactly that. + */ +export function withOAuthResourceChallenge( + response: T, + { resource, scopes }: Pick +): T { + const existing = response.headers.get('WWW-Authenticate') + const insufficientScope = response.status === 403 && existing?.includes('insufficient_scope') + if (response.status !== 401 && !insufficientScope) return response + const scope = insufficientScope + ? (existing?.match(/scope="([^"]*)"/)?.[1] ?? scopes.join(' ')) + : scopes.join(' ') + const reason = existing?.match(/error="([^"]*)"/)?.[1] + const error = reason ? `error="${reason}", ` : '' + response.headers.set( + 'WWW-Authenticate', + `Bearer ${error}resource_metadata="${getProtectedResourceMetadataUrl(resource)}", scope="${scope}"` + ) + response.headers.set('Cache-Control', 'private, no-store') + return response +} diff --git a/apps/sim/lib/auth/oauth-provider-registration.test.ts b/apps/sim/lib/auth/oauth-provider-registration.test.ts index 17fa09d785b..4fcb0fa674c 100644 --- a/apps/sim/lib/auth/oauth-provider-registration.test.ts +++ b/apps/sim/lib/auth/oauth-provider-registration.test.ts @@ -6,10 +6,10 @@ import { memoryAdapter } from 'better-auth/adapters/memory' import { symmetricEncrypt } from 'better-auth/crypto' import { beforeEach, describe, expect, it } from 'vitest' import { - registerSearchOAuthClientBodySchema, - registerSearchOAuthClientResponseSchema, + registerOAuthClientBodySchema, + registerOAuthClientResponseSchema, } from '@/lib/api/contracts/oauth-provider' -import { OAUTH_SCOPES, OAUTH_SEARCH_SCOPES } from '@/lib/auth/oauth-provider' +import { OAUTH_PUBLIC_REGISTRATION_SCOPES, OAUTH_SCOPES } from '@/lib/auth/oauth-provider' const BASE_URL = 'https://sim.test' const AUTH_SECRET = 'isolated-oauth-registration-test-secret-123456789' @@ -42,8 +42,8 @@ function createProvider(database: Record[]>) { grantTypes: ['authorization_code', 'refresh_token'], allowDynamicClientRegistration: true, allowUnauthenticatedClientRegistration: true, - clientRegistrationAllowedScopes: [...OAUTH_SEARCH_SCOPES], - clientRegistrationDefaultScopes: [...OAUTH_SEARCH_SCOPES], + clientRegistrationAllowedScopes: [...OAUTH_PUBLIC_REGISTRATION_SCOPES], + clientRegistrationDefaultScopes: [...OAUTH_PUBLIC_REGISTRATION_SCOPES], clientPrivileges: () => false, silenceWarnings: { oauthAuthServerConfig: true, openidConfig: true }, }), @@ -51,7 +51,7 @@ function createProvider(database: Record[]>) { }) } -describe('Search registration with the installed OAuth provider', () => { +describe('MCP client registration with the installed OAuth provider', () => { let database: Record[]> let provider: ReturnType @@ -70,7 +70,7 @@ describe('Search registration with the installed OAuth provider', () => { }) async function register(metadata: object = claudeMetadata) { - const body = registerSearchOAuthClientBodySchema.parse(metadata) + const body = registerOAuthClientBodySchema.parse(metadata) return provider.handler( new Request(`${BASE_URL}/api/auth/oauth2/register`, { method: 'POST', @@ -151,7 +151,7 @@ describe('Search registration with the installed OAuth provider', () => { }) expect(response.ok).toBe(true) const body = await response.json() - expect(registerSearchOAuthClientResponseSchema.parse(body)).toMatchObject({ + expect(registerOAuthClientResponseSchema.parse(body)).toMatchObject({ client_name: 'Claude', redirect_uris: [REDIRECT_URI], token_endpoint_auth_method: 'none', @@ -170,7 +170,7 @@ describe('Search registration with the installed OAuth provider', () => { } ) - it('narrows issuer scopes and strips privileged metadata before persistence', async () => { + it('keeps registrable issuer scopes and strips privileged metadata before persistence', async () => { const response = await register({ ...claudeMetadata, scope: 'api:read api:write search:read offline_access', @@ -183,7 +183,7 @@ describe('Search registration with the installed OAuth provider', () => { expect(response.ok).toBe(true) expect(database.oauthClient[0]).toMatchObject({ public: true, - scopes: ['search:read', 'offline_access'], + scopes: ['api:read', 'api:write', 'offline_access', 'search:read'], }) expect(database.oauthClient[0].clientSecret).toBeFalsy() expect(database.oauthClient[0].skipConsent).toBeFalsy() @@ -194,7 +194,7 @@ describe('Search registration with the installed OAuth provider', () => { 'rejects authorization without PKCE or with unregistered scope %s', async (scope) => { const registered = await register() - const client = registerSearchOAuthClientResponseSchema.parse(await registered.json()) + const client = registerOAuthClientResponseSchema.parse(await registered.json()) const url = new URL(`${BASE_URL}/api/auth/oauth2/authorize`) url.search = new URLSearchParams({ client_id: client.client_id, @@ -213,7 +213,7 @@ describe('Search registration with the installed OAuth provider', () => { it('continues negotiated public clients to sign-in with S256 PKCE', async () => { const registered = await register() - const client = registerSearchOAuthClientResponseSchema.parse(await registered.json()) + const client = registerOAuthClientResponseSchema.parse(await registered.json()) const url = new URL(`${BASE_URL}/api/auth/oauth2/authorize`) url.search = new URLSearchParams({ client_id: client.client_id, @@ -237,7 +237,7 @@ describe('Search registration with the installed OAuth provider', () => { ...claudeMetadata, token_endpoint_auth_method: authMethod, }) - const client = registerSearchOAuthClientResponseSchema.parse(await registered.json()) + const client = registerOAuthClientResponseSchema.parse(await registered.json()) const cookie = await signIn() const code = await authorize(client.client_id, cookie) const tokenResponse = await requestToken({ @@ -285,7 +285,7 @@ describe('Search registration with the installed OAuth provider', () => { 'rejects code exchange with verifier %s', async (verifier) => { const registered = await register() - const client = registerSearchOAuthClientResponseSchema.parse(await registered.json()) + const client = registerOAuthClientResponseSchema.parse(await registered.json()) const cookie = await signIn() const code = await authorize(client.client_id, cookie) const response = await requestToken({ diff --git a/apps/sim/lib/auth/oauth-provider.ts b/apps/sim/lib/auth/oauth-provider.ts index 38f313a68f1..67a1ce94ea7 100644 --- a/apps/sim/lib/auth/oauth-provider.ts +++ b/apps/sim/lib/auth/oauth-provider.ts @@ -21,6 +21,12 @@ export const OAUTH_API_WRITE_SCOPE = 'api:write' export const OAUTH_SEARCH_READ_SCOPE = 'search:read' export const OAUTH_SEARCH_SCOPES = [OAUTH_SEARCH_READ_SCOPE, 'offline_access'] as const +/** What a token bound to the Sim MCP server may carry: the Sim API, never Search. */ +export const OAUTH_API_SCOPES = [ + OAUTH_API_READ_SCOPE, + OAUTH_API_WRITE_SCOPE, + 'offline_access', +] as const export const OAUTH_SCOPES = [ 'offline_access', OAUTH_API_READ_SCOPE, @@ -30,20 +36,42 @@ export const OAUTH_SCOPES = [ export type OAuthScope = (typeof OAUTH_SCOPES)[number] +/** The scopes each kind of MCP resource may grant: the Sim API, or Search. */ +export const OAUTH_RESOURCE_SCOPES = { api: OAUTH_API_SCOPES, search: OAUTH_SEARCH_SCOPES } as const + +export type OAuthResourceKind = keyof typeof OAUTH_RESOURCE_SCOPES + /** * RFC 6749 permits granting fewer scopes than requested. Some MCP clients request - * every scope advertised by the shared issuer; a Search resource can only grant - * Search access, and the returned scope always reports that narrower grant. + * every scope advertised by the shared issuer; a resource can only grant its own + * family, and the returned scope always reports that narrower grant. `null` when + * the request names an unknown scope or nothing but `offline_access` from the family. */ -export function narrowSearchOAuthScopes(scope: string): string | null { +export function narrowResourceOAuthScopes(scope: string, kind: OAuthResourceKind): string | null { const requested = scope.split(' ').filter(Boolean) - if ( - !requested.includes(OAUTH_SEARCH_READ_SCOPE) || - requested.some((value) => !OAUTH_SCOPES.some((allowed) => allowed === value)) - ) { - return null - } - return OAUTH_SEARCH_SCOPES.filter((value) => requested.includes(value)).join(' ') + if (requested.some((value) => !OAUTH_SCOPES.some((allowed) => allowed === value))) return null + const granted = OAUTH_RESOURCE_SCOPES[kind].filter((value) => requested.includes(value)) + return granted.some((value) => value !== 'offline_access') ? granted.join(' ') : null +} + +/** + * What a publicly registered MCP client may be granted. Registration cannot know + * which server the client will connect to, so it may hold both families; each + * authorization is narrowed to the one family its resource grants. + */ +export const OAUTH_PUBLIC_REGISTRATION_SCOPES = [ + ...OAUTH_API_SCOPES, + OAUTH_SEARCH_READ_SCOPE, +] as const + +/** The registrable subset of a client's requested scopes, or `null` when none is registrable. */ +export function narrowRegistrationOAuthScopes(scope: string): string | null { + const granted = new Set( + (['api', 'search'] as const).flatMap( + (kind) => narrowResourceOAuthScopes(scope, kind)?.split(' ') ?? [] + ) + ) + return granted.size > 0 ? [...granted].join(' ') : null } /** diff --git a/apps/sim/lib/auth/oauth-resource.test.ts b/apps/sim/lib/auth/oauth-resource.test.ts index 1c6fc5a8a46..2ae67989972 100644 --- a/apps/sim/lib/auth/oauth-resource.test.ts +++ b/apps/sim/lib/auth/oauth-resource.test.ts @@ -10,18 +10,20 @@ import { getOAuthIssuedResource, InvalidOAuthResourceError, oauthResourcePlugin, - parseOAuthSearchResource, + parseOAuthResource, withOAuthResourceIssuance, } from '@/lib/auth/oauth-resource' const resource = 'https://sim.example/api/mcp/search/organizations/org-one' +const simMcpResource = 'https://sim.example/api/mcp' const otherResource = 'https://sim.example/api/mcp/search/organizations/org-two' const scopes = ['search:read', 'offline_access'] describe('OAuth resource binding', () => { - it('accepts exact organization Search endpoints and an absent API audience', () => { - expect(parseOAuthSearchResource(resource)).toBe(resource) - expect(parseOAuthSearchResource(null)).toBeNull() + it('accepts exact organization Search endpoints, the Sim MCP server, and an absent API audience', () => { + expect(parseOAuthResource(resource)).toEqual({ kind: 'search', url: resource }) + expect(parseOAuthResource(simMcpResource)).toEqual({ kind: 'api', url: simMcpResource }) + expect(parseOAuthResource(null)).toBeNull() }) it.each([ @@ -39,8 +41,11 @@ describe('OAuth resource binding', () => { 'https://sim.example/api/mcp/search/organizations', 'https://sim.example/api/v2/workspaces', 'https://sim.example:443/api/mcp/search/organizations/org-one', + 'https://sim.example/api/mcp/', + 'https://sim.example/api/mcp?workspaceId=ws-1', + 'https://attacker.example/api/mcp', ])('rejects noncanonical or unsupported resources: %s', (value) => { - expect(() => parseOAuthSearchResource(value)).toThrow(InvalidOAuthResourceError) + expect(() => parseOAuthResource(value)).toThrow(InvalidOAuthResourceError) }) it('binds only the resource from the verified authorization request before insertion', async () => { @@ -82,19 +87,32 @@ describe('OAuth resource binding', () => { [resource, ['api:read']], [resource, ['search:read', 'api:read']], [null, ['search:read']], - ])( - 'requires search scope and resource together without wider API authority', - async (target, granted) => { - await expect( - withOAuthResourceIssuance(target, async () => - bindOAuthIssuedResource({ - verificationValue: { query: { resource: target ?? undefined } }, - scopes: granted, - }) - ) - ).rejects.toMatchObject({ body: { error: 'invalid_scope' } }) - } - ) + [simMcpResource, ['search:read']], + [simMcpResource, ['api:write', 'search:read']], + [simMcpResource, ['offline_access']], + ])('grants each resource only its own scope family: %s %j', async (target, granted) => { + await expect( + withOAuthResourceIssuance(target, async () => + bindOAuthIssuedResource({ + verificationValue: { query: { resource: target ?? undefined } }, + scopes: granted, + }) + ) + ).rejects.toMatchObject({ body: { error: 'invalid_scope' } }) + }) + + it('binds Sim API grants to the Sim MCP server', async () => { + const apiScopes = ['api:write', 'offline_access'] + await withOAuthResourceIssuance(simMcpResource, async () => { + expect( + bindOAuthIssuedResource({ + verificationValue: { query: { resource: simMcpResource } }, + scopes: apiScopes, + }) + ).toEqual({}) + expect(getOAuthIssuedResource(apiScopes)).toBe(simMcpResource) + }) + }) it('preserves existing API issuance and refuses direct Search provider calls', async () => { expect(bindOAuthIssuedResource({ scopes: ['api:read'] })).toEqual({}) diff --git a/apps/sim/lib/auth/oauth-resource.ts b/apps/sim/lib/auth/oauth-resource.ts index 38545b27e2d..3ba42fb3bc7 100644 --- a/apps/sim/lib/auth/oauth-resource.ts +++ b/apps/sim/lib/auth/oauth-resource.ts @@ -1,7 +1,12 @@ import { AsyncLocalStorage } from 'node:async_hooks' import type { BetterAuthPlugin } from 'better-auth' import { APIError } from 'better-auth/api' -import { OAUTH_SEARCH_READ_SCOPE } from '@/lib/auth/oauth-provider' +import { getSimMcpUrl } from '@/lib/api/mcp/urls' +import { + narrowResourceOAuthScopes, + OAUTH_SEARCH_READ_SCOPE, + type OAuthResourceKind, +} from '@/lib/auth/oauth-provider' import { getBaseUrl } from '@/lib/core/utils/urls' interface OAuthResourceIssuance { @@ -9,37 +14,59 @@ interface OAuthResourceIssuance { verifiedResource?: string | null } +/** + * An RFC 8707 audience this deployment issues tokens for. `api` is the Sim MCP + * server, which serves the Sim API; `search` is an organization's Search server. + */ +export interface OAuthResource { + kind: OAuthResourceKind + url: string +} + const issuance = new AsyncLocalStorage() const SEARCH_RESOURCE_PATH = /^\/api\/mcp\/search\/organizations\/[A-Za-z0-9_-]{1,128}$/ export class InvalidOAuthResourceError extends Error { constructor() { - super('The resource must be a canonical Sim Search MCP URL.') + super('The resource must be a canonical Sim MCP server URL.') this.name = 'InvalidOAuthResourceError' } } -/** Accepts only organization Search MCP endpoints on this deployment's canonical origin. */ -export function parseOAuthSearchResource(value: string | null): string | null { +/** Accepts only this deployment's canonical Sim MCP URL and its organization Search endpoints. */ +export function parseOAuthResource(value: string | null): OAuthResource | null { if (value === null) return null - try { - const url = new URL(value) - if ( - value.length > 2048 || - url.href !== value || - url.origin !== new URL(getBaseUrl()).origin || - url.username || - url.password || - url.search || - url.hash || - !SEARCH_RESOURCE_PATH.test(url.pathname) - ) { - throw new InvalidOAuthResourceError() - } - return value - } catch { + if (value === getSimMcpUrl()) return { kind: 'api', url: value } + if (!URL.canParse(value)) throw new InvalidOAuthResourceError() + const url = new URL(value) + if ( + value.length > 2048 || + url.href !== value || + url.origin !== new URL(getBaseUrl()).origin || + url.username || + url.password || + url.search || + url.hash || + !SEARCH_RESOURCE_PATH.test(url.pathname) + ) { throw new InvalidOAuthResourceError() } + return { kind: 'search', url: value } +} + +/** + * Whether a grant's scopes belong to its audience: exactly the resource's own + * family. An unbound token may carry anything but Search, which is never issued + * without its resource. + */ +function oauthScopesFitResource( + resource: OAuthResource | null, + scopes: readonly string[] +): boolean { + if (!resource) return !scopes.includes(OAUTH_SEARCH_READ_SCOPE) + return ( + narrowResourceOAuthScopes(scopes.join(' '), resource.kind)?.split(' ').length === scopes.length + ) } /** Keeps the token request audience isolated while Better Auth validates the authorization code. */ @@ -66,12 +93,12 @@ export function bindOAuthIssuedResource({ const query = verificationValue?.query const resourceValue = query && typeof query === 'object' && 'resource' in query ? query.resource : undefined - let resource: string | null + let resource: OAuthResource | null try { if (resourceValue !== undefined && typeof resourceValue !== 'string') { throw new InvalidOAuthResourceError() } - resource = parseOAuthSearchResource(resourceValue ?? null) + resource = parseOAuthResource(resourceValue ?? null) } catch { throw new APIError('BAD_REQUEST', { error: 'invalid_target', @@ -79,21 +106,16 @@ export function bindOAuthIssuedResource({ }) } - if (resource !== (context?.requestedResource ?? null)) { + if ((resource?.url ?? null) !== (context?.requestedResource ?? null)) { throw new APIError('BAD_REQUEST', { error: 'invalid_target', error_description: 'The token resource must match the authorization request.', }) } - if ( - resource - ? !scopes.includes(OAUTH_SEARCH_READ_SCOPE) || - scopes.some((scope) => scope !== OAUTH_SEARCH_READ_SCOPE && scope !== 'offline_access') - : scopes.includes(OAUTH_SEARCH_READ_SCOPE) - ) { + if (!oauthScopesFitResource(resource, scopes)) { throw new APIError('BAD_REQUEST', { error: 'invalid_scope', - error_description: 'Search access requires its matching resource and search scope.', + error_description: 'The granted scopes do not match the token resource.', }) } if (resource && !context) { @@ -102,7 +124,7 @@ export function bindOAuthIssuedResource({ error_description: 'Resource-bound issuance requires a token request.', }) } - if (context) context.verifiedResource = resource + if (context) context.verifiedResource = resource?.url ?? null return {} } diff --git a/apps/sim/lib/auth/oauth-token-family.ts b/apps/sim/lib/auth/oauth-token-family.ts index b0ca9c0b365..ebf9d68ea04 100644 --- a/apps/sim/lib/auth/oauth-token-family.ts +++ b/apps/sim/lib/auth/oauth-token-family.ts @@ -22,7 +22,7 @@ import { OAUTH_REFRESH_TOKEN_PREFIX, OAUTH_TOKEN_FAMILY_MAX_GENERATION, } from '@/lib/auth/oauth-provider' -import { parseOAuthSearchResource } from '@/lib/auth/oauth-resource' +import { parseOAuthResource } from '@/lib/auth/oauth-resource' import { acquireOrganizationUserMutationLocks, getUserOrganization, @@ -282,7 +282,7 @@ export async function rotateOAuthRefreshToken( return protocolError('invalid_target', 'The resource must match the original token grant.') } try { - parseOAuthSearchResource(provisionalToken.resource) + parseOAuthResource(provisionalToken.resource) } catch { return protocolError('invalid_target', 'The original resource is no longer supported.') } diff --git a/apps/sim/lib/copilot/generated/docs-manifest.ts b/apps/sim/lib/copilot/generated/docs-manifest.ts index a9c8fc6b419..d62ef97b64e 100644 --- a/apps/sim/lib/copilot/generated/docs-manifest.ts +++ b/apps/sim/lib/copilot/generated/docs-manifest.ts @@ -371,6 +371,9 @@ export const DOCS_MANIFEST: readonly string[] = [ 'logs-debugging.mdx', 'logs-debugging/alerts.mdx', 'logs-debugging/logging.mdx', + 'mcp.mdx', + 'mcp/authentication.mdx', + 'mcp/tools.mdx', 'platform/connected-accounts.mdx', 'platform/costs.mdx', 'platform/credentials.mdx', diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 8d61dba5005..dae6a930805 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -485,6 +485,7 @@ export const env = createEnv({ // Real-time Communication SOCKET_SERVER_URL: z.string().url().optional(), // WebSocket server URL for real-time features PORT: z.number().optional(), // Main application port + SIM_MCP_URL: z.string().url().optional(), // Public URL of the Sim MCP server when served on its own host (e.g., https://mcp.sim.ai/mcp); defaults to /api/mcp INTERNAL_API_BASE_URL: z.string().optional(), // Optional internal base URL for server-side self-calls; must include protocol if set (e.g., http://sim-app.namespace.svc.cluster.local:3000) ALLOWED_ORIGINS: z.string().optional(), // CORS allowed origins PII_URL: z.string().optional(), // Presidio PII service base URL serving /analyze + /anonymize (standalone ECS service; default http://localhost:5001 for local dev) diff --git a/apps/sim/lib/knowledge/mcp/oauth-metadata.ts b/apps/sim/lib/knowledge/mcp/oauth-metadata.ts index e22d12caaa3..3448cf479ac 100644 --- a/apps/sim/lib/knowledge/mcp/oauth-metadata.ts +++ b/apps/sim/lib/knowledge/mcp/oauth-metadata.ts @@ -1,41 +1,17 @@ -import { NextResponse } from 'next/server' +import { + protectedResourceMetadataResponse, + withOAuthResourceChallenge, +} from '@/lib/auth/oauth-protected-resource' import { OAUTH_SEARCH_SCOPES } from '@/lib/auth/oauth-provider' -import { getBaseUrl } from '@/lib/core/utils/urls' -/** Public protocol metadata describes the endpoint without looking up protected organization data. */ export function searchMcpResourceMetadata(resource: string) { - return NextResponse.json( - { - resource, - resource_name: 'Sim Search', - authorization_servers: [`${getBaseUrl()}/api/auth`], - scopes_supported: OAUTH_SEARCH_SCOPES, - bearer_methods_supported: ['header'], - }, - { - headers: { - 'Cache-Control': 'public, max-age=300', - 'Access-Control-Allow-Origin': '*', - }, - } - ) + return protectedResourceMetadataResponse({ + resource, + name: 'Sim Search', + scopes: OAUTH_SEARCH_SCOPES, + }) } -/** Requests refresh consent so clients that follow the challenge can stay connected after expiry. */ export function withSearchMcpAuthChallenge(response: T, resource: string): T { - if ( - response.status !== 401 && - response.headers.get('WWW-Authenticate')?.includes('insufficient_scope') !== true - ) { - return response - } - const url = new URL(resource) - const metadata = `${url.origin}/.well-known/oauth-protected-resource${url.pathname}` - const error = response.status === 403 ? 'error="insufficient_scope", ' : '' - response.headers.set( - 'WWW-Authenticate', - `Bearer ${error}resource_metadata="${metadata}", scope="${OAUTH_SEARCH_SCOPES.join(' ')}"` - ) - response.headers.set('Cache-Control', 'private, no-store') - return response + return withOAuthResourceChallenge(response, { resource, scopes: OAUTH_SEARCH_SCOPES }) } diff --git a/apps/sim/lib/knowledge/mcp/route-handler.test.ts b/apps/sim/lib/knowledge/mcp/route-handler.test.ts index 1111efb376a..07335d2f799 100644 --- a/apps/sim/lib/knowledge/mcp/route-handler.test.ts +++ b/apps/sim/lib/knowledge/mcp/route-handler.test.ts @@ -153,7 +153,7 @@ describe('organization MCP request admission', () => { const response = await post() expect(response.status).toBe(403) expect(response.headers.get('WWW-Authenticate')).toContain('error="insufficient_scope"') - expect(response.headers.get('WWW-Authenticate')).toContain('scope="search:read offline_access"') + expect(response.headers.get('WWW-Authenticate')).toContain('scope="search:read"') expect(mocks.index).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/knowledge/mcp/route-handler.ts b/apps/sim/lib/knowledge/mcp/route-handler.ts index 2b3a94b033e..006b69ad43e 100644 --- a/apps/sim/lib/knowledge/mcp/route-handler.ts +++ b/apps/sim/lib/knowledge/mcp/route-handler.ts @@ -1,13 +1,12 @@ -import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js' import type { NextRequest } from 'next/server' import { organizationKnowledgeMcpContract } from '@/lib/api/contracts/knowledge/mcp' import { parseRequest } from '@/lib/api/server' import { - authenticateV2ApiKey, - V2ApiKeyUnauthenticatedError, -} from '@/lib/api/server/routes/v2-api-key-auth' + mcpCredentialAuth, + mcpMethodNotAllowed, + serveStatelessMcp, +} from '@/lib/api/server/routes/mcp-server-route' import { admitV2Request, v2RateLimits } from '@/lib/api/server/routes/v2-json-route' -import { OAUTH_ACCESS_TOKEN_PREFIX } from '@/lib/auth/oauth-provider' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { knowledgeOperations } from '@/lib/knowledge/application/operations' @@ -18,25 +17,7 @@ import { getSearchMcpUrl } from '@/lib/knowledge/mcp/urls' import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' function mcpAuth(resource: string) { - return { - authenticate(request: NextRequest) { - const apiKey = request.headers.get('x-api-key') - const authorization = request.headers.get('authorization') - const bearer = authorization?.match(/^Bearer ([^\s]+)$/i)?.[1] - if ((authorization && !bearer) || (apiKey && bearer && apiKey !== bearer)) { - throw new V2ApiKeyUnauthenticatedError('Provide one valid API key') - } - /** MCP clients also send existing Sim API keys as bearer credentials. */ - const oauthBearer = bearer?.startsWith(OAUTH_ACCESS_TOKEN_PREFIX) ? bearer : null - return authenticateV2ApiKey( - { - apiKey: apiKey ?? (oauthBearer ? null : (bearer ?? null)), - bearer: oauthBearer, - }, - { resource, allowUnboundApiTokens: true } - ) - }, - } + return mcpCredentialAuth({ resource, allowUnboundApiTokens: true }) } export function createKnowledgeMcpHandlers() { @@ -72,18 +53,7 @@ export function createKnowledgeMcpHandlers() { ...parsed.data.params, searchIndexId: index.knowledgeBaseId, }) - const transport = new WebStandardStreamableHTTPServerTransport({ - sessionIdGenerator: undefined, - enableJsonResponse: true, - }) - try { - await server.connect(transport) - const response = await transport.handleRequest(request, { parsedBody: parsed.data.body }) - response.headers.set('Cache-Control', 'private, no-store') - return response - } finally { - await server.close() - } + return await serveStatelessMcp(server, request, parsed.data.body) } catch (error) { const response = v2CaughtOrchestrationError(error) if (response) return withSearchMcpAuthChallenge(response, resource) @@ -104,10 +74,7 @@ export function createKnowledgeMcpHandlers() { v2RateLimits.publicApi ) if (!admission.success) return withSearchMcpAuthChallenge(admission.response, resource) - return new Response(null, { - status: 405, - headers: { Allow: 'POST', 'Cache-Control': 'private, no-store' }, - }) + return mcpMethodNotAllowed() } ) diff --git a/apps/sim/lib/knowledge/mcp/server.ts b/apps/sim/lib/knowledge/mcp/server.ts index 226fece5474..c2d1ab67303 100644 --- a/apps/sim/lib/knowledge/mcp/server.ts +++ b/apps/sim/lib/knowledge/mcp/server.ts @@ -25,6 +25,7 @@ import { type SearchMcpActivityInput, } from '@/lib/knowledge/mcp/activity' import { createKnowledgeDocumentCitation } from '@/lib/knowledge/search/citation' +import { toolError } from '@/lib/mcp/tool-result' import { v2CaughtOrchestrationError } from '@/app/api/v2/lib/response' import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' @@ -45,10 +46,6 @@ interface KnowledgeMcpContext { searchIndexId: string | null } -function toolError(message: string): CallToolResult { - return { isError: true, content: [{ type: 'text', text: message }] } -} - function projectResult(value: unknown, registry: ResolvedSecretTraceRegistry): CallToolResult { if (!registry.isComplete()) { return toolError( diff --git a/apps/sim/lib/mcp/tool-result.ts b/apps/sim/lib/mcp/tool-result.ts new file mode 100644 index 00000000000..51d4cadad65 --- /dev/null +++ b/apps/sim/lib/mcp/tool-result.ts @@ -0,0 +1,11 @@ +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js' + +/** A tool result the model reads as a failure it can act on. */ +export function toolError(message: string): CallToolResult { + return { isError: true, content: [{ type: 'text', text: message }] } +} + +/** A tool result carrying a JSON value as text. */ +export function jsonToolResult(value: unknown): CallToolResult { + return { content: [{ type: 'text', text: JSON.stringify(value) }] } +} diff --git a/apps/sim/proxy.test.ts b/apps/sim/proxy.test.ts index b1dc2145a2e..ae998cd880e 100644 --- a/apps/sim/proxy.test.ts +++ b/apps/sim/proxy.test.ts @@ -2,14 +2,17 @@ * @vitest-environment node */ import { createEnvMock } from '@sim/testing' -import type { NextRequest } from 'next/server' +import { NextRequest } from 'next/server' import { describe, expect, it, vi } from 'vitest' vi.mock('@/lib/core/config/env', () => - createEnvMock({ NEXT_PUBLIC_APP_URL: 'https://app.sim.test' }) + createEnvMock({ + NEXT_PUBLIC_APP_URL: 'https://app.sim.test', + SIM_MCP_URL: 'https://mcp.sim.test/mcp', + }) ) -import { resolveApiCorsPolicy } from '@/proxy' +import { proxy, resolveApiCorsPolicy } from '@/proxy' const EXPOSED_HEADERS = 'Retry-After, WWW-Authenticate, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-Request-Id, X-Run-Id' @@ -215,3 +218,37 @@ describe('resolveApiCorsPolicy', () => { } }) }) + +describe('proxy on the dedicated MCP host', () => { + function mcpRequest(pathname: string, method = 'POST') { + return new NextRequest(`https://mcp.sim.test${pathname}`, { + method, + headers: { host: 'mcp.sim.test', origin: 'https://app.sim.test' }, + }) + } + + it('answers the endpoint preflight with the API CORS policy', () => { + const response = proxy(mcpRequest('/mcp', 'OPTIONS')) + expect(response.status).toBe(204) + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('https://app.sim.test') + expect(response.headers.get('Access-Control-Allow-Headers')).toContain('Authorization') + }) + + it('rewrites the endpoint to the MCP route with the same CORS headers', () => { + const response = proxy(mcpRequest('/mcp')) + expect(response.headers.get('x-middleware-rewrite')).toBe('https://mcp.sim.test/api/mcp') + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('https://app.sim.test') + }) + + it('leaves the metadata rewrite to its own wildcard CORS', () => { + const response = proxy(mcpRequest('/.well-known/oauth-protected-resource/mcp', 'GET')) + expect(response.headers.get('x-middleware-rewrite')).toBe( + 'https://mcp.sim.test/.well-known/oauth-protected-resource/api/mcp' + ) + expect(response.headers.get('Access-Control-Allow-Origin')).toBeNull() + }) + + it('serves nothing else on the MCP host', () => { + expect(proxy(mcpRequest('/login', 'GET')).status).toBe(404) + }) +}) diff --git a/apps/sim/proxy.ts b/apps/sim/proxy.ts index 24d09a30419..9771768c549 100644 --- a/apps/sim/proxy.ts +++ b/apps/sim/proxy.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { getSessionCookie } from 'better-auth/cookies' import { type NextRequest, NextResponse } from 'next/server' +import { resolveSimMcpHostPath } from '@/lib/api/mcp/host-routing' +import { SIM_MCP_ROUTE_PATH } from '@/lib/api/mcp/urls' import { APP_ENTRY_PATH, isAppSurfacePath } from '@/lib/navigation/paths' import { isOAuthAuthorizationCallback, resolveAuthRedirect } from '@/app/(auth)/auth-redirect' import { getEnv } from './lib/core/config/env' @@ -336,6 +338,18 @@ function handleSecurityFiltering(request: NextRequest): NextResponse | null { export function proxy(request: NextRequest) { const url = request.nextUrl + const mcpPath = resolveSimMcpHostPath(request.headers.get('host'), url.pathname) + if (mcpPath === 'not_found') return new NextResponse(null, { status: 404 }) + if (mcpPath && mcpPath !== url.pathname) { + const rewrite = NextResponse.rewrite(new URL(`${mcpPath}${url.search}`, request.url)) + if (mcpPath !== SIM_MCP_ROUTE_PATH) return rewrite + /** The endpoint keeps the `/api` CORS policy it has on the app host; its metadata sets its own. */ + const policy = resolveApiCorsPolicy(request) + if (request.method === 'OPTIONS') return buildPreflightResponse(policy) + applyCorsHeaders(rewrite, policy) + return rewrite + } + if (url.pathname.startsWith('/api/')) { const policy = resolveApiCorsPolicy(request) if (request.method === 'OPTIONS') { diff --git a/package.json b/package.json index ca71919251a..3001fbfe967 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,8 @@ "check:openapi": "bun run scripts/check-openapi.ts", "generate:cli-api": "bun run scripts/generate-v2-cli-api.ts", "check:cli-api": "bun run scripts/generate-v2-cli-api.ts --check", + "generate:mcp-operations": "bun run scripts/generate-v2-mcp-operations.ts", + "check:mcp-operations": "bun run scripts/generate-v2-mcp-operations.ts --check", "generate:cli-docs": "bun run scripts/generate-cli-docs.ts", "check:cli-docs": "bun run scripts/generate-cli-docs.ts --check", "check:canonical-index": "bun run scripts/check-canonical-index-surface.ts", diff --git a/packages/utils/src/client-info.ts b/packages/utils/src/client-info.ts index cc990d9e5cc..e623d49d6ae 100644 --- a/packages/utils/src/client-info.ts +++ b/packages/utils/src/client-info.ts @@ -27,8 +27,11 @@ export const CLIENT_INFO_HEADER = 'x-sim-client-info' -/** The official Sim clients, as they name themselves on the wire. */ -export const SIM_SURFACES = ['web', 'desktop', 'cli', 'sdk-js', 'sdk-python'] as const +/** + * The official Sim clients, as they name themselves on the wire. `mcp` is the + * Sim MCP server, which declares itself on each v2 request it dispatches. + */ +export const SIM_SURFACES = ['web', 'desktop', 'cli', 'sdk-js', 'sdk-python', 'mcp'] as const export type SimSurface = (typeof SIM_SURFACES)[number] diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index c81e28a034d..08630a0fc1c 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -45,6 +45,7 @@ const INDIRECT_ZOD_ROUTES = new Set([ /** Shared MCP protocol factory validates the owner and JSON-RPC envelope before SDK dispatch. */ 'apps/sim/app/api/mcp/search/[workspaceId]/route.ts', 'apps/sim/app/api/mcp/search/organizations/[organizationId]/route.ts', + 'apps/sim/app/api/mcp/route.ts', // SCIM discovery documents (RFC 7644 section 4). Each serves a fixed document // describing what this server implements and accepts no params, query, or body, // so there is no input to validate and no contract to bind. They are deliberately diff --git a/scripts/generate-v2-cli-api.ts b/scripts/generate-v2-cli-api.ts index a1b2378e4e9..0a34d2409ad 100644 --- a/scripts/generate-v2-cli-api.ts +++ b/scripts/generate-v2-cli-api.ts @@ -66,6 +66,8 @@ function specFiles(): string[] { export interface OperationDoc { /** The spec's one-line summary, used as the command's `--help` description. */ summary?: string + /** The spec's longer prose, which the MCP server returns when describing the operation. */ + description?: string /** * The operation refuses a workspace API key, per its `description`. * @@ -97,6 +99,11 @@ export async function loadWorkspaceKeyDenialMarkers(): Promise description.includes(marker)) @@ -169,7 +179,7 @@ function contractModules(): string[] { .sort() } -interface RouteContract { +export interface RouteContract { method: string path: string params?: z.ZodType @@ -179,9 +189,11 @@ interface RouteContract { response: { mode: string; schema?: z.ZodType } } -interface Operation { +export interface Operation { /** `listTables` — derived from the export name. */ name: string + /** `v2ListTablesContract` — the contract module's export. */ + exportName: string domain: string contract: RouteContract } @@ -206,14 +218,15 @@ function pascal(name: string): string { return name.charAt(0).toUpperCase() + name.slice(1) } -async function collectOperations(): Promise { +/** Every v2 route contract, sorted by operation name. */ +export async function collectOperations(): Promise { const operations: Operation[] = [] for (const domain of contractModules()) { const mod: Record = await import(path.join(CONTRACTS_DIR, `${domain}.ts`)) for (const [exportName, value] of Object.entries(mod)) { if (!exportName.endsWith('Contract') || !isRouteContract(value)) continue - operations.push({ name: operationName(exportName), domain, contract: value }) + operations.push({ name: operationName(exportName), exportName, domain, contract: value }) } } @@ -593,9 +606,7 @@ function render(operations: Operation[], docs: Map): strin } out.push(` responseMode: '${op.contract.response.mode}',`) // OpenAPI writes `{id}` where the contract writes `[id]`. - const doc = docs.get( - `${op.contract.method} ${op.contract.path.replace(/\[([^\]]+)\]/g, '{$1}')}` - ) + const doc = docs.get(docPathKey(op.contract.method, op.contract.path)) if (doc?.summary) out.push(` summary: ${JSON.stringify(doc.summary)},`) if (doc?.workspaceKeyUnsupported) out.push(` workspaceKeyUnsupported: true,`) for (const slot of ['query', 'body'] as const) { diff --git a/scripts/generate-v2-mcp-operations.test.ts b/scripts/generate-v2-mcp-operations.test.ts new file mode 100644 index 00000000000..3a6e9f9cc36 --- /dev/null +++ b/scripts/generate-v2-mcp-operations.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import type { Operation } from './generate-v2-cli-api' +import { + classifyOperation, + render, + routeBuilder, + routeModulePath, +} from './generate-v2-mcp-operations' + +function operation(method: string, path: string, mode = 'json'): Operation { + return { + name: 'op', + exportName: 'v2OpContract', + domain: 'tables', + contract: { method, path, response: { mode } }, + } +} + +const routeSource = (method: string, builder: string) => + `export const dynamic = 'force-dynamic'\nexport const ${method} = ${builder}({\n contract,\n})\n` + +describe('the Sim MCP operation table', () => { + it('finds a route module by its contract path', () => { + expect(routeModulePath('/api/v2/tables/[tableId]/rows')).toBe( + 'app/api/v2/tables/[tableId]/rows/route.ts' + ) + }) + + it('reads the builder behind one method, not its neighbours', () => { + const source = `${routeSource('GET', 'defineV2BinaryRoute')}${routeSource('PATCH', 'defineV2JsonRoute')}` + expect(routeBuilder(source, 'GET')).toBe('defineV2BinaryRoute') + expect(routeBuilder(source, 'PATCH')).toBe('defineV2JsonRoute') + expect(routeBuilder(source, 'DELETE')).toBeNull() + }) + + it.each([ + ['defineV2JsonRoute', 'json'], + ['defineV2BinaryRoute', 'excluded'], + ['defineV2BodyLifecycleRoute', 'excluded'], + ])('classifies %s routes as %s', (builder, expected) => { + expect( + classifyOperation(operation('POST', '/api/v2/tables'), () => routeSource('POST', builder)) + ).toBe(expected) + }) + + it('accepts a raw route only once it has been reviewed', () => { + const raw = () => routeSource('POST', 'withRouteHandler') + expect(classifyOperation({ ...operation('POST', '/api/v2/chat'), name: 'chat' }, raw)).toBe( + 'json' + ) + expect(() => classifyOperation(operation('POST', '/api/v2/tables'), raw)).toThrow('classify it') + }) + + it('excludes binary responses without reading the route', () => { + expect( + classifyOperation(operation('GET', '/api/v2/files/[fileId]', 'binary'), () => { + throw new Error('should not read') + }) + ).toBe('excluded') + }) + + it('refuses to guess about a missing module or an unknown builder', () => { + expect(() => classifyOperation(operation('GET', '/api/v2/tables'), () => null)).toThrow( + 'no route module' + ) + expect(() => + classifyOperation(operation('GET', '/api/v2/tables'), () => + routeSource('GET', 'defineV3Route') + ) + ).toThrow('classify it') + }) + + it('pairs each contract with a lazily imported handler', () => { + const source = render([ + { + name: 'listTables', + exportName: 'v2ListTablesContract', + domain: 'tables', + method: 'GET', + modulePath: 'app/api/v2/tables/route.ts', + doc: { summary: 'List Tables' }, + }, + ]) + expect(source).toContain("import { v2ListTablesContract } from '@/lib/api/contracts/v2/tables'") + expect(source).toContain( + "handler: () => import('@/app/api/v2/tables/route').then((route) => route.GET)" + ) + expect(source).toContain('summary: "List Tables"') + }) +}) diff --git a/scripts/generate-v2-mcp-operations.ts b/scripts/generate-v2-mcp-operations.ts new file mode 100644 index 00000000000..f921ebd65e6 --- /dev/null +++ b/scripts/generate-v2-mcp-operations.ts @@ -0,0 +1,208 @@ +#!/usr/bin/env bun +/** + * Generates the Sim MCP server's operation table: every public v2 operation an + * MCP tool call can reach, paired with the route handler that serves it. + * + * The MCP server is a second transport for the v2 API, not a second API. A tool + * call is dispatched to the same route handler an HTTP request would reach, so + * authentication, OAuth scopes, rate limits, validation, the application use + * case, and the error envelope are all the route's own. This table is the one + * thing that cannot be derived at runtime: Next.js loads route modules by file + * path, so something has to name each module statically for the bundler. + * + * Operations come from {@link collectOperations} — the same contract discovery + * the CLI generator uses — so the terminal and MCP expose one operation set + * under one set of names. An operation is left out only when its transport + * cannot be expressed as a JSON tool call: a binary response, or a body that + * must be streamed as multipart. A route built by anything this script does not + * recognize fails generation rather than being guessed at. + * + * Usage: + * bun run scripts/generate-v2-mcp-operations.ts # write the generated file + * bun run scripts/generate-v2-mcp-operations.ts --check # fail if it is stale + */ + +import { spawnSync } from 'node:child_process' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { + collectOperations, + docPathKey, + loadSummaries, + loadWorkspaceKeyDenialMarkers, + type Operation, + type OperationDoc, +} from './generate-v2-cli-api' +import { localBin } from './local-bin' + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const APP_ROOT = path.join(ROOT, 'apps/sim') +const OUTPUT = path.join(APP_ROOT, 'lib/api/mcp/generated/v2-operations.ts') + +/** Route builders whose handlers answer a JSON request with a JSON response. */ +const JSON_BUILDERS = new Set(['defineV2JsonRoute']) + +/** + * Raw `withRouteHandler` routes reviewed to answer a JSON request with JSON: + * each streams only when the caller asks for it, which the MCP dispatcher + * refuses. A raw route is a protocol exception by definition, so any other one + * fails generation until it is reviewed and listed here. + */ +const REVIEWED_RAW_JSON_ROUTES = new Set(['chat', 'executeWorkflow', 'resumeWorkflow']) + +/** Route builders whose transport a JSON tool call cannot carry. */ +const NON_JSON_BUILDERS = new Set(['defineV2BinaryRoute', 'defineV2BodyLifecycleRoute']) + +/** `/api/v2/tables/[tableId]/rows` → `app/api/v2/tables/[tableId]/rows/route.ts`. */ +export function routeModulePath(contractPath: string): string { + return `app${contractPath}/route.ts` +} + +/** The builder a route module's `export const METHOD = builder(` uses, or `null` if none. */ +export function routeBuilder(source: string, method: string): string | null { + return source.match(new RegExp(`export const ${method} = (\\w+)\\(`))?.[1] ?? null +} + +export interface McpOperation { + name: string + exportName: string + domain: string + method: string + modulePath: string + doc?: OperationDoc +} + +/** + * Whether an operation is reachable over MCP, reading its route module to learn + * which builder serves it. Throws on a missing module or an unknown builder so a + * new transport has to be classified here before it can ship. + */ +export function classifyOperation( + operation: Operation, + readRoute: (relativePath: string) => string | null +): 'json' | 'excluded' { + if (operation.contract.response.mode !== 'json') return 'excluded' + const modulePath = routeModulePath(operation.contract.path) + const source = readRoute(modulePath) + if (source === null) { + throw new Error(`${operation.name}: no route module at apps/sim/${modulePath}`) + } + const builder = routeBuilder(source, operation.contract.method) + if (builder && JSON_BUILDERS.has(builder)) return 'json' + if (builder === 'withRouteHandler' && REVIEWED_RAW_JSON_ROUTES.has(operation.name)) return 'json' + if (builder && NON_JSON_BUILDERS.has(builder)) return 'excluded' + throw new Error( + `${operation.name}: apps/sim/${modulePath} exports ${operation.contract.method} through ${ + builder ?? 'an unrecognized form' + }; classify it in scripts/generate-v2-mcp-operations.ts` + ) +} + +export function render(operations: readonly McpOperation[]): string { + const importsByDomain = new Map() + for (const op of operations) { + const names = importsByDomain.get(op.domain) ?? [] + names.push(op.exportName) + importsByDomain.set(op.domain, names) + } + + const out: string[] = [ + '/**', + ' * GENERATED FILE — DO NOT EDIT.', + ' *', + ' * Emitted from the Zod route contracts in `apps/sim/lib/api/contracts/v2/**`', + ' * by `scripts/generate-v2-mcp-operations.ts`. Regenerate with', + ' * `bun run generate:mcp-operations`; CI fails when this file is stale.', + ' */', + '', + ] + for (const domain of [...importsByDomain.keys()].sort()) { + const names = [...(importsByDomain.get(domain) ?? [])].sort() + out.push(`import { ${names.join(', ')} } from '@/lib/api/contracts/v2/${domain}'`) + } + out.push("import type { V2McpOperation } from '@/lib/api/mcp/types'") + out.push('') + out.push('export const V2_MCP_OPERATIONS = {') + for (const op of operations) { + const specifier = `@/${op.modulePath.replace(/\.ts$/, '')}` + out.push(` ${op.name}: {`) + out.push(` contract: ${op.exportName},`) + if (op.doc?.summary) out.push(` summary: ${JSON.stringify(op.doc.summary)},`) + if (op.doc?.description) out.push(` description: ${JSON.stringify(op.doc.description)},`) + if (op.doc?.workspaceKeyUnsupported) out.push(' workspaceKeyUnsupported: true,') + out.push(` handler: () => import('${specifier}').then((route) => route.${op.method}),`) + out.push(' },') + } + out.push('} as const satisfies Record') + out.push('') + out.push('export type V2McpOperationName = keyof typeof V2_MCP_OPERATIONS') + out.push('') + return out.join('\n') +} + +/** + * Runs the emitted source through `biome check --write`, not only the + * formatter: the import list is sorted too, and lint-staged applies exactly + * that to a committed file, so anything less leaves a file the hook rewrites + * and `--check` then reports as stale. + */ +function format(source: string): string { + const result = spawnSync(localBin('biome'), ['check', '--write', `--stdin-file-path=${OUTPUT}`], { + cwd: ROOT, + encoding: 'utf8', + input: source, + }) + if (result.status !== 0 || !result.stdout) { + throw new Error(`biome failed on the generated operation table: ${result.stderr ?? ''}`) + } + return result.stdout +} + +async function main() { + const check = process.argv.includes('--check') + const docs = loadSummaries(await loadWorkspaceKeyDenialMarkers()) + const readRoute = (relativePath: string) => { + const file = path.join(APP_ROOT, relativePath) + return existsSync(file) ? readFileSync(file, 'utf8') : null + } + + const operations: McpOperation[] = [] + let excluded = 0 + for (const operation of await collectOperations()) { + if (classifyOperation(operation, readRoute) === 'excluded') { + excluded++ + continue + } + operations.push({ + name: operation.name, + exportName: operation.exportName, + domain: operation.domain, + method: operation.contract.method, + modulePath: routeModulePath(operation.contract.path), + doc: docs.get(docPathKey(operation.contract.method, operation.contract.path)), + }) + } + + const generated = format(render(operations)) + const relative = path.relative(ROOT, OUTPUT) + + if (check) { + const current = existsSync(OUTPUT) ? readFileSync(OUTPUT, 'utf8') : null + if (current !== generated) { + console.error( + `${relative} is ${current === null ? 'missing' : 'stale'}. Run: bun run generate:mcp-operations` + ) + process.exit(1) + } + console.log(`${relative} is up to date (${operations.length} operations).`) + return + } + + writeFileSync(OUTPUT, generated) + console.log( + `Wrote ${relative} — ${operations.length} operations (${excluded} excluded: binary or multipart transport).` + ) +} + +if (import.meta.main) main()