Skip to content

Commit 67ffdac

Browse files
authored
improvement(mship): mcp persistence in chat
1 parent ae49070 commit 67ffdac

6 files changed

Lines changed: 129 additions & 9 deletions

File tree

apps/sim/lib/copilot/chat/payload.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,10 @@ interface BuildPayloadParams {
3434
model: string
3535
provider?: string
3636
contexts?: Array<{ type: string; content: string; tag?: string; path?: string }>
37-
/** MCP servers explicitly tagged on this turn. Untagged servers stay unavailable. */
37+
/**
38+
* MCP servers enabled for this chat — every server tagged on this or any
39+
* earlier turn. Servers never tagged in the chat stay unavailable.
40+
*/
3841
mcpServerIds?: string[]
3942
fileAttachments?: Array<{ id: string; key: string; size: number; [key: string]: unknown }>
4043
commands?: string[]

apps/sim/lib/copilot/chat/post.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,81 @@ describe('handleUnifiedChatPost', () => {
289289
)
290290
})
291291

292+
it('keeps MCP servers tagged on earlier turns enabled for the rest of the chat', async () => {
293+
resolveOrCreateChat.mockResolvedValue({
294+
chatId: 'chat-1',
295+
chat: { id: 'chat-1' },
296+
conversationHistory: [
297+
{
298+
id: 'msg-1',
299+
role: 'user',
300+
content: '/Docs search auth',
301+
contexts: [{ kind: 'mcp', serverId: 'mcp-server-1', label: 'Docs' }],
302+
},
303+
{ id: 'msg-2', role: 'assistant', content: 'here you go' },
304+
],
305+
isNew: false,
306+
})
307+
308+
const response = await handleUnifiedChatPost(
309+
new NextRequest('http://localhost/api/copilot/chat', {
310+
method: 'POST',
311+
body: JSON.stringify({
312+
message: 'now search billing',
313+
workspaceId: 'ws-1',
314+
chatId: 'chat-1',
315+
}),
316+
})
317+
)
318+
319+
expect(response.status).toBe(200)
320+
expect(buildCopilotRequestPayload).toHaveBeenCalledWith(
321+
expect.objectContaining({ mcpServerIds: ['mcp-server-1'] }),
322+
{ selectedModel: '' }
323+
)
324+
// The tools ride the tool array every turn, so re-expanding the listing for
325+
// an inherited server would only duplicate what the model already sees.
326+
const expandedContexts = processContextsServer.mock.calls[0]?.[0] ?? []
327+
expect(expandedContexts).not.toContainEqual(expect.objectContaining({ kind: 'mcp' }))
328+
})
329+
330+
it('unions MCP servers across turns without duplicating a re-tagged server', async () => {
331+
resolveOrCreateChat.mockResolvedValue({
332+
chatId: 'chat-1',
333+
chat: { id: 'chat-1' },
334+
conversationHistory: [
335+
{
336+
id: 'msg-1',
337+
role: 'user',
338+
content: '/Docs search auth',
339+
contexts: [{ kind: 'mcp', serverId: 'mcp-server-1', label: 'Docs' }],
340+
},
341+
],
342+
isNew: false,
343+
})
344+
345+
const response = await handleUnifiedChatPost(
346+
new NextRequest('http://localhost/api/copilot/chat', {
347+
method: 'POST',
348+
body: JSON.stringify({
349+
message: '/Docs /Issues cross-reference',
350+
workspaceId: 'ws-1',
351+
chatId: 'chat-1',
352+
contexts: [
353+
{ kind: 'mcp', serverId: 'mcp-server-1', label: 'Docs' },
354+
{ kind: 'mcp', serverId: 'mcp-server-2', label: 'Issues' },
355+
],
356+
}),
357+
})
358+
)
359+
360+
expect(response.status).toBe(200)
361+
expect(buildCopilotRequestPayload).toHaveBeenCalledWith(
362+
expect.objectContaining({ mcpServerIds: ['mcp-server-1', 'mcp-server-2'] }),
363+
{ selectedModel: '' }
364+
)
365+
})
366+
292367
it('persists cancelled partial responses from the server lifecycle', async () => {
293368
await handleUnifiedChatPost(
294369
new NextRequest('http://localhost/api/copilot/chat', {

apps/sim/lib/copilot/chat/post.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,44 @@ function normalizeContexts(contexts: UnifiedChatRequest['contexts']) {
246246
})
247247
}
248248

249+
/**
250+
* An MCP server tagged with `/name` stays enabled for the rest of the chat, not
251+
* just the turn it was tagged on. Persisted user messages already carry their
252+
* `mcp` contexts, so the transcript is the source of truth — enablement survives
253+
* reloads and reopened chats with no extra state to keep in sync. There is
254+
* deliberately no off switch: history is append-only.
255+
*
256+
* Only the ids travel forward, not the contexts themselves. The tools ride the
257+
* tool array on every turn, so the model always sees their names and schemas;
258+
* re-expanding the prompt listing each turn would just duplicate that. Keeping
259+
* inherited servers out of the persisted contexts also keeps the `/name` chips
260+
* on a sent message showing only what the user actually typed that turn.
261+
*/
262+
function collectChatMcpServerIds(
263+
conversationHistory: unknown[],
264+
currentContexts: UnifiedChatRequest['contexts']
265+
): string[] {
266+
const serverIds = new Set<string>()
267+
268+
const collect = (contexts: unknown) => {
269+
if (!Array.isArray(contexts)) return
270+
for (const ctx of contexts) {
271+
if (!ctx || typeof ctx !== 'object') continue
272+
const { kind, serverId } = ctx as { kind?: unknown; serverId?: unknown }
273+
if (kind === 'mcp' && typeof serverId === 'string' && serverId) {
274+
serverIds.add(serverId)
275+
}
276+
}
277+
}
278+
279+
for (const message of conversationHistory) {
280+
collect((message as { contexts?: unknown } | null)?.contexts)
281+
}
282+
collect(currentContexts)
283+
284+
return Array.from(serverIds)
285+
}
286+
249287
async function resolveAgentContexts(params: {
250288
contexts?: UnifiedChatRequest['contexts']
251289
resourceAttachments?: UnifiedChatRequest['resourceAttachments']
@@ -1003,9 +1041,7 @@ export async function handleUnifiedChatPost(req: NextRequest) {
10031041
[TraceAttr.CopilotContextsCount]: normalizedContexts.length,
10041042
},
10051043
() => {
1006-
const mcpServerIds = normalizedContexts.flatMap((context) =>
1007-
context.kind === 'mcp' && context.serverId ? [context.serverId] : []
1008-
)
1044+
const mcpServerIds = collectChatMcpServerIds(conversationHistory, normalizedContexts)
10091045
return branch.kind === 'workflow'
10101046
? branch.buildPayload({
10111047
message: body.message,

apps/sim/lib/copilot/chat/process-contents.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,9 @@ export async function processContextsServer(
9494
type: 'mcp',
9595
tag: ctx.label ? `/${ctx.label}` : '/',
9696
content: [
97-
`The user explicitly enabled the MCP server "${ctx.label || ctx.serverId}" for this turn.`,
98-
'Its request-scoped tools are listed below. Load a tool with load_custom_tool({ type: "mcp", name: "<exact name>" }) before calling it.',
99-
'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.',
97+
`The user explicitly enabled the MCP server "${ctx.label || ctx.serverId}". It stays enabled for the rest of this chat, and its tools remain callable on every later turn.`,
98+
'Its tools are listed below and are callable directly by the exact name shown — there is no loading step.',
99+
'Do not narrate discovery, 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.',
100100
...toolLines,
101101
].join('\n'),
102102
}

apps/sim/lib/copilot/mcp-tools.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ describe('mothership MCP tool schemas', () => {
3737
expect(tools).toEqual([
3838
expect.objectContaining({
3939
name: 'mcp-server-1-search',
40-
defer_loading: true,
40+
// Explicitly enabled by the user, so it is callable without an unlock step.
41+
defer_loading: false,
4142
executeLocally: false,
4243
params: expect.objectContaining({
4344
mothershipToolKind: 'mcp',

apps/sim/lib/copilot/mcp-tools.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,12 @@ function toMothershipMcpTool(tool: {
2121
description:
2222
tool.description || `MCP tool ${tool.name} from ${tool.serverName || tool.serverId}`,
2323
input_schema: tool.inputSchema,
24-
defer_loading: true,
24+
// Not deferred: deferral keeps the 200+ unrequested integration schemas out
25+
// of the tool array, but an MCP server is explicitly enabled by the user and
26+
// stays enabled for the chat. Deferring it means every turn after the first
27+
// needs a load_custom_tool round-trip the model has no listing to prompt it
28+
// for, and a direct call is rejected as unavailable.
29+
defer_loading: false,
2530
executeLocally: false,
2631
service: `mcp:${tool.serverId}`,
2732
params: {

0 commit comments

Comments
 (0)