Skip to content

Commit 9a7e1ec

Browse files
committed
feat(mship): add external mcps to mship
1 parent 7fbf3dc commit 9a7e1ec

33 files changed

Lines changed: 696 additions & 65 deletions

File tree

apps/sim/app/api/mothership/execute/route.ts

Lines changed: 61 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
MothershipStreamV1EventType,
1414
MothershipStreamV1TextChannel,
1515
} from '@/lib/copilot/generated/mothership-stream-v1'
16+
import { buildSelectedMcpToolSchemas, buildTaggedMcpToolSchemas } from '@/lib/copilot/mcp-tools'
1617
import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless'
1718
import { requestExplicitStreamAbort } from '@/lib/copilot/request/session/explicit-abort'
1819
import type { StreamEvent } from '@/lib/copilot/request/types'
@@ -105,6 +106,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
105106
requestId: providedRequestId,
106107
fileAttachments,
107108
contexts,
109+
mcpTools,
108110
workflowId,
109111
executionId,
110112
userMetadata,
@@ -141,25 +143,42 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
141143
const lastUserMessage = messages.filter((m) => m.role === 'user').at(-1)?.content
142144
// double-cast-allowed: the contract validates contexts as open kind/label objects; processContextsServer narrows on `kind` at runtime
143145
const agentMentions = contexts as unknown as ChatContext[] | undefined
144-
const [workspaceContext, integrationTools, userPermission, entitlements, agentContexts] =
145-
await Promise.all([
146-
generateWorkspaceContext(workspaceId, userId),
147-
buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId),
148-
getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null),
149-
computeWorkspaceEntitlements(workspaceId, userId),
150-
processContextsServer(
151-
agentMentions,
152-
userId,
153-
lastUserMessage,
154-
workspaceId,
155-
effectiveChatId
156-
).catch((error) => {
157-
reqLogger.warn('Failed to resolve agent contexts for execution', {
158-
error: toError(error).message,
159-
})
160-
return []
161-
}),
162-
])
146+
const taggedMcpServerIds = (agentMentions ?? []).flatMap((context) =>
147+
context.kind === 'mcp' && context.serverId ? [context.serverId] : []
148+
)
149+
const nonMcpAgentMentions = agentMentions?.filter((context) => context.kind !== 'mcp')
150+
const [
151+
workspaceContext,
152+
integrationTools,
153+
mothershipTools,
154+
userPermission,
155+
entitlements,
156+
agentContexts,
157+
] = await Promise.all([
158+
generateWorkspaceContext(workspaceId, userId),
159+
buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId),
160+
Promise.all([
161+
buildSelectedMcpToolSchemas(userId, workspaceId, mcpTools ?? []),
162+
buildTaggedMcpToolSchemas(userId, workspaceId, taggedMcpServerIds),
163+
]).then((groups) => {
164+
const byName = new Map(groups.flat().map((tool) => [tool.name, tool]))
165+
return [...byName.values()]
166+
}),
167+
getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null),
168+
computeWorkspaceEntitlements(workspaceId, userId),
169+
processContextsServer(
170+
nonMcpAgentMentions,
171+
userId,
172+
lastUserMessage,
173+
workspaceId,
174+
effectiveChatId
175+
).catch((error) => {
176+
reqLogger.warn('Failed to resolve agent contexts for execution', {
177+
error: toError(error).message,
178+
})
179+
return []
180+
}),
181+
])
163182
const requestPayload: Record<string, unknown> = {
164183
messages,
165184
responseFormat,
@@ -178,8 +197,30 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
178197
...(isE2BDocEnabled ? { docCompiler: 'python' } : {}),
179198
...(userMetadata ? { userMetadata } : {}),
180199
...(fileAttachments && fileAttachments.length > 0 ? { fileAttachments } : {}),
181-
...(agentContexts.length > 0 ? { contexts: agentContexts } : {}),
200+
...(agentContexts.length > 0 || mothershipTools.length > 0
201+
? {
202+
contexts: [
203+
...agentContexts,
204+
...(mothershipTools.length > 0
205+
? [
206+
{
207+
type: 'mcp',
208+
content: [
209+
'The following MCP tools are explicitly enabled for this request.',
210+
'Load one with load_custom_tool({ type: "mcp", name: "<exact name>" }) before calling it.',
211+
'Do not narrate discovery, loading, tool-name selection, or retries. Call the tool first, then respond once with the result. Never claim the server works before a successful tool result. Do not automatically retry a timed-out or abandoned MCP call.',
212+
...mothershipTools.map(
213+
(tool) => `- ${tool.name}: ${tool.description || tool.name}`
214+
),
215+
].join('\n'),
216+
},
217+
]
218+
: []),
219+
],
220+
}
221+
: {}),
182222
...(integrationTools.length > 0 ? { integrationTools } : {}),
223+
...(mothershipTools.length > 0 ? { mothershipTools } : {}),
183224
...(userPermission ? { userPermission } : {}),
184225
...(entitlements.length > 0 ? { entitlements } : {}),
185226
}

apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
Task,
99
Workflow,
1010
} from '@sim/emcn/icons'
11-
import { AgentSkillsIcon } from '@/components/icons'
11+
import { AgentSkillsIcon, McpIcon } from '@/components/icons'
1212
import { getDocumentIcon } from '@/components/icons/document-icons'
1313
import type { ChatContextKind, ChatMessageContext } from '@/app/workspace/[workspaceId]/home/types'
1414
import { getBareIconStyle } from '@/blocks/icon-color'
@@ -99,4 +99,8 @@ export const CHAT_CONTEXT_KIND_REGISTRY: Record<ChatContextKind, ChatContextKind
9999
label: 'Skill',
100100
renderIcon: ({ className }) => <AgentSkillsIcon className={className} />,
101101
},
102+
mcp: {
103+
label: 'MCP server',
104+
renderIcon: ({ className }) => <McpIcon className={className} />,
105+
},
102106
}

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const PORTABLE_KIND_TO_ID_FIELD = {
3333
workflow: 'workflowId',
3434
logs: 'executionId',
3535
skill: 'skillId',
36+
mcp: 'serverId',
3637
integration: 'blockType',
3738
slash_command: 'command',
3839
} as const satisfies Partial<Record<ChatContext['kind'], string>>
@@ -220,6 +221,8 @@ export function chipLinkToContext(link: ParsedChipLink): ChatContext {
220221
return { kind: 'logs', executionId: link.id, label: link.label }
221222
case 'skill':
222223
return { kind: 'skill', skillId: link.id, label: link.label }
224+
case 'mcp':
225+
return { kind: 'mcp', serverId: link.id, label: link.label }
223226
case 'integration':
224227
return { kind: 'integration', blockType: link.id, label: link.label }
225228
case 'slash_command':

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,9 @@ export function PromptEditor({
214214
<SkillsMenuDropdown
215215
ref={editor.skillsMenuRef}
216216
skills={editor.skills}
217+
mcpServers={editor.mcpServers}
217218
onSkillSelect={editor.handleSkillSelect}
219+
onMcpSelect={editor.handleMcpSelect}
218220
onClose={editor.handleSkillsMenuClose}
219221
textareaRef={editor.textareaRef}
220222
pendingCursorRef={editor.pendingCursorRef}

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { createRoot, type Root } from 'react-dom/client'
66
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
77

88
vi.mock('@/hooks/queries/skills', () => ({ useSkills: () => ({ data: [] }) }))
9+
vi.mock('@/hooks/queries/mcp', () => ({ useMcpServers: () => ({ data: [] }) }))
910
vi.mock('@/hooks/queries/workflows', () => ({ useWorkflows: () => ({ data: [] }) }))
1011
vi.mock('@/hooks/queries/tables', () => ({ useTablesList: () => ({ data: [] }) }))
1112
vi.mock('@/hooks/queries/workspace-files', () => ({ useWorkspaceFiles: () => ({ data: [] }) }))

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
restoreSkillTriggerText,
2525
SKILL_CHIP_TRIGGER,
2626
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
27+
import { type McpServer, useMcpServers } from '@/hooks/queries/mcp'
2728
import { type SkillDefinition, useSkills } from '@/hooks/queries/skills'
2829
import type { ChatContext } from '@/stores/panel'
2930

@@ -155,6 +156,11 @@ export function usePromptEditor({
155156
onPasteFiles,
156157
}: UsePromptEditorProps) {
157158
const { data: skills = [] } = useSkills(workspaceId)
159+
const { data: allMcpServers = [] } = useMcpServers(workspaceId)
160+
const mcpServers = useMemo(
161+
() => allMcpServers.filter((server) => server.enabled && server.workspaceId === workspaceId),
162+
[allMcpServers, workspaceId]
163+
)
158164

159165
const [value, setValueState] = useState(initialValue)
160166
const valueRef = useRef(value)
@@ -224,6 +230,7 @@ export function usePromptEditor({
224230

225231
const skillAutoMention = useSkillAutoMention({
226232
skills,
233+
mcpServers,
227234
setSelectedContexts: contextManagement.setSelectedContexts,
228235
})
229236

@@ -272,8 +279,8 @@ export function usePromptEditor({
272279
valueRef.current = converted
273280
setValueState(converted)
274281
}
275-
seedRef.current = skills.length > 0 ? null : converted
276-
}, [skills.length, applyAutoMentions])
282+
seedRef.current = skills.length > 0 || mcpServers.length > 0 ? null : converted
283+
}, [skills.length, mcpServers.length, applyAutoMentions])
277284

278285
const existingResourceKeys = useMemo(() => {
279286
const keys = new Set<string>()
@@ -444,6 +451,32 @@ export function usePromptEditor({
444451
[textareaRef, addContextNotified]
445452
)
446453

454+
const handleMcpSelect = useCallback(
455+
(server: McpServer) => {
456+
const textarea = textareaRef.current
457+
if (textarea) {
458+
const currentValue = valueRef.current
459+
const range = slashRangeRef.current
460+
const insertAt = range?.start ?? textarea.selectionStart ?? currentValue.length
461+
const end = range?.end ?? insertAt
462+
const needsSpaceBefore = insertAt > 0 && !/\s/.test(currentValue.charAt(insertAt - 1))
463+
const insertText = `${needsSpaceBefore ? ' ' : ''}${SKILL_CHIP_TRIGGER}${server.name} `
464+
const newValue = `${currentValue.slice(0, insertAt)}${insertText}${currentValue.slice(end)}`
465+
const newPos = insertAt + insertText.length
466+
467+
pendingCursorRef.current = newPos
468+
valueRef.current = newValue
469+
slashRangeRef.current = null
470+
setSlashQuery(null)
471+
dismissedSlashStartRef.current = null
472+
setValueState(newValue)
473+
}
474+
475+
addContextNotified({ kind: 'mcp', serverId: server.id, label: server.name })
476+
},
477+
[textareaRef, addContextNotified]
478+
)
479+
447480
/**
448481
* Only reachable via Radix's own dismiss detection (outside click /
449482
* Escape) — programmatic closes (`skillsMenuRef.current?.close()`) bypass
@@ -1010,6 +1043,8 @@ export function usePromptEditor({
10101043
/** @internal Wiring consumed by the {@link PromptEditor} view. */
10111044
skills,
10121045
/** @internal */
1046+
mcpServers,
1047+
/** @internal */
10131048
availableResources,
10141049
/** @internal */
10151050
mentionQuery,
@@ -1026,6 +1061,8 @@ export function usePromptEditor({
10261061
/** @internal */
10271062
handleSkillSelect,
10281063
/** @internal */
1064+
handleMcpSelect,
1065+
/** @internal */
10291066
handlePlusMenuClose,
10301067
/** @internal */
10311068
handleSkillsMenuClose,

0 commit comments

Comments
 (0)