Skip to content

Commit dd355cb

Browse files
Sg312icecrasher321
authored andcommitted
feat(mship): add external mcps to mship
1 parent 1aa776d commit dd355cb

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
@@ -14,6 +14,7 @@ import {
1414
MothershipStreamV1EventType,
1515
MothershipStreamV1TextChannel,
1616
} from '@/lib/copilot/generated/mothership-stream-v1'
17+
import { buildSelectedMcpToolSchemas, buildTaggedMcpToolSchemas } from '@/lib/copilot/mcp-tools'
1718
import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless'
1819
import { requestExplicitStreamAbort } from '@/lib/copilot/request/session/explicit-abort'
1920
import type { StreamEvent } from '@/lib/copilot/request/types'
@@ -106,6 +107,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
106107
requestId: providedRequestId,
107108
fileAttachments,
108109
contexts,
110+
mcpTools,
109111
workflowId,
110112
executionId,
111113
userMetadata,
@@ -146,25 +148,42 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
146148
const lastUserMessage = messages.filter((m) => m.role === 'user').at(-1)?.content
147149
// double-cast-allowed: the contract validates contexts as open kind/label objects; processContextsServer narrows on `kind` at runtime
148150
const agentMentions = contexts as unknown as ChatContext[] | undefined
149-
const [workspaceContext, integrationTools, userPermission, entitlements, agentContexts] =
150-
await Promise.all([
151-
generateWorkspaceContext(workspaceId, userId),
152-
buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId),
153-
getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null),
154-
computeWorkspaceEntitlements(workspaceId, userId),
155-
processContextsServer(
156-
agentMentions,
157-
userId,
158-
lastUserMessage,
159-
workspaceId,
160-
effectiveChatId
161-
).catch((error) => {
162-
reqLogger.warn('Failed to resolve agent contexts for execution', {
163-
error: toError(error).message,
164-
})
165-
return []
166-
}),
167-
])
151+
const taggedMcpServerIds = (agentMentions ?? []).flatMap((context) =>
152+
context.kind === 'mcp' && context.serverId ? [context.serverId] : []
153+
)
154+
const nonMcpAgentMentions = agentMentions?.filter((context) => context.kind !== 'mcp')
155+
const [
156+
workspaceContext,
157+
integrationTools,
158+
mothershipTools,
159+
userPermission,
160+
entitlements,
161+
agentContexts,
162+
] = await Promise.all([
163+
generateWorkspaceContext(workspaceId, userId),
164+
buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId),
165+
Promise.all([
166+
buildSelectedMcpToolSchemas(userId, workspaceId, mcpTools ?? []),
167+
buildTaggedMcpToolSchemas(userId, workspaceId, taggedMcpServerIds),
168+
]).then((groups) => {
169+
const byName = new Map(groups.flat().map((tool) => [tool.name, tool]))
170+
return [...byName.values()]
171+
}),
172+
getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null),
173+
computeWorkspaceEntitlements(workspaceId, userId),
174+
processContextsServer(
175+
nonMcpAgentMentions,
176+
userId,
177+
lastUserMessage,
178+
workspaceId,
179+
effectiveChatId
180+
).catch((error) => {
181+
reqLogger.warn('Failed to resolve agent contexts for execution', {
182+
error: toError(error).message,
183+
})
184+
return []
185+
}),
186+
])
168187
const requestPayload: Record<string, unknown> = {
169188
messages,
170189
responseFormat,
@@ -183,8 +202,30 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
183202
...(isE2BDocEnabled ? { docCompiler: 'python' } : {}),
184203
...(userMetadata ? { userMetadata } : {}),
185204
...(fileAttachments && fileAttachments.length > 0 ? { fileAttachments } : {}),
186-
...(agentContexts.length > 0 ? { contexts: agentContexts } : {}),
205+
...(agentContexts.length > 0 || mothershipTools.length > 0
206+
? {
207+
contexts: [
208+
...agentContexts,
209+
...(mothershipTools.length > 0
210+
? [
211+
{
212+
type: 'mcp',
213+
content: [
214+
'The following MCP tools are explicitly enabled for this request.',
215+
'Load one with load_custom_tool({ type: "mcp", name: "<exact name>" }) before calling it.',
216+
'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.',
217+
...mothershipTools.map(
218+
(tool) => `- ${tool.name}: ${tool.description || tool.name}`
219+
),
220+
].join('\n'),
221+
},
222+
]
223+
: []),
224+
],
225+
}
226+
: {}),
187227
...(integrationTools.length > 0 ? { integrationTools } : {}),
228+
...(mothershipTools.length > 0 ? { mothershipTools } : {}),
188229
...(userPermission ? { userPermission } : {}),
189230
...(entitlements.length > 0 ? { entitlements } : {}),
190231
}

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)