Skip to content

Commit cd3efef

Browse files
committed
fix(v2): stop a third-party tool description from 500ing MCP discovery
`v2McpToolInputSchema` declared `description: z.string().optional()` inside a `.catchall(z.unknown())` object, and a declared key beats the catchall. The MCP SDK's own `ToolSchema.inputSchema` does not declare `description` at all, so any value — including the JSON `null` a Python server emits for an absent one — passes its validation and reaches Sim unchecked. The builder's outbound `.parse()` then threw, and the discovery error policy correctly declines to classify a Sim-side schema defect, so the endpoint that completes MCP onboarding answered a bare 500. The key is dropped and left to the catchall; `type`, `properties`, and `required` stay pinned because the SDK enforces those at least as tightly. Also in the v2 resources family: - The single-resource query schemas for MCP servers, skills, custom tools, and secrets are now `.strict()`, matching every list in the same family. A mistyped flag was silently ignored behind a 200. - `openapi/resources.ts` re-derived `RESOURCE_ERRORS` and `RESOURCE_CONFLICT_ERRORS` inline in 21 of 22 operations. They now import the shared constants; the generated spec is unchanged, which is the point. - The internal MCP refresh route stamped `updatedAt` alongside `lastToolsRefresh`. `updatedAt` means "configuration last changed" and is a public keyset sort, so a refresh moved rows out from under an in-flight page. `updateServerStatus` already held that invariant; the route now matches it. - The discovery cooldown is a typed `McpServerCooldownError` rather than a substring search for `cooldown`. `McpConnectionError` interpolates the server's display name into its message, so a server named after the word was reported as a transient cooldown when its connection had genuinely failed.
1 parent 1faac4e commit cd3efef

21 files changed

Lines changed: 407 additions & 56 deletions

File tree

apps/docs/openapi-v2-resources.json

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3019,10 +3019,6 @@
30193019
"type": "string",
30203020
"description": "Name of a required argument."
30213021
}
3022-
},
3023-
"description": {
3024-
"description": "Description of the argument object.",
3025-
"type": "string"
30263022
}
30273023
},
30283024
"required": ["type"],

apps/sim/app/api/mcp/servers/[id]/refresh/route.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,35 @@ describe('MCP server refresh route', () => {
9898
)
9999
})
100100

101+
/**
102+
* `updatedAt` means "when the server's configuration last changed" and is one
103+
* of the public list's keyset sorts, so a refresh must not stamp it. The
104+
* service's discovery status write already holds that invariant; this route
105+
* writes the same row from the UI's refresh button, and stamping it here moves
106+
* the row to the head of `sortBy=updatedAt` under an in-flight v2 page, which
107+
* duplicates some servers across pages and skips others. Liveness is published
108+
* through `lastToolsRefresh`, `lastConnected`, and `lastError`.
109+
*/
110+
it('records the refresh without stamping updatedAt', async () => {
111+
mockDiscoverServerTools.mockResolvedValueOnce([])
112+
113+
const request = new Request('http://localhost/api/mcp/servers/server-1/refresh', {
114+
method: 'POST',
115+
}) as NextRequest
116+
await POST(request, { params: Promise.resolve({ id: 'server-1' }) })
117+
118+
const refreshWrites = dbChainMockFns.set.mock.calls.filter(
119+
([values]) => (values as Record<string, unknown>)?.lastToolsRefresh !== undefined
120+
)
121+
expect(refreshWrites.length).toBeGreaterThan(0)
122+
for (const [values] of refreshWrites) {
123+
expect(
124+
(values as Record<string, unknown>).updatedAt,
125+
'the refresh route stamped updatedAt, corrupting the updatedAt keyset page'
126+
).toBeUndefined()
127+
}
128+
})
129+
101130
it('reports the discovery failure when status persistence leaves a stale connected row', async () => {
102131
const reflectedSecret = 'Bearer reflected-static-token'
103132
mockDiscoverServerTools.mockRejectedValueOnce(

apps/sim/app/api/mcp/servers/[id]/refresh/route.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,11 +229,20 @@ export const POST = withRouteHandler(
229229

230230
const now = new Date()
231231

232+
/**
233+
* Deliberately leaves `updatedAt` alone, matching the invariant
234+
* `McpService.updateServerStatus` holds: `updatedAt` means "when the
235+
* server's configuration last changed", and it is one of the public
236+
* list's keyset sorts. A refresh stamping it moves the row to the head
237+
* of `sortBy=updatedAt` under an in-flight page, so a caller walking the
238+
* list while anyone presses this button sees servers duplicated across
239+
* pages and others skipped. Refresh liveness is already published
240+
* through `lastToolsRefresh`, `lastConnected`, and `lastError`.
241+
*/
232242
const [refreshedServer] = await db
233243
.update(mcpServers)
234244
.set({
235245
lastToolsRefresh: now,
236-
updatedAt: now,
237246
})
238247
.where(
239248
and(

apps/sim/app/api/v2/credentials/route.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,32 @@ describe('GET /api/v2/credentials', () => {
134134
expect(JSON.stringify(body)).not.toContain('createdBy')
135135
})
136136

137+
/**
138+
* The projection is an explicit field-by-field copy, which is what makes a
139+
* column added to the credential table later inert here: a field nobody wrote
140+
* into `toV2Credential` is simply never read. The outbound response `.parse()`
141+
* strips whatever survives, so a leak needs two independent mistakes. This
142+
* pins the pairing against a row carrying a column the projection has never
143+
* heard of.
144+
*/
145+
it('withholds a credential column the projection was never taught to publish', async () => {
146+
mocks.execute.mockResolvedValueOnce({
147+
credentials: [{ ...credential, encryptedFutureSecret: 'MUST_NOT_LEAK_EITHER' }],
148+
nextCursorKeys: null,
149+
sortBy: 'createdAt',
150+
sortOrder: 'desc',
151+
})
152+
153+
const response = await GET(
154+
new NextRequest(`http://localhost:3000/api/v2/credentials?workspaceId=${WORKSPACE_ID}`)
155+
)
156+
const body = await response.json()
157+
158+
expect(response.status).toBe(200)
159+
expect(JSON.stringify(body)).not.toContain('encryptedFutureSecret')
160+
expect(JSON.stringify(body)).not.toContain('MUST_NOT_LEAK_EITHER')
161+
})
162+
137163
it('hides repository errors that may contain secret details', async () => {
138164
mocks.execute.mockRejectedValueOnce(new Error('encryptedServiceAccountKey failed'))
139165

apps/sim/app/api/v2/custom-tools/[id]/route.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,25 @@ describe('/api/v2/custom-tools/[id]', () => {
130130
})
131131
})
132132

133+
/**
134+
* Every list in this family rejects a query param it does not implement, so
135+
* the single-resource reads must too. A caller who mistypes a flag otherwise
136+
* gets a 200 that silently ignored it, which reads as confirmation the flag
137+
* exists and does nothing.
138+
*/
139+
it('rejects a query param it does not implement', async () => {
140+
const response = await GET(
141+
new NextRequest(
142+
`http://localhost:3000/api/v2/custom-tools/${tool.id}?workspaceId=${WORKSPACE_ID}&includeCodes=true`,
143+
{ method: 'GET', headers: { 'x-api-key': 'key' } }
144+
),
145+
context
146+
)
147+
148+
expect(response.status).toBe(400)
149+
expect(mocks.get).not.toHaveBeenCalled()
150+
})
151+
133152
it('updates a custom tool through its semantic update operation', async () => {
134153
const response = await PATCH(
135154
request('PATCH', { workspaceId: WORKSPACE_ID, code: 'return 2' }),

apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,25 @@ describe('/api/v2/mcp-servers/[id]', () => {
121121
})
122122
})
123123

124+
/**
125+
* Every list in this family rejects a query param it does not implement, so
126+
* the single-resource reads must too. A caller who mistypes a flag otherwise
127+
* gets a 200 that silently ignored it, which reads as confirmation the flag
128+
* exists and does nothing.
129+
*/
130+
it('rejects a query param it does not implement', async () => {
131+
const response = await GET(
132+
new NextRequest(
133+
`http://localhost:3000/api/v2/mcp-servers/${server.id}?workspaceId=${WORKSPACE_ID}&includeTools=true`,
134+
{ method: 'GET', headers: { 'x-api-key': 'key' } }
135+
),
136+
context
137+
)
138+
139+
expect(response.status).toBe(400)
140+
expect(mocks.get).not.toHaveBeenCalled()
141+
})
142+
124143
it('updates an MCP server through the strict semantic update operation', async () => {
125144
const response = await PATCH(
126145
request('PATCH', { workspaceId: WORKSPACE_ID, name: 'New docs' }),

apps/sim/app/api/v2/mcp-servers/[id]/tools/route.test.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,11 @@ vi.mock('@/lib/mcp/application/use-cases', () => ({
3636
}))
3737

3838
import { WorkspaceApiKeyAuthorizationError } from '@/lib/core/application'
39-
import { McpConnectionError, McpOauthAuthorizationRequiredError } from '@/lib/mcp/types'
39+
import {
40+
McpConnectionError,
41+
McpOauthAuthorizationRequiredError,
42+
McpServerCooldownError,
43+
} from '@/lib/mcp/types'
4044
import { GET } from '@/app/api/v2/mcp-servers/[id]/tools/route'
4145

4246
const WORKSPACE_ID = 'workspace-1'
@@ -173,6 +177,63 @@ describe('/api/v2/mcp-servers/[id]/tools', () => {
173177
expect(body.error.code).toBe('INTERNAL_ERROR')
174178
})
175179

180+
/**
181+
* `inputSchema` below the `object` wrapper is authored by the third-party
182+
* server, and the MCP SDK's own `ToolSchema` does not declare `description`
183+
* there — its `.catchall(z.unknown())` lets any value through, so a server
184+
* serializing an absent description as JSON `null` (what a Python `None`
185+
* produces) reaches Sim unvalidated. Declaring the key more tightly than the
186+
* upstream schema does made the builder's outbound `.parse()` throw, and
187+
* discovery answered a bare 500 for a payload the protocol permits.
188+
*/
189+
it('publishes a tool whose server reported a non-string inputSchema description', async () => {
190+
mocks.discover.mockResolvedValueOnce({
191+
tools: [
192+
{
193+
...TOOL,
194+
inputSchema: { type: 'object' as const, description: null, properties: {} },
195+
},
196+
],
197+
})
198+
199+
const response = await GET(request(`workspaceId=${WORKSPACE_ID}`), { ...context })
200+
const body = await response.json()
201+
202+
expect(response.status).toBe(200)
203+
expect(body.data[0].inputSchema).toEqual({
204+
type: 'object',
205+
description: null,
206+
properties: {},
207+
})
208+
})
209+
210+
/**
211+
* The 503 wording used to be selected by searching the error message for
212+
* `cooldown`. `McpConnectionError` interpolates the server's display name into
213+
* that message, so a server the caller happened to name after the word
214+
* borrowed the negative-cache wording and told them to wait out a cooldown
215+
* that was never entered.
216+
*/
217+
it('does not read cooldown wording out of a server display name', async () => {
218+
mocks.discover.mockRejectedValueOnce(new McpConnectionError('ECONNREFUSED', 'Cooldown Docs'))
219+
220+
const response = await GET(request(`workspaceId=${WORKSPACE_ID}`), { ...context })
221+
const body = await response.json()
222+
223+
expect(response.status).toBe(503)
224+
expect(body.error.message).toBe('The MCP server could not be reached')
225+
})
226+
227+
it('reports a server inside the discovery cooldown with its own wording', async () => {
228+
mocks.discover.mockRejectedValueOnce(new McpServerCooldownError(SERVER_ID))
229+
230+
const response = await GET(request(`workspaceId=${WORKSPACE_ID}`), { ...context })
231+
const body = await response.json()
232+
233+
expect(response.status).toBe(503)
234+
expect(body.error.message).toBe('The MCP server recently failed and is in cooldown')
235+
})
236+
176237
it('rejects a workspace API key, which cannot supply the caller`s OAuth grant', async () => {
177238
mocks.discover.mockRejectedValueOnce(new WorkspaceApiKeyAuthorizationError())
178239

apps/sim/app/api/v2/mcp-servers/utils.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import { createV2ResourceConcealmentPolicy, type V2ErrorPolicy } from '@/lib/api
66
import { isTimeoutError } from '@/lib/core/execution-limits'
77
import { projectMcpHeaders } from '@/lib/mcp/projection'
88
import type { McpServerRow } from '@/lib/mcp/queries'
9-
import { McpConnectionError, McpOauthAuthorizationRequiredError } from '@/lib/mcp/types'
9+
import {
10+
McpConnectionError,
11+
McpOauthAuthorizationRequiredError,
12+
McpServerCooldownError,
13+
} from '@/lib/mcp/types'
1014
import { v2Error } from '@/app/api/v2/lib/response'
1115

1216
/**
@@ -49,10 +53,15 @@ export const MCP_SERVER_REAUTHORIZATION_REQUIRED = 'MCP_SERVER_REAUTHORIZATION_R
4953
*
5054
* Every branch returns a constant, so an upstream message — which may quote a
5155
* hostname, a token endpoint, or a stack — never reaches the caller.
56+
*
57+
* Selection is typed for the same reason classification is. The cooldown branch
58+
* used to search the message for `cooldown`, but `McpConnectionError`
59+
* interpolates the server's display name into its message, so a server a caller
60+
* named after the word was told to wait out a cooldown it was never in.
5261
*/
5362
function unreachableServerMessage(error: unknown): string {
5463
if (isTimeoutError(error)) return 'The MCP server took too long to respond'
55-
if (error instanceof McpConnectionError && error.message.toLowerCase().includes('cooldown')) {
64+
if (error instanceof McpServerCooldownError) {
5665
return 'The MCP server recently failed and is in cooldown'
5766
}
5867
return 'The MCP server could not be reached'

apps/sim/app/api/v2/secrets/[name]/route.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,25 @@ describe('/api/v2/secrets/[name]', () => {
157157
})
158158
})
159159

160+
/**
161+
* The secrets list rejects a query param it does not implement, so the delete
162+
* must too. A caller who mistypes `scope` otherwise gets a 400 for the missing
163+
* required param — but a caller who adds a param that does not exist at all
164+
* would have had it silently ignored.
165+
*/
166+
it('rejects a query param it does not implement', async () => {
167+
const response = await DELETE(
168+
new NextRequest(
169+
`http://localhost:3000/api/v2/secrets/${SECRET_NAME}?workspaceId=${WORKSPACE_ID}&scope=workspace&scopes=personal`,
170+
{ method: 'DELETE', headers: { 'x-api-key': 'key' } }
171+
),
172+
context
173+
)
174+
175+
expect(response.status).toBe(400)
176+
expect(mocks.remove).not.toHaveBeenCalled()
177+
})
178+
160179
it('renders typed application errors without leaking raw errors', async () => {
161180
mocks.remove.mockRejectedValueOnce(new OrchestrationError('not_found', 'stored detail'))
162181

apps/sim/app/api/v2/skills/[id]/route.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,25 @@ describe('/api/v2/skills/[id]', () => {
124124
})
125125
})
126126

127+
/**
128+
* Every list in this family rejects a query param it does not implement, so
129+
* the single-resource reads must too. A caller who mistypes a flag otherwise
130+
* gets a 200 that silently ignored it, which reads as confirmation the flag
131+
* exists and does nothing.
132+
*/
133+
it('rejects a query param it does not implement', async () => {
134+
const response = await GET(
135+
new NextRequest(
136+
`http://localhost:3000/api/v2/skills/${skill.id}?workspaceId=${WORKSPACE_ID}&includeContents=true`,
137+
{ method: 'GET', headers: { 'x-api-key': 'key' } }
138+
),
139+
context
140+
)
141+
142+
expect(response.status).toBe(400)
143+
expect(mocks.get).not.toHaveBeenCalled()
144+
})
145+
127146
it('updates a skill and emits only surface analytics', async () => {
128147
const response = await PATCH(
129148
request('PATCH', { workspaceId: WORKSPACE_ID, content: '# Updated' }),

0 commit comments

Comments
 (0)