Skip to content

Commit 11e3ece

Browse files
fix(api): correct credential role, skill permission bar, MCP url identity, and custom-tool conflict mapping
1 parent b642721 commit 11e3ece

11 files changed

Lines changed: 152 additions & 10 deletions

File tree

apps/docs/openapi-v2-resources.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,7 @@
275275
"patch": {
276276
"operationId": "updateMcpServer",
277277
"summary": "Update MCP Server",
278-
"description": "Update an MCP server's configuration. Only the fields you send are changed. Requires `write` permission on the workspace.\n\nChanging `url`, the auth type, or the OAuth client credentials invalidates any existing OAuth grant for the server and resets its connection state.",
278+
"description": "Update an MCP server's configuration. Only the fields you send are changed. Requires `write` permission on the workspace.\n\n`url` is immutable: a server's id is derived from its URL, so re-pointing it would leave the id hashing an address the server no longer uses and allow two servers on one URL. Sending a different `url` returns `400` — delete the server and create one at the new address. Sending the URL it already has is accepted, so a full-object PATCH still works.\n\nChanging the auth type or the OAuth client credentials invalidates any existing OAuth grant for the server and resets its connection state.",
279279
"tags": ["MCP Servers"],
280280
"x-codeSamples": [
281281
{
@@ -2173,7 +2173,7 @@
21732173
"type": "string",
21742174
"minLength": 1,
21752175
"maxLength": 2048,
2176-
"description": "Absolute http or https endpoint URL. May not contain `{{ENV_VAR}}` references."
2176+
"description": "Immutable. Must equal the server's current URL — a different value returns `400`, because the server's id is derived from its URL."
21772177
},
21782178
"authType": { "type": "string", "enum": ["none", "headers", "oauth"] },
21792179
"headers": {

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@ const {
1313
mockCheckWorkspaceAccess,
1414
mockListVisibleWorkspaceCredentials,
1515
mockPerformCreateCredential,
16+
mockGetCredentialActorContext,
1617
} = vi.hoisted(() => ({
1718
mockCheckRateLimit: vi.fn(),
1819
mockResolveWorkspaceAccess: vi.fn(),
1920
mockCheckWorkspaceAccess: vi.fn(),
2021
mockListVisibleWorkspaceCredentials: vi.fn(),
2122
mockPerformCreateCredential: vi.fn(),
23+
mockGetCredentialActorContext: vi.fn(),
2224
}))
2325

2426
vi.mock('@/app/api/v1/middleware', () => ({
@@ -38,6 +40,10 @@ vi.mock('@/lib/credentials/orchestration', () => ({
3840
performCreateCredential: mockPerformCreateCredential,
3941
}))
4042

43+
vi.mock('@/lib/credentials/access', () => ({
44+
getCredentialActorContext: mockGetCredentialActorContext,
45+
}))
46+
4147
vi.mock('@/app/api/v2/lib/gate', () => ({
4248
v2ApiGateError: vi.fn().mockResolvedValue(null),
4349
}))
@@ -210,6 +216,7 @@ describe('POST /api/v2/credentials', () => {
210216
credential: buildRow(),
211217
created: true,
212218
})
219+
mockGetCredentialActorContext.mockResolvedValue({ member: null, isAdmin: false })
213220
})
214221

215222
it('returns 404 when the v2 API surface flag is off', async () => {
@@ -279,6 +286,28 @@ describe('POST /api/v2/credentials', () => {
279286
expect((await res.json()).error.code).toBe('SERVICE_UNAVAILABLE')
280287
})
281288

289+
it('reports the real role when an idempotent create matches a credential the caller only belongs to', async () => {
290+
mockPerformCreateCredential.mockResolvedValue({
291+
success: true,
292+
credential: buildRow(),
293+
created: false,
294+
})
295+
mockGetCredentialActorContext.mockResolvedValue({ member: { role: 'member' }, isAdmin: false })
296+
297+
const res = await callCreate(VALID_BODY)
298+
const body = await res.json()
299+
300+
expect(res.status).toBe(201)
301+
expect(body.data.credential.role).toBe('member')
302+
})
303+
304+
it('reports admin for a fresh insert without a second access lookup', async () => {
305+
const res = await callCreate(VALID_BODY)
306+
307+
expect((await res.json()).data.credential.role).toBe('admin')
308+
expect(mockGetCredentialActorContext).not.toHaveBeenCalled()
309+
})
310+
282311
it('creates the credential and never echoes the submitted secret', async () => {
283312
const res = await callCreate({
284313
workspaceId: WORKSPACE_ID,

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

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
import { parseRequest } from '@/lib/api/server'
99
import { generateRequestId } from '@/lib/core/utils/request'
1010
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
11+
import { getCredentialActorContext } from '@/lib/credentials/access'
1112
import { performCreateCredential } from '@/lib/credentials/orchestration'
1213
import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries'
1314
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
@@ -119,10 +120,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
119120
}
120121

121122
/**
122-
* The creator is always an admin of the credential they just made, whether
123-
* the row was inserted now or matched an existing source.
123+
* A fresh insert makes the creator an admin, but an idempotent match against
124+
* an existing source does not — the orchestration admits a caller who is
125+
* only a *member* of that credential. Resolve the real role rather than
126+
* assuming the create case, or the response would advertise administrative
127+
* actions the caller cannot perform.
124128
*/
125-
const credential = toV2CredentialRow(result.credential, 'admin')
129+
const actor = result.created
130+
? { isAdmin: true }
131+
: await getCredentialActorContext(result.credential.id, userId)
132+
const credential = toV2CredentialRow(result.credential, actor.isAdmin ? 'admin' : 'member')
126133

127134
/**
128135
* Always 201, including when an existing credential already occupied this

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
upsertCustomTools,
1818
} from '@/lib/workflows/custom-tools/operations'
1919
import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
20-
import { toV2CustomTool } from '@/app/api/v2/custom-tools/utils'
20+
import { toV2CustomTool, v2CustomToolWriteError } from '@/app/api/v2/custom-tools/utils'
2121
import { v2ApiGateError } from '@/app/api/v2/lib/gate'
2222
import {
2323
v2Data,
@@ -144,6 +144,9 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout
144144

145145
return v2Data({ customTool: toV2CustomTool(updated) }, { rateLimit })
146146
} catch (error) {
147+
const writeError = v2CustomToolWriteError(error)
148+
if (writeError) return writeError
149+
147150
logger.error(`[${requestId}] Error updating custom tool`, {
148151
error: getErrorMessage(error, 'Unknown error'),
149152
})

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,17 @@ describe('POST /api/v2/custom-tools', () => {
227227
expect(mockUpsertCustomTools).not.toHaveBeenCalled()
228228
})
229229

230+
it('409s when a concurrent create loses the title race inside the lib', async () => {
231+
mockUpsertCustomTools.mockRejectedValue(
232+
new Error('A tool with the title "v2_smoke_tool" already exists in this workspace')
233+
)
234+
235+
const res = await callCreate(VALID_BODY)
236+
237+
expect(res.status).toBe(409)
238+
expect((await res.json()).error.code).toBe('CONFLICT')
239+
})
240+
230241
it('creates the tool and returns 201 with the single tool', async () => {
231242
const res = await callCreate(VALID_BODY)
232243
const body = await res.json()

apps/sim/app/api/v2/custom-tools/route.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
upsertCustomTools,
1616
} from '@/lib/workflows/custom-tools/operations'
1717
import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
18-
import { toV2CustomTool } from '@/app/api/v2/custom-tools/utils'
18+
import { toV2CustomTool, v2CustomToolWriteError } from '@/app/api/v2/custom-tools/utils'
1919
import { v2ApiGateError } from '@/app/api/v2/lib/gate'
2020
import {
2121
v2CursorList,
@@ -125,6 +125,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
125125

126126
return v2Data({ customTool: toV2CustomTool(created) }, { rateLimit, status: 201 })
127127
} catch (error) {
128+
const writeError = v2CustomToolWriteError(error)
129+
if (writeError) return writeError
130+
128131
logger.error(`[${requestId}] Error creating custom tool`, {
129132
error: getErrorMessage(error, 'Unknown error'),
130133
})

apps/sim/app/api/v2/custom-tools/utils.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,24 @@
11
import type { customTools } from '@sim/db/schema'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import type { NextResponse } from 'next/server'
24
import type { V2CustomTool } from '@/lib/api/contracts/v2/custom-tools'
5+
import { v2Error } from '@/app/api/v2/lib/response'
36

4-
/** Shared serialization for the v2 custom tool surface. */
7+
/** Shared serialization + error mapping for the v2 custom tool surface. */
8+
9+
/**
10+
* `upsertCustomTools` reports a title collision as a thrown Error, and the unique
11+
* index behind it fires on the race the pre-check cannot cover (two concurrent
12+
* creates of the same title both pass the check, then one insert loses). Classify
13+
* it as a conflict so that race surfaces as 409 rather than a generic 500.
14+
*/
15+
export function v2CustomToolWriteError(error: unknown): NextResponse | null {
16+
const message = getErrorMessage(error, '')
17+
if (/already exists in this workspace/i.test(message)) {
18+
return v2Error('CONFLICT', message)
19+
}
20+
return null
21+
}
522

623
type CustomToolRow = typeof customTools.$inferSelect
724

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,32 @@ describe('PATCH /api/v2/mcp-servers/[id]', () => {
240240
expect((await res.json()).error.code).toBe('NOT_FOUND')
241241
})
242242

243+
it('400s when the url is changed, since the id is derived from it', async () => {
244+
mockGetWorkspaceMcpServer.mockResolvedValue(buildRow())
245+
246+
const res = await callPatch({
247+
workspaceId: 'workspace-1',
248+
url: 'https://different.example.com/sse',
249+
})
250+
251+
expect(res.status).toBe(400)
252+
expect((await res.json()).error.message).toContain('url cannot be changed')
253+
expect(mockPerformUpdateMcpServer).not.toHaveBeenCalled()
254+
})
255+
256+
it('allows a url that matches the stored one, so a full-object PATCH still works', async () => {
257+
mockGetWorkspaceMcpServer.mockResolvedValue(buildRow())
258+
259+
const res = await callPatch({
260+
workspaceId: 'workspace-1',
261+
url: 'https://mcp.example.com/sse',
262+
enabled: false,
263+
})
264+
265+
expect(res.status).toBe(200)
266+
expect(mockPerformUpdateMcpServer).toHaveBeenCalled()
267+
})
268+
243269
it('updates the server and returns the public shape', async () => {
244270
const res = await callPatch({ workspaceId: 'workspace-1', name: 'Renamed', enabled: false })
245271
const body = await res.json()

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,24 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout
9191
const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
9292
if (access) return v2WorkspaceAccessError(access)
9393

94+
/**
95+
* A server's id is the hash of its workspace + URL, and this surface promises
96+
* that identity. The lib will happily move `url` while the id keeps hashing
97+
* the old one, which both breaks that promise and defeats the duplicate
98+
* check on create (id-keyed, so it would not see the moved URL) — leaving two
99+
* rows on one URL. Re-pointing a server at a different URL is a new server.
100+
*/
101+
if (body.url !== undefined) {
102+
const current = await getWorkspaceMcpServer({ workspaceId, serverId: id })
103+
if (!current) return v2Error('NOT_FOUND', 'MCP server not found')
104+
if (current.url !== body.url) {
105+
return v2Error(
106+
'BAD_REQUEST',
107+
'url cannot be changed: an MCP server’s id is derived from its URL. Delete this server and create one at the new URL.'
108+
)
109+
}
110+
}
111+
94112
const result = await performUpdateMcpServer({
95113
workspaceId,
96114
userId,

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,16 @@ describe('PATCH /api/v2/skills/[id]', () => {
227227
expect((await res.json()).error.message).toContain('Built-in')
228228
})
229229

230+
it('gates on workspace read, leaving edit rights to the per-skill editor check', async () => {
231+
await callPatch({ workspaceId: 'workspace-1', description: 'Updated' })
232+
expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith(
233+
expect.anything(),
234+
'user-1',
235+
'workspace-1',
236+
'read'
237+
)
238+
})
239+
230240
it('updates the skill and returns the single skill', async () => {
231241
const res = await callPatch({ workspaceId: 'workspace-1', description: 'Updated' })
232242
const body = await res.json()
@@ -295,6 +305,16 @@ describe('DELETE /api/v2/skills/[id]', () => {
295305
expect(res.status).toBe(400)
296306
})
297307

308+
it('gates on workspace read, leaving delete rights to the per-skill editor check', async () => {
309+
await callDelete()
310+
expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith(
311+
expect.anything(),
312+
'user-1',
313+
'workspace-1',
314+
'read'
315+
)
316+
})
317+
298318
it('deletes the skill and acknowledges the id', async () => {
299319
const res = await callDelete()
300320
expect(res.status).toBe(200)

0 commit comments

Comments
 (0)