From 2c060aeae119163e21ae1c6bcc9a6b3fe9e8d544 Mon Sep 17 00:00:00 2001 From: Forrest Surprenant <57930975+ForrestOfFidum@users.noreply.github.com> Date: Sun, 3 May 2026 00:46:44 -0400 Subject: [PATCH 1/7] feat(byok): use BYOK keys on self-hosted instances Previously, BYOK key lookup was gated by isHosted, meaning self-hosted deployments could add provider keys via the BYOK UI but they would never be consulted at execution time. Self-hosted users had to fall back to per-block apiKey fields or env-var template references. Add a self-hosted-only branch that consults BYOK first for any supported provider (openai, anthropic, google, mistral) when no hosted-model gate applies. Hosted behavior is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/sim/lib/api-key/byok.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/sim/lib/api-key/byok.ts b/apps/sim/lib/api-key/byok.ts index a2aa198e859..df01b55eb71 100644 --- a/apps/sim/lib/api-key/byok.ts +++ b/apps/sim/lib/api-key/byok.ts @@ -114,6 +114,21 @@ export async function getApiKeyWithBYOK( const byokProviderId = isGeminiModel ? 'google' : (provider as BYOKProviderId) + // On self-hosted, BYOK keys take precedence over a per-block apiKey for any + // supported provider — no hosted-model gate. Hosted environments retain the + // original behavior (BYOK only consulted for hosted-tier models). + if ( + !isHosted && + workspaceId && + (isOpenAIModel || isClaudeModel || isGeminiModel || isMistralModel) + ) { + const byokResult = await getBYOKKey(workspaceId, byokProviderId) + if (byokResult) { + logger.info('Using BYOK key (self-hosted)', { provider, model, workspaceId }) + return byokResult + } + } + if ( isHosted && workspaceId && From fdcbfa71fd30404fcbd1e82235af22f6a51fab6a Mon Sep 17 00:00:00 2001 From: Forrest Surprenant <57930975+ForrestOfFidum@users.noreply.github.com> Date: Sun, 3 May 2026 01:45:23 -0400 Subject: [PATCH 2/7] feat(mcp): expose all locally-implemented tool handlers via Copilot MCP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DIRECT_TOOL_DEFS previously surfaced only 10 of the ~40+ direct tool handlers registered in apps/sim/lib/copilot/tool-executor/register-handlers.ts. Internal callers (sim's UI Copilot panel, the sim_* SUBAGENT tools) had access to the full surface, but external MCP clients (claude.ai, etc.) saw only the small subset. This commit extends DIRECT_TOOL_DEFS to expose the rest of the local handlers — workflow CRUD/inspect, execution, deployment, MCP server publishing, custom tools/skills/credentials, OAuth, jobs, and workspace VFS. No new handler implementations. Each entry maps to an existing toolId in tool-catalog-v1.ts; runtime dispatch in apps/sim/app/api/mcp/copilot/route.ts already finds the handler via executeTool(). Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/sim/lib/copilot/tools/mcp/definitions.ts | 616 ++++++++++++++++++ 1 file changed, 616 insertions(+) diff --git a/apps/sim/lib/copilot/tools/mcp/definitions.ts b/apps/sim/lib/copilot/tools/mcp/definitions.ts index 5adc4471c6b..1add99532df 100644 --- a/apps/sim/lib/copilot/tools/mcp/definitions.ts +++ b/apps/sim/lib/copilot/tools/mcp/definitions.ts @@ -256,6 +256,622 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [ }, annotations: { destructiveHint: false }, }, + + // === Workflow read/inspect === + { + name: 'get_workflow_data', + toolId: 'get_workflow_data', + description: + "Get a workflow's full normalized state (blocks, edges, loops, parallels, variables) or a slice via data_type. Returns the same shape sim's internal copilot uses for inspection.", + inputSchema: { + type: 'object', + properties: { + workflowId: { type: 'string', description: 'Workflow ID to inspect.' }, + data_type: { + type: 'string', + description: + 'Optional slice: "all" (default), "blocks", "edges", "variables", "schedules", or "metadata".', + }, + }, + required: ['workflowId'], + }, + annotations: { readOnlyHint: true }, + }, + { + name: 'get_block_outputs', + toolId: 'get_block_outputs', + description: + "Get the output schema for one or more blocks in a workflow. Useful for understanding what data downstream blocks can reference via .", + inputSchema: { + type: 'object', + properties: { + workflowId: { type: 'string', description: 'Workflow ID.' }, + blockIds: { + type: 'array', + items: { type: 'string' }, + description: 'Block IDs to inspect outputs for. Omit for all blocks.', + }, + }, + required: ['workflowId'], + }, + annotations: { readOnlyHint: true }, + }, + { + name: 'get_block_upstream_references', + toolId: 'get_block_upstream_references', + description: + "Get the variable references ( and {{ENV_VAR}}) available to a given block based on its upstream connections. Use when wiring inputs to a new block.", + inputSchema: { + type: 'object', + properties: { + workflowId: { type: 'string', description: 'Workflow ID.' }, + blockIds: { + type: 'array', + items: { type: 'string' }, + description: 'Block IDs to compute upstream references for.', + }, + }, + required: ['workflowId', 'blockIds'], + }, + annotations: { readOnlyHint: true }, + }, + + // === Workflow mutations === + { + name: 'delete_workflow', + toolId: 'delete_workflow', + description: + 'Permanently delete one or more workflows. Cascades to deployment versions, MCP tool registrations, and execution history.', + inputSchema: { + type: 'object', + properties: { + workflowIds: { + type: 'array', + items: { type: 'string' }, + description: 'Workflow IDs to delete.', + }, + }, + required: ['workflowIds'], + }, + annotations: { destructiveHint: true }, + }, + { + name: 'delete_folder', + toolId: 'delete_folder', + description: + 'Delete one or more empty folders. Workflows inside the folder must be moved or deleted first.', + inputSchema: { + type: 'object', + properties: { + folderIds: { + type: 'array', + items: { type: 'string' }, + description: 'Folder IDs to delete.', + }, + }, + required: ['folderIds'], + }, + annotations: { destructiveHint: true }, + }, + { + name: 'set_block_enabled', + toolId: 'set_block_enabled', + description: + 'Enable or disable a single block in a workflow. Disabled blocks are skipped during execution.', + inputSchema: { + type: 'object', + properties: { + workflowId: { type: 'string', description: 'Workflow ID.' }, + blockId: { type: 'string', description: 'Block ID to toggle.' }, + enabled: { type: 'boolean', description: 'true to enable, false to disable.' }, + }, + required: ['workflowId', 'blockId', 'enabled'], + }, + annotations: { destructiveHint: false, idempotentHint: true }, + }, + { + name: 'set_global_workflow_variables', + toolId: 'set_global_workflow_variables', + description: + "Add, edit, or delete a workflow's global variables in one batched operation. Each operation specifies a name and an action.", + inputSchema: { + type: 'object', + properties: { + workflowId: { type: 'string', description: 'Workflow ID.' }, + operations: { + type: 'array', + description: 'Variable mutations to apply.', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Variable name.' }, + operation: { + type: 'string', + description: '"add", "edit", or "delete".', + }, + value: { description: 'Variable value (for add/edit).' }, + type: { + type: 'string', + description: 'Variable type: "string", "number", "boolean", "object", or "array".', + }, + }, + required: ['name', 'operation'], + }, + }, + }, + required: ['workflowId', 'operations'], + }, + annotations: { destructiveHint: false }, + }, + + // === Execution === + { + name: 'run_workflow', + toolId: 'run_workflow', + description: + "Execute a workflow end-to-end. By default runs the draft state — set useDeployedState: true to run the deployed snapshot. Pass workflow inputs via the input field.", + inputSchema: { + type: 'object', + properties: { + workflowId: { type: 'string', description: 'Workflow ID to execute.' }, + input: { + description: "Inputs to pass to the start trigger. Shape matches the workflow's defined input format.", + }, + triggerBlockId: { + type: 'string', + description: 'Optional trigger block ID when the workflow has multiple entrypoints.', + }, + useDeployedState: { + type: 'boolean', + description: 'true to run the deployed version, false (default) for the draft.', + }, + }, + required: ['workflowId'], + }, + annotations: { destructiveHint: true, openWorldHint: true }, + }, + { + name: 'run_workflow_until_block', + toolId: 'run_workflow_until_block', + description: + 'Execute a workflow but halt after a specific block completes. Useful for inspecting intermediate state during debugging.', + inputSchema: { + type: 'object', + properties: { + workflowId: { type: 'string', description: 'Workflow ID.' }, + stopAfterBlockId: { type: 'string', description: 'Block ID to stop after.' }, + input: { description: "Inputs for the start trigger." }, + triggerBlockId: { type: 'string', description: 'Optional explicit trigger block ID.' }, + useDeployedState: { type: 'boolean', description: 'Default: false (draft).' }, + }, + required: ['workflowId', 'stopAfterBlockId'], + }, + annotations: { destructiveHint: true, openWorldHint: true }, + }, + { + name: 'run_from_block', + toolId: 'run_from_block', + description: + 'Resume execution of a workflow starting from a specific block, using a previous execution snapshot for upstream values. Pass executionId to pin to a specific snapshot, otherwise the latest is used.', + inputSchema: { + type: 'object', + properties: { + workflowId: { type: 'string', description: 'Workflow ID.' }, + startBlockId: { type: 'string', description: 'Block ID to start execution from.' }, + executionId: { + type: 'string', + description: 'Optional execution ID to load the snapshot from (latest if omitted).', + }, + input: { description: 'Optional fresh inputs.' }, + useDeployedState: { type: 'boolean', description: 'Default: false (draft).' }, + }, + required: ['workflowId', 'startBlockId'], + }, + annotations: { destructiveHint: true, openWorldHint: true }, + }, + { + name: 'run_block', + toolId: 'run_block', + description: + 'Execute a single block in isolation, reusing cached upstream outputs from a previous execution. Useful for fast iteration on a single block during debugging.', + inputSchema: { + type: 'object', + properties: { + workflowId: { type: 'string', description: 'Workflow ID.' }, + blockId: { type: 'string', description: 'Block ID to run.' }, + executionId: { + type: 'string', + description: 'Optional execution ID for upstream snapshot (latest if omitted).', + }, + input: { description: 'Optional fresh inputs.' }, + useDeployedState: { type: 'boolean', description: 'Default: false (draft).' }, + }, + required: ['workflowId', 'blockId'], + }, + annotations: { destructiveHint: true, openWorldHint: true }, + }, + + // === Deployment === + { + name: 'deploy_api', + toolId: 'deploy_api', + description: + 'Deploy a workflow as an API endpoint, or undeploy it. Once deployed, the workflow is callable at /api/workflows/{id}/execute with a workspace API key.', + inputSchema: { + type: 'object', + properties: { + workflowId: { type: 'string', description: 'Workflow ID.' }, + action: { + type: 'string', + description: '"deploy" (default) or "undeploy".', + }, + }, + required: ['workflowId'], + }, + annotations: { destructiveHint: false }, + }, + { + name: 'deploy_chat', + toolId: 'deploy_chat', + description: + "Deploy a workflow as a hosted chat UI, update its config, or undeploy it. Supports password/email/SSO/public auth modes and customizations.", + inputSchema: { + type: 'object', + properties: { + workflowId: { type: 'string', description: 'Workflow ID.' }, + action: { type: 'string', description: '"deploy" (default), "update", or "undeploy".' }, + identifier: { type: 'string', description: 'Public chat slug (subdomain).' }, + title: { type: 'string', description: 'Display title for the chat UI.' }, + description: { type: 'string', description: 'Display description.' }, + welcomeMessage: { type: 'string', description: 'First message shown in the chat.' }, + authType: { + type: 'string', + description: '"public", "password", "email", or "sso".', + }, + password: { type: 'string', description: 'Required when authType="password".' }, + allowedEmails: { + type: 'array', + items: { type: 'string' }, + description: 'Required when authType="email".', + }, + subdomain: { type: 'string', description: 'Optional subdomain override.' }, + }, + required: ['workflowId'], + }, + annotations: { destructiveHint: false }, + }, + { + name: 'deploy_mcp', + toolId: 'deploy_mcp', + description: + "Publish a workflow as a tool on a workflow MCP server, or undeploy it. If serverId is omitted a new server is created. The tool's input schema is auto-generated from the workflow's start block input format.", + inputSchema: { + type: 'object', + properties: { + workflowId: { type: 'string', description: 'Workflow ID to publish as an MCP tool.' }, + action: { type: 'string', description: '"deploy" (default) or "undeploy".' }, + toolName: { type: 'string', description: 'Tool name as exposed to MCP clients.' }, + toolDescription: { type: 'string', description: 'Tool description for MCP clients.' }, + serverId: { + type: 'string', + description: 'Existing workflow MCP server to attach the tool to. Omit to create a new server.', + }, + }, + required: ['workflowId'], + }, + annotations: { destructiveHint: false }, + }, + { + name: 'redeploy', + toolId: 'redeploy', + description: + "Force a fresh deployment of a workflow's current draft state, replacing the active deployed version. Use after editing a deployed workflow to push changes live.", + inputSchema: { + type: 'object', + properties: { + workflowId: { type: 'string', description: 'Workflow ID to redeploy.' }, + }, + required: ['workflowId'], + }, + annotations: { destructiveHint: false }, + }, + { + name: 'check_deployment_status', + toolId: 'check_deployment_status', + description: + "Get the deployment status of a workflow across all surfaces: API endpoint, chat UI, and MCP tools. Includes whether a redeploy is needed because the draft has diverged.", + inputSchema: { + type: 'object', + properties: { + workflowId: { type: 'string', description: 'Workflow ID to check.' }, + }, + required: ['workflowId'], + }, + annotations: { readOnlyHint: true }, + }, + { + name: 'get_deployment_version', + toolId: 'get_deployment_version', + description: + "Get a specific deployment version's snapshot for a workflow. Pass version=1 for the first deploy, etc. Useful for diffing versions or restoring an older deployment.", + inputSchema: { + type: 'object', + properties: { + workflowId: { type: 'string', description: 'Workflow ID.' }, + version: { type: 'number', description: 'Deployment version number (1-indexed).' }, + }, + required: ['workflowId', 'version'], + }, + annotations: { readOnlyHint: true }, + }, + { + name: 'revert_to_version', + toolId: 'revert_to_version', + description: + "Revert a workflow's draft state to a previous deployment version's snapshot. Does not change the active deployment — call redeploy after if you want to push the reverted state live.", + inputSchema: { + type: 'object', + properties: { + workflowId: { type: 'string', description: 'Workflow ID.' }, + version: { type: 'number', description: 'Deployment version number to revert to.' }, + }, + required: ['workflowId', 'version'], + }, + annotations: { destructiveHint: true }, + }, + + // === Workflow MCP server publishing === + { + name: 'list_workspace_mcp_servers', + toolId: 'list_workspace_mcp_servers', + description: + 'List all workflow MCP servers in a workspace, including each server\'s registered tools (workflow IDs and tool names). Pass workflowId to filter to servers that include a specific workflow.', + inputSchema: { + type: 'object', + properties: { + workspaceId: { type: 'string', description: 'Workspace ID. Defaults to current.' }, + workflowId: { type: 'string', description: 'Optional: filter to servers that include this workflow.' }, + }, + }, + annotations: { readOnlyHint: true }, + }, + { + name: 'create_workspace_mcp_server', + toolId: 'create_workspace_mcp_server', + description: + "Create a new workflow MCP server in the workspace. Optionally seed it with one or more deployed workflows as tools.", + inputSchema: { + type: 'object', + properties: { + serverName: { type: 'string', description: 'Display name for the new server.' }, + description: { type: 'string', description: 'Optional server description.' }, + isPublic: { type: 'boolean', description: 'Whether the server is publicly accessible (default: false).' }, + workflowIds: { + type: 'array', + items: { type: 'string' }, + description: 'Optional initial workflow IDs to register as tools on this server.', + }, + }, + required: ['serverName'], + }, + annotations: { destructiveHint: false }, + }, + { + name: 'update_workspace_mcp_server', + toolId: 'update_workspace_mcp_server', + description: 'Rename a workflow MCP server, update its description, or toggle isPublic.', + inputSchema: { + type: 'object', + properties: { + serverId: { type: 'string', description: 'Server ID to update.' }, + name: { type: 'string', description: 'New display name.' }, + description: { type: 'string', description: 'New description.' }, + isPublic: { type: 'boolean', description: 'Toggle public access.' }, + }, + required: ['serverId'], + }, + annotations: { destructiveHint: false, idempotentHint: true }, + }, + { + name: 'delete_workspace_mcp_server', + toolId: 'delete_workspace_mcp_server', + description: 'Permanently delete a workflow MCP server and unregister all its tools.', + inputSchema: { + type: 'object', + properties: { + serverId: { type: 'string', description: 'Server ID to delete.' }, + }, + required: ['serverId'], + }, + annotations: { destructiveHint: true }, + }, + + // === Workspace assets (custom tools, MCP tools, skills, credentials) === + { + name: 'manage_custom_tool', + toolId: 'manage_custom_tool', + description: + 'Create, edit, delete, or list workspace custom JavaScript tools that agent blocks can call. Pass operation: "list", "get", "add", "edit", or "delete" plus operation-specific args.', + inputSchema: { + type: 'object', + properties: { + operation: { + type: 'string', + description: '"list", "get", "add", "edit", or "delete".', + }, + workspaceId: { type: 'string', description: 'Workspace ID. Defaults to current.' }, + toolId: { type: 'string', description: 'Tool ID (required for get/edit/delete).' }, + name: { type: 'string', description: 'Tool name (for add/edit).' }, + description: { type: 'string', description: 'Tool description (for add/edit).' }, + code: { type: 'string', description: 'JavaScript implementation (for add/edit).' }, + schema: { description: 'JSON schema for tool inputs (for add/edit).' }, + }, + required: ['operation'], + }, + annotations: { destructiveHint: false }, + }, + { + name: 'manage_mcp_tool', + toolId: 'manage_mcp_tool', + description: + "Manage external MCP server connections used as tools by agent blocks. Pass operation: 'list', 'get', 'add', 'edit', or 'delete' plus operation-specific args (URL, auth, etc.).", + inputSchema: { + type: 'object', + properties: { + operation: { type: 'string', description: '"list", "get", "add", "edit", or "delete".' }, + serverId: { type: 'string', description: 'Server ID (required for get/edit/delete).' }, + name: { type: 'string', description: 'Display name (for add/edit).' }, + url: { type: 'string', description: 'MCP server URL (for add/edit).' }, + transport: { type: 'string', description: 'Transport type, e.g. "streamable-http" (for add/edit).' }, + headers: { description: 'Optional auth headers as a JSON object.' }, + }, + required: ['operation'], + }, + annotations: { destructiveHint: false }, + }, + { + name: 'manage_skill', + toolId: 'manage_skill', + description: + 'Manage workspace skills (reusable prompt+tool packages used by agent blocks). Pass operation: "list", "get", "add", "edit", or "delete".', + inputSchema: { + type: 'object', + properties: { + operation: { type: 'string', description: '"list", "get", "add", "edit", or "delete".' }, + skillId: { type: 'string', description: 'Skill ID (required for get/edit/delete).' }, + name: { type: 'string', description: 'Skill name (for add/edit).' }, + description: { type: 'string', description: 'Skill description (for add/edit).' }, + prompt: { type: 'string', description: 'Skill prompt body (for add/edit).' }, + }, + required: ['operation'], + }, + annotations: { destructiveHint: false }, + }, + { + name: 'manage_credential', + toolId: 'manage_credential', + description: + 'Manage OAuth/API credentials for third-party integrations (Slack, Google, GitHub, etc.). Pass operation: "list", "get", "add", "edit", or "delete".', + inputSchema: { + type: 'object', + properties: { + operation: { type: 'string', description: '"list", "get", "add", "edit", or "delete".' }, + credentialId: { type: 'string', description: 'Credential ID (required for get/edit/delete).' }, + provider: { type: 'string', description: 'Provider name, e.g. "slack" (for add).' }, + }, + required: ['operation'], + }, + annotations: { destructiveHint: false }, + }, + + // === OAuth === + { + name: 'oauth_get_auth_link', + toolId: 'oauth_get_auth_link', + description: + "Get an authorization URL for a third-party OAuth provider. The user opens this URL in their browser to grant access. Returns the URL only — does not initiate the flow.", + inputSchema: { + type: 'object', + properties: { + providerName: { type: 'string', description: 'Provider name (e.g. "slack", "google", "github").' }, + }, + required: ['providerName'], + }, + annotations: { readOnlyHint: true }, + }, + { + name: 'oauth_request_access', + toolId: 'oauth_request_access', + description: + 'Notify the user they need to grant OAuth access for a provider. Returns a structured prompt the calling agent can show to guide the user.', + inputSchema: { + type: 'object', + properties: { + providerName: { type: 'string', description: 'Provider name (e.g. "slack", "google").' }, + }, + required: ['providerName'], + }, + annotations: { readOnlyHint: true }, + }, + + // === Jobs === + { + name: 'manage_job', + toolId: 'manage_job', + description: + 'List, get, pause, resume, update, or delete scheduled background jobs. Pass operation plus operation-specific args (jobId, etc.).', + inputSchema: { + type: 'object', + properties: { + operation: { type: 'string', description: '"list", "get", "pause", "resume", "update", or "delete".' }, + args: { description: 'Operation-specific arguments.' }, + }, + required: ['operation'], + }, + annotations: { destructiveHint: false }, + }, + { + name: 'complete_job', + toolId: 'complete_job', + description: + 'Mark a scheduled job complete (stops further executions). Use for until_complete-lifecycle jobs once their success condition has been met.', + inputSchema: { + type: 'object', + properties: { + jobId: { type: 'string', description: 'Job ID to mark complete.' }, + }, + required: ['jobId'], + }, + annotations: { destructiveHint: false, idempotentHint: true }, + }, + + // === Workspace VFS (file artifacts produced by workflows) === + { + name: 'read_file', + toolId: 'read', + description: + "Read a workspace file by path. Workspace files are produced by Function blocks (outputPath) and tools that emit artifacts. Returns text content.", + inputSchema: { + type: 'object', + properties: { + path: { type: 'string', description: 'File path within the workspace, e.g. "files/result.json".' }, + }, + required: ['path'], + }, + annotations: { readOnlyHint: true }, + }, + { + name: 'glob_files', + toolId: 'glob', + description: + 'List workspace files matching a glob pattern. Useful for finding artifacts produced by previous workflow runs.', + inputSchema: { + type: 'object', + properties: { + pattern: { type: 'string', description: 'Glob pattern, e.g. "files/**/*.json".' }, + }, + required: ['pattern'], + }, + annotations: { readOnlyHint: true }, + }, + { + name: 'grep_files', + toolId: 'grep', + description: + 'Search workspace file contents with a regex pattern. Returns matching lines with context.', + inputSchema: { + type: 'object', + properties: { + pattern: { type: 'string', description: 'Regex pattern to search for.' }, + output_mode: { + type: 'string', + description: '"content" (default), "files_with_matches", or "count".', + }, + }, + required: ['pattern'], + }, + annotations: { readOnlyHint: true }, + }, ] export const SUBAGENT_TOOL_DEFS: SubagentToolDef[] = [ From deb9faab037c6d9292efb7114e1794ec5306c3e1 Mon Sep 17 00:00:00 2001 From: Forrest Surprenant <57930975+ForrestOfFidum@users.noreply.github.com> Date: Sun, 3 May 2026 02:02:27 -0400 Subject: [PATCH 3/7] feat(self-hosted): accept workspace API keys for MCP + drop apiKey required field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related self-hosted polish changes: 1. authenticateCopilotApiKey() now does a local lookup against the api_key table first when !isHosted. Self-hosted Mothership rejects all calls (it only trusts sim.ai's production INTERNAL_API_SECRET), so the workspace API key (sk-sim-...) was useless for MCP X-API-Key auth even though it worked everywhere else. Local lookup gives external MCP clients a single credential surface. 2. Agent block apiKey field is no longer required on self-hosted. BYOK (workspace_byok_keys) is the canonical credential source there — making the field required forced workflows to either embed a per-block key, reference an env var template, or use the placeholder workaround. Together these eliminate the last self-hosted credential papercuts. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/sim/app/api/mcp/copilot/route.ts | 27 ++++++++++++++++++++++++++- apps/sim/blocks/utils.ts | 6 +++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/api/mcp/copilot/route.ts b/apps/sim/app/api/mcp/copilot/route.ts index 021b04aac04..6b25341e196 100644 --- a/apps/sim/app/api/mcp/copilot/route.ts +++ b/apps/sim/app/api/mcp/copilot/route.ts @@ -11,7 +11,7 @@ import { type RequestId, } from '@modelcontextprotocol/sdk/types.js' import { db } from '@sim/db' -import { userStats } from '@sim/db/schema' +import { apiKey as apiKeyTable, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' @@ -23,6 +23,7 @@ import { validateOAuthAccessToken } from '@/lib/auth/oauth-token' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context' import { ORCHESTRATION_TIMEOUT_MS, SIM_AGENT_API_URL } from '@/lib/copilot/constants' +import { isHosted } from '@/lib/core/config/feature-flags' import { createRequestId } from '@/lib/copilot/request/http' import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' import { orchestrateSubagentStream } from '@/lib/copilot/request/subagent' @@ -52,8 +53,32 @@ interface CopilotKeyAuthResult { /** * Validates a copilot API key by forwarding it to the Go copilot service's * `/api/validate-key` endpoint. Returns the associated userId on success. + * + * On self-hosted instances, also accepts local workspace/personal API keys + * (sk-sim-...) by looking them up directly in the api_key table — the + * Mothership service does not trust self-hosted INTERNAL_API_SECRETs, so + * remote validation always fails. The local lookup gives external MCP + * clients (claude.ai, etc.) a way to authenticate using the same + * workspace API key they use for /api/v1/workflows execution. */ async function authenticateCopilotApiKey(apiKey: string): Promise { + if (!isHosted) { + try { + const [row] = await db + .select({ userId: apiKeyTable.userId }) + .from(apiKeyTable) + .where(eq(apiKeyTable.key, apiKey)) + .limit(1) + if (row?.userId) { + return { success: true, userId: row.userId } + } + } catch (error) { + logger.warn('Local Copilot API key lookup failed, falling through to remote validation', { + error: toError(error).message, + }) + } + } + try { const internalSecret = env.INTERNAL_API_SECRET if (!internalSecret) { diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts index fd5c15bdc0a..2adb1892fd0 100644 --- a/apps/sim/blocks/utils.ts +++ b/apps/sim/blocks/utils.ts @@ -213,7 +213,11 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { placeholder: 'Enter your API key', password: true, connectionDroppable: false, - required: true, + // On self-hosted, BYOK keys (workspace_byok_keys table) provide + // credentials at execution time — see lib/api-key/byok.ts. Per-block + // apiKey override stays available but is no longer required, so + // workflows authored without one don't fail serializer validation. + required: isHosted, condition: getApiKeyCondition(), }, { From 973a85f5ba74d89e03f84efeaa096132672d3698 Mon Sep 17 00:00:00 2001 From: Forrest Surprenant <57930975+ForrestOfFidum@users.noreply.github.com> Date: Sun, 3 May 2026 02:06:43 -0400 Subject: [PATCH 4/7] feat(self-hosted): show BYOK + Copilot Keys in settings sidebar Both pages render correctly on self-hosted (BYOK is fully functional via the byok.ts patch; Copilot Keys page works for storing keys locally even though new-key generation still proxies to sim.ai). Removing the requiresHosted gate makes them discoverable instead of requiring users to type the URL by hand. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/sim/app/workspace/[workspaceId]/settings/navigation.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts index 921175cee89..ab9fcccdff5 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts @@ -142,14 +142,16 @@ export const allNavigationItems: NavigationItem[] = [ label: 'BYOK', icon: KeySquare, section: 'system', - requiresHosted: true, + // BYOK is functional on self-hosted via the byok.ts patch — surface in + // sidebar so users can manage workspace credentials from the UI. }, { id: 'copilot', label: 'Copilot Keys', icon: HexSimple, section: 'system', - requiresHosted: true, + // Copilot keys page works locally for managing keys (proxies to sim.ai + // for new key generation but listing/storing existing keys works). }, { id: 'inbox', From d54d375c4371a80698a3b357df839444d2b72265 Mon Sep 17 00:00:00 2001 From: Forrest Surprenant <57930975+ForrestOfFidum@users.noreply.github.com> Date: Sun, 3 May 2026 02:57:14 -0400 Subject: [PATCH 5/7] fix(self-hosted): hash workspace API key before lookup The local-first auth path I added used eq(apiKeyTable.key, apiKey) but sim stores api keys as a sha256 hash in keyHash, not as plaintext in key. Mirror authenticateApiKeyByHash() in lib/api-key/service.ts: hash the incoming key with hashApiKey() and look up by keyHash. Also honor expiresAt to match the existing service behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/sim/app/api/mcp/copilot/route.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/api/mcp/copilot/route.ts b/apps/sim/app/api/mcp/copilot/route.ts index 6b25341e196..75c48d8c4cd 100644 --- a/apps/sim/app/api/mcp/copilot/route.ts +++ b/apps/sim/app/api/mcp/copilot/route.ts @@ -22,6 +22,7 @@ import { mcpRequestBodySchema, mcpToolCallParamsSchema } from '@/lib/api/contrac import { validateOAuthAccessToken } from '@/lib/auth/oauth-token' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context' +import { hashApiKey } from '@/lib/api-key/crypto' import { ORCHESTRATION_TIMEOUT_MS, SIM_AGENT_API_URL } from '@/lib/copilot/constants' import { isHosted } from '@/lib/core/config/feature-flags' import { createRequestId } from '@/lib/copilot/request/http' @@ -64,12 +65,13 @@ interface CopilotKeyAuthResult { async function authenticateCopilotApiKey(apiKey: string): Promise { if (!isHosted) { try { + const keyHash = hashApiKey(apiKey) const [row] = await db - .select({ userId: apiKeyTable.userId }) + .select({ userId: apiKeyTable.userId, expiresAt: apiKeyTable.expiresAt }) .from(apiKeyTable) - .where(eq(apiKeyTable.key, apiKey)) + .where(eq(apiKeyTable.keyHash, keyHash)) .limit(1) - if (row?.userId) { + if (row?.userId && (!row.expiresAt || row.expiresAt > new Date())) { return { success: true, userId: row.userId } } } catch (error) { From f504521774388fb6eeb19e314d308f9af5ff431b Mon Sep 17 00:00:00 2001 From: Forrest Surprenant <57930975+ForrestOfFidum@users.noreply.github.com> Date: Sun, 3 May 2026 03:14:07 -0400 Subject: [PATCH 6/7] fix(mcp): drop client-routed run_* tools from DIRECT_TOOL_DEFS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_workflow, run_workflow_until_block, run_from_block, and run_block are catalogued as route: 'client' in tool-catalog-v1.ts — sim's tool-executor.routeToolCall returns 'client' for them and the dispatch in executor.ts calls executeAppTool() instead of the registered handler. That falls through to the workflow-block tool registry, which doesn't have these IDs, producing 'Built-in tool not found: run_workflow'. External MCP callers should use POST /api/workflows/{id}/execute for synchronous execution — the same endpoint simstudio-ts-sdk wraps and the existing MCP-published-as-tool path uses. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/sim/lib/copilot/tools/mcp/definitions.ts | 94 ++----------------- 1 file changed, 7 insertions(+), 87 deletions(-) diff --git a/apps/sim/lib/copilot/tools/mcp/definitions.ts b/apps/sim/lib/copilot/tools/mcp/definitions.ts index 1add99532df..6ed596566b7 100644 --- a/apps/sim/lib/copilot/tools/mcp/definitions.ts +++ b/apps/sim/lib/copilot/tools/mcp/definitions.ts @@ -404,94 +404,14 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [ annotations: { destructiveHint: false }, }, - // === Execution === - { - name: 'run_workflow', - toolId: 'run_workflow', - description: - "Execute a workflow end-to-end. By default runs the draft state — set useDeployedState: true to run the deployed snapshot. Pass workflow inputs via the input field.", - inputSchema: { - type: 'object', - properties: { - workflowId: { type: 'string', description: 'Workflow ID to execute.' }, - input: { - description: "Inputs to pass to the start trigger. Shape matches the workflow's defined input format.", - }, - triggerBlockId: { - type: 'string', - description: 'Optional trigger block ID when the workflow has multiple entrypoints.', - }, - useDeployedState: { - type: 'boolean', - description: 'true to run the deployed version, false (default) for the draft.', - }, - }, - required: ['workflowId'], - }, - annotations: { destructiveHint: true, openWorldHint: true }, - }, - { - name: 'run_workflow_until_block', - toolId: 'run_workflow_until_block', - description: - 'Execute a workflow but halt after a specific block completes. Useful for inspecting intermediate state during debugging.', - inputSchema: { - type: 'object', - properties: { - workflowId: { type: 'string', description: 'Workflow ID.' }, - stopAfterBlockId: { type: 'string', description: 'Block ID to stop after.' }, - input: { description: "Inputs for the start trigger." }, - triggerBlockId: { type: 'string', description: 'Optional explicit trigger block ID.' }, - useDeployedState: { type: 'boolean', description: 'Default: false (draft).' }, - }, - required: ['workflowId', 'stopAfterBlockId'], - }, - annotations: { destructiveHint: true, openWorldHint: true }, - }, - { - name: 'run_from_block', - toolId: 'run_from_block', - description: - 'Resume execution of a workflow starting from a specific block, using a previous execution snapshot for upstream values. Pass executionId to pin to a specific snapshot, otherwise the latest is used.', - inputSchema: { - type: 'object', - properties: { - workflowId: { type: 'string', description: 'Workflow ID.' }, - startBlockId: { type: 'string', description: 'Block ID to start execution from.' }, - executionId: { - type: 'string', - description: 'Optional execution ID to load the snapshot from (latest if omitted).', - }, - input: { description: 'Optional fresh inputs.' }, - useDeployedState: { type: 'boolean', description: 'Default: false (draft).' }, - }, - required: ['workflowId', 'startBlockId'], - }, - annotations: { destructiveHint: true, openWorldHint: true }, - }, - { - name: 'run_block', - toolId: 'run_block', - description: - 'Execute a single block in isolation, reusing cached upstream outputs from a previous execution. Useful for fast iteration on a single block during debugging.', - inputSchema: { - type: 'object', - properties: { - workflowId: { type: 'string', description: 'Workflow ID.' }, - blockId: { type: 'string', description: 'Block ID to run.' }, - executionId: { - type: 'string', - description: 'Optional execution ID for upstream snapshot (latest if omitted).', - }, - input: { description: 'Optional fresh inputs.' }, - useDeployedState: { type: 'boolean', description: 'Default: false (draft).' }, - }, - required: ['workflowId', 'blockId'], - }, - annotations: { destructiveHint: true, openWorldHint: true }, - }, - // === Deployment === + // NOTE: run_workflow / run_workflow_until_block / run_from_block / run_block + // are intentionally NOT exposed via MCP. Their entries in + // tool-catalog-v1.ts are route: 'client' — sim's tool-executor only + // dispatches sim-routed tools to local handlers, so client-routed tools + // fail with "Built-in tool not found" when called via MCP. External + // callers should use the regular HTTP endpoint + // POST /api/workflows/{id}/execute for synchronous workflow execution. { name: 'deploy_api', toolId: 'deploy_api', From 04be30692a3000f4bcdbe1d55f5d5c854d973fb2 Mon Sep 17 00:00:00 2001 From: Forrest Surprenant <57930975+ForrestOfFidum@users.noreply.github.com> Date: Sun, 3 May 2026 03:23:39 -0400 Subject: [PATCH 7/7] feat(self-hosted): handle Copilot API keys locally on self-hosted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Copilot Keys settings page (POST/GET/DELETE /api/copilot/api-keys) forwards every operation to the Mothership service at copilot.sim.ai. Self-hosted callers get rejected because Mothership only trusts sim.ai's production INTERNAL_API_SECRET, so the UI shows 'Failed to create API key. Please check your connection and try again.' on every action. When isHosted is false, fall back to local DB operations against the api_key table — same pattern the personal Sim Keys page already uses. Generated keys land as type='personal' and are valid for the authenticateCopilotApiKey local-first lookup in apps/sim/app/api/mcp/copilot/route.ts. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/copilot/api-keys/generate/route.ts | 33 ++++++++++++++ apps/sim/app/api/copilot/api-keys/route.ts | 44 +++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/apps/sim/app/api/copilot/api-keys/generate/route.ts b/apps/sim/app/api/copilot/api-keys/generate/route.ts index 5147de13d7d..dab9626c047 100644 --- a/apps/sim/app/api/copilot/api-keys/generate/route.ts +++ b/apps/sim/app/api/copilot/api-keys/generate/route.ts @@ -1,11 +1,17 @@ +import { db } from '@sim/db' +import { apiKey as apiKeyTable } from '@sim/db/schema' +import { generateShortId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { generateCopilotApiKeyContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' +import { createApiKey } from '@/lib/api-key/auth' +import { hashApiKey } from '@/lib/api-key/crypto' import { getSession } from '@/lib/auth' import { SIM_AGENT_API_URL } from '@/lib/copilot/constants' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { fetchGo } from '@/lib/copilot/request/go/fetch' import { env } from '@/lib/core/config/env' +import { isHosted } from '@/lib/core/config/feature-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const POST = withRouteHandler(async (req: NextRequest) => { @@ -22,6 +28,33 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const { name } = parsed.data.body + // Self-hosted: create the key locally as a personal API key. The + // Mothership service does not trust self-hosted callers, so the + // remote generation flow always fails. Local keys work everywhere + // (X-API-Key on /api/mcp/copilot, /api/v1/workflows/{id}/execute, etc.). + if (!isHosted) { + const { key: plainKey, encryptedKey } = await createApiKey(true) + if (!encryptedKey) { + return NextResponse.json({ error: 'Failed to encrypt API key' }, { status: 500 }) + } + const id = generateShortId() + await db.insert(apiKeyTable).values({ + id, + userId, + workspaceId: null, + name, + key: encryptedKey, + keyHash: hashApiKey(plainKey), + type: 'personal', + createdAt: new Date(), + updatedAt: new Date(), + }) + return NextResponse.json( + { success: true, key: { id, apiKey: plainKey } }, + { status: 201 } + ) + } + const res = await fetchGo(`${SIM_AGENT_API_URL}/api/validate-key/generate`, { method: 'POST', headers: { diff --git a/apps/sim/app/api/copilot/api-keys/route.ts b/apps/sim/app/api/copilot/api-keys/route.ts index 8b2d7bf55bf..0b2617120a6 100644 --- a/apps/sim/app/api/copilot/api-keys/route.ts +++ b/apps/sim/app/api/copilot/api-keys/route.ts @@ -1,3 +1,6 @@ +import { db } from '@sim/db' +import { apiKey as apiKeyTable } from '@sim/db/schema' +import { and, desc, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { deleteCopilotApiKeyQuerySchema } from '@/lib/api/contracts' import { getSession } from '@/lib/auth' @@ -5,6 +8,7 @@ import { SIM_AGENT_API_URL } from '@/lib/copilot/constants' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { fetchGo } from '@/lib/copilot/request/go/fetch' import { env } from '@/lib/core/config/env' +import { isHosted } from '@/lib/core/config/feature-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const GET = withRouteHandler(async (request: NextRequest) => { @@ -16,6 +20,33 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const userId = session.user.id + // Self-hosted: list user's personal keys directly from the api_key + // table. These are the same keys the generate endpoint creates and + // are valid for X-API-Key auth on /api/mcp/copilot (via the + // local-first lookup in route.ts). + if (!isHosted) { + const rows = await db + .select({ + id: apiKeyTable.id, + name: apiKeyTable.name, + keyHash: apiKeyTable.keyHash, + createdAt: apiKeyTable.createdAt, + lastUsed: apiKeyTable.lastUsed, + }) + .from(apiKeyTable) + .where(and(eq(apiKeyTable.userId, userId), eq(apiKeyTable.type, 'personal'))) + .orderBy(desc(apiKeyTable.createdAt)) + const keys = rows.map((k) => ({ + id: k.id, + // Last 6 of keyHash for display (we don't decrypt the stored key here) + displayKey: `•••••${(k.keyHash || '').slice(-6)}`, + name: k.name, + createdAt: k.createdAt ? k.createdAt.toISOString() : null, + lastUsed: k.lastUsed ? k.lastUsed.toISOString() : null, + })) + return NextResponse.json({ keys }, { status: 200 }) + } + const res = await fetchGo(`${SIM_AGENT_API_URL}/api/validate-key/get-api-keys`, { method: 'POST', headers: { @@ -75,6 +106,19 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { } const { id } = queryResult.data + // Self-hosted: delete the row directly. Scoped to the requesting user + // so callers can only delete their own keys. + if (!isHosted) { + const deleted = await db + .delete(apiKeyTable) + .where(and(eq(apiKeyTable.id, id), eq(apiKeyTable.userId, userId))) + .returning({ id: apiKeyTable.id }) + if (deleted.length === 0) { + return NextResponse.json({ error: 'Key not found' }, { status: 404 }) + } + return NextResponse.json({ success: true }, { status: 200 }) + } + const res = await fetchGo(`${SIM_AGENT_API_URL}/api/validate-key/delete`, { method: 'POST', headers: {