From f38b71e301ff28ec2fdbea65fa928bb7c6477fb7 Mon Sep 17 00:00:00 2001 From: Quinn Stearns Date: Thu, 21 May 2026 22:04:29 -0700 Subject: [PATCH 1/3] feat(toolsets): add clearUserSessionIssuer endpoint Adds toolsets.clearUserSessionIssuer to unlink any user_session_issuer attached to a toolset (sets toolsets.user_session_issuer_id to NULL). The USI row itself is untouched. Calling it on a toolset that already has no USI is a no-op. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../clear-toolset-user-session-issuer.md | 5 + .speakeasy/out.openapi.yaml | 109 ++++++++ cli/internal/api/toolsets.go | 1 + .../funcs/toolsetsClearUserSessionIssuer.ts | 233 ++++++++++++++++++ .../cleartoolsetusersessionissuer.ts | 190 ++++++++++++++ client/sdk/src/models/operations/index.ts | 1 + .../clearToolsetUserSessionIssuer.ts | 114 +++++++++ client/sdk/src/react-query/index.ts | 1 + client/sdk/src/sdk/toolsets.ts | 20 ++ server/design/toolsets/design.go | 27 ++ .../toolsets/clear_user_session_issuer.go | 89 +++++++ .../clear_user_session_issuer_test.go | 106 ++++++++ 12 files changed, 896 insertions(+) create mode 100644 .changeset/clear-toolset-user-session-issuer.md create mode 100644 client/sdk/src/funcs/toolsetsClearUserSessionIssuer.ts create mode 100644 client/sdk/src/models/operations/cleartoolsetusersessionissuer.ts create mode 100644 client/sdk/src/react-query/clearToolsetUserSessionIssuer.ts create mode 100644 server/internal/toolsets/clear_user_session_issuer.go create mode 100644 server/internal/toolsets/clear_user_session_issuer_test.go diff --git a/.changeset/clear-toolset-user-session-issuer.md b/.changeset/clear-toolset-user-session-issuer.md new file mode 100644 index 0000000000..331b78c2e1 --- /dev/null +++ b/.changeset/clear-toolset-user-session-issuer.md @@ -0,0 +1,5 @@ +--- +"server": minor +--- + +Add `toolsets.clearUserSessionIssuer` endpoint that unlinks any user_session_issuer attached to a toolset. diff --git a/.speakeasy/out.openapi.yaml b/.speakeasy/out.openapi.yaml index 9f6a20fda0..ca99bf8182 100644 --- a/.speakeasy/out.openapi.yaml +++ b/.speakeasy/out.openapi.yaml @@ -23037,6 +23037,115 @@ paths: x-speakeasy-name-override: checkMCPSlugAvailability x-speakeasy-react-hook: name: CheckMCPSlugAvailability + /rpc/toolsets.clearUserSessionIssuer: + post: + description: Unlink the user_session_issuer from a toolset. No-op if the toolset has no user_session_issuer linked. + operationId: clearToolsetUserSessionIssuer + parameters: + - allowEmptyValue: true + description: The slug of the toolset to unlink + in: query + name: slug + required: true + schema: + description: A short url-friendly label that uniquely identifies a resource. + maxLength: 40 + pattern: ^[a-z0-9_-]{1,128}$ + type: string + - allowEmptyValue: true + description: Session header + in: header + name: Gram-Session + schema: + description: Session header + type: string + - allowEmptyValue: true + description: API Key header + in: header + name: Gram-Key + schema: + description: API Key header + type: string + - allowEmptyValue: true + description: project header + in: header + name: Gram-Project + schema: + description: project header + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/Toolset' + description: OK response. + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'bad_request: request is invalid' + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'unauthorized: unauthorized access' + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'forbidden: permission denied' + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'not_found: resource not found' + "409": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'conflict: resource already exists' + "415": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'unsupported_media: unsupported media type' + "422": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'invalid: request contains one or more invalidation fields' + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'unexpected: an unexpected error occurred' + "502": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'gateway_error: an unexpected error occurred' + security: + - project_slug_header_Gram-Project: [] + session_header_Gram-Session: [] + - apikey_header_Gram-Key: [] + project_slug_header_Gram-Project: [] + - {} + summary: clearUserSessionIssuer toolsets + tags: + - toolsets + x-speakeasy-name-override: clearUserSessionIssuer + x-speakeasy-react-hook: + name: ClearToolsetUserSessionIssuer /rpc/toolsets.clone: post: description: Clone an existing toolset with a new name diff --git a/cli/internal/api/toolsets.go b/cli/internal/api/toolsets.go index 71675dc4a5..cab070e029 100644 --- a/cli/internal/api/toolsets.go +++ b/cli/internal/api/toolsets.go @@ -43,6 +43,7 @@ func NewToolsetsClient(options *ToolsetsClientOptions) *ToolsetsClient { h.AddOAuthProxyServer(), h.UpdateOAuthProxyServer(), h.SetUserSessionIssuer(), + h.ClearUserSessionIssuer(), ) return &ToolsetsClient{client: client} diff --git a/client/sdk/src/funcs/toolsetsClearUserSessionIssuer.ts b/client/sdk/src/funcs/toolsetsClearUserSessionIssuer.ts new file mode 100644 index 0000000000..be0f6c6b28 --- /dev/null +++ b/client/sdk/src/funcs/toolsetsClearUserSessionIssuer.ts @@ -0,0 +1,233 @@ +/* + * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + */ + +import * as z from "zod/v4-mini"; +import { GramCore } from "../core.js"; +import { encodeFormQuery, encodeSimple } from "../lib/encodings.js"; +import * as M from "../lib/matchers.js"; +import { compactMap } from "../lib/primitives.js"; +import { safeParse } from "../lib/schemas.js"; +import { RequestOptions } from "../lib/sdks.js"; +import { resolveSecurity } from "../lib/security.js"; +import { pathToFunc } from "../lib/url.js"; +import * as components from "../models/components/index.js"; +import { GramError } from "../models/errors/gramerror.js"; +import { + ConnectionError, + InvalidRequestError, + RequestAbortedError, + RequestTimeoutError, + UnexpectedClientError, +} from "../models/errors/httpclienterrors.js"; +import * as errors from "../models/errors/index.js"; +import { ResponseValidationError } from "../models/errors/responsevalidationerror.js"; +import { SDKValidationError } from "../models/errors/sdkvalidationerror.js"; +import * as operations from "../models/operations/index.js"; +import { APICall, APIPromise } from "../types/async.js"; +import { Result } from "../types/fp.js"; + +/** + * clearUserSessionIssuer toolsets + * + * @remarks + * Unlink the user_session_issuer from a toolset. No-op if the toolset has no user_session_issuer linked. + */ +export function toolsetsClearUserSessionIssuer( + client: GramCore, + request: operations.ClearToolsetUserSessionIssuerRequest, + security?: operations.ClearToolsetUserSessionIssuerSecurity | undefined, + options?: RequestOptions, +): APIPromise< + Result< + components.Toolset, + | errors.ServiceError + | GramError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + > +> { + return new APIPromise($do( + client, + request, + security, + options, + )); +} + +async function $do( + client: GramCore, + request: operations.ClearToolsetUserSessionIssuerRequest, + security?: operations.ClearToolsetUserSessionIssuerSecurity | undefined, + options?: RequestOptions, +): Promise< + [ + Result< + components.Toolset, + | errors.ServiceError + | GramError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + >, + APICall, + ] +> { + const parsed = safeParse( + request, + (value) => + z.parse( + operations.ClearToolsetUserSessionIssuerRequest$outboundSchema, + value, + ), + "Input validation failed", + ); + if (!parsed.ok) { + return [parsed, { status: "invalid" }]; + } + const payload = parsed.value; + const body = null; + + const path = pathToFunc("/rpc/toolsets.clearUserSessionIssuer")(); + + const query = encodeFormQuery({ + "slug": payload.slug, + }); + + const headers = new Headers(compactMap({ + Accept: "application/json", + "Gram-Key": encodeSimple("Gram-Key", payload["Gram-Key"], { + explode: false, + charEncoding: "none", + }), + "Gram-Project": encodeSimple("Gram-Project", payload["Gram-Project"], { + explode: false, + charEncoding: "none", + }), + "Gram-Session": encodeSimple("Gram-Session", payload["Gram-Session"], { + explode: false, + charEncoding: "none", + }), + })); + + const requestSecurity = resolveSecurity( + [ + { + fieldName: "Gram-Project", + type: "apiKey:header", + value: security?.option1?.projectSlugHeaderGramProject, + }, + { + fieldName: "Gram-Session", + type: "apiKey:header", + value: security?.option1?.sessionHeaderGramSession, + }, + ], + [ + { + fieldName: "Gram-Key", + type: "apiKey:header", + value: security?.option2?.apikeyHeaderGramKey, + }, + { + fieldName: "Gram-Project", + type: "apiKey:header", + value: security?.option2?.projectSlugHeaderGramProject, + }, + ], + ); + + const context = { + options: client._options, + baseURL: options?.serverURL ?? client._baseURL ?? "", + operationID: "clearToolsetUserSessionIssuer", + oAuth2Scopes: null, + + resolvedSecurity: requestSecurity, + + securitySource: security, + retryConfig: options?.retries + || client._options.retryConfig + || { strategy: "none" }, + retryCodes: options?.retryCodes || ["429", "500", "502", "503", "504"], + }; + + const requestRes = client._createRequest(context, { + security: requestSecurity, + method: "POST", + baseURL: options?.serverURL, + path: path, + headers: headers, + query: query, + body: body, + userAgent: client._options.userAgent, + timeoutMs: options?.timeoutMs || client._options.timeoutMs || -1, + }, options); + if (!requestRes.ok) { + return [requestRes, { status: "invalid" }]; + } + const req = requestRes.value; + + const doResult = await client._do(req, { + context, + errorCodes: [ + "400", + "401", + "403", + "404", + "409", + "415", + "422", + "4XX", + "500", + "502", + "5XX", + ], + retryConfig: context.retryConfig, + retryCodes: context.retryCodes, + }); + if (!doResult.ok) { + return [doResult, { status: "request-error", request: req }]; + } + const response = doResult.value; + + const responseFields = { + HttpMeta: { Response: response, Request: req }, + }; + + const [result] = await M.match< + components.Toolset, + | errors.ServiceError + | GramError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + >( + M.json(200, components.Toolset$inboundSchema), + M.jsonErr( + [400, 401, 403, 404, 409, 415, 422], + errors.ServiceError$inboundSchema, + ), + M.jsonErr([500, 502], errors.ServiceError$inboundSchema), + M.fail("4XX"), + M.fail("5XX"), + )(response, req, { extraFields: responseFields }); + if (!result.ok) { + return [result, { status: "complete", request: req, response }]; + } + + return [result, { status: "complete", request: req, response }]; +} diff --git a/client/sdk/src/models/operations/cleartoolsetusersessionissuer.ts b/client/sdk/src/models/operations/cleartoolsetusersessionissuer.ts new file mode 100644 index 0000000000..6ed1fb6c38 --- /dev/null +++ b/client/sdk/src/models/operations/cleartoolsetusersessionissuer.ts @@ -0,0 +1,190 @@ +/* + * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + */ + +import * as z from "zod/v4-mini"; +import { remap as remap$ } from "../../lib/primitives.js"; + +export type ClearToolsetUserSessionIssuerSecurityOption1 = { + projectSlugHeaderGramProject: string; + sessionHeaderGramSession: string; +}; + +export type ClearToolsetUserSessionIssuerSecurityOption2 = { + apikeyHeaderGramKey: string; + projectSlugHeaderGramProject: string; +}; + +export type ClearToolsetUserSessionIssuerSecurity = { + option1?: ClearToolsetUserSessionIssuerSecurityOption1 | undefined; + option2?: ClearToolsetUserSessionIssuerSecurityOption2 | undefined; +}; + +export type ClearToolsetUserSessionIssuerRequest = { + /** + * The slug of the toolset to unlink + */ + slug: string; + /** + * Session header + */ + gramSession?: string | undefined; + /** + * API Key header + */ + gramKey?: string | undefined; + /** + * project header + */ + gramProject?: string | undefined; +}; + +/** @internal */ +export type ClearToolsetUserSessionIssuerSecurityOption1$Outbound = { + "project_slug_header_Gram-Project": string; + "session_header_Gram-Session": string; +}; + +/** @internal */ +export const ClearToolsetUserSessionIssuerSecurityOption1$outboundSchema: + z.ZodMiniType< + ClearToolsetUserSessionIssuerSecurityOption1$Outbound, + ClearToolsetUserSessionIssuerSecurityOption1 + > = z.pipe( + z.object({ + projectSlugHeaderGramProject: z.string(), + sessionHeaderGramSession: z.string(), + }), + z.transform((v) => { + return remap$(v, { + projectSlugHeaderGramProject: "project_slug_header_Gram-Project", + sessionHeaderGramSession: "session_header_Gram-Session", + }); + }), + ); + +export function clearToolsetUserSessionIssuerSecurityOption1ToJSON( + clearToolsetUserSessionIssuerSecurityOption1: + ClearToolsetUserSessionIssuerSecurityOption1, +): string { + return JSON.stringify( + ClearToolsetUserSessionIssuerSecurityOption1$outboundSchema.parse( + clearToolsetUserSessionIssuerSecurityOption1, + ), + ); +} + +/** @internal */ +export type ClearToolsetUserSessionIssuerSecurityOption2$Outbound = { + "apikey_header_Gram-Key": string; + "project_slug_header_Gram-Project": string; +}; + +/** @internal */ +export const ClearToolsetUserSessionIssuerSecurityOption2$outboundSchema: + z.ZodMiniType< + ClearToolsetUserSessionIssuerSecurityOption2$Outbound, + ClearToolsetUserSessionIssuerSecurityOption2 + > = z.pipe( + z.object({ + apikeyHeaderGramKey: z.string(), + projectSlugHeaderGramProject: z.string(), + }), + z.transform((v) => { + return remap$(v, { + apikeyHeaderGramKey: "apikey_header_Gram-Key", + projectSlugHeaderGramProject: "project_slug_header_Gram-Project", + }); + }), + ); + +export function clearToolsetUserSessionIssuerSecurityOption2ToJSON( + clearToolsetUserSessionIssuerSecurityOption2: + ClearToolsetUserSessionIssuerSecurityOption2, +): string { + return JSON.stringify( + ClearToolsetUserSessionIssuerSecurityOption2$outboundSchema.parse( + clearToolsetUserSessionIssuerSecurityOption2, + ), + ); +} + +/** @internal */ +export type ClearToolsetUserSessionIssuerSecurity$Outbound = { + Option1?: ClearToolsetUserSessionIssuerSecurityOption1$Outbound | undefined; + Option2?: ClearToolsetUserSessionIssuerSecurityOption2$Outbound | undefined; +}; + +/** @internal */ +export const ClearToolsetUserSessionIssuerSecurity$outboundSchema: + z.ZodMiniType< + ClearToolsetUserSessionIssuerSecurity$Outbound, + ClearToolsetUserSessionIssuerSecurity + > = z.pipe( + z.object({ + option1: z.optional( + z.lazy(() => + ClearToolsetUserSessionIssuerSecurityOption1$outboundSchema + ), + ), + option2: z.optional( + z.lazy(() => + ClearToolsetUserSessionIssuerSecurityOption2$outboundSchema + ), + ), + }), + z.transform((v) => { + return remap$(v, { + option1: "Option1", + option2: "Option2", + }); + }), + ); + +export function clearToolsetUserSessionIssuerSecurityToJSON( + clearToolsetUserSessionIssuerSecurity: ClearToolsetUserSessionIssuerSecurity, +): string { + return JSON.stringify( + ClearToolsetUserSessionIssuerSecurity$outboundSchema.parse( + clearToolsetUserSessionIssuerSecurity, + ), + ); +} + +/** @internal */ +export type ClearToolsetUserSessionIssuerRequest$Outbound = { + slug: string; + "Gram-Session"?: string | undefined; + "Gram-Key"?: string | undefined; + "Gram-Project"?: string | undefined; +}; + +/** @internal */ +export const ClearToolsetUserSessionIssuerRequest$outboundSchema: z.ZodMiniType< + ClearToolsetUserSessionIssuerRequest$Outbound, + ClearToolsetUserSessionIssuerRequest +> = z.pipe( + z.object({ + slug: z.string(), + gramSession: z.optional(z.string()), + gramKey: z.optional(z.string()), + gramProject: z.optional(z.string()), + }), + z.transform((v) => { + return remap$(v, { + gramSession: "Gram-Session", + gramKey: "Gram-Key", + gramProject: "Gram-Project", + }); + }), +); + +export function clearToolsetUserSessionIssuerRequestToJSON( + clearToolsetUserSessionIssuerRequest: ClearToolsetUserSessionIssuerRequest, +): string { + return JSON.stringify( + ClearToolsetUserSessionIssuerRequest$outboundSchema.parse( + clearToolsetUserSessionIssuerRequest, + ), + ); +} diff --git a/client/sdk/src/models/operations/index.ts b/client/sdk/src/models/operations/index.ts index 92440bc650..0ceffad6c3 100644 --- a/client/sdk/src/models/operations/index.ts +++ b/client/sdk/src/models/operations/index.ts @@ -13,6 +13,7 @@ export * from "./captureevent.js"; export * from "./checkmcpendpointslugavailability.js"; export * from "./checkmcpslugavailability.js"; export * from "./clearmcpregistrycache.js"; +export * from "./cleartoolsetusersessionissuer.js"; export * from "./cloneclientfromoauthproxyprovider.js"; export * from "./cloneenvironment.js"; export * from "./clonetoolset.js"; diff --git a/client/sdk/src/react-query/clearToolsetUserSessionIssuer.ts b/client/sdk/src/react-query/clearToolsetUserSessionIssuer.ts new file mode 100644 index 0000000000..24b2ddda50 --- /dev/null +++ b/client/sdk/src/react-query/clearToolsetUserSessionIssuer.ts @@ -0,0 +1,114 @@ +/* + * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + */ + +import { + MutationKey, + useMutation, + UseMutationResult, +} from "@tanstack/react-query"; +import { GramCore } from "../core.js"; +import { toolsetsClearUserSessionIssuer } from "../funcs/toolsetsClearUserSessionIssuer.js"; +import { combineSignals } from "../lib/primitives.js"; +import { RequestOptions } from "../lib/sdks.js"; +import * as components from "../models/components/index.js"; +import { GramError } from "../models/errors/gramerror.js"; +import { + ConnectionError, + InvalidRequestError, + RequestAbortedError, + RequestTimeoutError, + UnexpectedClientError, +} from "../models/errors/httpclienterrors.js"; +import * as errors from "../models/errors/index.js"; +import { ResponseValidationError } from "../models/errors/responsevalidationerror.js"; +import { SDKValidationError } from "../models/errors/sdkvalidationerror.js"; +import * as operations from "../models/operations/index.js"; +import { unwrapAsync } from "../types/fp.js"; +import { useGramContext } from "./_context.js"; +import { MutationHookOptions } from "./_types.js"; + +export type ClearToolsetUserSessionIssuerMutationVariables = { + request: operations.ClearToolsetUserSessionIssuerRequest; + security?: operations.ClearToolsetUserSessionIssuerSecurity | undefined; + options?: RequestOptions; +}; + +export type ClearToolsetUserSessionIssuerMutationData = components.Toolset; + +export type ClearToolsetUserSessionIssuerMutationError = + | errors.ServiceError + | GramError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError; + +/** + * clearUserSessionIssuer toolsets + * + * @remarks + * Unlink the user_session_issuer from a toolset. No-op if the toolset has no user_session_issuer linked. + */ +export function useClearToolsetUserSessionIssuerMutation( + options?: MutationHookOptions< + ClearToolsetUserSessionIssuerMutationData, + ClearToolsetUserSessionIssuerMutationError, + ClearToolsetUserSessionIssuerMutationVariables + >, +): UseMutationResult< + ClearToolsetUserSessionIssuerMutationData, + ClearToolsetUserSessionIssuerMutationError, + ClearToolsetUserSessionIssuerMutationVariables +> { + const client = useGramContext(); + return useMutation({ + ...buildClearToolsetUserSessionIssuerMutation(client, options), + ...options, + }); +} + +export function mutationKeyClearToolsetUserSessionIssuer(): MutationKey { + return ["@gram/client", "toolsets", "clearUserSessionIssuer"]; +} + +export function buildClearToolsetUserSessionIssuerMutation( + client$: GramCore, + hookOptions?: RequestOptions, +): { + mutationKey: MutationKey; + mutationFn: ( + variables: ClearToolsetUserSessionIssuerMutationVariables, + ) => Promise; +} { + return { + mutationKey: mutationKeyClearToolsetUserSessionIssuer(), + mutationFn: function clearToolsetUserSessionIssuerMutationFn({ + request, + security, + options, + }): Promise { + const mergedOptions = { + ...hookOptions, + ...options, + fetchOptions: { + ...hookOptions?.fetchOptions, + ...options?.fetchOptions, + signal: combineSignals( + hookOptions?.fetchOptions?.signal, + options?.fetchOptions?.signal, + ), + }, + }; + return unwrapAsync(toolsetsClearUserSessionIssuer( + client$, + request, + security, + mergedOptions, + )); + }, + }; +} diff --git a/client/sdk/src/react-query/index.ts b/client/sdk/src/react-query/index.ts index 467f5b0fa1..1857661981 100644 --- a/client/sdk/src/react-query/index.ts +++ b/client/sdk/src/react-query/index.ts @@ -26,6 +26,7 @@ export * from "./chatSessionsRevoke.js"; export * from "./chatSubmitFeedback.js"; export * from "./checkMcpEndpointSlugAvailability.js"; export * from "./checkMCPSlugAvailability.js"; +export * from "./clearToolsetUserSessionIssuer.js"; export * from "./cloneClientFromOAuthProxyProvider.js"; export * from "./cloneEnvironment.js"; export * from "./cloneToolset.js"; diff --git a/client/sdk/src/sdk/toolsets.ts b/client/sdk/src/sdk/toolsets.ts index f5df093e58..ff2ff0a800 100644 --- a/client/sdk/src/sdk/toolsets.ts +++ b/client/sdk/src/sdk/toolsets.ts @@ -5,6 +5,7 @@ import { toolsetsAddExternalOAuthServer } from "../funcs/toolsetsAddExternalOAuthServer.js"; import { toolsetsAddOAuthProxyServer } from "../funcs/toolsetsAddOAuthProxyServer.js"; import { toolsetsCheckMCPSlugAvailability } from "../funcs/toolsetsCheckMCPSlugAvailability.js"; +import { toolsetsClearUserSessionIssuer } from "../funcs/toolsetsClearUserSessionIssuer.js"; import { toolsetsCloneBySlug } from "../funcs/toolsetsCloneBySlug.js"; import { toolsetsCreate } from "../funcs/toolsetsCreate.js"; import { toolsetsDeleteBySlug } from "../funcs/toolsetsDeleteBySlug.js"; @@ -78,6 +79,25 @@ export class Toolsets extends ClientSDK { )); } + /** + * clearUserSessionIssuer toolsets + * + * @remarks + * Unlink the user_session_issuer from a toolset. No-op if the toolset has no user_session_issuer linked. + */ + async clearUserSessionIssuer( + request: operations.ClearToolsetUserSessionIssuerRequest, + security?: operations.ClearToolsetUserSessionIssuerSecurity | undefined, + options?: RequestOptions, + ): Promise { + return unwrapAsync(toolsetsClearUserSessionIssuer( + this, + request, + security, + options, + )); + } + /** * cloneToolset toolsets * diff --git a/server/design/toolsets/design.go b/server/design/toolsets/design.go index 50d60e9c79..40b62443fe 100644 --- a/server/design/toolsets/design.go +++ b/server/design/toolsets/design.go @@ -348,6 +348,33 @@ var _ = Service("toolsets", func() { Meta("openapi:extension:x-speakeasy-react-hook", `{"name": "SetToolsetUserSessionIssuer"}`) }) + Method("clearUserSessionIssuer", func() { + Description("Unlink the user_session_issuer from a toolset. No-op if the toolset has no user_session_issuer linked.") + + Payload(func() { + Required("slug") + Attribute("slug", shared.Slug, "The slug of the toolset to unlink") + security.SessionPayload() + security.ByKeyPayload() + security.ProjectPayload() + }) + + Result(shared.Toolset) + + HTTP(func() { + Param("slug") + POST("/rpc/toolsets.clearUserSessionIssuer") + security.SessionHeader() + security.ByKeyHeader() + security.ProjectHeader() + Response(StatusOK) + }) + + Meta("openapi:operationId", "clearToolsetUserSessionIssuer") + Meta("openapi:extension:x-speakeasy-name-override", "clearUserSessionIssuer") + Meta("openapi:extension:x-speakeasy-react-hook", `{"name": "ClearToolsetUserSessionIssuer"}`) + }) + }) var CreateToolsetForm = Type("CreateToolsetForm", func() { diff --git a/server/internal/toolsets/clear_user_session_issuer.go b/server/internal/toolsets/clear_user_session_issuer.go new file mode 100644 index 0000000000..57b72ba374 --- /dev/null +++ b/server/internal/toolsets/clear_user_session_issuer.go @@ -0,0 +1,89 @@ +package toolsets + +import ( + "context" + "errors" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + + gen "github.com/speakeasy-api/gram/server/gen/toolsets" + "github.com/speakeasy-api/gram/server/gen/types" + "github.com/speakeasy-api/gram/server/internal/audit" + "github.com/speakeasy-api/gram/server/internal/authz" + "github.com/speakeasy-api/gram/server/internal/contextvalues" + "github.com/speakeasy-api/gram/server/internal/mv" + "github.com/speakeasy-api/gram/server/internal/o11y" + "github.com/speakeasy-api/gram/server/internal/oops" + "github.com/speakeasy-api/gram/server/internal/toolsets/repo" + "github.com/speakeasy-api/gram/server/internal/urn" +) + +// ClearUserSessionIssuer unlinks any user_session_issuer attached to a +// toolset by nulling toolsets.user_session_issuer_id. The underlying USI row +// is left untouched. Calling this on a toolset that already has no USI +// returns the toolset unchanged. +func (s *Service) ClearUserSessionIssuer(ctx context.Context, payload *gen.ClearUserSessionIssuerPayload) (*types.Toolset, error) { + authCtx, ok := contextvalues.GetAuthContext(ctx) + if !ok || authCtx == nil || authCtx.ProjectID == nil { + return nil, oops.C(oops.CodeUnauthorized) + } + + beforeView, err := mv.DescribeToolset(ctx, s.logger, s.db, mv.ProjectID(*authCtx.ProjectID), mv.ToolsetSlug(payload.Slug), new(s.toolsetCache.SkipCache())) + if err != nil { + return nil, err + } + + if err := s.authz.Require(ctx, authz.Check{Scope: authz.ScopeMCPWrite, ResourceKind: "", ResourceID: beforeView.ID, Dimensions: nil}); err != nil { + return nil, err + } + + dbtx, err := s.db.Begin(ctx) + if err != nil { + return nil, oops.E(oops.CodeUnexpected, err, "begin transaction").Log(ctx, s.logger) + } + defer o11y.NoLogDefer(func() error { return dbtx.Rollback(ctx) }) + + if _, err := s.repo.WithTx(dbtx).UpdateToolsetUserSessionIssuer(ctx, repo.UpdateToolsetUserSessionIssuerParams{ + UserSessionIssuerID: uuid.NullUUID{UUID: uuid.Nil, Valid: false}, + Slug: string(payload.Slug), + ProjectID: *authCtx.ProjectID, + }); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, oops.E(oops.CodeNotFound, err, "toolset not found").Log(ctx, s.logger) + } + return nil, oops.E(oops.CodeUnexpected, err, "clear toolset user_session_issuer").Log(ctx, s.logger) + } + + afterView, err := mv.DescribeToolset(ctx, s.logger, dbtx, mv.ProjectID(*authCtx.ProjectID), mv.ToolsetSlug(payload.Slug), new(s.toolsetCache.SkipCache())) + if err != nil { + return nil, err + } + + toolsetUUID, err := uuid.Parse(afterView.ID) + if err != nil { + return nil, oops.E(oops.CodeUnexpected, err, "invalid toolset id").Log(ctx, s.logger) + } + + if err := s.audit.LogToolsetUpdate(ctx, dbtx, audit.LogToolsetUpdateEvent{ + OrganizationID: authCtx.ActiveOrganizationID, + ProjectID: *authCtx.ProjectID, + Actor: urn.NewPrincipal(urn.PrincipalTypeUser, authCtx.UserID), + ActorDisplayName: authCtx.Email, + ActorSlug: nil, + ToolsetURN: urn.NewToolset(toolsetUUID), + ToolsetName: afterView.Name, + ToolsetSlug: string(afterView.Slug), + ToolsetVersionAfter: afterView.ToolsetVersion, + ToolsetSnapshotBefore: beforeView, + ToolsetSnapshotAfter: afterView, + }); err != nil { + return nil, oops.E(oops.CodeUnexpected, err, "log toolset update").Log(ctx, s.logger) + } + + if err := dbtx.Commit(ctx); err != nil { + return nil, oops.E(oops.CodeUnexpected, err, "commit transaction").Log(ctx, s.logger) + } + + return afterView, nil +} diff --git a/server/internal/toolsets/clear_user_session_issuer_test.go b/server/internal/toolsets/clear_user_session_issuer_test.go new file mode 100644 index 0000000000..c88bbb2a05 --- /dev/null +++ b/server/internal/toolsets/clear_user_session_issuer_test.go @@ -0,0 +1,106 @@ +package toolsets_test + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/stretchr/testify/require" + + gen "github.com/speakeasy-api/gram/server/gen/toolsets" + "github.com/speakeasy-api/gram/server/internal/authz" + "github.com/speakeasy-api/gram/server/internal/authztest" + "github.com/speakeasy-api/gram/server/internal/contextvalues" + "github.com/speakeasy-api/gram/server/internal/oops" + toolsetsRepo "github.com/speakeasy-api/gram/server/internal/toolsets/repo" + usersessionsRepo "github.com/speakeasy-api/gram/server/internal/usersessions/repo" +) + +func TestClearUserSessionIssuer_UnlinksAttachedIssuer(t *testing.T) { + t.Parallel() + + ctx, ti := newTestToolsetsService(t) + authCtx, ok := contextvalues.GetAuthContext(ctx) + require.True(t, ok) + require.NotNil(t, authCtx.ProjectID) + + toolset := createMinimalPrivateToolset(t, ctx, ti, "clear-usi-linked") + + usi, err := usersessionsRepo.New(ti.conn).CreateUserSessionIssuer(ctx, usersessionsRepo.CreateUserSessionIssuerParams{ + ProjectID: *authCtx.ProjectID, + Slug: "test-usi-linked", + AuthnChallengeMode: "none", + SessionDuration: pgtype.Interval{Microseconds: int64(time.Hour / time.Microsecond), Days: 0, Months: 0, Valid: true}, + }) + require.NoError(t, err) + + _, err = toolsetsRepo.New(ti.conn).UpdateToolsetUserSessionIssuer(ctx, toolsetsRepo.UpdateToolsetUserSessionIssuerParams{ + UserSessionIssuerID: uuid.NullUUID{UUID: usi.ID, Valid: true}, + Slug: string(toolset.Slug), + ProjectID: *authCtx.ProjectID, + }) + require.NoError(t, err) + + cleared, err := ti.service.ClearUserSessionIssuer(ctx, &gen.ClearUserSessionIssuerPayload{ + SessionToken: nil, + ApikeyToken: nil, + ProjectSlugInput: nil, + Slug: toolset.Slug, + }) + require.NoError(t, err) + require.NotNil(t, cleared) + require.Nil(t, cleared.UserSessionIssuerID) +} + +func TestClearUserSessionIssuer_NoopWhenAlreadyClear(t *testing.T) { + t.Parallel() + + ctx, ti := newTestToolsetsService(t) + toolset := createMinimalPrivateToolset(t, ctx, ti, "clear-usi-noop") + + cleared, err := ti.service.ClearUserSessionIssuer(ctx, &gen.ClearUserSessionIssuerPayload{ + SessionToken: nil, + ApikeyToken: nil, + ProjectSlugInput: nil, + Slug: toolset.Slug, + }) + require.NoError(t, err) + require.NotNil(t, cleared) + require.Nil(t, cleared.UserSessionIssuerID) +} + +func TestClearUserSessionIssuer_DeniedWithoutWriteScope(t *testing.T) { + t.Parallel() + + ctx, ti := newTestToolsetsService(t) + toolset := createMinimalPrivateToolset(t, ctx, ti, "clear-usi-denied") + + ctx = authztest.WithExactGrants(t, ctx, authz.Grant{Scope: authz.ScopeMCPRead, Selector: authz.NewSelector(authz.ScopeMCPRead, toolset.ID)}) + + _, err := ti.service.ClearUserSessionIssuer(ctx, &gen.ClearUserSessionIssuerPayload{ + SessionToken: nil, + ApikeyToken: nil, + ProjectSlugInput: nil, + Slug: toolset.Slug, + }) + var oopsErr *oops.ShareableError + require.ErrorAs(t, err, &oopsErr) + require.Equal(t, oops.CodeForbidden, oopsErr.Code) +} + +func TestClearUserSessionIssuer_NotFound(t *testing.T) { + t.Parallel() + + ctx, ti := newTestToolsetsService(t) + + _, err := ti.service.ClearUserSessionIssuer(ctx, &gen.ClearUserSessionIssuerPayload{ + SessionToken: nil, + ApikeyToken: nil, + ProjectSlugInput: nil, + Slug: "does-not-exist", + }) + var oopsErr *oops.ShareableError + require.ErrorAs(t, err, &oopsErr) + require.Equal(t, oops.CodeNotFound, oopsErr.Code) +} From 387969e8391c705d1e1a0db4cfedfbf047c9a380 Mon Sep 17 00:00:00 2001 From: Quinn Stearns Date: Thu, 21 May 2026 22:08:50 -0700 Subject: [PATCH 2/3] feat(dashboard): admin-only "Remove user session issuer" button Adds a small destructive button next to the "Login Secured" badge on the MCP authentication tab, visible only to admins when a user_session_issuer is wired. Clicking it calls toolsets.clearUserSessionIssuer to unlink the USI from the toolset so the wiring can be re-done without dropping into the database. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../remove-user-session-issuer-button.md | 5 +++ .../src/pages/mcp/MCPEnvironmentSettings.tsx | 31 ++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 .changeset/remove-user-session-issuer-button.md diff --git a/.changeset/remove-user-session-issuer-button.md b/.changeset/remove-user-session-issuer-button.md new file mode 100644 index 0000000000..75e7ed4aa0 --- /dev/null +++ b/.changeset/remove-user-session-issuer-button.md @@ -0,0 +1,5 @@ +--- +"dashboard": patch +--- + +Add admin-only "Remove user session issuer" button on the MCP authentication tab that unlinks the toolset's user_session_issuer via `toolsets.clearUserSessionIssuer`. diff --git a/client/dashboard/src/pages/mcp/MCPEnvironmentSettings.tsx b/client/dashboard/src/pages/mcp/MCPEnvironmentSettings.tsx index 0f3740f540..374eb73e74 100644 --- a/client/dashboard/src/pages/mcp/MCPEnvironmentSettings.tsx +++ b/client/dashboard/src/pages/mcp/MCPEnvironmentSettings.tsx @@ -7,7 +7,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { useSession } from "@/contexts/Auth"; +import { useIsAdmin, useSession } from "@/contexts/Auth"; import { useTelemetry } from "@/contexts/Telemetry"; import { useMissingRequiredEnvVars } from "@/hooks/useMissingEnvironmentVariables"; import { ONBOARD_EXTERNAL_MCP_TO_USER_SESSIONS_FLAG } from "@/lib/externalMcpUserSessions"; @@ -18,6 +18,7 @@ import { invalidateAllGetMcpMetadata, invalidateAllListEnvironments, invalidateAllToolset, + useClearToolsetUserSessionIssuerMutation, useCreateEnvironmentMutation, useGetMcpMetadata, useListEnvironments, @@ -902,10 +903,23 @@ function OAuthSection({ toolset }: OAuthSectionProps) { ] = useState(false); const telemetry = useTelemetry(); + const isAdmin = useIsAdmin(); + const queryClient = useQueryClient(); const { data: environmentsData } = useListEnvironments(); const environments = environmentsData?.environments ?? []; + const clearUserSessionIssuerMutation = + useClearToolsetUserSessionIssuerMutation({ + onSuccess: () => { + invalidateAllToolset(queryClient); + toast.success("Removed user session issuer from toolset"); + }, + onError: (err) => { + toast.error(`Failed to remove user session issuer: ${err.message}`); + }, + }); + const loginSecured = !!toolset.userSessionIssuerSlug; const isOAuthConnected = !!( toolset?.oauthProxyServer || toolset?.externalOauthServer @@ -973,6 +987,21 @@ function OAuthSection({ toolset }: OAuthSectionProps) { Login Secured )} + {userSessionIssuerWired && isAdmin && ( + + )} {showWireUserSessionIssuer && (